diff --git a/src/command.ts b/src/command.ts index 1f82d68343a..3dd7391ff52 100644 --- a/src/command.ts +++ b/src/command.ts @@ -224,12 +224,15 @@ export class Command { }); } const duration = Math.floor((process.uptime() - start) * 1000); - const trackSuccess = trackGA4("command_execution", { - command_name: this.name, - result: "success", + const trackSuccess = trackGA4( + "command_execution", + { + command_name: this.name, + result: "success", + interactive: getInheritedOption(options, "nonInteractive") ? "false" : "true", + }, duration, - interactive: getInheritedOption(options, "nonInteractive") ? "false" : "true", - }); + ); if (!isEmulator) { await withTimeout(5000, trackSuccess); } else { diff --git a/src/dataconnect/provisionCloudSql.ts b/src/dataconnect/provisionCloudSql.ts index 0b22170d61f..0b6f1748a64 100755 --- a/src/dataconnect/provisionCloudSql.ts +++ b/src/dataconnect/provisionCloudSql.ts @@ -24,7 +24,7 @@ export async function setupCloudSql(args: { instanceId: string; databaseId: string; requireGoogleMlIntegration: boolean; - source: "init" | "mcp_init" | "deploy"; + source: "mcp_init" | "init" | "init_sdk" | "deploy"; dryRun?: boolean; }): Promise { const { projectId, instanceId, requireGoogleMlIntegration, dryRun } = args; @@ -37,7 +37,7 @@ export async function setupCloudSql(args: { success = true; } finally { if (!dryRun) { - await trackGA4( + void trackGA4( "dataconnect_cloud_sql", { source: args.source, @@ -76,9 +76,10 @@ async function upsertInstance( `Found existing Cloud SQL instance ${clc.bold(instanceId)}.`, ); stats.databaseVersion = existingInstance.databaseVersion; - stats.dataconnectLabel = existingInstance.settings?.userLabels?.["firebase-data-connect"] as - | cloudSqlAdminClient.DataConnectLabel - | undefined; + stats.dataconnectLabel = + (existingInstance.settings?.userLabels?.[ + "firebase-data-connect" + ] as cloudSqlAdminClient.DataConnectLabel) || "absent"; const why = getUpdateReason(existingInstance, requireGoogleMlIntegration); if (why) { diff --git a/src/init/features/dataconnect/index.spec.ts b/src/init/features/dataconnect/index.spec.ts index d51c5cb1559..8f6b1f69944 100644 --- a/src/init/features/dataconnect/index.spec.ts +++ b/src/init/features/dataconnect/index.spec.ts @@ -247,7 +247,8 @@ function mockConfig(data: Record = {}): Config { } function mockRequiredInfo(info: Partial = {}): init.RequiredInfo { return { - analyticsFlow: "test", + source: "init", + flow: "test", appDescription: "", serviceId: "test-service", locationId: "europe-north3", diff --git a/src/init/features/dataconnect/index.ts b/src/init/features/dataconnect/index.ts index 7171ce4b3b1..d6d624df5d8 100644 --- a/src/init/features/dataconnect/index.ts +++ b/src/init/features/dataconnect/index.ts @@ -52,7 +52,8 @@ const SEED_DATA_TEMPLATE = readTemplateSync("init/dataconnect/seed_data.gql"); export interface RequiredInfo { // The GA analytics metric to track how developers go through `init dataconnect`. - analyticsFlow: string; + source: "mcp_init" | "init" | "init_sdk"; + flow: string; appDescription: string; serviceId: string; locationId: string; @@ -99,7 +100,8 @@ const templateServiceInfo: ServiceGQL = { // logic should live here, and _no_ actuation logic should live here. export async function askQuestions(setup: Setup): Promise { const info: RequiredInfo = { - analyticsFlow: "cli", + source: "init", + flow: "", appDescription: "", serviceId: "", locationId: "", @@ -166,20 +168,28 @@ export async function actuate(setup: Setup, config: Config, options: any): Promi info.locationId = info.locationId || FDC_DEFAULT_REGION; info.cloudSqlDatabase = info.cloudSqlDatabase || `fdcdb`; + const startTime = Date.now(); try { await actuateWithInfo(setup, config, info, options); await sdk.actuate(setup, config); } finally { - void trackGA4("dataconnect_init", { - flow: info.analyticsFlow, - project_status: setup.projectId - ? setup.isBillingEnabled - ? info.shouldProvisionCSQL - ? "blaze_provisioned_csql" - : "blaze" - : "spark" - : "missing", - }); + const sdkInfo = setup.featureInfo?.dataconnectSdk; + void trackGA4( + "dataconnect_init", + { + source: info.source, + flow: info.flow.substring(1), // Trim the leading `_` + project_status: setup.projectId + ? setup.isBillingEnabled + ? info.shouldProvisionCSQL + ? "blaze_provisioned_csql" + : "blaze" + : "spark" + : "missing", + ...(sdkInfo ? sdk.initAppCounters(sdkInfo) : {}), + }, + Date.now() - startTime, + ); } if (info.appDescription) { @@ -206,7 +216,7 @@ async function actuateWithInfo( const projectId = setup.projectId; if (!projectId) { // If no project is present, just save the template files. - info.analyticsFlow += "_save_template"; + info.flow += "_save_template"; return await writeFiles(config, info, templateServiceInfo, options); } @@ -220,7 +230,7 @@ async function actuateWithInfo( instanceId: info.cloudSqlInstanceId, databaseId: info.cloudSqlDatabase, requireGoogleMlIntegration: false, - source: info.analyticsFlow.startsWith("mcp") ? "mcp_init" : "init", + source: info.source, }); } @@ -233,11 +243,11 @@ async function actuateWithInfo( } if (info.serviceGql) { // Save the downloaded service from the backend. - info.analyticsFlow += "_save_downloaded"; + info.flow += "_save_downloaded"; return await writeFiles(config, info, info.serviceGql, options); } // Use the static template if it starts from scratch or the existing service has no GQL source. - info.analyticsFlow += "_save_template"; + info.flow += "_save_template"; return await writeFiles(config, info, templateServiceInfo, options); } const serviceAlreadyExists = !(await createService(projectId, info.locationId, info.serviceId)); @@ -259,7 +269,7 @@ async function actuateWithInfo( "dataconnect", `Data Connect Service ${serviceName} already exists. Skip saving them...`, ); - info.analyticsFlow += "_save_gemini_service_already_exists"; + info.flow += "_save_gemini_service_already_exists"; return await writeFiles(config, info, { schemaGql: schemaFiles, connectors: [] }, options); } @@ -301,7 +311,7 @@ async function actuateWithInfo( ], }, ]; - info.analyticsFlow += "_save_gemini"; + info.flow += "_save_gemini"; await writeFiles( config, info, @@ -312,7 +322,7 @@ async function actuateWithInfo( logLabeledError("dataconnect", `Operation Generation failed...`); // GiF generate operation API has stability concerns. // Fallback to save only the generated schema. - info.analyticsFlow += "_save_gemini_operation_error"; + info.flow += "_save_gemini_operation_error"; await writeFiles(config, info, { schemaGql: schemaFiles, connectors: [] }, options); throw err; } @@ -492,11 +502,11 @@ async function promptForExistingServices(setup: Setup, info: RequiredInfo): Prom if (!choice) { const existingServiceIds = existingServices.map((s) => s.name.split("/").pop()!); info.serviceId = newUniqueId(defaultServiceId(), existingServiceIds); - info.analyticsFlow += "_pick_new_service"; + info.flow += "_pick_new_service"; return; } // Choose to use an existing service. - info.analyticsFlow += "_pick_existing_service"; + info.flow += "_pick_existing_service"; const serviceName = parseServiceName(choice.name); info.serviceId = serviceName.serviceId; info.locationId = serviceName.location; @@ -618,11 +628,11 @@ async function promptForCloudSQL(setup: Setup, info: RequiredInfo): Promise c.value === info.cloudSqlInstanceId)!.location; } else { - info.analyticsFlow += "_pick_new_csql"; + info.flow += "_pick_new_csql"; info.cloudSqlInstanceId = await input({ message: `What ID would you like to use for your new CloudSQL instance?`, default: newUniqueId( diff --git a/src/init/features/dataconnect/sdk.ts b/src/init/features/dataconnect/sdk.ts index d5a9eb5b58c..76e02aaf226 100644 --- a/src/init/features/dataconnect/sdk.ts +++ b/src/init/features/dataconnect/sdk.ts @@ -155,28 +155,60 @@ export async function chooseApp(): Promise { } export async function actuate(setup: Setup, config: Config) { - const fdcInfo = setup.featureInfo?.dataconnect; const sdkInfo = setup.featureInfo?.dataconnectSdk; if (!sdkInfo) { throw new Error("Data Connect SDK feature RequiredInfo is not provided"); } + const startTime = Date.now(); try { await actuateWithInfo(setup, config, sdkInfo); } finally { - let flow = "no_app"; - if (sdkInfo.apps.length) { - const platforms = sdkInfo.apps.map((a) => a.platform.toLowerCase()).sort(); - flow = `${platforms.join("_")}_app`; + // If `firebase init dataconnect:sdk` is run alone, emit GA stats. + // Otherwise, `firebase init dataconnect` will emit those stats. + const fdcInfo = setup.featureInfo?.dataconnect; + if (!fdcInfo) { + void trackGA4( + "dataconnect_init", + { + flow: "cli_sdk", + project_status: setup.projectId + ? setup.isBillingEnabled + ? "blaze" + : "spark" + : "missing", + ...initAppCounters(sdkInfo), + }, + Date.now() - startTime, + ); } - if (fdcInfo) { - fdcInfo.analyticsFlow += `_${flow}`; - } else { - void trackGA4("dataconnect_init", { - project_status: setup.projectId ? (setup.isBillingEnabled ? "blaze" : "spark") : "missing", - flow: `cli_sdk_${flow}`, - }); + } +} + +export function initAppCounters(info: SdkRequiredInfo): { [key: string]: number } { + const counts = { + num_web_apps: 0, + num_android_apps: 0, + num_ios_apps: 0, + num_flutter_apps: 0, + }; + + for (const app of info.apps ?? []) { + switch (app.platform) { + case Platform.WEB: + counts.num_web_apps++; + break; + case Platform.ANDROID: + counts.num_android_apps++; + break; + case Platform.IOS: + counts.num_ios_apps++; + break; + case Platform.FLUTTER: + counts.num_flutter_apps++; + break; } } + return counts; } async function actuateWithInfo(setup: Setup, config: Config, info: SdkRequiredInfo) { diff --git a/src/init/index.ts b/src/init/index.ts index f3ca925b9dc..a3067f9946b 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -141,7 +141,7 @@ export async function init(setup: Setup, config: Config, options: any): Promise< } const duration = Math.floor((process.uptime() - start) * 1000); - await trackGA4("product_init", { feature: nextFeature }, duration); + void trackGA4("product_init", { feature: nextFeature }, duration); return init(setup, config, options); } @@ -167,7 +167,7 @@ export async function actuate(setup: Setup, config: Config, options: any): Promi } const duration = Math.floor((process.uptime() - start) * 1000); - await trackGA4("product_init_mcp", { feature: nextFeature }, duration); + void trackGA4("product_init_mcp", { feature: nextFeature }, duration); return actuate(setup, config, options); } diff --git a/src/mcp/tools/core/init.ts b/src/mcp/tools/core/init.ts index 46075a01df7..e884e1611e9 100644 --- a/src/mcp/tools/core/init.ts +++ b/src/mcp/tools/core/init.ts @@ -183,7 +183,8 @@ export const init = tool( } featuresList.push("dataconnect"); featureInfo.dataconnect = { - analyticsFlow: "mcp", + source: "mcp_init", + flow: "", appDescription: features.dataconnect.app_description || "", serviceId: features.dataconnect.service_id || "", locationId: features.dataconnect.location_id || "",