diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts index c3b4a642375a..41e16e072901 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts @@ -1,10 +1,22 @@ import * as Sentry from '@sentry/node'; +// When `E2E_ORCHESTRION=true`, exercise the diagnostics-channel injection path (the orchestrion-based +// `Firebase` integration) instead of the OTel one. Opting in before `init()` is enough: this file is +// imported before `app.ts` imports `firebase/firestore/lite`, so the channel-injection hooks are +// installed before firestore loads. +const useOrchestrion = process.env.E2E_ORCHESTRION === 'true'; + +if (useOrchestrion) { + Sentry.experimentalUseDiagnosticsChannelInjection(); +} + Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, - integrations: [Sentry.firebaseIntegration()], + integrations: useOrchestrion + ? [Sentry.diagnosticsChannelInjectionIntegrations().firebaseIntegration()] + : [Sentry.firebaseIntegration()], defaultIntegrations: false, tunnel: `http://localhost:3031/`, // proxy server }); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/package.json index a1d4965e9745..884b6e4461fa 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/package.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -11,7 +11,8 @@ "test": "playwright test", "clean": "npx rimraf node_modules **/node_modules pnpm-lock.yaml **/dist *-debug.log test-results", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm firebase emulators:exec --project demo-functions 'pnpm test'" + "test:assert": "pnpm firebase emulators:exec --project demo-functions 'pnpm test'", + "test:assert:orchestrion": "E2E_ORCHESTRION=true pnpm test:assert" }, "dependencies": { "@types/node": "^22.13.14", @@ -26,5 +27,13 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "assert-command": "pnpm test:assert:orchestrion", + "label": "node-firebase (Orchestrion)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts index 749d818aee66..fa07880c87d1 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts @@ -1,105 +1,47 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -const spanAddDoc = expect.objectContaining({ - description: 'addDoc cities', - data: expect.objectContaining({ +// The same suite runs against both the OTel integration and (with `E2E_ORCHESTRION=true`) the +// orchestrion diagnostics-channel one. The spans are identical apart from the origin — and the +// orchestrion spans are Sentry-native, so they carry no OTel-only `otel.kind` attribute. +const orchestrion = process.env.E2E_ORCHESTRION === 'true'; +const origin = orchestrion ? 'auto.firebase.orchestrion.firestore' : 'auto.firebase.otel.firestore'; + +function firestoreSpan(operation: string): unknown { + const data: Record = { 'db.collection.name': 'cities', 'db.namespace': '[DEFAULT]', - 'db.operation.name': 'addDoc', + 'db.operation.name': operation, 'db.system.name': 'firebase.firestore', 'firebase.firestore.options.projectId': 'sentry-15d85', 'firebase.firestore.type': 'collection', - 'otel.kind': 'CLIENT', 'server.address': '127.0.0.1', 'server.port': 8080, - 'sentry.origin': 'auto.firebase.otel.firestore', + 'sentry.origin': origin, 'sentry.op': 'db.query', - }), - op: 'db.query', - origin: 'auto.firebase.otel.firestore', - parent_span_id: expect.any(String), - trace_id: expect.any(String), - span_id: expect.any(String), - timestamp: expect.any(Number), - start_timestamp: expect.any(Number), - status: 'ok', -}); - -const spanSetDocs = expect.objectContaining({ - description: 'setDoc cities', - data: expect.objectContaining({ - 'db.collection.name': 'cities', - 'db.namespace': '[DEFAULT]', - 'db.operation.name': 'setDoc', - 'db.system.name': 'firebase.firestore', - 'firebase.firestore.options.projectId': 'sentry-15d85', - 'firebase.firestore.type': 'collection', - 'otel.kind': 'CLIENT', - 'server.address': '127.0.0.1', - 'server.port': 8080, - 'sentry.origin': 'auto.firebase.otel.firestore', - 'sentry.op': 'db.query', - }), - op: 'db.query', - origin: 'auto.firebase.otel.firestore', - parent_span_id: expect.any(String), - trace_id: expect.any(String), - span_id: expect.any(String), - timestamp: expect.any(Number), - start_timestamp: expect.any(Number), - status: 'ok', -}); - -const spanGetDocs = expect.objectContaining({ - description: 'getDocs cities', - data: expect.objectContaining({ - 'db.collection.name': 'cities', - 'db.namespace': '[DEFAULT]', - 'db.operation.name': 'getDocs', - 'db.system.name': 'firebase.firestore', - 'firebase.firestore.options.projectId': 'sentry-15d85', - 'firebase.firestore.type': 'collection', - 'otel.kind': 'CLIENT', - 'server.address': '127.0.0.1', - 'server.port': 8080, - 'sentry.origin': 'auto.firebase.otel.firestore', - 'sentry.op': 'db.query', - }), - op: 'db.query', - origin: 'auto.firebase.otel.firestore', - parent_span_id: expect.any(String), - trace_id: expect.any(String), - span_id: expect.any(String), - timestamp: expect.any(Number), - start_timestamp: expect.any(Number), - status: 'ok', -}); + }; + if (!orchestrion) { + data['otel.kind'] = 'CLIENT'; + } + + return expect.objectContaining({ + description: `${operation} cities`, + data: expect.objectContaining(data), + op: 'db.query', + origin, + parent_span_id: expect.any(String), + trace_id: expect.any(String), + span_id: expect.any(String), + timestamp: expect.any(Number), + start_timestamp: expect.any(Number), + status: 'ok', + }); +} -const spanDeleteDoc = expect.objectContaining({ - description: 'deleteDoc cities', - data: expect.objectContaining({ - 'db.collection.name': 'cities', - 'db.namespace': '[DEFAULT]', - 'db.operation.name': 'deleteDoc', - 'db.system.name': 'firebase.firestore', - 'firebase.firestore.options.projectId': 'sentry-15d85', - 'firebase.firestore.type': 'collection', - 'otel.kind': 'CLIENT', - 'server.address': '127.0.0.1', - 'server.port': 8080, - 'sentry.origin': 'auto.firebase.otel.firestore', - 'sentry.op': 'db.query', - }), - op: 'db.query', - origin: 'auto.firebase.otel.firestore', - parent_span_id: expect.any(String), - trace_id: expect.any(String), - span_id: expect.any(String), - timestamp: expect.any(Number), - start_timestamp: expect.any(Number), - status: 'ok', -}); +const spanAddDoc = firestoreSpan('addDoc'); +const spanSetDocs = firestoreSpan('setDoc'); +const spanGetDocs = firestoreSpan('getDocs'); +const spanDeleteDoc = firestoreSpan('deleteDoc'); test('should add, set, get and delete document', async ({ baseURL, page }) => { const serverTransactionPromise = waitForTransaction('node-firebase', span => { diff --git a/packages/server-utils/src/integrations/tracing-channel/firebase/firestore-types.ts b/packages/server-utils/src/integrations/tracing-channel/firebase/firestore-types.ts new file mode 100644 index 000000000000..3253d0c35a6b --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/firebase/firestore-types.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// Minimal structural types inlined from `firebase/app` and `firebase/firestore`, kept just wide enough +// for the attributes the subscriber reads off a Firestore reference. Inlined (rather than imported) so +// `@sentry/server-utils` needs no firebase dependency. + +export interface FirebaseOptions { + [key: string]: any; + apiKey?: string; + projectId?: string; + appId?: string; + messagingSenderId?: string; + storageBucket?: string; +} + +export interface FirebaseApp { + name: string; + options: FirebaseOptions; +} + +export interface FirestoreSettings { + host?: string; + ssl?: boolean; +} + +interface FirestoreLike { + app: FirebaseApp; + settings: FirestoreSettings; + toJSON: () => { app: FirebaseApp; settings: FirestoreSettings }; +} + +export interface DocumentData { + [field: string]: any; +} + +export interface DocumentReference { + id: string; + firestore: FirestoreLike; + type: string; + path: string; + parent: CollectionReference | null; +} + +export interface CollectionReference { + id: string; + firestore: FirestoreLike; + type: string; + path: string; + parent: DocumentReference | null; +} + +export type FirestoreReference = CollectionReference | DocumentReference; diff --git a/packages/server-utils/src/integrations/tracing-channel/firebase/firestore.ts b/packages/server-utils/src/integrations/tracing-channel/firebase/firestore.ts new file mode 100644 index 000000000000..521e6e5bbc16 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/firebase/firestore.ts @@ -0,0 +1,109 @@ +import * as net from 'node:net'; +import { + DB_COLLECTION_NAME, + DB_NAMESPACE, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + SERVER_ADDRESS, + SERVER_PORT, +} from '@sentry/conventions/attributes'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan } from '@sentry/core'; +import type { FirebaseApp, FirebaseOptions, FirestoreReference, FirestoreSettings } from './firestore-types'; + +/** + * Opens the inactive `db.query` span for a Firestore operation. `bindTracingChannelToSpan` makes it the + * active span for the traced call and ends it when the call settles. Mirrors the OTel integration's span, + * with a distinct `auto.firebase.orchestrion.firestore` origin. + */ +export function startFirestoreSpan(spanName: string, reference: FirestoreReference): Span { + return startInactiveSpan({ + name: `${spanName} ${reference.path}`, + op: 'db.query', + kind: SPAN_KIND.CLIENT, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.firebase.orchestrion.firestore', + [DB_OPERATION_NAME]: spanName, + ...buildAttributes(reference), + }, + }); +} + +/** + * Gets the server address and port attributes from the Firestore settings. + * It's best effort to extract the address and port from the settings, especially for IPv6. + * @param settings - The Firestore settings containing host information. + */ +export function getPortAndAddress(settings: FirestoreSettings): { + address?: string; + port?: number; +} { + let address: string | undefined; + let port: string | undefined; + + if (typeof settings.host === 'string') { + if (settings.host.startsWith('[')) { + // IPv6 addresses can be enclosed in square brackets, e.g., [2001:db8::1]:8080 + if (settings.host.endsWith(']')) { + // IPv6 with square brackets without port + address = settings.host.replace(/^\[|\]$/g, ''); + } else if (settings.host.includes(']:')) { + // IPv6 with square brackets with port + const lastColonIndex = settings.host.lastIndexOf(':'); + if (lastColonIndex !== -1) { + address = settings.host.slice(1, lastColonIndex).replace(/^\[|\]$/g, ''); + port = settings.host.slice(lastColonIndex + 1); + } + } + } else { + // IPv4 or IPv6 without square brackets + // If it's an IPv6 address without square brackets, we assume it does not have a port. + if (net.isIPv6(settings.host)) { + address = settings.host; + } + // If it's an IPv4 address, we can extract the port if it exists. + else { + const lastColonIndex = settings.host.lastIndexOf(':'); + if (lastColonIndex !== -1) { + address = settings.host.slice(0, lastColonIndex); + port = settings.host.slice(lastColonIndex + 1); + } else { + address = settings.host; + } + } + } + } + return { + address: address, + port: port ? parseInt(port, 10) : undefined, + }; +} + +function buildAttributes(reference: FirestoreReference): SpanAttributes { + const firestoreApp: FirebaseApp = reference.firestore.app; + const firestoreOptions: FirebaseOptions = firestoreApp.options; + const json: { settings?: FirestoreSettings } = reference.firestore.toJSON() || {}; + const settings: FirestoreSettings = json.settings || {}; + + const attributes: SpanAttributes = { + [DB_COLLECTION_NAME]: reference.path, + [DB_NAMESPACE]: firestoreApp.name, + [DB_SYSTEM_NAME]: 'firebase.firestore', + 'firebase.firestore.type': reference.type, + 'firebase.firestore.options.projectId': firestoreOptions.projectId, + 'firebase.firestore.options.appId': firestoreOptions.appId, + 'firebase.firestore.options.messagingSenderId': firestoreOptions.messagingSenderId, + 'firebase.firestore.options.storageBucket': firestoreOptions.storageBucket, + }; + + const { address, port } = getPortAndAddress(settings); + + if (address) { + attributes[SERVER_ADDRESS] = address; + } + if (port) { + attributes[SERVER_PORT] = port; + } + + return attributes; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/firebase/functions.ts b/packages/server-utils/src/integrations/tracing-channel/firebase/functions.ts new file mode 100644 index 000000000000..5917513dbb84 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/firebase/functions.ts @@ -0,0 +1,101 @@ +import { FAAS_NAME, FAAS_TRIGGER } from '@sentry/conventions/attributes'; +import type { SpanAttributes } from '@sentry/core'; +import { + captureException, + flush, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + SPAN_STATUS_ERROR, + startSpanManual, +} from '@sentry/core'; + +const FUNCTIONS_ORIGIN = 'auto.firebase.orchestrion.functions'; + +// Set on a wrapped handler so re-entrant `start` events don't double-wrap it. +const WRAPPED = '__sentryFirebaseWrapped'; + +type Handler = (this: unknown, ...args: unknown[]) => unknown; + +interface FunctionsChannelContext { + // The live args of the `onX(...)` registration call. firebase-functions accepts either + // `onX(handler)` or `onX(documentOrOptions, handler)`, so the handler is `arguments[0]` when it's a + // function, otherwise `arguments[1]`. Mutating the entry here swaps in the wrapped handler. + arguments: unknown[]; + self?: unknown; +} + +/** + * Rewrap the handler argument of a firebase-functions `onX(...)` registration so the returned cloud + * function opens a `SERVER` span (and error boundary) each time it's invoked. Runs as the tracing + * channel's `start` subscriber, before orchestrion forwards the (mutated) arguments to the real call. + * + * The registration call itself is trivial and synchronous, so — unlike the firestore path — this does + * not bind a span to the channel; it only uses the channel as an injection point. + */ +export function wrapFunctionsRegistration(data: FunctionsChannelContext, triggerType: string): void { + const args = data.arguments; + if (!Array.isArray(args) || args.length === 0) { + return; + } + + const handlerIndex = typeof args[0] === 'function' ? 0 : 1; + const handler = args[handlerIndex]; + + if (typeof handler !== 'function' || (handler as unknown as Record)[WRAPPED]) { + return; + } + + args[handlerIndex] = wrapHandler(handler as Handler, triggerType); +} + +function wrapHandler(handler: Handler, triggerType: string): Handler { + const wrapped = async function (this: unknown, ...handlerArgs: unknown[]): Promise { + const functionName = process.env.FUNCTION_TARGET || process.env.K_SERVICE || 'unknown'; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: FUNCTIONS_ORIGIN, + [FAAS_NAME]: functionName, + [FAAS_TRIGGER]: triggerType, + 'faas.provider': 'firebase', + }; + + if (process.env.GCLOUD_PROJECT) { + attributes['cloud.project_id'] = process.env.GCLOUD_PROJECT; + } + + if (process.env.EVENTARC_CLOUD_EVENT_SOURCE) { + attributes['cloud.event_source'] = process.env.EVENTARC_CLOUD_EVENT_SOURCE; + } + + // `startSpanManual` keeps the span active while still allowing us to end it before flushing on error. + return startSpanManual( + { + name: `firebase.function.${triggerType}`, + op: 'function.firebase', + kind: SPAN_KIND.SERVER, + attributes, + }, + async span => { + try { + const result = await handler.apply(this, handlerArgs); + span.end(); + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR }); + captureException(error, { + mechanism: { + type: FUNCTIONS_ORIGIN, + handled: false, + }, + }); + span.end(); + await flush(2000); + throw error; + } + }, + ); + }; + + (wrapped as unknown as Record)[WRAPPED] = true; + return wrapped; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/firebase/index.ts b/packages/server-utils/src/integrations/tracing-channel/firebase/index.ts new file mode 100644 index 000000000000..9d8f9f9cfb4f --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/firebase/index.ts @@ -0,0 +1,35 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { instrumentFirebase } from './instrumentation'; + +const INTEGRATION_NAME = 'Firebase' as const; + +const _firebaseChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + instrumentFirebase(); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven firebase integration. + * + * Subscribes to the `orchestrion:@firebase/firestore:*` and `orchestrion:firebase-functions:*` + * diagnostics_channels the orchestrion code transform injects into firestore's `addDoc`/`getDocs`/ + * `setDoc`/`deleteDoc` and firebase-functions' `onX` registration functions, emitting spans identical + * to the OTel integration — with a distinct `auto.firebase.orchestrion.*` origin. Requires the + * orchestrion runtime hook or bundler plugin — wire it up via `experimentalUseDiagnosticsChannelInjection()`. + * + * @experimental + */ +export const firebaseChannelIntegration = defineIntegration(_firebaseChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/firebase/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/firebase/instrumentation.ts new file mode 100644 index 000000000000..2d59a03ae8e2 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/firebase/instrumentation.ts @@ -0,0 +1,86 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../debug-build'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import type { FirestoreReference } from './firestore-types'; +import { startFirestoreSpan } from './firestore'; +import { wrapFunctionsRegistration } from './functions'; + +// The context orchestrion's transform attaches to each firestore channel: `arguments` is the live args +// of the wrapped `addDoc`/`getDocs`/`setDoc`/`deleteDoc` call, `arguments[0]` the reference. +interface FirestoreChannelContext { + arguments: unknown[]; + self?: unknown; + result?: unknown; + error?: unknown; +} + +// The firestore operations, keyed by channel. `useParent` mirrors the OTel integration: `setDoc`/ +// `deleteDoc` take a *document* reference but the span is named after its parent *collection*. +const FIRESTORE_OPERATIONS: Array<{ channel: string; spanName: string; useParent: boolean }> = [ + { channel: CHANNELS.FIREBASE_FIRESTORE_ADD_DOC, spanName: 'addDoc', useParent: false }, + { channel: CHANNELS.FIREBASE_FIRESTORE_GET_DOCS, spanName: 'getDocs', useParent: false }, + { channel: CHANNELS.FIREBASE_FIRESTORE_SET_DOC, spanName: 'setDoc', useParent: true }, + { channel: CHANNELS.FIREBASE_FIRESTORE_DELETE_DOC, spanName: 'deleteDoc', useParent: true }, +]; + +// The firebase-functions triggers, keyed by channel. The value is the faas trigger type used for the +// span name (`firebase.function.`) and `faas.trigger` attribute. +const FUNCTIONS_TRIGGERS: Array<{ channel: string; triggerType: string }> = [ + { channel: CHANNELS.FIREBASE_FUNCTIONS_HTTP_REQUEST, triggerType: 'http.request' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_HTTP_CALL, triggerType: 'http.call' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_FIRESTORE_CREATED, triggerType: 'firestore.document.created' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_FIRESTORE_UPDATED, triggerType: 'firestore.document.updated' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_FIRESTORE_DELETED, triggerType: 'firestore.document.deleted' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_FIRESTORE_WRITTEN, triggerType: 'firestore.document.written' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_SCHEDULER, triggerType: 'scheduler.scheduled' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_STORAGE_FINALIZED, triggerType: 'storage.object.finalized' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_STORAGE_ARCHIVED, triggerType: 'storage.object.archived' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_STORAGE_DELETED, triggerType: 'storage.object.deleted' }, + { channel: CHANNELS.FIREBASE_FUNCTIONS_STORAGE_METADATA_UPDATED, triggerType: 'storage.object.metadataUpdated' }, +]; + +const NOOP = (): void => {}; + +/** + * Runs a span-building callback so a throw inside it can never break the user's firebase call: these run + * inside the `tracingChannel(...)` machinery wrapping the real function, where an unguarded throw would + * propagate into the traced call. + */ +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:firebase] error handling channel event', error); + return undefined; + } +} + +export function instrumentFirebase() { + for (const { channel, spanName, useParent } of FIRESTORE_OPERATIONS) { + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(channel), data => + safe(() => { + const reference = data.arguments[0] as FirestoreReference | undefined; + if (!reference) { + return undefined; + } + const spanReference = useParent ? reference.parent || reference : reference; + return startFirestoreSpan(spanName, spanReference); + }), + ); + } + + for (const { channel, triggerType } of FUNCTIONS_TRIGGERS) { + // Functions are wrapped, not span-bound: the handler runs long after this synchronous + // registration call, so we only rewrap the handler argument here (in `start`) and open the + // span inside that wrapper. The other lifecycle events are irrelevant, so no-op them. + diagnosticsChannel.tracingChannel(channel).subscribe({ + start: data => void safe(() => wrapFunctionsRegistration(data as { arguments: unknown[] }, triggerType)), + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); + } +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index a714fcbce94b..96eab39611ac 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -11,6 +11,7 @@ import { hapiChannels } from './config/hapi'; import { redisChannels } from './config/redis'; import { expressChannels } from './config/express'; import { graphqlChannels } from './config/graphql'; +import { firebaseChannels } from './config/firebase'; /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. @@ -39,6 +40,7 @@ export const CHANNELS = { ...redisChannels, ...expressChannels, ...graphqlChannels, + ...firebaseChannels, } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config/firebase.ts b/packages/server-utils/src/orchestrion/config/firebase.ts new file mode 100644 index 000000000000..f4af1fdc7d04 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/firebase.ts @@ -0,0 +1,91 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +// firebase 9+ ships firestore as `@firebase/firestore` (matches the OTel integration's range). Only the +// `lite` SDK exposes the free `addDoc`/`getDocs`/`setDoc`/`deleteDoc` functions we trace, and only the +// two `node` entry points (CJS `require`, ESM `import`) are reachable from `@sentry/node`; the +// browser/react-native builds are irrelevant here. Each is a top-level `function ` declaration, so +// `functionName` matches. They return promises, so `Auto` settles the span on `asyncEnd`. +const FIRESTORE_VERSION_RANGE = '>=3.0.0 <5'; +const FIRESTORE_FILES = ['dist/lite/index.node.cjs.js', 'dist/lite/index.node.mjs']; +const FIRESTORE_OPERATIONS = [ + { functionName: 'addDoc', channelName: 'add-doc' }, + { functionName: 'getDocs', channelName: 'get-docs' }, + { functionName: 'setDoc', channelName: 'set-doc' }, + { functionName: 'deleteDoc', channelName: 'delete-doc' }, +] as const; + +// firebase-functions v2 (CJS-only). The `onX` provider functions *register* a handler and return a +// synchronous cloud function, so `Sync` is required — the span itself is opened later, when the handler +// runs, by rewrapping the handler argument in the channel's `start` (see `./firebase/functions`). One +// channel per faas trigger so the subscriber knows the trigger type without inspecting arguments. +const FUNCTIONS_VERSION_RANGE = '>=6.0.0 <7'; +const FUNCTIONS_TRIGGERS = [ + { file: 'lib/v2/providers/https.js', functionName: 'onRequest', channelName: 'http-request' }, + { file: 'lib/v2/providers/https.js', functionName: 'onCall', channelName: 'http-call' }, + { file: 'lib/v2/providers/firestore.js', functionName: 'onDocumentCreated', channelName: 'firestore-created' }, + { + file: 'lib/v2/providers/firestore.js', + functionName: 'onDocumentCreatedWithAuthContext', + channelName: 'firestore-created', + }, + { file: 'lib/v2/providers/firestore.js', functionName: 'onDocumentUpdated', channelName: 'firestore-updated' }, + { + file: 'lib/v2/providers/firestore.js', + functionName: 'onDocumentUpdatedWithAuthContext', + channelName: 'firestore-updated', + }, + { file: 'lib/v2/providers/firestore.js', functionName: 'onDocumentDeleted', channelName: 'firestore-deleted' }, + { + file: 'lib/v2/providers/firestore.js', + functionName: 'onDocumentDeletedWithAuthContext', + channelName: 'firestore-deleted', + }, + { file: 'lib/v2/providers/firestore.js', functionName: 'onDocumentWritten', channelName: 'firestore-written' }, + { + file: 'lib/v2/providers/firestore.js', + functionName: 'onDocumentWrittenWithAuthContext', + channelName: 'firestore-written', + }, + { file: 'lib/v2/providers/scheduler.js', functionName: 'onSchedule', channelName: 'scheduler' }, + { file: 'lib/v2/providers/storage.js', functionName: 'onObjectFinalized', channelName: 'storage-finalized' }, + { file: 'lib/v2/providers/storage.js', functionName: 'onObjectArchived', channelName: 'storage-archived' }, + { file: 'lib/v2/providers/storage.js', functionName: 'onObjectDeleted', channelName: 'storage-deleted' }, + { + file: 'lib/v2/providers/storage.js', + functionName: 'onObjectMetadataUpdated', + channelName: 'storage-metadata-updated', + }, +] as const; + +export const firebaseConfig = [ + ...FIRESTORE_FILES.flatMap(filePath => + FIRESTORE_OPERATIONS.map(({ functionName, channelName }) => ({ + channelName, + module: { name: '@firebase/firestore', versionRange: FIRESTORE_VERSION_RANGE, filePath }, + functionQuery: { functionName, kind: 'Auto' as const }, + })), + ), + ...FUNCTIONS_TRIGGERS.map(({ file, functionName, channelName }) => ({ + channelName, + module: { name: 'firebase-functions', versionRange: FUNCTIONS_VERSION_RANGE, filePath: file }, + functionQuery: { functionName, kind: 'Sync' as const }, + })), +] satisfies InstrumentationConfig[]; + +export const firebaseChannels = { + FIREBASE_FIRESTORE_ADD_DOC: 'orchestrion:@firebase/firestore:add-doc', + FIREBASE_FIRESTORE_GET_DOCS: 'orchestrion:@firebase/firestore:get-docs', + FIREBASE_FIRESTORE_SET_DOC: 'orchestrion:@firebase/firestore:set-doc', + FIREBASE_FIRESTORE_DELETE_DOC: 'orchestrion:@firebase/firestore:delete-doc', + FIREBASE_FUNCTIONS_HTTP_REQUEST: 'orchestrion:firebase-functions:http-request', + FIREBASE_FUNCTIONS_HTTP_CALL: 'orchestrion:firebase-functions:http-call', + FIREBASE_FUNCTIONS_FIRESTORE_CREATED: 'orchestrion:firebase-functions:firestore-created', + FIREBASE_FUNCTIONS_FIRESTORE_UPDATED: 'orchestrion:firebase-functions:firestore-updated', + FIREBASE_FUNCTIONS_FIRESTORE_DELETED: 'orchestrion:firebase-functions:firestore-deleted', + FIREBASE_FUNCTIONS_FIRESTORE_WRITTEN: 'orchestrion:firebase-functions:firestore-written', + FIREBASE_FUNCTIONS_SCHEDULER: 'orchestrion:firebase-functions:scheduler', + FIREBASE_FUNCTIONS_STORAGE_FINALIZED: 'orchestrion:firebase-functions:storage-finalized', + FIREBASE_FUNCTIONS_STORAGE_ARCHIVED: 'orchestrion:firebase-functions:storage-archived', + FIREBASE_FUNCTIONS_STORAGE_DELETED: 'orchestrion:firebase-functions:storage-deleted', + FIREBASE_FUNCTIONS_STORAGE_METADATA_UPDATED: 'orchestrion:firebase-functions:storage-metadata-updated', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index bf8fb24b2f21..357b4d89db17 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -12,6 +12,7 @@ import { hapiConfig } from './hapi'; import { redisConfig } from './redis'; import { expressConfig } from './express'; import { graphqlConfig } from './graphql'; +import { firebaseConfig } from './firebase'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, @@ -27,6 +28,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...redisConfig, ...expressConfig, ...graphqlConfig, + ...firebaseConfig, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 09e5ce88f295..375bfdc58ddd 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -13,6 +13,7 @@ import { postgresChannelIntegration } from '../integrations/tracing-channel/post import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js'; import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai'; import { expressChannelIntegration } from '../integrations/tracing-channel/express'; +import { firebaseChannelIntegration } from '../integrations/tracing-channel/firebase'; export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; export { @@ -28,6 +29,7 @@ export { postgresJsChannelIntegration, vercelAiChannelIntegration, expressChannelIntegration, + firebaseChannelIntegration, }; export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../integrations/tracing-channel/ioredis'; export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; @@ -63,4 +65,5 @@ export const channelIntegrations = { hapiIntegration: hapiChannelIntegration, expressIntegration: expressChannelIntegration, graphqlIntegration: graphqlDiagnosticsChannelIntegration, + firebaseIntegration: firebaseChannelIntegration, } as const; diff --git a/packages/server-utils/test/orchestrion/firebase.test.ts b/packages/server-utils/test/orchestrion/firebase.test.ts new file mode 100644 index 000000000000..e3dcf67751b9 --- /dev/null +++ b/packages/server-utils/test/orchestrion/firebase.test.ts @@ -0,0 +1,292 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, +} from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { firebaseChannelIntegration } from '../../src/orchestrion'; +import { CHANNELS } from '../../src/orchestrion/channels'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +// `bindTracingChannelToSpan` only binds (and `setupOnce` only subscribes via +// `waitForTracingChannelBinding`) when an async-context strategy exposes a +// `getTracingChannelBinding`. Install a minimal one so the channel +// subscriptions actually register in this unit-test context (no SDK `init`). +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return asyncStorage.getStore() || { scope: getDefaultCurrentScope(), isolationScope: getDefaultIsolationScope() }; + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +function makeSpan(): Span { + return { end: vi.fn(), setStatus: vi.fn(), setAttributes: vi.fn() } as unknown as Span; +} + +// A minimal Firestore reference shaped like what `addDoc`/`getDocs`/... receive as `arguments[0]`. +function makeReference( + path: string, + type: string, + parent: unknown = null, + host = 'localhost:8080', +): Record { + const firestore = { + app: { + name: '[DEFAULT]', + options: { + projectId: 'sentry-15d85', + appId: 'app-id', + messagingSenderId: 'sender-id', + storageBucket: 'bucket', + }, + }, + settings: { host }, + toJSON: () => ({ settings: { host } }), + }; + return { id: 'ref-id', path, type, parent, firestore }; +} + +interface ChannelContext { + arguments: unknown[]; + self?: unknown; +} + +describe('firebaseChannelIntegration', () => { + beforeAll(() => { + installTestAsyncContextStrategy(); + firebaseChannelIntegration().setupOnce?.(); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + describe('firestore', () => { + let startInactiveSpanSpy: MockInstance; + let span: Span; + + beforeEach(() => { + span = makeSpan(); + startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('addDoc: builds a `db.query` span from the collection reference with the orchestrion origin', async () => { + const ctx: ChannelContext = { arguments: [makeReference('cities', 'collection')] }; + + await tracingChannel(CHANNELS.FIREBASE_FIRESTORE_ADD_DOC).tracePromise(async () => ({}), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'addDoc cities', + op: 'db.query', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.firebase.orchestrion.firestore', + 'db.operation.name': 'addDoc', + 'db.collection.name': 'cities', + 'db.namespace': '[DEFAULT]', + 'db.system.name': 'firebase.firestore', + 'firebase.firestore.type': 'collection', + 'firebase.firestore.options.projectId': 'sentry-15d85', + 'server.address': 'localhost', + 'server.port': 8080, + }), + }), + ); + // Ended on `asyncEnd` (the full promise round-trip). + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('getDocs: names the span after the queried collection reference', async () => { + const ctx: ChannelContext = { arguments: [makeReference('cities', 'collection')] }; + + await tracingChannel(CHANNELS.FIREBASE_FIRESTORE_GET_DOCS).tracePromise(async () => ({ docs: [] }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'getDocs cities', op: 'db.query' }), + ); + }); + + it('setDoc: names the span after the parent collection of the document reference', async () => { + const parent = makeReference('cities', 'collection'); + const docRef = makeReference('cities/SF', 'document', parent); + const ctx: ChannelContext = { arguments: [docRef, { name: 'SF' }] }; + + await tracingChannel(CHANNELS.FIREBASE_FIRESTORE_SET_DOC).tracePromise(async () => undefined, ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'setDoc cities', + attributes: expect.objectContaining({ 'db.operation.name': 'setDoc', 'db.collection.name': 'cities' }), + }), + ); + }); + + it('deleteDoc: sets error status and ends the span when the operation rejects', async () => { + const parent = makeReference('cities', 'collection'); + const docRef = makeReference('cities/SF', 'document', parent); + const ctx: ChannelContext = { arguments: [docRef] }; + + await expect( + tracingChannel(CHANNELS.FIREBASE_FIRESTORE_DELETE_DOC).tracePromise(async () => { + throw new Error('boom'); + }, ctx), + ).rejects.toThrow('boom'); + + expect(span.setStatus).toHaveBeenCalledWith({ code: expect.anything(), message: 'boom' }); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('does not build a span when the reference argument is missing', async () => { + const ctx: ChannelContext = { arguments: [] }; + + await tracingChannel(CHANNELS.FIREBASE_FIRESTORE_ADD_DOC).tracePromise(async () => ({}), ctx); + + expect(startInactiveSpanSpy).not.toHaveBeenCalled(); + }); + }); + + describe('functions', () => { + let startSpanManualSpy: MockInstance; + let captureExceptionSpy: MockInstance; + let span: Span; + + beforeEach(() => { + span = makeSpan(); + // Drive the callback with a fake span so we can assert the span lifecycle. + startSpanManualSpy = vi + .spyOn(SentryCore, 'startSpanManual') + .mockImplementation((_options: unknown, callback: unknown) => (callback as (s: Span) => unknown)(span)); + captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'id'); + vi.spyOn(SentryCore, 'flush').mockResolvedValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Publish `start`, then read the (mutated) handler back out of the context — this is what + // orchestrion's transform forwards to the real `onRequest(...)` call. + function registerAndGetWrappedHandler(channel: string, args: unknown[]): (...a: unknown[]) => unknown { + const ctx: ChannelContext = { arguments: args }; + tracingChannel(channel).traceSync(() => undefined, ctx); + const handlerIndex = typeof ctx.arguments[0] === 'function' ? 0 : 1; + return ctx.arguments[handlerIndex] as (...a: unknown[]) => unknown; + } + + it('onRequest: rewraps the handler and opens a SERVER span with the orchestrion origin on invocation', async () => { + const original = vi.fn().mockResolvedValue('ok'); + const wrapped = registerAndGetWrappedHandler(CHANNELS.FIREBASE_FUNCTIONS_HTTP_REQUEST, [original]); + + expect(wrapped).not.toBe(original); + + const result = await wrapped('req', 'res'); + + expect(result).toBe('ok'); + expect(original).toHaveBeenCalledWith('req', 'res'); + expect(startSpanManualSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'firebase.function.http.request', + op: 'http.request', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.firebase.orchestrion.functions', + 'faas.trigger': 'http.request', + 'faas.provider': 'firebase', + }), + }), + expect.any(Function), + ); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('firestore trigger: uses the document-created trigger and handles the `(document, handler)` signature', async () => { + const original = vi.fn().mockResolvedValue(undefined); + const wrapped = registerAndGetWrappedHandler(CHANNELS.FIREBASE_FUNCTIONS_FIRESTORE_CREATED, [ + 'cities/{cityId}', + original, + ]); + + await wrapped({ some: 'event' }); + + expect(original).toHaveBeenCalledWith({ some: 'event' }); + expect(startSpanManualSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'firebase.function.firestore.document.created', + attributes: expect.objectContaining({ 'faas.trigger': 'firestore.document.created' }), + }), + expect.any(Function), + ); + }); + + it('captures the error, ends the span, and rethrows when the handler throws', async () => { + const error = new Error('handler failed'); + const original = vi.fn().mockRejectedValue(error); + const wrapped = registerAndGetWrappedHandler(CHANNELS.FIREBASE_FUNCTIONS_HTTP_CALL, [original]); + + await expect(wrapped()).rejects.toThrow('handler failed'); + + expect(span.setStatus).toHaveBeenCalledWith({ code: expect.anything() }); + expect(captureExceptionSpy).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: expect.objectContaining({ type: 'auto.firebase.orchestrion.functions' }), + }), + ); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('does not double-wrap an already-wrapped handler', () => { + const original = vi.fn(); + const wrappedOnce = registerAndGetWrappedHandler(CHANNELS.FIREBASE_FUNCTIONS_HTTP_REQUEST, [original]); + const ctx: ChannelContext = { arguments: [wrappedOnce] }; + tracingChannel(CHANNELS.FIREBASE_FUNCTIONS_HTTP_REQUEST).traceSync(() => undefined, ctx); + + expect(ctx.arguments[0]).toBe(wrappedOnce); + }); + }); +});