Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
76 changes: 70 additions & 6 deletions src/crashlytics/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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(() => {
Expand All @@ -86,14 +88,32 @@ 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",
"global",
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",
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this test just be to.not.have.been.called ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

esnureStub is called twice with different parameters above so this is to make sure it doesn't get called with these parameters. Otherwise, it would fail as it is called twice.

"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,
Expand Down Expand Up @@ -206,14 +252,32 @@ 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",
"global",
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",
Expand Down
18 changes: 14 additions & 4 deletions src/crashlytics/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions src/gcp/cloudtrace.spec.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
});
63 changes: 63 additions & 0 deletions src/gcp/cloudtrace.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<BatchWriteSpansRequest, void>(
`/projects/${projectId}/traces:batchWrite`,
payload,
);
} catch (err: unknown) {
throw new FirebaseError(`Failed to provision trace storage for project ${projectId}`, {
original: getError(err),
});
}
Comment thread
ssakhamu marked this conversation as resolved.
}
Loading