Skip to content

Commit 4d8cdbd

Browse files
nicohrubecclaude
andauthored
feat(server-utils): Migrate FirebaseInstrumentation to orchestrion (#22141)
Rewrites the `Firebase` integration off `InstrumentationBase` to an orchestrion diagnostics-channel listener in `@sentry/server-utils`, following the mysql ([#20900](<#20900>)) and graphql (#21885) migrations. The channel-based integration reuses the OTel `Firebase` name, so opting in via `experimentalUseDiagnosticsChannelInjection()` swaps it in 1:1 for the OTel one (which stays the default). **Firestore** — subscribes to `orchestrion:@firebase/firestore:{add,get,set,delete}-doc` (injected into firestore's lite `addDoc`/`getDocs`/`setDoc`/`deleteDoc`) and emits the same `db.query` client spans via `bindTracingChannelToSpan`, with a distinct `auto.firebase.orchestrion.firestore` origin. **firebase-functions** — the `onX(...)` provider functions *register* a handler and return synchronously, so the span can't be bound to the registration call. Instead the channel `start` subscriber rewraps the handler argument (orchestrion forwards the mutated args array to the real call), so the returned cloud function opens a `SERVER` span + error boundary on invocation, with `auto.firebase.orchestrion.functions` origin. The `node-firebase` e2e firestore suite additionally runs with `E2E_ORCHESTRION=true` to assert the orchestrion origin. Closes #20919 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Nicolas Hrubec <nico.hrubec@sentry.io>
1 parent 4a7cc0c commit 4d8cdbd

14 files changed

Lines changed: 814 additions & 98 deletions

File tree

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import * as Sentry from '@sentry/node';
22

3+
// When `E2E_ORCHESTRION=true`, exercise the diagnostics-channel injection path (the orchestrion-based
4+
// `Firebase` integration) instead of the OTel one. Opting in before `init()` is enough: this file is
5+
// imported before `app.ts` imports `firebase/firestore/lite`, so the channel-injection hooks are
6+
// installed before firestore loads.
7+
const useOrchestrion = process.env.E2E_ORCHESTRION === 'true';
8+
9+
if (useOrchestrion) {
10+
Sentry.experimentalUseDiagnosticsChannelInjection();
11+
}
12+
313
Sentry.init({
414
dsn: 'https://public@dsn.ingest.sentry.io/1337',
515
release: '1.0',
616
tracesSampleRate: 1.0,
7-
integrations: [Sentry.firebaseIntegration()],
17+
integrations: useOrchestrion
18+
? [Sentry.diagnosticsChannelInjectionIntegrations().firebaseIntegration()]
19+
: [Sentry.firebaseIntegration()],
820
defaultIntegrations: false,
921
tunnel: `http://localhost:3031/`, // proxy server
1022
});

dev-packages/e2e-tests/test-applications/node-firebase/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"test": "playwright test",
1212
"clean": "npx rimraf node_modules **/node_modules pnpm-lock.yaml **/dist *-debug.log test-results",
1313
"test:build": "pnpm install && pnpm build",
14-
"test:assert": "pnpm firebase emulators:exec --project demo-functions 'pnpm test'"
14+
"test:assert": "pnpm firebase emulators:exec --project demo-functions 'pnpm test'",
15+
"test:assert:orchestrion": "E2E_ORCHESTRION=true pnpm test:assert"
1516
},
1617
"dependencies": {
1718
"@types/node": "^22.13.14",
@@ -26,5 +27,13 @@
2627
},
2728
"volta": {
2829
"extends": "../../package.json"
30+
},
31+
"sentryTest": {
32+
"variants": [
33+
{
34+
"assert-command": "pnpm test:assert:orchestrion",
35+
"label": "node-firebase (Orchestrion)"
36+
}
37+
]
2938
}
3039
}

dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts

Lines changed: 32 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,47 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForTransaction } from '@sentry-internal/test-utils';
33

