Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -26,5 +27,13 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"assert-command": "pnpm test:assert:orchestrion",
"label": "node-firebase (Orchestrion)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
'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';
}
Comment thread
nicohrubec marked this conversation as resolved.

return expect.objectContaining({
description: `${operation} cities`,
data: expect.objectContaining(data),
Comment thread
cursor[bot] marked this conversation as resolved.
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 => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { debug } from '@sentry/core';
import * as path from 'path';
import { getOrchestrionLoaderPath, getSentryInstrumentations } from '@sentry/server-utils/orchestrion/webpack';
import {
getOrchestrionLoaderPath,
getSentryInstrumentations,
serializeInstrumentations,
} from '@sentry/server-utils/orchestrion/webpack';
import type { VercelCronsConfig } from '../../common/types';
import type { RouteManifest } from '../manifest/types';
import type {
Expand Down Expand Up @@ -141,8 +145,10 @@ function maybeAddOrchestrionRule(
loaders: [
{
loader: getOrchestrionLoaderPath(),
// `instrumentations` is JSON-serializable
options: { instrumentations: getSentryInstrumentations() as unknown as JSONValue[] },
// Turbopack JSON-serializes loader options, so a RegExp `filePath` must be encoded first.
options: {
instrumentations: serializeInstrumentations(getSentryInstrumentations()) as unknown as JSONValue[],
},
Comment thread
nicohrubec marked this conversation as resolved.
},
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,47 @@ describe('componentAnnotation with turbopackReactComponentAnnotation', () => {
});
});

describe('orchestrion diagnostics-channel injection', () => {
function getOrchestrionOptions(result: ReturnType<typeof constructTurbopackConfig>): {
instrumentations: Array<{ module: { name: string; filePath: unknown } }>;
} {
const rule = result.rules!['*.{js,mjs,cjs}'] as {
loaders: Array<{ options: { instrumentations: Array<{ module: { name: string; filePath: unknown } }> } }>;
};
return rule.loaders[0]!.options;
}

it('serializes a RegExp filePath so it survives Turbopack JSON loader options', () => {
const result = constructTurbopackConfig({
userNextConfig: {},
userSentryOptions: { _experimental: { useDiagnosticsChannelInjection: true } },
nextJsVersion: '16.0.0',
});

const firestore = getOrchestrionOptions(result).instrumentations.find(i => i.module.name === '@firebase/firestore');

expect(firestore).toBeDefined();
expect(firestore!.module.filePath).toEqual({
type: 'RegExp',
source: expect.any(String),
flags: expect.any(String),
});
expect(firestore!.module.filePath).not.toBeInstanceOf(RegExp);
// A raw RegExp would `JSON.stringify` to `{}`, dropping the match entirely.
expect(JSON.parse(JSON.stringify(firestore!.module.filePath))).not.toEqual({});
});

it('does not add the orchestrion rule when injection is not opted in', () => {
const result = constructTurbopackConfig({
userNextConfig: {},
userSentryOptions: {},
nextJsVersion: '16.0.0',
});

expect(result.rules!['*.{js,mjs,cjs}']).toBeUndefined();
});
});

describe('safelyAddTurbopackRule', () => {
const mockRule = {
loaders: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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]: unknown;
apiKey?: string;
projectId?: string;
appId?: string;
messagingSenderId?: string;
storageBucket?: string;
}

export interface FirebaseApp {
name: string;
options: FirebaseOptions;
}

export interface FirestoreSettings {
host?: string;
}

interface FirestoreLike {
app: FirebaseApp;
toJSON: () => { settings?: FirestoreSettings };
}

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;
Loading
Loading