diff --git a/src/deploy/functions/args.ts b/src/deploy/functions/args.ts index 1138b9d9491..bc6e1e21db8 100644 --- a/src/deploy/functions/args.ts +++ b/src/deploy/functions/args.ts @@ -2,6 +2,7 @@ import * as backend from "./backend"; import * as gcfV2 from "../../gcp/cloudfunctionsv2"; import * as projectConfig from "../../functions/projectConfig"; import * as deployHelper from "./functionsDeployHelper"; +import { Runtime } from "./runtimes"; // These types should probably be in a root deploy.ts, but we can only boil the ocean one bit at a time. interface CodebasePayload { @@ -49,6 +50,19 @@ export interface Context { gcfV1: string[]; gcfV2: string[]; }; + + // Tracks metrics about codebase deployments to send to GA4 + codebaseDeployEvents?: Record; +} + +export interface CodebaseDeployEvent { + params?: "env_only" | "with_secrets" | "none"; + runtime?: Runtime; + runtime_notice?: string; + fn_deploy_num_successes: number; + fn_deploy_num_failures: number; + fn_deploy_num_canceled: number; + fn_deploy_num_skipped: number; } export interface FirebaseConfig { diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index 222b0a75d4e..846a49043b3 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -6,12 +6,14 @@ import { FirebaseError } from "../../error"; import { assertExhaustive, mapObject, nullsafeVisitor } from "../../functional"; import { UserEnvsOpts, writeUserEnvs } from "../../functions/env"; import { FirebaseConfig } from "./args"; +import { Runtime } from "./runtimes"; /* The union of a customer-controlled deployment and potentially deploy-time defined parameters */ export interface Build { requiredAPIs: RequiredApi[]; endpoints: Record; params: params.Param[]; + runtime?: Runtime; } /** diff --git a/src/deploy/functions/ensure.ts b/src/deploy/functions/ensure.ts index b48740bd748..7006e872af0 100644 --- a/src/deploy/functions/ensure.ts +++ b/src/deploy/functions/ensure.ts @@ -8,7 +8,6 @@ import { logLabeledBullet, logLabeledSuccess } from "../../utils"; import { ensureServiceAgentRole } from "../../gcp/secretManager"; import { getFirebaseProject } from "../../management/projects"; import { assertExhaustive } from "../../functional"; -import { track } from "../../track"; import * as backend from "./backend"; const FAQ_URL = "https://firebase.google.com/support/faq#functions-runtime"; @@ -37,7 +36,6 @@ export async function defaultServiceAccount(e: backend.Endpoint): Promise): boolean { - // "firebase" key is always going to exist in runtime config. - // If any other key exists, we can assume that user is using runtime config. - return Object.keys(config).length > 1; -} /** * Prepare functions codebases for deploy. @@ -88,6 +82,8 @@ export async function prepare( runtimeConfig = { ...runtimeConfig, ...(await getFunctionsConfig(projectId)) }; } + context.codebaseDeployEvents = {}; + // ===Phase 1. Load codebases from source. const wantBuilds = await loadCodebases( context.config, @@ -155,15 +151,23 @@ export async function prepare( codebaseUsesEnvs.push(codebase); } + context.codebaseDeployEvents[codebase] = { + fn_deploy_num_successes: 0, + fn_deploy_num_failures: 0, + fn_deploy_num_canceled: 0, + fn_deploy_num_skipped: 0, + }; + if (wantBuild.params.length > 0) { if (wantBuild.params.every((p) => p.type !== "secret")) { - void track("functions_params_in_build", "env_only"); + context.codebaseDeployEvents[codebase].params = "env_only"; } else { - void track("functions_params_in_build", "with_secrets"); + context.codebaseDeployEvents[codebase].params = "with_secrets"; } } else { - void track("functions_params_in_build", "none"); + context.codebaseDeployEvents[codebase].params = "none"; } + context.codebaseDeployEvents[codebase].runtime = wantBuild.runtime; } // ===Phase 2.5. Before proceeding further, let's make sure that we don't have conflicting function names. @@ -214,18 +218,6 @@ export async function prepare( inferBlockingDetails(wantBackend); } - const tag = hasUserConfig(runtimeConfig) - ? codebaseUsesEnvs.length > 0 - ? "mixed" - : "runtime_config" - : codebaseUsesEnvs.length > 0 - ? "dotenv" - : "none"; - void track("functions_codebase_deploy_env_method", tag); - - const codebaseCnt = Object.keys(payload.functions).length; - void track("functions_codebase_deploy_count", codebaseCnt >= 5 ? "5+" : codebaseCnt.toString()); - // ===Phase 5. Enable APIs required by the deploying backends. const wantBackend = backend.merge(...Object.values(wantBackends)); const haveBackend = backend.merge(...Object.values(haveBackends)); @@ -468,6 +460,7 @@ export async function loadCodebases( // in order for .init() calls to succeed. GOOGLE_CLOUD_QUOTA_PROJECT: projectId, }); + wantBuilds[codebase].runtime = codebaseConfig.runtime; } return wantBuilds; } diff --git a/src/deploy/functions/release/index.ts b/src/deploy/functions/release/index.ts index 08c46191304..e32749feda8 100644 --- a/src/deploy/functions/release/index.ts +++ b/src/deploy/functions/release/index.ts @@ -76,7 +76,7 @@ export async function release( const summary = await fab.applyPlan(plan); - await reporter.logAndTrackDeployStats(summary); + await reporter.logAndTrackDeployStats(summary, context); reporter.printErrors(summary); // N.B. Fabricator::applyPlan updates the endpoints it deploys to include the diff --git a/src/deploy/functions/release/reporter.ts b/src/deploy/functions/release/reporter.ts index 61611c0a7ba..10a4a64757c 100644 --- a/src/deploy/functions/release/reporter.ts +++ b/src/deploy/functions/release/reporter.ts @@ -1,8 +1,9 @@ import * as backend from "../backend"; import * as clc from "colorette"; +import * as args from "../args"; import { logger } from "../../../logger"; -import { track } from "../../../track"; +import { trackGA4 } from "../../../track"; import * as utils from "../../../utils"; import { getFunctionLabel } from "../functionsDeployHelper"; @@ -56,7 +57,10 @@ export class AbortedDeploymentError extends DeploymentError { } /** Add debugger logs and GA metrics for deploy stats. */ -export async function logAndTrackDeployStats(summary: Summary): Promise { +export async function logAndTrackDeployStats( + summary: Summary, + context?: args.Context +): Promise { let totalTime = 0; let totalErrors = 0; let totalSuccesses = 0; @@ -64,54 +68,65 @@ export async function logAndTrackDeployStats(summary: Summary): Promise { const reports: Array> = []; const regions = new Set(); + const codebases = new Set(); for (const result of summary.results) { - const tag = triggerTag(result.endpoint); + const fnDeployEvent = { + platform: result.endpoint.platform, + trigger_type: backend.endpointTriggerType(result.endpoint), + region: result.endpoint.region, + runtime: result.endpoint.runtime, + status: !result.error + ? "success" + : result.error instanceof AbortedDeploymentError + ? "aborted" + : "failure", + duration: result.durationMs, + }; + reports.push(trackGA4("function_deploy", fnDeployEvent)); + regions.add(result.endpoint.region); + codebases.add(result.endpoint.codebase || "default"); totalTime += result.durationMs; if (!result.error) { totalSuccesses++; - reports.push(track("function_deploy_success", tag, result.durationMs)); + if (context?.codebaseDeployEvents?.[result.endpoint.codebase || "default"] !== undefined) { + context.codebaseDeployEvents[result.endpoint.codebase || "default"] + .fn_deploy_num_successes++; + } } else if (result.error instanceof AbortedDeploymentError) { totalAborts++; - reports.push(track("function_deploy_abort", tag, result.durationMs)); + if (context?.codebaseDeployEvents?.[result.endpoint.codebase || "default"] !== undefined) { + context.codebaseDeployEvents[result.endpoint.codebase || "default"] + .fn_deploy_num_canceled++; + } } else { totalErrors++; - reports.push(track("function_deploy_failure", tag, result.durationMs)); + if (context?.codebaseDeployEvents?.[result.endpoint.codebase || "default"] !== undefined) { + context.codebaseDeployEvents[result.endpoint.codebase || "default"] + .fn_deploy_num_failures++; + } } } - const regionCountTag = regions.size < 5 ? regions.size.toString() : ">=5"; - reports.push(track("functions_region_count", regionCountTag, 1)); - - const gcfv1 = summary.results.find((r) => r.endpoint.platform === "gcfv1"); - const gcfv2 = summary.results.find((r) => r.endpoint.platform === "gcfv2"); - const tag = gcfv1 && gcfv2 ? "v1+v2" : gcfv1 ? "v1" : "v2"; - reports.push(track("functions_codebase_deploy", tag, summary.results.length)); + for (const codebase of codebases) { + if (context?.codebaseDeployEvents) { + reports.push(trackGA4("codebase_deploy", { ...context.codebaseDeployEvents[codebase] })); + } + } + const fnDeployGroupEvent = { + codebase_deploy_count: codebases.size >= 5 ? "5+" : codebases.size.toString(), + fn_deploy_num_successes: totalSuccesses, + fn_deploy_num_canceled: totalAborts, + fn_deploy_num_failures: totalErrors, + }; + reports.push(trackGA4("function_deploy_group", fnDeployGroupEvent)); const avgTime = totalTime / (totalSuccesses + totalErrors); - logger.debug(`Total Function Deployment time: ${summary.totalTime}`); logger.debug(`${totalErrors + totalSuccesses + totalAborts} Functions Deployed`); logger.debug(`${totalErrors} Functions Errored`); logger.debug(`${totalAborts} Function Deployments Aborted`); logger.debug(`Average Function Deployment time: ${avgTime}`); - if (totalErrors + totalSuccesses > 0) { - if (totalErrors === 0) { - reports.push(track("functions_deploy_result", "success", totalSuccesses)); - } else if (totalSuccesses > 0) { - reports.push(track("functions_deploy_result", "partial_success", totalSuccesses)); - reports.push(track("functions_deploy_result", "partial_failure", totalErrors)); - reports.push( - track( - "functions_deploy_result", - "partial_error_ratio", - totalErrors / (totalSuccesses + totalErrors) - ) - ); - } else { - reports.push(track("functions_deploy_result", "failure", totalErrors)); - } - } await utils.allSettled(reports); } diff --git a/src/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.ts b/src/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.ts index 4ce21970fa8..58fd94e9a2c 100644 --- a/src/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.ts +++ b/src/deploy/functions/runtimes/node/parseRuntimeAndValidateSDK.ts @@ -2,7 +2,6 @@ import * as path from "path"; import * as clc from "colorette"; import { FirebaseError } from "../../../../error"; -import { track } from "../../../../track"; import * as runtimes from "../../runtimes"; // have to require this because no @types/cjson available @@ -80,7 +79,6 @@ export function getRuntimeChoice(sourceDir: string, runtimeFromConfig?: string): : UNSUPPORTED_NODE_VERSION_PACKAGE_JSON_MSG) + DEPRECATED_NODE_VERSION_INFO; if (!runtime || !ENGINE_RUNTIMES_NAMES.includes(runtime)) { - void track("functions_runtime_notices", "package_missing_runtime"); throw new FirebaseError(errorMessage, { exit: 1 }); } @@ -88,7 +86,6 @@ export function getRuntimeChoice(sourceDir: string, runtimeFromConfig?: string): // it's in ENGINE_RUNTIME_NAMES and not in DEPRECATED_RUNTIMES. This is still a // good defense in depth and also lets us upcast the response to Runtime safely. if (runtimes.isDeprecatedRuntime(runtime) || !runtimes.isValidRuntime(runtime)) { - void track("functions_runtime_notices", `${runtime}_deploy_prohibited`); throw new FirebaseError(errorMessage, { exit: 1 }); } diff --git a/src/deploy/functions/runtimes/node/versioning.ts b/src/deploy/functions/runtimes/node/versioning.ts index d0c400c9bbb..4945309c11f 100644 --- a/src/deploy/functions/runtimes/node/versioning.ts +++ b/src/deploy/functions/runtimes/node/versioning.ts @@ -6,7 +6,6 @@ import * as spawn from "cross-spawn"; import * as semver from "semver"; import { logger } from "../../../../logger"; -import { track } from "../../../../track"; import * as utils from "../../../../utils"; interface NpmShowResult { @@ -113,7 +112,6 @@ export function getLatestSDKVersion(): string | undefined { export function checkFunctionsSDKVersion(currentVersion: string): void { try { if (semver.lt(currentVersion, MIN_SDK_VERSION)) { - void track("functions_runtime_notices", "functions_sdk_too_old"); utils.logWarning(FUNCTIONS_SDK_VERSION_TOO_OLD_WARNING); } diff --git a/src/test/deploy/functions/release/reporter.spec.ts b/src/test/deploy/functions/release/reporter.spec.ts index ae2de5cd3f2..b18fee9f728 100644 --- a/src/test/deploy/functions/release/reporter.spec.ts +++ b/src/test/deploy/functions/release/reporter.spec.ts @@ -6,6 +6,7 @@ import * as backend from "../../../../deploy/functions/backend"; import * as reporter from "../../../../deploy/functions/release/reporter"; import * as track from "../../../../track"; import * as events from "../../../../functions/events"; +import * as args from "../../../../deploy/functions/args"; const ENDPOINT_BASE: Omit = { platform: "gcfv1", @@ -117,11 +118,11 @@ describe("reporter", () => { }); describe("logAndTrackDeployStats", () => { - let trackStub: sinon.SinonStub; + let trackGA4Stub: sinon.SinonStub; let debugStub: sinon.SinonStub; beforeEach(() => { - trackStub = sinon.stub(track, "track"); + trackGA4Stub = sinon.stub(track, "trackGA4"); debugStub = sinon.stub(logger, "debug"); }); @@ -134,105 +135,97 @@ describe("reporter", () => { totalTime: 2_000, results: [ { - endpoint: ENDPOINT, + endpoint: { ...ENDPOINT, codebase: "codebase0" }, durationMs: 2_000, }, { - endpoint: ENDPOINT, + endpoint: { ...ENDPOINT, codebase: "codebase1" }, durationMs: 1_000, - error: new reporter.DeploymentError(ENDPOINT, "update", undefined), + error: new reporter.DeploymentError( + { ...ENDPOINT, codebase: "codebase1" }, + "update", + undefined + ), }, { - endpoint: ENDPOINT, + endpoint: { ...ENDPOINT, codebase: "codebase1" }, durationMs: 0, - error: new reporter.AbortedDeploymentError(ENDPOINT), + error: new reporter.AbortedDeploymentError({ ...ENDPOINT, codebase: "codebase1" }), }, ], }; - await reporter.logAndTrackDeployStats(summary); - - expect(trackStub).to.have.been.calledWith("functions_region_count", "1", 1); - expect(trackStub).to.have.been.calledWith("function_deploy_success", "v1.https", 2_000); - expect(trackStub).to.have.been.calledWith("function_deploy_failure", "v1.https", 1_000); - // Aborts aren't tracked because they would throw off timing metrics - expect(trackStub).to.not.have.been.calledWith("function_deploy_failure", "v1.https", 0); - - expect(debugStub).to.have.been.calledWith("Total Function Deployment time: 2000"); - expect(debugStub).to.have.been.calledWith("3 Functions Deployed"); - expect(debugStub).to.have.been.calledWith("1 Functions Errored"); - expect(debugStub).to.have.been.calledWith("1 Function Deployments Aborted"); - - // The 0ms for an aborted function isn't counted. - expect(debugStub).to.have.been.calledWith("Average Function Deployment time: 1500"); - }); - - it("tracks v1 vs v2 codebases", async () => { - const v1 = { ...ENDPOINT }; - const v2: backend.Endpoint = { ...ENDPOINT, platform: "gcfv2" }; - - const summary: reporter.Summary = { - totalTime: 1_000, - results: [ - { - endpoint: v1, - durationMs: 1_000, + const context: args.Context = { + projectId: "id", + codebaseDeployEvents: { + codebase0: { + params: "none", + fn_deploy_num_successes: 0, + fn_deploy_num_canceled: 0, + fn_deploy_num_failures: 0, + fn_deploy_num_skipped: 0, }, - { - endpoint: v2, - durationMs: 1_000, + codebase1: { + params: "none", + fn_deploy_num_successes: 0, + fn_deploy_num_canceled: 0, + fn_deploy_num_failures: 0, + fn_deploy_num_skipped: 0, }, - ], - }; - - await reporter.logAndTrackDeployStats(summary); - expect(trackStub).to.have.been.calledWith("functions_codebase_deploy", "v1+v2", 2); - trackStub.resetHistory(); - - summary.results = [{ endpoint: v1, durationMs: 1_000 }]; - await reporter.logAndTrackDeployStats(summary); - expect(trackStub).to.have.been.calledWith("functions_codebase_deploy", "v1", 1); - trackStub.resetHistory(); - - summary.results = [{ endpoint: v2, durationMs: 1_000 }]; - await reporter.logAndTrackDeployStats(summary); - expect(trackStub).to.have.been.calledWith("functions_codebase_deploy", "v2", 1); - }); - - it("tracks overall success/failure", async () => { - const success: reporter.DeployResult = { - endpoint: ENDPOINT, - durationMs: 1_000, - }; - const failure: reporter.DeployResult = { - endpoint: ENDPOINT, - durationMs: 1_000, - error: new reporter.DeploymentError(ENDPOINT, "create", undefined), + }, }; - const summary: reporter.Summary = { - totalTime: 1_000, - results: [success, failure], - }; + await reporter.logAndTrackDeployStats(summary, context); + + expect(trackGA4Stub).to.have.been.calledWith("function_deploy", { + platform: "gcfv1", + trigger_type: "https", + region: "region", + runtime: "nodejs16", + status: "success", + duration: 2_000, + }); + expect(trackGA4Stub).to.have.been.calledWith("function_deploy", { + platform: "gcfv1", + trigger_type: "https", + region: "region", + runtime: "nodejs16", + status: "failure", + duration: 1_000, + }); + expect(trackGA4Stub).to.have.been.calledWith("function_deploy", { + platform: "gcfv1", + trigger_type: "https", + region: "region", + runtime: "nodejs16", + status: "aborted", + duration: 0, + }); + + expect(trackGA4Stub).to.have.been.calledWith("codebase_deploy", { + params: "none", + fn_deploy_num_successes: 1, + fn_deploy_num_canceled: 0, + fn_deploy_num_failures: 0, + fn_deploy_num_skipped: 0, + }); + expect(trackGA4Stub).to.have.been.calledWith("codebase_deploy", { + params: "none", + fn_deploy_num_successes: 0, + fn_deploy_num_canceled: 1, + fn_deploy_num_failures: 1, + fn_deploy_num_skipped: 0, + }); + + expect(trackGA4Stub).to.have.been.calledWith("function_deploy_group", { + codebase_deploy_count: "2", + fn_deploy_num_successes: 1, + fn_deploy_num_canceled: 1, + fn_deploy_num_failures: 1, + }); - await reporter.logAndTrackDeployStats(summary); - expect(trackStub).to.have.been.calledWith("functions_deploy_result", "partial_success", 1); - expect(trackStub).to.have.been.calledWith("functions_deploy_result", "partial_failure", 1); - expect(trackStub).to.have.been.calledWith( - "functions_deploy_result", - "partial_error_ratio", - 0.5 - ); - trackStub.resetHistory(); - - summary.results = [success]; - await reporter.logAndTrackDeployStats(summary); - expect(trackStub).to.have.been.calledWith("functions_deploy_result", "success", 1); - trackStub.resetHistory(); - - summary.results = [failure]; - await reporter.logAndTrackDeployStats(summary); - expect(trackStub).to.have.been.calledWith("functions_deploy_result", "failure", 1); + // The 0ms for an aborted function isn't counted. + expect(debugStub).to.have.been.calledWith("Average Function Deployment time: 1500"); }); }); diff --git a/src/track.ts b/src/track.ts index 69a0c92f4bd..3f523752d5e 100644 --- a/src/track.ts +++ b/src/track.ts @@ -16,7 +16,10 @@ type cliEventNames = | "hosting_version" | "extension_added_to_manifest" | "extensions_deploy" - | "extensions_emulated"; + | "extensions_emulated" + | "function_deploy" + | "codebase_deploy" + | "function_deploy_group"; type GA4Property = "cli" | "emulator"; interface GA4Info { measurementId: string;