Skip to content

Commit 7b72760

Browse files
committed
feat(firebase): instrument cloud functions for firebase v2
1 parent 4dc6c7b commit 7b72760

File tree

6 files changed

+451
-0
lines changed

6 files changed

+451
-0
lines changed

packages/node/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"@sentry/core": "10.20.0",
9999
"@sentry/node-core": "10.20.0",
100100
"@sentry/opentelemetry": "10.20.0",
101+
"firebase-functions": "^6.5.0",
101102
"import-in-the-middle": "^1.14.2",
102103
"minimatch": "^9.0.0"
103104
},

packages/node/src/integrations/tracing/firebase/firebase.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ const config: FirebaseInstrumentationConfig = {
1111

1212
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'db.query');
1313
},
14+
functionsSpanCreationHook: span => {
15+
addOriginToSpan(span, 'auto.firebase.otel.functions');
16+
17+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.request');
18+
},
1419
};
1520

1621
export const instrumentFirebase = generateInstrumentOnce(INTEGRATION_NAME, () => new FirebaseInstrumentation(config));

packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { type InstrumentationNodeModuleDefinition, InstrumentationBase } from '@opentelemetry/instrumentation';
22
import { SDK_VERSION } from '@sentry/core';
33
import { patchFirestore } from './patches/firestore';
4+
import { patchFunctions } from './patches/functions';
45
import type { FirebaseInstrumentationConfig } from './types';
56

67
const DefaultFirebaseInstrumentationConfig: FirebaseInstrumentationConfig = {};
78
const firestoreSupportedVersions = ['>=3.0.0 <5']; // firebase 9+
9+
const functionsSupportedVersions = ['>=6.0.0 <7']; // firebase-functions v2
810