4-
const spanAddDoc = expect.objectContaining({
5-
description: 'addDoc cities',
6-
data: expect.objectContaining({
4+
// The same suite runs against both the OTel integration and (with `E2E_ORCHESTRION=true`) the
5+
// orchestrion diagnostics-channel one. The spans are identical apart from the origin — and the
6+
// orchestrion spans are Sentry-native, so they carry no OTel-only `otel.kind` attribute.
7+
const orchestrion = process.env.E2E_ORCHESTRION === 'true';
8+
const origin = orchestrion ? 'auto.firebase.orchestrion.firestore' : 'auto.firebase.otel.firestore';
9+
10+
function firestoreSpan(operation: string): unknown {
11+
const data: Record<string, unknown> = {
712
'db.collection.name': 'cities',
813
'db.namespace': '[DEFAULT]',
9-
'db.operation.name': 'addDoc',
14+
'db.operation.name': operation,
1015
'db.system.name': 'firebase.firestore',
1116
'firebase.firestore.options.projectId': 'sentry-15d85',
1217
'firebase.firestore.type': 'collection',
13-
'otel.kind': 'CLIENT',
1418
'server.address': '127.0.0.1',
1519
'server.port': 8080,
16-
'sentry.origin': 'auto.firebase.otel.firestore',
20+
'sentry.origin': origin,
1721
'sentry.op': 'db.query',
18-
}),
19-
op: 'db.query',
20-
origin: 'auto.firebase.otel.firestore',
21-
parent_span_id: expect.any(String),
22-
trace_id: expect.any(String),
23-
span_id: expect.any(String),
24-
timestamp: expect.any(Number),
25-
start_timestamp: expect.any(Number),
26-
status: 'ok',
27-
});
28-
29-
const spanSetDocs = expect.objectContaining({
30-
description: 'setDoc cities',
31-
data: expect.objectContaining({
32-
'db.collection.name': 'cities',
33-
'db.namespace': '[DEFAULT]',
34-
'db.operation.name': 'setDoc',
35-
'db.system.name': 'firebase.firestore',
36-
'firebase.firestore.options.projectId': 'sentry-15d85',
37-
'firebase.firestore.type': 'collection',
38-
'otel.kind': 'CLIENT',
39-
'server.address': '127.0.0.1',
40-
'server.port': 8080,
41-
'sentry.origin': 'auto.firebase.otel.firestore',
42-
'sentry.op': 'db.query',
43-
}),
44-
op: 'db.query',
45-
origin: 'auto.firebase.otel.firestore',
46-
parent_span_id: expect.any(String),
47-
trace_id: expect.any(String),
48-
span_id: expect.any(String),
49-
timestamp: expect.any(Number),
50-
start_timestamp: expect.any(Number),
51-
status: 'ok',
52-
});
53-
54-
const spanGetDocs = expect.objectContaining({
55-
description: 'getDocs cities',
56-
data: expect.objectContaining({
57-
'db.collection.name': 'cities',
58-
'db.namespace': '[DEFAULT]',
59-
'db.operation.name': 'getDocs',
60-
'db.system.name': 'firebase.firestore',
61-
'firebase.firestore.options.projectId': 'sentry-15d85',
62-
'firebase.firestore.type': 'collection',
63-
'otel.kind': 'CLIENT',
64-
'server.address': '127.0.0.1',
65-
'server.port': 8080,
66-
'sentry.origin': 'auto.firebase.otel.firestore',
67-
'sentry.op': 'db.query',
68-
}),
69-
op: 'db.query',
70-
origin: 'auto.firebase.otel.firestore',
71-
parent_span_id: expect.any(String),
72-
trace_id: expect.any(String),
73-
span_id: expect.any(String),
74-
timestamp: expect.any(Number),
75-
start_timestamp: expect.any(Number),
76-
status: 'ok',
77-
});
22+
};
23+
if (!orchestrion) {
24+
data['otel.kind'] = 'CLIENT';
25+
}
26+
27+
return expect.objectContaining({
28+
description: `${operation} cities`,
29+
data: expect.objectContaining(data),
30+
op: 'db.query',
31+
origin,
32+
parent_span_id: expect.any(String),
33+
trace_id: expect.any(String),
34+
span_id: expect.any(String),
35+
timestamp: expect.any(Number),
36+
start_timestamp: expect.any(Number),
37+
status: 'ok',
38+
});
39+
}
7840

79-
const spanDeleteDoc = expect.objectContaining({
80-
description: 'deleteDoc cities',
81-
data: expect.objectContaining({
82-
'db.collection.name': 'cities',
83-
'db.namespace': '[DEFAULT]',
84-
'db.operation.name': 'deleteDoc',
85-
'db.system.name': 'firebase.firestore',
86-
'firebase.firestore.options.projectId': 'sentry-15d85',
87-
'firebase.firestore.type': 'collection',
88-
'otel.kind': 'CLIENT',
89-
'server.address': '127.0.0.1',
90-
'server.port': 8080,
91-
'sentry.origin': 'auto.firebase.otel.firestore',
92-
'sentry.op': 'db.query',
93-
}),
94-
op: 'db.query',
95-
origin: 'auto.firebase.otel.firestore',
96-
parent_span_id: expect.any(String),
97-
trace_id: expect.any(String),
98-
span_id: expect.any(String),
99-
timestamp: expect.any(Number),
100-
start_timestamp: expect.any(Number),
101-
status: 'ok',
102-
});
41+
const spanAddDoc = firestoreSpan('addDoc');
42+
const spanSetDocs = firestoreSpan('setDoc');
43+
const spanGetDocs = firestoreSpan('getDocs');
44+
const spanDeleteDoc = firestoreSpan('deleteDoc');
10345

