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..84b8c7b5ba8 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", @@ -72,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(() => { @@ -86,7 +88,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 +113,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", @@ -150,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, @@ -206,7 +252,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 +277,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..69f8eae76ae 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, @@ -71,10 +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), - ]); + ]; + 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); @@ -126,6 +130,12 @@ export async function onboardCrashlyticsWeb( ); logLabeledSuccess("crashlytics", "Cloud Logging routing sink configured."); + 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( projectId, 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.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..2fe43434c20 --- /dev/null +++ b/src/gcp/cloudtrace.ts @@ -0,0 +1,63 @@ +import { cloudTraceOrigin } from "../api"; +import { Client } from "../apiv2"; +import { FirebaseError, getError } from "../error"; + +const API_VERSION = "v2"; + +const getTraceClient = (): Client => + 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 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 { + // 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 mock span to trigger BigQuery _Trace database creation + await getTraceClient().post( + `/projects/${projectId}/traces:batchWrite`, + payload, + ); + } catch (err: unknown) { + throw new FirebaseError(`Failed to provision trace storage for project ${projectId}`, { + original: getError(err), + }); + } +}