911
/**
1012
* Instrumentation for Firebase services, specifically Firestore.
@@ -31,6 +33,7 @@ export class FirebaseInstrumentation extends InstrumentationBase<FirebaseInstrum
3133
const modules: InstrumentationNodeModuleDefinition[] = [];
3234

3335
modules.push(patchFirestore(this.tracer, firestoreSupportedVersions, this._wrap, this._unwrap, this.getConfig()));
36+
modules.push(patchFunctions(this.tracer, functionsSupportedVersions, this._wrap, this._unwrap, this.getConfig()));
3437

3538
return modules;
3639
}
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
import type { Span, Tracer } from '@opentelemetry/api';
2+
import { context, diag, SpanKind, trace } from '@opentelemetry/api';
3+
import type { InstrumentationBase } from '@opentelemetry/instrumentation';
4+
import {
5+
InstrumentationNodeModuleDefinition,
6+
InstrumentationNodeModuleFile,
7+
isWrapped,
8+
safeExecuteInTheMiddle,
9+
safeExecuteInTheMiddleAsync,
10+
} from '@opentelemetry/instrumentation';
11+
import type { SpanAttributes } from '@sentry/core';
12+
import type {
13+
onDocumentCreated,
14+
onDocumentCreatedWithAuthContext,
15+
onDocumentDeleted,
16+
onDocumentDeletedWithAuthContext,
17+
onDocumentUpdated,
18+
onDocumentUpdatedWithAuthContext,
19+
onDocumentWritten,
20+
onDocumentWrittenWithAuthContext,
21+
} from 'firebase-functions/firestore';
22+
import type { onCall, onRequest } from 'firebase-functions/https';
23+
import type { onSchedule } from 'firebase-functions/scheduler';
24+
import type {
25+
onObjectArchived,
26+
onObjectDeleted,
27+
onObjectFinalized,
28+
onObjectMetadataUpdated,
29+
} from 'firebase-functions/storage';
30+
import type { FirebaseInstrumentation } from '../firebaseInstrumentation';
31+
import type { FirebaseInstrumentationConfig, FunctionsSpanCreationHook } from '../types';
32+
33+
/**
34+
* Patches Firebase Functions v2 to add OpenTelemetry instrumentation
35+
* @param tracer - Opentelemetry Tracer
36+
* @param functionsSupportedVersions - supported versions of firebase-functions
37+
* @param wrap - reference to native instrumentation wrap function
38+
* @param unwrap - reference to native instrumentation unwrap function
39+
* @param config - Firebase instrumentation config
40+
*/
41+
export function patchFunctions(
42+
tracer: Tracer,
43+
functionsSupportedVersions: string[],
44+
wrap: InstrumentationBase['_wrap'],
45+
unwrap: InstrumentationBase['_unwrap'],
46+
config: FirebaseInstrumentationConfig,
47+
): InstrumentationNodeModuleDefinition {
48+
const defaultFunctionsSpanCreationHook: FunctionsSpanCreationHook = () => {};
49+
50+
let functionsSpanCreationHook: FunctionsSpanCreationHook = defaultFunctionsSpanCreationHook;
51+
const configFunctionsSpanCreationHook = config.functionsSpanCreationHook;
52+
53+
if (typeof configFunctionsSpanCreationHook === 'function') {
54+
functionsSpanCreationHook = (span: Span) => {
55+
safeExecuteInTheMiddle(
56+
() => configFunctionsSpanCreationHook(span),
57+
error => {
58+
if (!error) {
59+
return;
60+
}
61+
diag.error(error?.message);
62+
},
63+
true,
64+
);
65+
};
66+
}
67+
68+
const moduleFunctionsCJS = new InstrumentationNodeModuleDefinition('firebase-functions', functionsSupportedVersions);
69+
70+
moduleFunctionsCJS.files.push(
71+
new InstrumentationNodeModuleFile(
72+
'firebase-functions/lib/v2/providers/https.js',
73+
functionsSupportedVersions,
74+
moduleExports => wrapCommonFunctions(moduleExports, wrap, unwrap, tracer, functionsSpanCreationHook, 'function'),
75+
moduleExports => unwrapCommonFunctions(moduleExports, unwrap),
76+
),
77+
);
78+
79+
moduleFunctionsCJS.files.push(
80+
new InstrumentationNodeModuleFile(
81+
'firebase-functions/lib/v2/providers/firestore.js',
82+
functionsSupportedVersions,
83+
moduleExports => wrapCommonFunctions(moduleExports, wrap, unwrap, tracer, functionsSpanCreationHook, 'firestore'),
84+
moduleExports => unwrapCommonFunctions(moduleExports, unwrap),
85+
),
86+
);
87+
88+
moduleFunctionsCJS.files.push(
89+
new InstrumentationNodeModuleFile(
90+
'firebase-functions/lib/v2/providers/scheduler.js',
91+
functionsSupportedVersions,
92+
moduleExports => wrapCommonFunctions(moduleExports, wrap, unwrap, tracer, functionsSpanCreationHook, 'scheduler'),
93+
moduleExports => unwrapCommonFunctions(moduleExports, unwrap),
94+
),
95+
);
96+
97+
moduleFunctionsCJS.files.push(
98+
new InstrumentationNodeModuleFile(
99+
'firebase-functions/lib/v2/storage.js',
100+
functionsSupportedVersions,
101+
moduleExports => wrapCommonFunctions(moduleExports, wrap, unwrap, tracer, functionsSpanCreationHook, 'storage'),
102+
moduleExports => unwrapCommonFunctions(moduleExports, unwrap),
103+
),
104+
);
105+
106+
return moduleFunctionsCJS;
107+
}
108+
109+
type OverloadedParameters<T> = T extends {
110+
(...args: infer A1): unknown;
111+
(...args: infer A2): unknown;
112+
(...args: infer A3): unknown;
113+
(...args: infer A4): unknown;
114+
}
115+
? A1 | A2 | A3 | A4
116+
: T extends { (...args: infer A1): unknown; (...args: infer A2): unknown; (...args: infer A3): unknown }
117+
? A1 | A2 | A3
118+
: T extends { (...args: infer A1): unknown; (...args: infer A2): unknown }
119+
? A1 | A2
120+
: T extends (...args: infer A) => unknown
121+
? A
122+
: unknown;
123+
124+
type AvailableFirebaseFunctions = {
125+
onRequest: typeof onRequest;
126+
onCall: typeof onCall;
127+
onDocumentCreated: typeof onDocumentCreated;
128+
onDocumentUpdated: typeof onDocumentUpdated;
129+
onDocumentDeleted: typeof onDocumentDeleted;
130+
onDocumentWritten: typeof onDocumentWritten;
131+
onDocumentCreatedWithAuthContext: typeof onDocumentCreatedWithAuthContext;
132+
onDocumentUpdatedWithAuthContext: typeof onDocumentUpdatedWithAuthContext;
133+
onDocumentDeletedWithAuthContext: typeof onDocumentDeletedWithAuthContext;
134+
onDocumentWrittenWithAuthContext: typeof onDocumentWrittenWithAuthContext;
135+
onSchedule: typeof onSchedule;
136+
onObjectFinalized: typeof onObjectFinalized;
137+
onObjectArchived: typeof onObjectArchived;
138+
onObjectDeleted: typeof onObjectDeleted;
139+
onObjectMetadataUpdated: typeof onObjectMetadataUpdated;
140+
};
141+
142+
type FirebaseFunctions = AvailableFirebaseFunctions[keyof AvailableFirebaseFunctions];
143+
144+
/**
145+
* Patches Cloud Functions for Firebase (v2) to add OpenTelemetry instrumentation
146+
*
147+
* @param tracer - Opentelemetry Tracer
148+
* @param functionsSpanCreationHook - Function to create a span for the function
149+
* @param triggerType - Type of trigger
150+
* @returns A function that patches the function
151+
*/
152+
export function patchV2Functions<T extends FirebaseFunctions = FirebaseFunctions>(
153+
tracer: Tracer,
154+
functionsSpanCreationHook: FunctionsSpanCreationHook,
155+
triggerType: string,
156+
): (original: T) => (...args: OverloadedParameters<T>) => ReturnType<T> {
157+
return function v2FunctionsWrapper(original: T): (...args: OverloadedParameters<T>) => ReturnType<T> {
158+
return function (this: FirebaseInstrumentation, ...args: OverloadedParameters<T>): ReturnType<T> {
159+
const handler = typeof args[0] === 'function' ? args[0] : args[1];
160+
const documentOrOptions = typeof args[0] === 'function' ? undefined : args[0];
161+
162+
if (!handler) {
163+
return original.call(this, ...args);
164+
}
165+
166+
const wrappedHandler = async function (this: unknown, ...handlerArgs: unknown[]): Promise<unknown> {
167+
const functionName = process.env.FUNCTION_TARGET || process.env.K_SERVICE || 'unknown';
168+
const span = tracer.startSpan(`firebase.function.${triggerType}`, {
169+
kind: SpanKind.SERVER,
170+
});
171+
172+
const attributes: SpanAttributes = {
173+
'faas.name': functionName,
174+
'faas.trigger': triggerType,
175+
'faas.provider': 'firebase',
176+
};
177+
178+
if (process.env.GCLOUD_PROJECT) {
179+
attributes['cloud.project_id'] = process.env.GCLOUD_PROJECT;
180+
}
181+
182+
if (process.env.EVENTARC_CLOUD_EVENT_SOURCE) {
183+
attributes['cloud.event_source'] = process.env.EVENTARC_CLOUD_EVENT_SOURCE;
184+
}
185+
186+
span.setAttributes(attributes);
187+
functionsSpanCreationHook(span);
188+
189+
return context.with(trace.setSpan(context.active(), span), () =>
190+
safeExecuteInTheMiddleAsync(
191+
() => handler.apply(this, handlerArgs),
192+
err => {
193+
if (err instanceof Error) {
194+
span.recordException(err);
195+
}
196+
197+
span.end();
198+
},
199+
),
200+
);
201+
};
202+
203+
if (documentOrOptions) {
204+
return original.call(this, documentOrOptions, wrappedHandler);
205+
} else {
206+
return original.call(this, wrappedHandler);
207+
}
208+
};
209+
};
210+
}
211+
212+
function wrapCommonFunctions(
213+
moduleExports: AvailableFirebaseFunctions,
214+
wrap: InstrumentationBase<FirebaseInstrumentationConfig>['_wrap'],
215+
unwrap: InstrumentationBase<FirebaseInstrumentationConfig>['_unwrap'],
216+
tracer: Tracer,
217+
functionsSpanCreationHook: FunctionsSpanCreationHook,
218+
triggerType: 'function' | 'firestore' | 'scheduler' | 'storage',
219+
): AvailableFirebaseFunctions {
220+
unwrapCommonFunctions(moduleExports, unwrap);
221+
222+
switch (triggerType) {
223+
case 'function':
224+
wrap(moduleExports, 'onRequest', patchV2Functions(tracer, functionsSpanCreationHook, 'http.request'));
225+
wrap(moduleExports, 'onCall', patchV2Functions(tracer, functionsSpanCreationHook, 'http.call'));
226+
break;
227+
228+
case 'firestore':
229+
wrap(
230+
moduleExports,
231+
'onDocumentCreated',
232+
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.created'),
233+
);
234+
wrap(
235+
moduleExports,
236+
'onDocumentUpdated',
237+
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.updated'),
238+
);
239+
wrap(
240+
moduleExports,
241+
'onDocumentDeleted',
242+
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.deleted'),
243+
);
244+
wrap(
245+
moduleExports,
246+
'onDocumentWritten',
247+
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.written'),
248+
);
249+
wrap(
250+
moduleExports,
251+
'onDocumentCreatedWithAuthContext',
252+
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.created'),
253+
);
254+
wrap(
255+
moduleExports,
256+
'onDocumentUpdatedWithAuthContext',
257+
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.updated'),
258+
);
259+
260+
wrap(
261+
moduleExports,
262+
'onDocumentDeletedWithAuthContext',
263+
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.deleted'),
264+
);
265+
266+
wrap(
267+
moduleExports,
268+
'onDocumentWrittenWithAuthContext',
269+
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.written'),
270+
);
271+
break;
272+
273+
case 'scheduler':
274+
wrap(moduleExports, 'onSchedule', patchV2Functions(tracer, functionsSpanCreationHook, 'scheduler.scheduled'));
275+
break;
276+
277+
case 'storage':
278+
wrap(
279+
moduleExports,
280+
'onObjectFinalized',
281+
patchV2Functions(tracer, functionsSpanCreationHook, 'storage.object.finalized'),
282+
);
283+
wrap(
284+
moduleExports,
285+
'onObjectArchived',
286+
patchV2Functions(tracer, functionsSpanCreationHook, 'storage.object.archived'),
287+
);
288+
wrap(
289+
moduleExports,
290+
'onObjectDeleted',
291+
patchV2Functions(tracer, functionsSpanCreationHook, 'storage.object.deleted'),
292+
);
293+
wrap(
294+
moduleExports,
295+
'onObjectMetadataUpdated',
296+
patchV2Functions(tracer, functionsSpanCreationHook, 'storage.object.metadataUpdated'),
297+
);
298+
break;
299+
}
300+
301+
return moduleExports;
302+
}
303+
304+
function unwrapCommonFunctions(
305+
moduleExports: AvailableFirebaseFunctions,
306+
unwrap: InstrumentationBase<FirebaseInstrumentationConfig>['_unwrap'],
307+
): AvailableFirebaseFunctions {
308+
const methods: (keyof AvailableFirebaseFunctions)[] = [
309+
'onSchedule',
310+
'onRequest',
311+
'onCall',
312+
'onObjectFinalized',
313+
'onObjectArchived',
314+
'onObjectDeleted',
315+
'onObjectMetadataUpdated',
316+
'onDocumentCreated',
317+
'onDocumentUpdated',
318+
'onDocumentDeleted',
319+
'onDocumentWritten',
320+
'onDocumentCreatedWithAuthContext',
321+
'onDocumentUpdatedWithAuthContext',
322+
'onDocumentDeletedWithAuthContext',
323+
'onDocumentWrittenWithAuthContext',
324+
];
325+
326+
for (const method of methods) {
327+
if (isWrapped(moduleExports[method])) {
328+
unwrap(moduleExports, method);
329+
}
330+
}
331+
return moduleExports;
332+
}

packages/node/src/integrations/tracing/firebase/otel/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,17 @@ export interface FirestoreSettings {
8888
*/
8989
export interface FirebaseInstrumentationConfig extends InstrumentationConfig {
9090
firestoreSpanCreationHook?: FirestoreSpanCreationHook;
91+
functionsSpanCreationHook?: FunctionsSpanCreationHook;
9192
}
9293

9394
export interface FirestoreSpanCreationHook {
9495
(span: Span): void;
9596
}
9697

98+
export interface FunctionsSpanCreationHook {
99+
(span: Span): void;
100+
}
101+
97102
// Function types (addDoc, getDocs, setDoc, deleteDoc) are defined below as types
98103
export type GetDocsType<AppModelType = DocumentData, DbModelType extends DocumentData = DocumentData> = (
99104
query: CollectionReference<AppModelType, DbModelType>,

0 commit comments

Comments
 (0)