10446
test('should add, set, get and delete document', async ({ baseURL, page }) => {
10547
const serverTransactionPromise = waitForTransaction('node-firebase', span => {

packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { debug } from '@sentry/core';
22
import * as path from 'path';
3-
import { getOrchestrionLoaderPath, getSentryInstrumentations } from '@sentry/server-utils/orchestrion/webpack';
3+
import {
4+
getOrchestrionLoaderPath,
5+
getSentryInstrumentations,
6+
serializeInstrumentations,
7+
} from '@sentry/server-utils/orchestrion/webpack';
48
import type { VercelCronsConfig } from '../../common/types';
59
import type { RouteManifest } from '../manifest/types';
610
import type {
@@ -141,8 +145,10 @@ function maybeAddOrchestrionRule(
141145
loaders: [
142146
{
143147
loader: getOrchestrionLoaderPath(),
144-
// `instrumentations` is JSON-serializable
145-
options: { instrumentations: getSentryInstrumentations() as unknown as JSONValue[] },
148+
// Turbopack JSON-serializes loader options, so a RegExp `filePath` must be encoded first.
149+
options: {
150+
instrumentations: serializeInstrumentations(getSentryInstrumentations()) as unknown as JSONValue[],
151+
},
146152
},
147153
],
148154
},

packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,47 @@ describe('componentAnnotation with turbopackReactComponentAnnotation', () => {
13071307
});
13081308
});
13091309

1310+
describe('orchestrion diagnostics-channel injection', () => {
1311+
function getOrchestrionOptions(result: ReturnType<typeof constructTurbopackConfig>): {
1312+
instrumentations: Array<{ module: { name: string; filePath: unknown } }>;
1313+
} {
1314+
const rule = result.rules!['*.{js,mjs,cjs}'] as {
1315+
loaders: Array<{ options: { instrumentations: Array<{ module: { name: string; filePath: unknown } }> } }>;
1316+
};
1317+
return rule.loaders[0]!.options;
1318+
}
1319+
1320+
it('serializes a RegExp filePath so it survives Turbopack JSON loader options', () => {
1321+
const result = constructTurbopackConfig({
1322+
userNextConfig: {},
1323+
userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
1324+
nextJsVersion: '16.0.0',
1325+
});
1326+
1327+
const firestore = getOrchestrionOptions(result).instrumentations.find(i => i.module.name === '@firebase/firestore');
1328+
1329+
expect(firestore).toBeDefined();
1330+
expect(firestore!.module.filePath).toEqual({
1331+
type: 'RegExp',
1332+
source: expect.any(String),
1333+
flags: expect.any(String),
1334+
});
1335+
expect(firestore!.module.filePath).not.toBeInstanceOf(RegExp);
1336+
// A raw RegExp would `JSON.stringify` to `{}`, dropping the match entirely.
1337+
expect(JSON.parse(JSON.stringify(firestore!.module.filePath))).not.toEqual({});
1338+
});
1339+
1340+
it('does not add the orchestrion rule when injection is not opted in', () => {
1341+
const result = constructTurbopackConfig({
1342+
userNextConfig: {},
1343+
userSentryOptions: {},
1344+
nextJsVersion: '16.0.0',
1345+
});
1346+
1347+
expect(result.rules!['*.{js,mjs,cjs}']).toBeUndefined();
1348+
});
1349+
});
1350+
13101351
describe('safelyAddTurbopackRule', () => {
13111352
const mockRule = {
13121353
loaders: [
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Minimal structural types inlined from `firebase/app` and `firebase/firestore`, kept just wide enough
2+
// for the attributes the subscriber reads off a Firestore reference. Inlined (rather than imported) so
3+
// `@sentry/server-utils` needs no firebase dependency.
4+
5+
export interface FirebaseOptions {
6+
[key: string]: unknown;
7+
apiKey?: string;
8+
projectId?: string;
9+
appId?: string;
10+
messagingSenderId?: string;
11+
storageBucket?: string;
12+
}
13+
14+
export interface FirebaseApp {
15+
name: string;
16+
options: FirebaseOptions;
17+
}
18+
19+
export interface FirestoreSettings {
20+
host?: string;
21+
}
22+
23+
interface FirestoreLike {
24+
app: FirebaseApp;
25+
toJSON: () => { settings?: FirestoreSettings };
26+
}
27+
28+
export interface DocumentReference {
29+
id: string;
30+
firestore: FirestoreLike;
31+
type: string;
32+
path: string;
33+
parent: CollectionReference | null;
34+
}
35+
36+
export interface CollectionReference {
37+
id: string;
38+
firestore: FirestoreLike;
39+
type: string;
40+
path: string;
41+
parent: DocumentReference | null;
42+
}
43+
44+
export type FirestoreReference = CollectionReference | DocumentReference;

0 commit comments

Comments
 (0)