diff --git a/src/dataconnect/build.ts b/src/dataconnect/build.ts index 8fc8749f4f5..cc2a97baff1 100644 --- a/src/dataconnect/build.ts +++ b/src/dataconnect/build.ts @@ -1,16 +1,17 @@ import { DataConnectBuildArgs, DataConnectEmulator } from "../emulator/dataconnectEmulator"; -import { Options } from "../options"; import { FirebaseError } from "../error"; import { select } from "../prompt"; import * as utils from "../utils"; import { prettify, prettifyTable } from "./graphqlError"; -import { DeploymentMetadata, GraphqlError } from "./types"; import { getProjectDefaultAccount } from "../auth"; +import { DeployOptions } from "../deploy"; +import { DeployStats } from "../deploy/dataconnect/context"; +import { DeploymentMetadata, GraphqlError } from "./types"; export async function build( - options: Options, + options: DeployOptions, configDir: string, - dryRun?: boolean, + deployStats: DeployStats, ): Promise { const account = getProjectDefaultAccount(options.projectRoot); const args: DataConnectBuildArgs = { configDir, account }; @@ -19,7 +20,24 @@ export async function build( } const buildResult = await DataConnectEmulator.build(args); if (buildResult?.errors?.length) { - await handleBuildErrors(buildResult.errors, options.nonInteractive, options.force, dryRun); + buildResult.errors.forEach((e) => { + if (e.extensions?.warningLevel) { + let key = e.extensions.warningLevel.toLowerCase(); + const msgSp = e.message.split(": "); + if (msgSp.length >= 2) { + key += `_${msgSp[0].toLowerCase()}`; + } + deployStats.numBuildWarnings.set(key, (deployStats.numBuildWarnings.get(key) ?? 0) + 1); + } else { + deployStats.numBuildErrors += 1; + } + }); + await handleBuildErrors( + buildResult.errors, + options.nonInteractive, + options.force, + options.dryRun, + ); } return buildResult?.metadata ?? {}; } diff --git a/src/dataconnect/schemaMigration.ts b/src/dataconnect/schemaMigration.ts index b26b92e1676..7de85a86d7f 100644 --- a/src/dataconnect/schemaMigration.ts +++ b/src/dataconnect/schemaMigration.ts @@ -21,6 +21,7 @@ import { DEFAULT_SCHEMA, firebaseowner } from "../gcp/cloudsql/permissions"; import { select, confirm } from "../prompt"; import { logger } from "../logger"; import { Schema } from "./types"; +import { DeployStats } from "../deploy/dataconnect/context"; import { Options } from "../options"; import { FirebaseError } from "../error"; import { logLabeledBullet, logLabeledWarning, logLabeledSuccess } from "../utils"; @@ -150,8 +151,9 @@ export async function migrateSchema(args: { /** true for `dataconnect:sql:migrate`, false for `deploy` */ validateOnly: boolean; schemaValidation?: SchemaValidation; + stats?: DeployStats; }): Promise { - const { options, schema, validateOnly, schemaValidation } = args; + const { options, schema, validateOnly, schemaValidation, stats } = args; // If the schema validation mode is unset, we prompt COMPATIBLE SQL diffs and then STRICT diffs. let validationMode: SchemaValidation = schemaValidation ?? "COMPATIBLE"; @@ -170,6 +172,9 @@ export async function migrateSchema(args: { // Check if Cloud SQL instance is still being created. const existingInstance = await cloudSqlAdminClient.getInstance(projectId, instanceId); if (existingInstance.state === "PENDING_CREATE") { + if (stats) { + stats.numSchemaSkippedDueToPendingCreate++; + } const postgresql = schema.datasources.find((d) => d.postgresql)?.postgresql; if (!postgresql) { throw new FirebaseError( @@ -211,6 +216,14 @@ export async function migrateSchema(args: { } throw err; } + if (stats) { + if (incompatible) { + stats.numSchemaSqlDiffs += incompatible.diffs.length; + } + if (invalidConnectors.length) { + stats.numSchemaInvalidConnectors += invalidConnectors.length; + } + } const migrationMode = await promptForSchemaMigration( options, @@ -259,11 +272,13 @@ export async function migrateSchema(args: { } // Parse and handle failed precondition errors, then retry. const incompatible = errors.getIncompatibleSchemaError(err); - const invalidConnectors = errors.getInvalidConnectors(err); - if (!incompatible && !invalidConnectors.length) { + if (!incompatible) { // If we got a different type of error, throw it throw err; } + if (stats && incompatible) { + stats.numSchemaSqlDiffs += incompatible.diffs.length; + } const migrationMode = await promptForSchemaMigration( options, diff --git a/src/deploy/dataconnect/context.ts b/src/deploy/dataconnect/context.ts new file mode 100644 index 00000000000..74c19d9e9af --- /dev/null +++ b/src/deploy/dataconnect/context.ts @@ -0,0 +1,67 @@ +import { ResourceFilter } from "../../dataconnect/filters"; +import { ServiceInfo } from "../../dataconnect/types"; +import { AnalyticsParams } from "../../track"; + +export interface Context { + dataconnect?: { + serviceInfos: ServiceInfo[]; + filters?: ResourceFilter[]; + deployStats: DeployStats; + }; +} + +export interface DeployStats { + // prepare.ts + missingBilling?: boolean; + numBuildErrors: number; + numBuildWarnings: Map; + + // deploy.ts + numServiceCreated: number; + numServiceDeleted: number; + + // release.ts + numSchemaMigrated: number; + numConnectorUpdatedBeforeSchema: number; + numConnectorUpdatedAfterSchema: number; + + // migrateSchema.ts + numSchemaSkippedDueToPendingCreate: number; + numSchemaSqlDiffs: number; + numSchemaInvalidConnectors: number; +} + +export function initDeployStats(): DeployStats { + return { + numBuildErrors: 0, + numBuildWarnings: new Map(), + numServiceCreated: 0, + numServiceDeleted: 0, + numSchemaMigrated: 0, + numConnectorUpdatedBeforeSchema: 0, + numConnectorUpdatedAfterSchema: 0, + numSchemaSkippedDueToPendingCreate: 0, + numSchemaSqlDiffs: 0, + numSchemaInvalidConnectors: 0, + }; +} + +export function deployStatsParams(stats: DeployStats): AnalyticsParams { + const buildWarnings: AnalyticsParams = {}; + for (const [type, num] of stats.numBuildWarnings.entries()) { + buildWarnings[`num_build_warnings_${type}`] = num; + } + return { + missing_billing: (!!stats.missingBilling).toString(), + num_service_created: stats.numServiceCreated, + num_service_deleted: stats.numServiceDeleted, + num_schema_migrated: stats.numSchemaMigrated, + num_connector_updated_before_schema: stats.numConnectorUpdatedBeforeSchema, + num_connector_updated_after_schema: stats.numConnectorUpdatedAfterSchema, + num_schema_skipped_due_to_pending_create: stats.numSchemaSkippedDueToPendingCreate, + num_schema_sql_diffs: stats.numSchemaSqlDiffs, + num_schema_invalid_connectors: stats.numSchemaInvalidConnectors, + num_build_errors: stats.numBuildErrors, + ...buildWarnings, + }; +} diff --git a/src/deploy/dataconnect/deploy.spec.ts b/src/deploy/dataconnect/deploy.spec.ts index 26def18d9b6..4e81d939cb3 100644 --- a/src/deploy/dataconnect/deploy.spec.ts +++ b/src/deploy/dataconnect/deploy.spec.ts @@ -9,6 +9,7 @@ import * as ensureApiEnabled from "../../ensureApiEnabled"; import * as prompt from "../../prompt"; import * as poller from "../../operation-poller"; import { dataconnectOrigin } from "../../api"; +import { initDeployStats } from "./context"; describe("dataconnect deploy", () => { let sandbox: sinon.SinonSandbox; @@ -47,7 +48,7 @@ describe("dataconnect deploy", () => { dataConnectYaml: { serviceId: "s1" }, }, ]; - const context = { dataconnect: { serviceInfos } }; + const context = { dataconnect: { serviceInfos, deployStats: initDeployStats() } }; const options = {} as any; await deploy.default(context as any, options); @@ -66,7 +67,7 @@ describe("dataconnect deploy", () => { confirmStub.resolves(true); const serviceInfos: any[] = []; - const context = { dataconnect: { serviceInfos } }; + const context = { dataconnect: { serviceInfos, deployStats: initDeployStats() } }; const options = {} as any; await deploy.default(context as any, options); @@ -83,7 +84,7 @@ describe("dataconnect deploy", () => { confirmStub.resolves(false); const serviceInfos: any[] = []; - const context = { dataconnect: { serviceInfos } }; + const context = { dataconnect: { serviceInfos, deployStats: initDeployStats() } }; const options = {} as any; await deploy.default(context as any, options); @@ -116,7 +117,7 @@ describe("dataconnect deploy", () => { dataConnectYaml: { serviceId: "s1" }, }, ]; - const context = { dataconnect: { serviceInfos } }; + const context = { dataconnect: { serviceInfos, deployStats: initDeployStats() } }; const options = {} as any; await deploy.default(context as any, options); @@ -131,7 +132,9 @@ describe("dataconnect deploy", () => { .reply(200, { services: existingServices }); const serviceInfos: any[] = []; - const context = { dataconnect: { serviceInfos, filters: [{ serviceId: "s1" }] } }; + const context = { + dataconnect: { serviceInfos, filters: [{ serviceId: "s1" }], deployStats: initDeployStats() }, + }; const options = {} as any; await deploy.default(context as any, options); diff --git a/src/deploy/dataconnect/deploy.ts b/src/deploy/dataconnect/deploy.ts index 91cafd11ac1..e2620f99515 100644 --- a/src/deploy/dataconnect/deploy.ts +++ b/src/deploy/dataconnect/deploy.ts @@ -9,6 +9,7 @@ import { ResourceFilter } from "../../dataconnect/filters"; import { vertexAIOrigin } from "../../api"; import * as ensureApiEnabled from "../../ensureApiEnabled"; import { confirm } from "../../prompt"; +import { Context } from "./context"; /** * Checks for and creates a Firebase DataConnect service, if needed. @@ -16,19 +17,15 @@ import { confirm } from "../../prompt"; * @param context The deploy context. * @param options The CLI options object. */ -export default async function ( - context: { - dataconnect: { - serviceInfos: ServiceInfo[]; - filters?: ResourceFilter[]; - }; - }, - options: Options, -): Promise { +export default async function (context: Context, options: Options): Promise { + const dataconnect = context.dataconnect; + if (!dataconnect) { + throw new Error("dataconnect.prepare must be run before dataconnect.deploy"); + } const projectId = needProjectId(options); - const serviceInfos = context.dataconnect.serviceInfos as ServiceInfo[]; + const serviceInfos = dataconnect.serviceInfos as ServiceInfo[]; const services = await client.listAllServices(projectId); - const filters = context.dataconnect.filters; + const filters = dataconnect.filters; if ( serviceInfos.some((si) => { @@ -41,12 +38,17 @@ export default async function ( const servicesToCreate = serviceInfos .filter((si) => !services.some((s) => matches(si, s))) .filter((si) => { - return !filters || filters?.some((f) => si.dataConnectYaml.serviceId === f.serviceId); + return ( + !filters || + filters?.some((f: ResourceFilter) => si.dataConnectYaml.serviceId === f.serviceId) + ); }); + dataconnect.deployStats.numServiceCreated = servicesToCreate.length; const servicesToDelete = filters ? [] : services.filter((s) => !serviceInfos.some((si) => matches(si, s))); + dataconnect.deployStats.numServiceDeleted = servicesToDelete.length; await Promise.all( servicesToCreate.map(async (s) => { const { projectId, locationId, serviceId } = splitName(s.serviceName); @@ -80,7 +82,10 @@ export default async function ( await Promise.all( serviceInfos .filter((si) => { - return !filters || filters?.some((f) => si.dataConnectYaml.serviceId === f.serviceId); + return ( + !filters || + filters?.some((f: ResourceFilter) => si.dataConnectYaml.serviceId === f.serviceId) + ); }) .map(async (s) => { const postgresDatasource = s.schema.datasources.find((d) => d.postgresql); diff --git a/src/deploy/dataconnect/prepare.spec.ts b/src/deploy/dataconnect/prepare.spec.ts index f4a78da3abd..f6b3254ffb0 100644 --- a/src/deploy/dataconnect/prepare.spec.ts +++ b/src/deploy/dataconnect/prepare.spec.ts @@ -52,6 +52,7 @@ describe("dataconnect prepare", () => { dataconnect: { serviceInfos: [], filters: undefined, + deployStats: (context as any).dataconnect.deployStats, }, }); }); @@ -77,6 +78,7 @@ describe("dataconnect prepare", () => { dataconnect: { serviceInfos: serviceInfos, filters: undefined, + deployStats: (context as any).dataconnect.deployStats, }, }); }); diff --git a/src/deploy/dataconnect/prepare.ts b/src/deploy/dataconnect/prepare.ts index 63fa87cf3ab..6624ab685c0 100644 --- a/src/deploy/dataconnect/prepare.ts +++ b/src/deploy/dataconnect/prepare.ts @@ -17,23 +17,29 @@ import { FirebaseError } from "../../error"; import { requiresVector } from "../../dataconnect/types"; import { diffSchema } from "../../dataconnect/schemaMigration"; import { upgradeInstructions } from "../../dataconnect/freeTrial"; +import { Context, initDeployStats } from "./context"; /** * Prepares for a Firebase DataConnect deployment by loading schemas and connectors from file. * @param context The deploy context. * @param options The CLI options object. */ -export default async function (context: any, options: DeployOptions): Promise { +export default async function (context: Context, options: DeployOptions): Promise { const projectId = needProjectId(options); + await ensureApis(projectId); + context.dataconnect = { + serviceInfos: await loadAll(projectId, options.config), + filters: getResourceFilters(options), + deployStats: initDeployStats(), + }; + const { serviceInfos, filters, deployStats } = context.dataconnect; if (!(await checkBillingEnabled(projectId))) { + deployStats.missingBilling = true; throw new FirebaseError(upgradeInstructions(projectId)); } - await ensureApis(projectId); await requireTosAcceptance(DATA_CONNECT_TOS_ID)(options); - const filters = getResourceFilters(options); - const serviceInfos = await loadAll(projectId, options.config); for (const si of serviceInfos) { - si.deploymentMetadata = await build(options, si.sourceDirectory, options.dryRun); + si.deploymentMetadata = await build(options, si.sourceDirectory, deployStats); } const unmatchedFilters = filters?.filter((f) => { // filter out all filters that match no service @@ -54,10 +60,6 @@ export default async function (context: any, options: DeployOptions): Promise { let sandbox: sinon.SinonSandbox; @@ -58,7 +59,7 @@ describe("dataconnect release", () => { ], }, ]; - const context = { dataconnect: { serviceInfos } }; + const context = { dataconnect: { serviceInfos, deployStats: initDeployStats() } }; const options = {} as any; await release.default(context as any, options); @@ -96,7 +97,7 @@ describe("dataconnect release", () => { ], }, ]; - const context = { dataconnect: { serviceInfos } }; + const context = { dataconnect: { serviceInfos, deployStats: initDeployStats() } }; const options = {} as any; await release.default(context as any, options); @@ -132,7 +133,7 @@ describe("dataconnect release", () => { ], }, ]; - const context = { dataconnect: { serviceInfos } }; + const context = { dataconnect: { serviceInfos, deployStats: initDeployStats() } }; const options = {} as any; await release.default(context as any, options); @@ -171,7 +172,9 @@ describe("dataconnect release", () => { ], }, ]; - const context = { dataconnect: { serviceInfos, filters: [{ serviceId: "s1" }] } }; + const context = { + dataconnect: { serviceInfos, filters: [{ serviceId: "s1" }], deployStats: initDeployStats() }, + }; const options = {} as any; await release.default(context as any, options); diff --git a/src/deploy/dataconnect/release.ts b/src/deploy/dataconnect/release.ts index 812df3b3f87..044ed039b3f 100644 --- a/src/deploy/dataconnect/release.ts +++ b/src/deploy/dataconnect/release.ts @@ -3,11 +3,11 @@ import { Connector, ServiceInfo } from "../../dataconnect/types"; import { listConnectors, upsertConnector } from "../../dataconnect/client"; import { promptDeleteConnector } from "../../dataconnect/prompts"; import { Options } from "../../options"; -import { ResourceFilter } from "../../dataconnect/filters"; import { migrateSchema } from "../../dataconnect/schemaMigration"; import { needProjectId } from "../../projectUtils"; import { parseServiceName } from "../../dataconnect/names"; import { logger } from "../../logger"; +import { Context } from "./context"; /** * Release deploys schemas and connectors. @@ -15,18 +15,14 @@ import { logger } from "../../logger"; * @param context The deploy context. * @param options The CLI options object. */ -export default async function ( - context: { - dataconnect: { - serviceInfos: ServiceInfo[]; - filters?: ResourceFilter[]; - }; - }, - options: Options, -): Promise { +export default async function (context: Context, options: Options): Promise { + const dataconnect = context.dataconnect; + if (!dataconnect) { + throw new Error("dataconnect.prepare must be run before dataconnect.release"); + } const project = needProjectId(options); - const serviceInfos = context.dataconnect.serviceInfos; - const filters = context.dataconnect.filters; + const serviceInfos = dataconnect.serviceInfos; + const filters = dataconnect.filters; // First, figure out the schemas and connectors to deploy. const wantSchemas = serviceInfos @@ -70,6 +66,7 @@ export default async function ( return c; // will try again after schema deployment. } utils.logLabeledSuccess("dataconnect", `Deployed connector ${c.name}`); + dataconnect.deployStats.numConnectorUpdatedBeforeSchema++; return undefined; }), ); @@ -81,8 +78,10 @@ export default async function ( schema: s.schema, validateOnly: false, schemaValidation: s.validationMode, + stats: dataconnect.deployStats, }); utils.logLabeledSuccess("dataconnect", `Migrated schema ${s.schema.name}`); + dataconnect.deployStats.numSchemaMigrated++; } // Lastly, deploy the remaining connectors that relies on the latest schema. @@ -91,6 +90,7 @@ export default async function ( if (c) { await upsertConnector(c); utils.logLabeledSuccess("dataconnect", `Deployed connector ${c.name}`); + dataconnect.deployStats.numConnectorUpdatedAfterSchema++; } }), ); diff --git a/src/deploy/index.ts b/src/deploy/index.ts index 7dd2d093a2b..e7f63d3d2b3 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -19,13 +19,18 @@ import * as ExtensionsTarget from "./extensions"; import * as DataConnectTarget from "./dataconnect"; import * as AppHostingTarget from "./apphosting"; import { prepareFrameworks } from "../frameworks"; -import { Context } from "./hosting/context"; +import { Context as HostingContext } from "./hosting/context"; import { addPinnedFunctionsToOnlyString, hasPinnedFunctions } from "./hosting/prepare"; import { isRunningInGithubAction } from "../init/features/hosting/github"; import { TARGET_PERMISSIONS } from "../commands/deploy"; import { requirePermissions } from "../requirePermissions"; import { Options } from "../options"; import { HostingConfig } from "../firebaseConfig"; +import { + Context as DataConnectContext, + DeployStats, + deployStatsParams, +} from "./dataconnect/context"; const TARGETS = { hosting: HostingTarget, @@ -90,7 +95,7 @@ export const deploy = async function ( const projectId = needProjectId(options); const payload = {}; // a shared context object for deploy targets to decorate as needed - const context: Context = Object.assign({ projectId }, customContext); + const context: HostingContext & DataConnectContext = Object.assign({ projectId }, customContext); const predeploys: Chain = []; const prepares: Chain = []; const deploys: Chain = []; @@ -148,25 +153,41 @@ export const deploy = async function ( logBullet("deploying " + bold(targetNames.join(", "))); - await chain(predeploys, context, options, payload); - await chain(prepares, context, options, payload); - await chain(deploys, context, options, payload); - await chain(releases, context, options, payload); - await chain(postdeploys, context, options, payload); - - const duration = Date.now() - startTime; - const analyticsParams: AnalyticsParams = { - interactive: options.nonInteractive ? "false" : "true", - }; - - Object.keys(TARGETS).reduce((accum, t) => { - accum[t] = "false"; - return accum; - }, analyticsParams); - for (const t of targetNames) { - analyticsParams[t] = "true"; + let result = "predeploys_error"; + try { + await chain(predeploys, context, options, payload); + result = "prepares_error"; + await chain(prepares, context, options, payload); + result = "deploys_error"; + await chain(deploys, context, options, payload); + result = "releases_error"; + await chain(releases, context, options, payload); + result = "postdeploys_error"; + await chain(postdeploys, context, options, payload); + result = "success"; + } finally { + const baseParams: AnalyticsParams = { + interactive: options.nonInteractive ? "false" : "true", + dry_run: options.dryRun ? "true" : "false", + result: result, + }; + const duration = Date.now() - startTime; + const params = Object.assign({}, baseParams); + Object.keys(TARGETS).reduce((accum, t) => { + accum[t] = "false"; + return accum; + }, params); + for (const t of targetNames) { + params[t] = "true"; + } + void trackGA4("product_deploy", params, duration); + + const stats: DeployStats | undefined = context?.dataconnect?.deployStats; + if (stats) { + const fdcParams = deployStatsParams(stats); + void trackGA4("dataconnect_deploy", { ...fdcParams, ...baseParams }, duration); + } } - await trackGA4("product_deploy", analyticsParams, duration); const successMessage = options.dryRun ? "Dry run complete!" : "Deploy complete!"; logger.info(); diff --git a/src/track.ts b/src/track.ts index 38013f68be9..02b12fff6dd 100644 --- a/src/track.ts +++ b/src/track.ts @@ -24,6 +24,7 @@ type cliEventNames = | "product_init" | "product_init_mcp" | "dataconnect_init" + | "dataconnect_deploy" | "dataconnect_cloud_sql" | "error" | "login"