Skip to content

Commit cd4cd56

Browse files
committed
feat(firebase): add error handling and rename hooks to align with otel standards
1 parent f201221 commit cd4cd56

File tree

5 files changed

+173
-79
lines changed

5 files changed

+173
-79
lines changed

dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export const helloWorld = onRequest(async (request, response) => {
1818
response.send('Hello from Firebase!');
1919
});
2020

21+
export const unhandeledError = onRequest(async (request, response) => {
22+
console.log('unhandeledError');
23+
throw new Error('There is an error!');
24+
25+
response.send('Hello from Firebase!');
26+
});
27+
2128
export const onCallSomething = onRequest(async (request, response) => {
2229
const data = {
2330
name: request.body?.name || 'Sample Document',

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
33

44
test('should only call the function once without any extra calls', async () => {
55
const serverTransactionPromise = waitForTransaction('node-firebase', span => {
@@ -15,6 +15,7 @@ test('should only call the function once without any extra calls', async () => {
1515
expect.objectContaining({
1616
trace: expect.objectContaining({
1717
data: {
18+
'cloud.project_id': 'demo-functions',
1819
'faas.name': 'helloWorld',
1920
'faas.provider': 'firebase',
2021
'faas.trigger': 'http.request',
@@ -34,6 +35,35 @@ test('should only call the function once without any extra calls', async () => {
3435
);
3536
});
3637

38+
test('should send failed transaction when the function fails', async () => {
39+
const errorEventPromise = waitForError('node-firebase', () => true);
40+
const serverTransactionPromise = waitForTransaction('node-firebase', span => {
41+
return !!span.transaction;
42+
});
43+
44+
await fetch(`http://localhost:5001/demo-functions/default/unhandeledError`);
45+
46+
const transactionEvent = await serverTransactionPromise;
47+
const errorEvent = await errorEventPromise;
48+
49+
expect(transactionEvent.transaction).toEqual('firebase.function.http.request');
50+
expect(transactionEvent.contexts?.trace?.trace_id).toEqual(errorEvent.contexts?.trace?.trace_id);
51+
expect(errorEvent).toMatchObject({
52+
exception: {
53+
values: [
54+
{
55+
type: 'Error',
56+
value: 'There is an error!',
57+
mechanism: {
58+
type: 'auto.firebase.otel.functions',
59+
handled: false,
60+
},
61+
}
62+
],
63+
},
64+
});
65+
});
66+
3767
test('should create a document and trigger onDocumentCreated and another with authContext', async () => {
3868
const serverTransactionPromise = waitForTransaction('node-firebase', span => {
3969
return span.transaction === 'firebase.function.http.request';
@@ -62,6 +92,7 @@ test('should create a document and trigger onDocumentCreated and another with au
6292
expect(transactionEvent.transaction).toEqual('firebase.function.http.request');
6393
expect(transactionEvent.contexts?.trace).toEqual({
6494
data: {
95+
'cloud.project_id': 'demo-functions',
6596
'faas.name': 'onCallSomething',
6697
'faas.provider': 'firebase',
6798
'faas.trigger': 'http.request',
@@ -80,6 +111,7 @@ test('should create a document and trigger onDocumentCreated and another with au
80111
expect(transactionEvent.spans).toHaveLength(3);
81112
expect(transactionEventOnDocumentCreate.contexts?.trace).toEqual({
82113
data: {
114+
'cloud.project_id': 'demo-functions',
83115
'faas.name': 'onDocumentCreate',
84116
'faas.provider': 'firebase',
85117
'faas.trigger': 'firestore.document.created',
@@ -98,6 +130,7 @@ test('should create a document and trigger onDocumentCreated and another with au
98130
expect(transactionEventOnDocumentCreate.spans).toHaveLength(2);
99131
expect(transactionEventOnDocumentWithAuthContextCreate.contexts?.trace).toEqual({
100132
data: {
133+
'cloud.project_id': 'demo-functions',
101134
'faas.name': 'onDocumentCreateWithAuthContext',
102135
'faas.provider': 'firebase',
103136
'faas.trigger': 'firestore.document.created',

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { IntegrationFn } from '@sentry/core';
2-
import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core';
2+
import { captureException, defineIntegration, flush, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core';
33
import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core';
44
import { type FirebaseInstrumentationConfig, FirebaseInstrumentation } from './otel';
55

@@ -11,10 +11,23 @@ const config: FirebaseInstrumentationConfig = {
1111

1212
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'db.query');
1313
},
14-
functionsSpanCreationHook: span => {
15-
addOriginToSpan(span, 'auto.firebase.otel.functions');
14+
functions: {
15+
requestHook: span => {
16+
addOriginToSpan(span, 'auto.firebase.otel.functions');
1617

17-
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.request');
18+
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.request');
19+
},
20+
errorHook: async (_, error) => {
21+
if (error) {
22+
captureException(error, {
23+
mechanism: {
24+
type: 'auto.firebase.otel.functions',
25+
handled: false,
26+
}
27+
});
28+
await flush(2000);
29+
}
30+
},
1831
},
1932
};
2033

packages/node/src/integrations/tracing/firebase/otel/patches/functions.ts

Lines changed: 105 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
InstrumentationNodeModuleFile,
77
isWrapped,
88
safeExecuteInTheMiddle,
9-
safeExecuteInTheMiddleAsync,
109
} from '@opentelemetry/instrumentation';
1110
import type { SpanAttributes } from '@sentry/core';
1211
import type {
@@ -28,7 +27,7 @@ import type {
2827
onObjectMetadataUpdated,
2928
} from 'firebase-functions/storage';
3029
import type { FirebaseInstrumentation } from '../firebaseInstrumentation';
31-
import type { FirebaseInstrumentationConfig, FunctionsSpanCreationHook } from '../types';
30+
import type { FirebaseInstrumentationConfig, RequestHook, ResponseHook } from '../types';
3231

3332
/**
3433
* Patches Firebase Functions v2 to add OpenTelemetry instrumentation
@@ -45,15 +44,30 @@ export function patchFunctions(
4544
unwrap: InstrumentationBase['_unwrap'],
4645
config: FirebaseInstrumentationConfig,
4746
): InstrumentationNodeModuleDefinition {
48-
const defaultFunctionsSpanCreationHook: FunctionsSpanCreationHook = () => {};
47+
let requestHook: RequestHook = () => {};
48+
let responseHook: ResponseHook = () => {};
49+
const errorHook = config.functions?.errorHook;
50+
const configRequestHook = config.functions?.requestHook;
51+
const configResponseHook = config.functions?.responseHook;
4952

50-
let functionsSpanCreationHook: FunctionsSpanCreationHook = defaultFunctionsSpanCreationHook;
51-
const configFunctionsSpanCreationHook = config.functionsSpanCreationHook;
52-
53-
if (typeof configFunctionsSpanCreationHook === 'function') {
54-
functionsSpanCreationHook = (span: Span) => {
53+
if (typeof configResponseHook === 'function') {
54+
responseHook = (span: Span, err: unknown) => {
5555
safeExecuteInTheMiddle(
56-
() => configFunctionsSpanCreationHook(span),
56+
() => configResponseHook(span, err),
57+
error => {
58+
if (!error) {
59+
return;
60+
}
61+
diag.error(error?.message);
62+
},
63+
true,
64+
);
65+
};
66+
}
67+
if (typeof configRequestHook === 'function') {
68+
requestHook = (span: Span) => {
69+
safeExecuteInTheMiddle(
70+
() => configRequestHook(span),
5771
error => {
5872
if (!error) {
5973
return;
@@ -79,7 +93,14 @@ export function patchFunctions(
7993
name,
8094
functionsSupportedVersions,
8195
moduleExports =>
82-
wrapCommonFunctions(moduleExports, wrap, unwrap, tracer, functionsSpanCreationHook, triggerType),
96+
wrapCommonFunctions(
97+
moduleExports,
98+
wrap,
99+
unwrap,
100+
tracer,
101+
{ requestHook, responseHook, errorHook },
102+
triggerType,
103+
),
83104
moduleExports => unwrapCommonFunctions(moduleExports, unwrap),
84105
),
85106
);
@@ -123,17 +144,44 @@ type AvailableFirebaseFunctions = {
123144

124145
type FirebaseFunctions = AvailableFirebaseFunctions[keyof AvailableFirebaseFunctions];
125146

147+
/**
148+
* Async function to execute patched function and being able to catch errors
149+
* @param execute - function to be executed
150+
* @param onFinish - callback to run when execute finishes
151+
*/
152+
export async function safeExecuteInTheMiddleAsync<T>(
153+
execute: () => T,
154+
onFinish: (e: Error | undefined, result: T | undefined) => Promise<void> | void,
155+
preventThrowingError?: boolean,
156+
): Promise<T> {
157+
let error: Error | undefined;
158+
let result: T | undefined;
159+
try {
160+
result = await execute();
161+
} catch (e) {
162+
error = e as Error;
163+
} finally {
164+
await onFinish?.(error, result);
165+
if (error && !preventThrowingError) {
166+
// eslint-disable-next-line no-unsafe-finally
167+
throw error;
168+
}
169+
// eslint-disable-next-line no-unsafe-finally
170+
return result as T;
171+
}
172+
}
173+
126174
/**
127175
* Patches Cloud Functions for Firebase (v2) to add OpenTelemetry instrumentation
128176
*
129177
* @param tracer - Opentelemetry Tracer
130-
* @param functionsSpanCreationHook - Function to create a span for the function
178+
* @param functionsConfig - Firebase instrumentation config
131179
* @param triggerType - Type of trigger
132180
* @returns A function that patches the function
133181
*/
134182
export function patchV2Functions<T extends FirebaseFunctions = FirebaseFunctions>(
135183
tracer: Tracer,
136-
functionsSpanCreationHook: FunctionsSpanCreationHook,
184+
functionsConfig: FirebaseInstrumentationConfig['functions'],
137185
triggerType: string,
138186
): (original: T) => (...args: OverloadedParameters<T>) => ReturnType<T> {
139187
return function v2FunctionsWrapper(original: T): (...args: OverloadedParameters<T>) => ReturnType<T> {
@@ -166,20 +214,35 @@ export function patchV2Functions<T extends FirebaseFunctions = FirebaseFunctions
166214
}
167215

168216
span.setAttributes(attributes);
169-
functionsSpanCreationHook(span);
170-
171-
return context.with(trace.setSpan(context.active(), span), () =>
172-
safeExecuteInTheMiddleAsync(
173-
() => handler.apply(this, handlerArgs),
174-
err => {
175-
if (err) {
176-
span.recordException(err);
177-
}
178-
179-
span.end();
180-
},
181-
),
182-
);
217+
functionsConfig?.requestHook?.(span);
218+
219+
// Can be changed to safeExecuteInTheMiddleAsync once following is merged and released
220+
// https://github.com/open-telemetry/opentelemetry-js/pull/6032
221+
return context.with(trace.setSpan(context.active(), span), async () => {
222+
let error: Error | undefined;
223+
let result: T | undefined;
224+
225+
try {
226+
result = await handler.apply(this, handlerArgs);
227+
} catch (e) {
228+
error = e as Error;
229+
}
230+
231+
functionsConfig?.responseHook?.(span, error);
232+
233+
if (error) {
234+
span.recordException(error);
235+
}
236+
237+
span.end();
238+
239+
if (error) {
240+
await functionsConfig?.errorHook?.(span, error);
241+
throw error;
242+
}
243+
244+
return result;
245+
});
183246
};
184247

185248
if (documentOrOptions) {
@@ -196,86 +259,58 @@ function wrapCommonFunctions(
196259
wrap: InstrumentationBase<FirebaseInstrumentationConfig>['_wrap'],
197260
unwrap: InstrumentationBase<FirebaseInstrumentationConfig>['_unwrap'],
198261
tracer: Tracer,
199-
functionsSpanCreationHook: FunctionsSpanCreationHook,
262+
functionsConfig: FirebaseInstrumentationConfig['functions'],
200263
triggerType: 'function' | 'firestore' | 'scheduler' | 'storage',
201264
): AvailableFirebaseFunctions {
202265
unwrapCommonFunctions(moduleExports, unwrap);
203266

204267
switch (triggerType) {
205268
case 'function':
206-
wrap(moduleExports, 'onRequest', patchV2Functions(tracer, functionsSpanCreationHook, 'http.request'));
207-
wrap(moduleExports, 'onCall', patchV2Functions(tracer, functionsSpanCreationHook, 'http.call'));
269+
wrap(moduleExports, 'onRequest', patchV2Functions(tracer, functionsConfig, 'http.request'));
270+
wrap(moduleExports, 'onCall', patchV2Functions(tracer, functionsConfig, 'http.call'));
208271
break;
209272

210273
case 'firestore':
211-
wrap(
212-
moduleExports,
213-
'onDocumentCreated',
214-
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.created'),
215-
);
216-
wrap(
217-
moduleExports,
218-
'onDocumentUpdated',
219-
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.updated'),
220-
);
221-
wrap(
222-
moduleExports,
223-
'onDocumentDeleted',
224-
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.deleted'),
225-
);
226-
wrap(
227-
moduleExports,
228-
'onDocumentWritten',
229-
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.written'),
230-
);
274+
wrap(moduleExports, 'onDocumentCreated', patchV2Functions(tracer, functionsConfig, 'firestore.document.created'));
275+
wrap(moduleExports, 'onDocumentUpdated', patchV2Functions(tracer, functionsConfig, 'firestore.document.updated'));
276+
wrap(moduleExports, 'onDocumentDeleted', patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted'));
277+
wrap(moduleExports, 'onDocumentWritten', patchV2Functions(tracer, functionsConfig, 'firestore.document.written'));
231278
wrap(
232279
moduleExports,
233280
'onDocumentCreatedWithAuthContext',
234-
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.created'),
281+
patchV2Functions(tracer, functionsConfig, 'firestore.document.created'),
235282
);
236283
wrap(
237284
moduleExports,
238285
'onDocumentUpdatedWithAuthContext',
239-
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.updated'),
286+
patchV2Functions(tracer, functionsConfig, 'firestore.document.updated'),
240287
);
241288

242289
wrap(
243290
moduleExports,
244291
'onDocumentDeletedWithAuthContext',
245-
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.deleted'),
292+
patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted'),
246293
);
247294

248295
wrap(
249296
moduleExports,
250297
'onDocumentWrittenWithAuthContext',
251-
patchV2Functions(tracer, functionsSpanCreationHook, 'firestore.document.written'),
298+
patchV2Functions(tracer, functionsConfig, 'firestore.document.written'),
252299
);
253300
break;
254301

255302
case 'scheduler':
256-
wrap(moduleExports, 'onSchedule', patchV2Functions(tracer, functionsSpanCreationHook, 'scheduler.scheduled'));
303+
wrap(moduleExports, 'onSchedule', patchV2Functions(tracer, functionsConfig, 'scheduler.scheduled'));
257304
break;
258305

259306
case 'storage':
260-
wrap(
261-
moduleExports,
262-
'onObjectFinalized',
263-
patchV2Functions(tracer, functionsSpanCreationHook, 'storage.object.finalized'),
264-
);
265-
wrap(
266-
moduleExports,
267-
'onObjectArchived',
268-
patchV2Functions(tracer, functionsSpanCreationHook, 'storage.object.archived'),
269-
);
270-
wrap(
271-
moduleExports,
272-
'onObjectDeleted',
273-
patchV2Functions(tracer, functionsSpanCreationHook, 'storage.object.deleted'),
274-
);
307+
wrap(moduleExports, 'onObjectFinalized', patchV2Functions(tracer, functionsConfig, 'storage.object.finalized'));
308+
wrap(moduleExports, 'onObjectArchived', patchV2Functions(tracer, functionsConfig, 'storage.object.archived'));
309+
wrap(moduleExports, 'onObjectDeleted', patchV2Functions(tracer, functionsConfig, 'storage.object.deleted'));
275310
wrap(
276311
moduleExports,
277312
'onObjectMetadataUpdated',
278-
patchV2Functions(tracer, functionsSpanCreationHook, 'storage.object.metadataUpdated'),
313+
patchV2Functions(tracer, functionsConfig, 'storage.object.metadataUpdated'),
279314
);
280315
break;
281316
}

0 commit comments

Comments
 (0)