Skip to content

Commit c0fff1b

Browse files
mydeaclaude
andcommitted
feat(server-utils): Migrate FirebaseInstrumentation to orchestrion
Adds an orchestrion diagnostics-channel `Firebase` integration in `@sentry/server-utils`, replacing the `InstrumentationBase` approach when `experimentalUseDiagnosticsChannelInjection()` is enabled (swapping in 1:1 for the OTel `Firebase` integration by name). - Firestore: subscribes to `orchestrion:@firebase/firestore:{add,get,set,delete}-doc` and emits the same `db.query` spans via `bindTracingChannelToSpan`, with a distinct `auto.firebase.orchestrion.firestore` origin. - firebase-functions: subscribes to `orchestrion:firebase-functions:*` and rewraps the handler argument of each `onX(...)` registration in the channel's `start` so the returned cloud function opens a SERVER span + error boundary on invocation, with `auto.firebase.orchestrion.functions` origin. Closes #20919 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7053503 commit c0fff1b

10 files changed

Lines changed: 806 additions & 95 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
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' && E2E_ORCHESTRION=true pnpm firebase emulators:exec --project demo-functions 'pnpm test tests/transactions.test.ts'"
1515
},
1616
"dependencies": {
1717
"@types/node": "^22.13.14",

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 => {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
3+
// Minimal structural types inlined from `firebase/app` and `firebase/firestore`, kept just wide enough
4+
// for the attributes the subscriber reads off a Firestore reference. Inlined (rather than imported) so
5+
// `@sentry/server-utils` needs no firebase dependency.
6+
7+
export interface FirebaseOptions {
8+
[key: string]: any;
9+
apiKey?: string;
10+
projectId?: string;
11+
appId?: string;
12+
messagingSenderId?: string;
13+
storageBucket?: string;
14+
}
15+
16+
export interface FirebaseApp {
17+
name: string;
18+
options: FirebaseOptions;
19+
}
20+
21+
export interface FirestoreSettings {
22+
host?: string;
23+
ssl?: boolean;
24+
}
25+
26+
interface FirestoreLike {
27+
app: FirebaseApp;
28+
settings: FirestoreSettings;
29+
toJSON: () => { app: FirebaseApp; settings: FirestoreSettings };
30+
}
31+
32+
export interface DocumentData {
33+
[field: string]: any;
34+
}
35+
36+
export interface DocumentReference {
37+
id: string;
38+
firestore: FirestoreLike;
39+
type: string;
40+
path: string;
41+
parent: CollectionReference | null;
42+
}
43+
44+
export interface CollectionReference {
45+
id: string;
46+
firestore: FirestoreLike;
47+
type: string;
48+
path: string;
49+
parent: DocumentReference | null;
50+
}
51+
52+
export type FirestoreReference = CollectionReference | DocumentReference;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as net from 'node:net';
2+
import {
3+
DB_COLLECTION_NAME,
4+
DB_NAMESPACE,
5+
DB_OPERATION_NAME,
6+
DB_SYSTEM_NAME,
7+
SERVER_ADDRESS,
8+
SERVER_PORT,
9+
} from '@sentry/conventions/attributes';
10+
import type { Span, SpanAttributes } from '@sentry/core';
11+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan } from '@sentry/core';
12+
import type { FirebaseApp, FirebaseOptions, FirestoreReference, FirestoreSettings } from './firestore-types';
13+
14+
/**
15+
* Opens the inactive `db.query` span for a Firestore operation. `bindTracingChannelToSpan` makes it the
16+
* active span for the traced call and ends it when the call settles. Mirrors the OTel integration's span,
17+
* with a distinct `auto.firebase.orchestrion.firestore` origin.
18+
*/
19+
export function startFirestoreSpan(spanName: string, reference: FirestoreReference): Span {
20+
return startInactiveSpan({
21+
name: `${spanName} ${reference.path}`,
22+
op: 'db.query',
23+
kind: SPAN_KIND.CLIENT,
24+
attributes: {
25+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.firebase.orchestrion.firestore',
26+
[DB_OPERATION_NAME]: spanName,
27+
...buildAttributes(reference),
28+
},
29+
});
30+
}
31+
32+
/**
33+
* Gets the server address and port attributes from the Firestore settings.
34+
* It's best effort to extract the address and port from the settings, especially for IPv6.
35+
* @param settings - The Firestore settings containing host information.
36+
*/
37+
export function getPortAndAddress(settings: FirestoreSettings): {
38+
address?: string;
39+
port?: number;
40+
} {
41+
let address: string | undefined;
42+
let port: string | undefined;
43+
44+
if (typeof settings.host === 'string') {
45+
if (settings.host.startsWith('[')) {
46+
// IPv6 addresses can be enclosed in square brackets, e.g., [2001:db8::1]:8080
47+
if (settings.host.endsWith(']')) {
48+
// IPv6 with square brackets without port
49+
address = settings.host.replace(/^\[|\]$/g, '');
50+
} else if (settings.host.includes(']:')) {
51+
// IPv6 with square brackets with port
52+
const lastColonIndex = settings.host.lastIndexOf(':');
53+
if (lastColonIndex !== -1) {
54+
address = settings.host.slice(1, lastColonIndex).replace(/^\[|\]$/g, '');
55+
port = settings.host.slice(lastColonIndex + 1);
56+
}
57+
}
58+
} else {
59+
// IPv4 or IPv6 without square brackets
60+
// If it's an IPv6 address without square brackets, we assume it does not have a port.
61+
if (net.isIPv6(settings.host)) {
62+
address = settings.host;
63+
}
64+
// If it's an IPv4 address, we can extract the port if it exists.
65+
else {
66+
const lastColonIndex = settings.host.lastIndexOf(':');
67+
if (lastColonIndex !== -1) {
68+
address = settings.host.slice(0, lastColonIndex);
69+
port = settings.host.slice(lastColonIndex + 1);
70+
} else {
71+
address = settings.host;
72+
}
73+
}
74+
}
75+
}
76+
return {
77+
address: address,
78+
port: port ? parseInt(port, 10) : undefined,
79+
};
80+
}
81+
82+
function buildAttributes(reference: FirestoreReference): SpanAttributes {
83+
const firestoreApp: FirebaseApp = reference.firestore.app;
84+
const firestoreOptions: FirebaseOptions = firestoreApp.options;
85+
const json: { settings?: FirestoreSettings } = reference.firestore.toJSON() || {};
86+
const settings: FirestoreSettings = json.settings || {};
87+
88+
const attributes: SpanAttributes = {
89+
[DB_COLLECTION_NAME]: reference.path,
90+
[DB_NAMESPACE]: firestoreApp.name,
91+
[DB_SYSTEM_NAME]: 'firebase.firestore',
92+
'firebase.firestore.type': reference.type,
93+
'firebase.firestore.options.projectId': firestoreOptions.projectId,
94+
'firebase.firestore.options.appId': firestoreOptions.appId,
95+
'firebase.firestore.options.messagingSenderId': firestoreOptions.messagingSenderId,
96+
'firebase.firestore.options.storageBucket': firestoreOptions.storageBucket,
97+
};
98+
99+
const { address, port } = getPortAndAddress(settings);
100+
101+
if (address) {
102+
attributes[SERVER_ADDRESS] = address;
103+
}
104+
if (port) {
105+
attributes[SERVER_PORT] = port;
106+
}
107+
108+
return attributes;
109+
}

0 commit comments

Comments
 (0)