From 02a2324bd8fa7ee9bccc4d1d76e74b7cf0d4ed41 Mon Sep 17 00:00:00 2001 From: Sai Sakhamuri Date: Thu, 1 Oct 2026 10:53:09 -0400 Subject: [PATCH 1/4] adding cloud trace storage provisioning to crashlytics onboarding --- src/api.ts | 2 + src/crashlytics/onboarding.spec.ts | 43 ++++++++++++++++++++- src/crashlytics/onboarding.ts | 9 ++++- src/gcp/cloudtrace.spec.ts | 62 ++++++++++++++++++++++++++++++ src/gcp/cloudtrace.ts | 62 ++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 src/gcp/cloudtrace.spec.ts create mode 100644 src/gcp/cloudtrace.ts diff --git a/src/api.ts b/src/api.ts index 64174fc4b18..be6b62986f9 100755 --- a/src/api.ts +++ b/src/api.ts @@ -22,6 +22,8 @@ export const cloudloggingOrigin = (): string => utils.envOverride("FIREBASE_CLOUDLOGGING_URL", "https://logging.googleapis.com"); export const cloudMonitoringOrigin = (): string => utils.envOverride("CLOUD_MONITORING_URL", "https://monitoring.googleapis.com"); +export const cloudTraceOrigin = (): string => + utils.envOverride("CLOUD_TRACE_URL", "https://cloudtrace.googleapis.com"); export const containerRegistryDomain = (): string => utils.envOverride("CONTAINER_REGISTRY_DOMAIN", "gcr.io"); diff --git a/src/crashlytics/onboarding.spec.ts b/src/crashlytics/onboarding.spec.ts index 7010c574618..959be65e474 100644 --- a/src/crashlytics/onboarding.spec.ts +++ b/src/crashlytics/onboarding.spec.ts @@ -13,6 +13,7 @@ import * as apps from "../management/apps"; import * as apikeys from "../gcp/apikeys"; import * as prompt from "../prompt"; import * as requireAuth from "../requireAuth"; +import * as cloudtrace from "../gcp/cloudtrace"; import * as utils from "../utils"; import { FirebaseError } from "../error"; import { AlertType } from "./types"; @@ -21,6 +22,7 @@ describe("onboarding", () => { let ensureStub: sinon.SinonStub; let bucketStub: sinon.SinonStub; let sinkStub: sinon.SinonStub; + let provisionTraceStub: sinon.SinonStub; let configStub: sinon.SinonStub; let checkBillingStub: sinon.SinonStub; let getAppConfigStub: sinon.SinonStub; @@ -51,6 +53,7 @@ describe("onboarding", () => { destination: "dest", filter: "filter", }); + provisionTraceStub = sinon.stub(cloudtrace, "provisionTraceStorage").resolves(); configStub = sinon.stub(firebasetelemetry, "createOrUpdateTelemetryConfig").resolves({ name: "projects/test-project/locations/global/configs/1-123-web-456", appId: "1:123:web:456", @@ -86,7 +89,24 @@ describe("onboarding", () => { it("should successfully onboard web app and enable selected alerts", async () => { const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); - expect(ensureStub).to.have.been.calledTwice; + expect(ensureStub).to.have.been.calledWith( + "test-project", + onboarding.CRASHLYTICS_TELEMETRY_SERVICE, + "crashlytics", + false, + ); + expect(ensureStub).to.have.been.calledWith( + "test-project", + "firebasetelemetryadmin.googleapis.com", + "crashlytics", + false, + ); + expect(ensureStub).to.have.been.calledWith( + "test-project", + "cloudtrace.googleapis.com", + "crashlytics", + false, + ); expect(bucketStub).to.have.been.calledWith( "test-project", "firebase-telemetry", @@ -94,6 +114,7 @@ describe("onboarding", () => { true, ); expect(sinkStub).to.have.been.calledOnce; + expect(provisionTraceStub).to.have.been.calledWith("test-project"); expect(configStub).to.have.been.calledWith( "test-project", "1:123:web:456", @@ -206,7 +227,24 @@ describe("onboarding", () => { const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); - expect(ensureStub).to.have.been.calledTwice; + expect(ensureStub).to.have.been.calledWith( + "test-project", + onboarding.CRASHLYTICS_TELEMETRY_SERVICE, + "crashlytics", + false, + ); + expect(ensureStub).to.have.been.calledWith( + "test-project", + "firebasetelemetryadmin.googleapis.com", + "crashlytics", + false, + ); + expect(ensureStub).to.have.been.calledWith( + "test-project", + "cloudtrace.googleapis.com", + "crashlytics", + false, + ); expect(bucketStub).to.have.been.calledWith( "test-project", "firebase-telemetry", @@ -214,6 +252,7 @@ describe("onboarding", () => { true, ); expect(sinkStub).to.have.been.calledOnce; + expect(provisionTraceStub).to.have.been.calledWith("test-project"); expect(configStub).to.have.been.calledWith( "test-project", "1:123:web:456", diff --git a/src/crashlytics/onboarding.ts b/src/crashlytics/onboarding.ts index 63e8e5cd250..7ce95ffa656 100644 --- a/src/crashlytics/onboarding.ts +++ b/src/crashlytics/onboarding.ts @@ -14,6 +14,7 @@ import { createOrUpdateTelemetryConfig, TelemetryConfig } from "./firebaseteleme import { AlertType } from "./types"; import { logLabeledBullet, logLabeledSuccess, logLabeledWarning } from "../utils"; import { updateAppApiKeyRestriction } from "../gcp/apikeys"; +import { provisionTraceStorage } from "../gcp/cloudtrace"; import { AppPlatform, getAppConfig } from "../management/apps"; import { logger } from "../logger"; import { checkbox } from "../prompt"; @@ -52,8 +53,7 @@ async function resolveAuthenticatedUserEmail( /** * Onboards a Firebase Web App to Crashlytics by enabling required APIs, - * setting up Cloud Logging bucket and sink routing, creating a Telemetry Config, - * and optionally configuring Crashlytics email alerts. + * setting up Cloud Logging bucket and sink routing, provisioning Cloud Trace storage, creating a Telemetry Config, and optionally configuring Crashlytics email alerts. */ export async function onboardCrashlyticsWeb( projectId: string, @@ -74,6 +74,7 @@ export async function onboardCrashlyticsWeb( await Promise.all([ ensure(projectId, CRASHLYTICS_TELEMETRY_SERVICE, "crashlytics", false), ensure(projectId, "firebasetelemetryadmin.googleapis.com", "crashlytics", false), + ensure(projectId, "cloudtrace.googleapis.com", "crashlytics", false), ]); logLabeledSuccess("crashlytics", "Telemetry APIs enabled."); @@ -126,6 +127,10 @@ export async function onboardCrashlyticsWeb( ); logLabeledSuccess("crashlytics", "Cloud Logging routing sink configured."); + logLabeledBullet("crashlytics", "Provisioning Cloud Trace storage..."); + await provisionTraceStorage(projectId); + logLabeledSuccess("crashlytics", "Cloud Trace storage provisioned."); + logLabeledBullet("crashlytics", "Configuring Crashlytics telemetry for web app..."); const config = await createOrUpdateTelemetryConfig( projectId, diff --git a/src/gcp/cloudtrace.spec.ts b/src/gcp/cloudtrace.spec.ts new file mode 100644 index 00000000000..3595c18254e --- /dev/null +++ b/src/gcp/cloudtrace.spec.ts @@ -0,0 +1,62 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import nock from "../test/helpers/nock"; + +import * as cloudtrace from "./cloudtrace"; +import { cloudTraceOrigin } from "../api"; +import { FirebaseError } from "../error"; + +describe("cloudtrace", () => { + let clock: sinon.SinonFakeTimers; + + before(() => { + nock.disableNetConnect(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + beforeEach(() => { + clock = sinon.useFakeTimers(new Date("2026-01-01T00:00:00.000Z").getTime()); + }); + + afterEach(() => { + clock.restore(); + nock.cleanAll(); + }); + + describe("provisionTraceStorage", () => { + it("should send a welcome span to batchWrite endpoint", async () => { + const batchWriteReq = nock(cloudTraceOrigin()) + .post("/v2/projects/test-project/traces:batchWrite", { + name: "projects/test-project", + spans: [ + { + name: "projects/test-project/traces/33fc0d8c45bb4e5cebb29f047931270d/spans/f8fde40b437488e5", + spanId: "f8fde40b437488e5", + displayName: { value: "/welcome" }, + startTime: "2026-01-01T00:00:00.000Z", + endTime: "2026-01-01T00:00:01.000Z", + }, + ], + }) + .reply(200, {}); + + await cloudtrace.provisionTraceStorage("test-project"); + + expect(batchWriteReq.isDone()).to.be.true; + }); + + it("should throw a FirebaseError when batchWrite fails", async () => { + nock(cloudTraceOrigin()) + .post("/v2/projects/test-project/traces:batchWrite") + .reply(500, { error: { message: "Internal error" } }); + + await expect(cloudtrace.provisionTraceStorage("test-project")).to.be.rejectedWith( + FirebaseError, + "Failed to provision trace storage for project test-project", + ); + }); + }); +}); diff --git a/src/gcp/cloudtrace.ts b/src/gcp/cloudtrace.ts new file mode 100644 index 00000000000..9ec695a8c04 --- /dev/null +++ b/src/gcp/cloudtrace.ts @@ -0,0 +1,62 @@ +import { cloudTraceOrigin } from "../api"; +import { Client } from "../apiv2"; +import { FirebaseError } from "../error"; + +const API_VERSION = "v2"; + +const traceClient = new Client({ + urlPrefix: cloudTraceOrigin(), + auth: true, + apiVersion: API_VERSION, +}); + +interface Span { + name: string; + spanId: string; + displayName: { value: string }; + startTime: string; + endTime: string; +} + +interface BatchWriteSpansRequest { + name: string; + spans: Span[]; +} + +/** + * Sends a dummy span to Cloud Trace to trigger BigQuery _Trace dataset provisioning. + * Ref: https://cloud.google.com/trace/docs/reference/v2/rest/v2/projects.traces/batchWrite + */ +export async function provisionTraceStorage(projectId: string): Promise { + // Use the same arbitrary IDs that the GCP backend uses + const TRACE_ID = "33fc0d8c45bb4e5cebb29f047931270d"; + const SPAN_ID = "f8fde40b437488e5"; + + const now = new Date(); + const later = new Date(now.getTime() + 1000); + + const payload: BatchWriteSpansRequest = { + name: `projects/${projectId}`, + spans: [ + { + name: `projects/${projectId}/traces/${TRACE_ID}/spans/${SPAN_ID}`, + spanId: SPAN_ID, + displayName: { value: "/welcome" }, + startTime: now.toISOString(), + endTime: later.toISOString(), + }, + ], + }; + + try { + // Send the dummy span to trigger BigQuery _Trace database creation + await traceClient.post( + `/projects/${projectId}/traces:batchWrite`, + payload, + ); + } catch (err: unknown) { + throw new FirebaseError(`Failed to provision trace storage for project ${projectId}`, { + original: err instanceof Error ? err : undefined, + }); + } +} From eb5066abc5acf481367f3d260e124838a65eba8c Mon Sep 17 00:00:00 2001 From: Sai Sakhamuri Date: Thu, 1 Oct 2026 11:20:05 -0400 Subject: [PATCH 2/4] addressing AI comments --- src/gcp/cloudtrace.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/gcp/cloudtrace.ts b/src/gcp/cloudtrace.ts index 9ec695a8c04..37d5cbf1983 100644 --- a/src/gcp/cloudtrace.ts +++ b/src/gcp/cloudtrace.ts @@ -1,14 +1,15 @@ import { cloudTraceOrigin } from "../api"; import { Client } from "../apiv2"; -import { FirebaseError } from "../error"; +import { FirebaseError, getError } from "../error"; const API_VERSION = "v2"; -const traceClient = new Client({ - urlPrefix: cloudTraceOrigin(), - auth: true, - apiVersion: API_VERSION, -}); +const getTraceClient = (): Client => + new Client({ + urlPrefix: cloudTraceOrigin(), + auth: true, + apiVersion: API_VERSION, + }); interface Span { name: string; @@ -50,13 +51,13 @@ export async function provisionTraceStorage(projectId: string): Promise { try { // Send the dummy span to trigger BigQuery _Trace database creation - await traceClient.post( + await getTraceClient().post( `/projects/${projectId}/traces:batchWrite`, payload, ); } catch (err: unknown) { throw new FirebaseError(`Failed to provision trace storage for project ${projectId}`, { - original: err instanceof Error ? err : undefined, + original: getError(err), }); } } From 3ede53665baa361eaf15a875c92dd512c112581c Mon Sep 17 00:00:00 2001 From: Sai Sakhamuri Date: Thu, 1 Oct 2026 15:24:25 -0400 Subject: [PATCH 3/4] adding feature as an experiment --- src/crashlytics/onboarding.spec.ts | 33 ++++++++++++++++++++++++++---- src/crashlytics/onboarding.ts | 17 +++++++++------ src/experiments.ts | 5 +++++ src/gcp/cloudtrace.ts | 4 ++-- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/crashlytics/onboarding.spec.ts b/src/crashlytics/onboarding.spec.ts index 959be65e474..84b8c7b5ba8 100644 --- a/src/crashlytics/onboarding.spec.ts +++ b/src/crashlytics/onboarding.spec.ts @@ -75,10 +75,9 @@ describe("onboarding", () => { { name: "projects/test-project/alertPolicies/222", notificationChannels: ["ch-1"] }, ]); requireAuthStub = sinon.stub(requireAuth, "requireAuth").resolves("user@example.com"); - isEnabledStub = sinon - .stub(experiments, "isEnabled") - .withArgs("crashlyticsWebAlerts") - .returns(true); + isEnabledStub = sinon.stub(experiments, "isEnabled"); + isEnabledStub.withArgs("crashlyticsWebAlerts").returns(true); + isEnabledStub.withArgs("crashlyticsWebTrace").returns(true); }); afterEach(() => { @@ -171,6 +170,32 @@ describe("onboarding", () => { expect(res.alertPolicies).to.be.undefined; }); + it("should skip only the Cloud Trace API enablement and storage provisioning when crashlyticsWebTrace experiment is disabled", async () => { + isEnabledStub.withArgs("crashlyticsWebTrace").returns(false); + + await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(ensureStub).to.have.been.calledWith( + "test-project", + onboarding.CRASHLYTICS_TELEMETRY_SERVICE, + "crashlytics", + false, + ); + expect(ensureStub).to.have.been.calledWith( + "test-project", + "firebasetelemetryadmin.googleapis.com", + "crashlytics", + false, + ); + expect(ensureStub).to.not.have.been.calledWith( + "test-project", + "cloudtrace.googleapis.com", + "crashlytics", + false, + ); + expect(provisionTraceStub).to.not.have.been.called; + }); + it("should skip alerting prompt and setup in non-interactive mode", async () => { const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456", { nonInteractive: true, diff --git a/src/crashlytics/onboarding.ts b/src/crashlytics/onboarding.ts index 7ce95ffa656..69f8eae76ae 100644 --- a/src/crashlytics/onboarding.ts +++ b/src/crashlytics/onboarding.ts @@ -71,11 +71,14 @@ export async function onboardCrashlyticsWeb( } logLabeledBullet("crashlytics", "Enabling required telemetry APIs..."); - await Promise.all([ + const requiredApis = [ ensure(projectId, CRASHLYTICS_TELEMETRY_SERVICE, "crashlytics", false), ensure(projectId, "firebasetelemetryadmin.googleapis.com", "crashlytics", false), - ensure(projectId, "cloudtrace.googleapis.com", "crashlytics", false), - ]); + ]; + if (experiments.isEnabled("crashlyticsWebTrace")) { + requiredApis.push(ensure(projectId, "cloudtrace.googleapis.com", "crashlytics", false)); + } + await Promise.all(requiredApis); logLabeledSuccess("crashlytics", "Telemetry APIs enabled."); const appConfig = await getAppConfig(appId, AppPlatform.WEB); @@ -127,9 +130,11 @@ export async function onboardCrashlyticsWeb( ); logLabeledSuccess("crashlytics", "Cloud Logging routing sink configured."); - logLabeledBullet("crashlytics", "Provisioning Cloud Trace storage..."); - await provisionTraceStorage(projectId); - logLabeledSuccess("crashlytics", "Cloud Trace storage provisioned."); + if (experiments.isEnabled("crashlyticsWebTrace")) { + logLabeledBullet("crashlytics", "Provisioning Cloud Trace storage..."); + await provisionTraceStorage(projectId); + logLabeledSuccess("crashlytics", "Cloud Trace storage provisioned."); + } logLabeledBullet("crashlytics", "Configuring Crashlytics telemetry for web app..."); const config = await createOrUpdateTelemetryConfig( diff --git a/src/experiments.ts b/src/experiments.ts index 2ee3cb262a2..c10bbd602b8 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -243,6 +243,11 @@ export const ALL_EXPERIMENTS = experiments({ default: false, public: false, }, + crashlyticsWebTrace: { + shortDescription: "Enable provisioning Cloud Trace storage during web app onboarding.", + default: false, + public: false, + }, secretEnvParams: { shortDescription: "Enable reading the backing resource binding for a Functions secret param from .env", diff --git a/src/gcp/cloudtrace.ts b/src/gcp/cloudtrace.ts index 37d5cbf1983..2fe43434c20 100644 --- a/src/gcp/cloudtrace.ts +++ b/src/gcp/cloudtrace.ts @@ -25,7 +25,7 @@ interface BatchWriteSpansRequest { } /** - * Sends a dummy span to Cloud Trace to trigger BigQuery _Trace dataset provisioning. + * Sends a mock span to Cloud Trace to trigger BigQuery _Trace dataset provisioning. * Ref: https://cloud.google.com/trace/docs/reference/v2/rest/v2/projects.traces/batchWrite */ export async function provisionTraceStorage(projectId: string): Promise { @@ -50,7 +50,7 @@ export async function provisionTraceStorage(projectId: string): Promise { }; try { - // Send the dummy span to trigger BigQuery _Trace database creation + // Send the mock span to trigger BigQuery _Trace database creation await getTraceClient().post( `/projects/${projectId}/traces:batchWrite`, payload, From c15e5c322643889c3ad575eac71205b64ee4d64a Mon Sep 17 00:00:00 2001 From: Sai Sakhamuri Date: Tue, 6 Oct 2026 11:23:13 -0400 Subject: [PATCH 4/4] addressing experimental ai review bot feedback --- src/crashlytics/onboarding.spec.ts | 13 +++++++++++++ src/crashlytics/onboarding.ts | 8 ++++++-- src/gcp/cloudtrace.spec.ts | 2 +- src/gcp/cloudtrace.ts | 11 +++++++---- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/crashlytics/onboarding.spec.ts b/src/crashlytics/onboarding.spec.ts index 84b8c7b5ba8..63a5e65ae59 100644 --- a/src/crashlytics/onboarding.spec.ts +++ b/src/crashlytics/onboarding.spec.ts @@ -304,6 +304,19 @@ describe("onboarding", () => { expect(res.config.enablementState).to.equal("ENABLED"); }); + it("should successfully onboard web app and log a warning if provisionTraceStorage throws an error", async () => { + const fakeError = new FirebaseError( + "Failed to provision trace storage for project test-project", + ); + provisionTraceStub.rejects(fakeError); + + const res = await onboarding.onboardCrashlyticsWeb("test-project", "1:123:web:456"); + + expect(provisionTraceStub).to.have.been.calledOnce; + expect(logLabeledWarningStub).to.have.been.calledWith("crashlytics", fakeError.message); + expect(res.config.enablementState).to.equal("ENABLED"); + }); + it("should throw in non-interactive mode if billing is not enabled", async () => { checkBillingStub.resolves(false); diff --git a/src/crashlytics/onboarding.ts b/src/crashlytics/onboarding.ts index 69f8eae76ae..aab3ef30d2e 100644 --- a/src/crashlytics/onboarding.ts +++ b/src/crashlytics/onboarding.ts @@ -132,8 +132,12 @@ export async function onboardCrashlyticsWeb( if (experiments.isEnabled("crashlyticsWebTrace")) { logLabeledBullet("crashlytics", "Provisioning Cloud Trace storage..."); - await provisionTraceStorage(projectId); - logLabeledSuccess("crashlytics", "Cloud Trace storage provisioned."); + try { + await provisionTraceStorage(projectId); + logLabeledSuccess("crashlytics", "Cloud Trace storage provisioned."); + } catch (err: unknown) { + logLabeledWarning("crashlytics", getErrMsg(err)); + } } logLabeledBullet("crashlytics", "Configuring Crashlytics telemetry for web app..."); diff --git a/src/gcp/cloudtrace.spec.ts b/src/gcp/cloudtrace.spec.ts index 3595c18254e..b3eb39d8083 100644 --- a/src/gcp/cloudtrace.spec.ts +++ b/src/gcp/cloudtrace.spec.ts @@ -55,7 +55,7 @@ describe("cloudtrace", () => { await expect(cloudtrace.provisionTraceStorage("test-project")).to.be.rejectedWith( FirebaseError, - "Failed to provision trace storage for project test-project", + "Failed to provision trace storage for project test-project: Request to https://cloudtrace.googleapis.com/v2/projects/test-project/traces:batchWrite had HTTP Error: 500, Internal error", ); }); }); diff --git a/src/gcp/cloudtrace.ts b/src/gcp/cloudtrace.ts index 2fe43434c20..8e537fba9b0 100644 --- a/src/gcp/cloudtrace.ts +++ b/src/gcp/cloudtrace.ts @@ -1,6 +1,6 @@ import { cloudTraceOrigin } from "../api"; import { Client } from "../apiv2"; -import { FirebaseError, getError } from "../error"; +import { FirebaseError, getErrMsg, getError } from "../error"; const API_VERSION = "v2"; @@ -56,8 +56,11 @@ export async function provisionTraceStorage(projectId: string): Promise { payload, ); } catch (err: unknown) { - throw new FirebaseError(`Failed to provision trace storage for project ${projectId}`, { - original: getError(err), - }); + throw new FirebaseError( + `Failed to provision trace storage for project ${projectId}: ${getErrMsg(err)}`, + { + original: getError(err), + }, + ); } }