diff --git a/.size-limit.js b/.size-limit.js index 269ce49b1cc1..7106f2e29b03 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '157 KB', + limit: '158 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc b/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc deleted file mode 100644 index 47e4665f6905..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "sentry-firebase-e2e-test-f4ed3" - } -} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/README.md b/dev-packages/e2e-tests/test-applications/node-firebase/README.md index e44ee12f5268..bd91bd5a872a 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/README.md +++ b/dev-packages/e2e-tests/test-applications/node-firebase/README.md @@ -1,64 +1,50 @@ -## Assuming you already have installed docker desktop or orbstack etc. or any other docker software +
+ + Firebase + +
-### Enabling / authorising firebase emulator through docker +## Description -1. Run the docker +[Firebase](https://firebase.google.com/) starter repository with Cloud Functions for Firebase and Firestore. -```bash -pnpm docker -``` - -2. In new tab, enter the docker container by simply running +## Project setup -```bash -docker exec -it sentry-firebase bash +```sh +$ pnpm install ``` -3. Now inside docker container run +## Compile and run the project -```bash -firebase login +```sh +$ pnpm dev # builds the functions and firestore app +$ pnpm emulate +$ pnpm start # run the firestore app ``` -4. You should now see a long link to authenticate with google account, copy the link and open it using your browser -5. Choose the account you want to authenticate with -6. Once you do this you should be able to see something like "Firebase CLI Login Successful" -7. And inside docker container you should see something like "Success! Logged in as " -8. Now you can exit docker container - -```bash -exit -``` +## Run tests -9. Switch back to previous tab, stop the docker container (ctrl+c). -10. You should now be able to run the test, as you have correctly authenticated the firebase emulator +Either run the tests directly: -### Preparing data for CLI - -1. Please authorize the docker first - see the previous section -2. Once you do that you can generate .env file locally, to do that just run - -```bash -npm run createEnvFromConfig +```sh +$ pnpm test:build +$ pnpm test:assert ``` -3. It will create a new file called ".env" inside folder "docker" -4. View the file. There will be 2 params CONFIG_FIREBASE_TOOLS and CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS. -5. Now inside the CLI create a new variable under the name CONFIG_FIREBASE_TOOLS and - CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS - take values from mentioned .env file -6. File .env is ignored to avoid situation when developer after authorizing firebase with private account will - accidently push the tokens to github. -7. But if we want the users to still have some default to be used for authorisation (on their local development) it will - be enough to commit this file, we just have to authorize it with some "special" account. +Or run develop while running the tests directly against the emulator. Start each script in a separate terminal: -**Some explanation towards environment settings, the environment variable defined directly in "environments" takes -precedence over .env file, that means it will be safe to define it in CLI and still keeps the .env file.** +```sh +$ pnpm dev +$ pnpm emulate +$ pnpm test --ui +``` -### Scripts - helpers +The tests will run against the Firebase Emulator Suite. -- createEnvFromConfig - it will use the firebase docker authentication and create .env file which will be used then by - docker whenever you run emulator -- createConfigFromEnv - it will use '.env' file in docker folder to create .config for the firebase to be used to - authenticate whenever you run docker, Docker by default loads .env file itself +## Resources -Use these scripts when testing and updating the environment settings on CLI +- [Firebase](https://firebase.google.com/) +- [Firebase Emulator Suite](https://firebase.google.com/docs/emulator-suite) +- [Firebase SDK](https://firebase.google.com/docs/sdk) +- [Firebase Functions](https://firebase.google.com/docs/functions) +- [Firestore](https://firebase.google.com/docs/firestore) diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json index 05203f1d6567..eb1b42b8aa9c 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json @@ -16,5 +16,12 @@ "enabled": true }, "singleProjectMode": true - } + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log", "*.local"] + } + ] } diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json new file mode 100644 index 000000000000..b5d19993bdae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "firestore-app", + "private": true, + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch", + "start": "node ./dist/app.js" + }, + "dependencies": { + "@firebase/app": "^0.13.1", + "@sentry/node": "latest || *", + "express": "^4.18.2", + "firebase": "^12.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/node": "^22.13.14", + "typescript": "5.9.3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/app.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts rename to dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/app.ts diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts rename to dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json new file mode 100644 index 000000000000..ee180965030d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json new file mode 100644 index 000000000000..c3be318b8c38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json @@ -0,0 +1,19 @@ +{ + "name": "functions", + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch" + }, + "engines": { + "node": "20" + }, + "main": "dist/index.js", + "dependencies": { + "firebase-admin": "^12.6.0", + "firebase-functions": "^6.0.1", + "@sentry/node": "latest || *" + }, + "devDependencies": { + "typescript": "5.9.3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts new file mode 100644 index 000000000000..6a3df6f4a61a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts @@ -0,0 +1,50 @@ +import './init'; + +import { onDocumentCreated, onDocumentCreatedWithAuthContext } from 'firebase-functions/firestore'; +import { onRequest } from 'firebase-functions/https'; +import * as logger from 'firebase-functions/logger'; +import { setGlobalOptions } from 'firebase-functions/options'; +import * as admin from 'firebase-admin'; + +setGlobalOptions({ region: 'default' }); + +admin.initializeApp(); + +const db = admin.firestore(); + +export const helloWorld = onRequest(async (request, response) => { + logger.info('Hello logs!', { structuredData: true }); + + response.send('Hello from Firebase!'); +}); + +export const unhandeledError = onRequest(async (request, response) => { + throw new Error('There is an error!'); +}); + +export const onCallSomething = onRequest(async (request, response) => { + const data = { + name: request.body?.name || 'Sample Document', + timestamp: performance.now(), + description: request.body?.description || 'Created via Cloud Function', + }; + + await db.collection('documents').add(data); + + logger.info('Create document!', { structuredData: true }); + + response.send({ message: 'Document created!' }); +}); + +export const onDocumentCreate = onDocumentCreated('documents/{documentId}', async event => { + const documentId = event.params.documentId; + + await db.collection('documents').doc(documentId).update({ + processed: true, + processedAt: new Date(), + }); +}); + +export const onDocumentCreateWithAuthContext = onDocumentCreatedWithAuthContext('documents/{documentId}', async () => { + // noop +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts new file mode 100644 index 000000000000..c3b4a642375a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.firebaseIntegration()], + defaultIntegrations: false, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json new file mode 100644 index 000000000000..ee180965030d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"] +} 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 0a23fbbeef92..41eb0ce085d4 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/package.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -3,34 +3,26 @@ "version": "0.0.1", "private": true, "scripts": { - "build": "tsc", - "dev": "tsc --build --watch", + "build": "pnpm run -r build", + "dev": "pnpm run -r dev", "proxy": "node start-event-proxy.mjs", - "emulate": "firebase emulators:start &", - "start": "node ./dist/app.js", + "emulate": "firebase emulators:start --project demo-functions", + "start": "pnpm run -r start", "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", + "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 'pnpm test'" + "test:assert": "pnpm firebase emulators:exec --project demo-functions 'pnpm test'" }, "dependencies": { - "@firebase/app": "^0.13.1", - "@sentry/node": "latest || *", - "@sentry/core": "latest || *", - "@sentry/opentelemetry": "latest || *", - "@types/node": "^18.19.1", + "@types/node": "^22.13.14", "dotenv": "^16.4.5", - "express": "^4.18.2", - "firebase": "^12.0.0", - "firebase-admin": "^12.0.0", "tsconfig-paths": "^4.2.0", - "typescript": "4.9.5" + "typescript": "5.9.3" }, "devDependencies": { "@playwright/test": "~1.53.2", "@sentry-internal/test-utils": "link:../../../test-utils", - "@types/express": "^4.17.13", - "firebase-tools": "^12.0.0" + "firebase-tools": "^14.20.0" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml new file mode 100644 index 000000000000..8a5eb172e019 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'functions' + - 'firestore-app' diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts new file mode 100644 index 000000000000..2600b8bc1ec5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('should only call the function once without any extra calls', async () => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'firebase.function.http.request'; + }); + + await fetch(`http://localhost:5001/demo-functions/default/helloWorld`); + + const transactionEvent = await serverTransactionPromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts).toEqual( + expect.objectContaining({ + trace: expect.objectContaining({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'helloWorld', + 'faas.provider': 'firebase', + 'faas.trigger': 'http.request', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.request', + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: 'http.request', + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }), + }), + ); +}); + +test('should send failed transaction when the function fails', async () => { + const errorEventPromise = waitForError('node-firebase', () => true); + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return !!span.transaction; + }); + + await fetch(`http://localhost:5001/demo-functions/default/unhandeledError`); + + const transactionEvent = await serverTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts?.trace?.trace_id).toEqual(errorEvent.contexts?.trace?.trace_id); + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'There is an error!', + mechanism: { + type: 'auto.firebase.otel.functions', + handled: false, + }, + }, + ], + }, + }); +}); + +test('should create a document and trigger onDocumentCreated and another with authContext', async () => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'firebase.function.http.request'; + }); + + const serverTransactionOnDocumentCreatePromise = waitForTransaction('node-firebase', span => { + return ( + span.transaction === 'firebase.function.firestore.document.created' && + span.contexts?.trace?.data?.['faas.name'] === 'onDocumentCreate' + ); + }); + + const serverTransactionOnDocumentWithAuthContextCreatePromise = waitForTransaction('node-firebase', span => { + return ( + span.transaction === 'firebase.function.firestore.document.created' && + span.contexts?.trace?.data?.['faas.name'] === 'onDocumentCreateWithAuthContext' + ); + }); + + await fetch(`http://localhost:5001/demo-functions/default/onCallSomething`); + + const transactionEvent = await serverTransactionPromise; + const transactionEventOnDocumentCreate = await serverTransactionOnDocumentCreatePromise; + const transactionEventOnDocumentWithAuthContextCreate = await serverTransactionOnDocumentWithAuthContextCreatePromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onCallSomething', + 'faas.provider': 'firebase', + 'faas.trigger': 'http.request', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.request', + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: 'http.request', + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEvent.spans).toHaveLength(3); + expect(transactionEventOnDocumentCreate.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onDocumentCreate', + 'faas.provider': 'firebase', + 'faas.trigger': 'firestore.document.created', + 'otel.kind': 'SERVER', + 'sentry.op': expect.any(String), + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: expect.any(String), + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEventOnDocumentCreate.spans).toHaveLength(2); + expect(transactionEventOnDocumentWithAuthContextCreate.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onDocumentCreateWithAuthContext', + 'faas.provider': 'firebase', + 'faas.trigger': 'firestore.document.created', + 'otel.kind': 'SERVER', + 'sentry.op': expect.any(String), + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: expect.any(String), + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEventOnDocumentWithAuthContextCreate.spans).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json deleted file mode 100644 index 26c30d4eddf2..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist"] -} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json index 8cb64e989ed9..881847032511 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json @@ -3,8 +3,6 @@ "types": ["node"], "esModuleInterop": true, "lib": ["es2018"], - "strict": true, - "outDir": "dist" - }, - "include": ["src/**/*.ts"] + "strict": true + } } diff --git a/packages/node/src/integrations/tracing/firebase/firebase.ts b/packages/node/src/integrations/tracing/firebase/firebase.ts index 649a7089289b..ceb521d54fa3 100644 --- a/packages/node/src/integrations/tracing/firebase/firebase.ts +++ b/packages/node/src/integrations/tracing/firebase/firebase.ts @@ -1,5 +1,5 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { captureException, defineIntegration, flush, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; import { type FirebaseInstrumentationConfig, FirebaseInstrumentation } from './otel'; @@ -11,6 +11,24 @@ const config: FirebaseInstrumentationConfig = { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'db.query'); }, + functions: { + requestHook: span => { + addOriginToSpan(span, 'auto.firebase.otel.functions'); + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.request'); + }, + errorHook: async (_, error) => { + if (error) { + captureException(error, { + mechanism: { + type: 'auto.firebase.otel.functions', + handled: false, + }, + }); + await flush(2000); + } + }, + }, }; export const instrumentFirebase = generateInstrumentOnce(INTEGRATION_NAME, () => new FirebaseInstrumentation(config)); diff --git a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts index ad67ea701079..724005e6f9ed 100644 --- a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts +++ b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts @@ -1,10 +1,12 @@ import { type InstrumentationNodeModuleDefinition, InstrumentationBase } from '@opentelemetry/instrumentation'; import { SDK_VERSION } from '@sentry/core'; import { patchFirestore } from './patches/firestore'; +import { patchFunctions } from './patches/functions'; import type { FirebaseInstrumentationConfig } from './types'; const DefaultFirebaseInstrumentationConfig: FirebaseInstrumentationConfig = {}; const firestoreSupportedVersions = ['>=3.0.0 <5']; // firebase 9+ +const functionsSupportedVersions = ['>=6.0.0 <7']; // firebase-functions v2 /** * Instrumentation for Firebase services, specifically Firestore. @@ -31,6 +33,7 @@ export class FirebaseInstrumentation extends InstrumentationBase {}; + let responseHook: ResponseHook = () => {}; + const errorHook = config.functions?.errorHook; + const configRequestHook = config.functions?.requestHook; + const configResponseHook = config.functions?.responseHook; + + if (typeof configResponseHook === 'function') { + responseHook = (span: Span, err: unknown) => { + safeExecuteInTheMiddle( + () => configResponseHook(span, err), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + if (typeof configRequestHook === 'function') { + requestHook = (span: Span) => { + safeExecuteInTheMiddle( + () => configRequestHook(span), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + + const moduleFunctionsCJS = new InstrumentationNodeModuleDefinition('firebase-functions', functionsSupportedVersions); + const modulesToInstrument = [ + { name: 'firebase-functions/lib/v2/providers/https.js', triggerType: 'function' }, + { name: 'firebase-functions/lib/v2/providers/firestore.js', triggerType: 'firestore' }, + { name: 'firebase-functions/lib/v2/providers/scheduler.js', triggerType: 'scheduler' }, + { name: 'firebase-functions/lib/v2/storage.js', triggerType: 'storage' }, + ] as const; + + modulesToInstrument.forEach(({ name, triggerType }) => { + moduleFunctionsCJS.files.push( + new InstrumentationNodeModuleFile( + name, + functionsSupportedVersions, + moduleExports => + wrapCommonFunctions( + moduleExports, + wrap, + unwrap, + tracer, + { requestHook, responseHook, errorHook }, + triggerType, + ), + moduleExports => unwrapCommonFunctions(moduleExports, unwrap), + ), + ); + }); + + return moduleFunctionsCJS; +} + +/** + * Patches Cloud Functions for Firebase (v2) to add OpenTelemetry instrumentation + * + * @param tracer - Opentelemetry Tracer + * @param functionsConfig - Firebase instrumentation config + * @param triggerType - Type of trigger + * @returns A function that patches the function + */ +export function patchV2Functions( + tracer: Tracer, + functionsConfig: FirebaseInstrumentationConfig['functions'], + triggerType: string, +): (original: T) => (...args: OverloadedParameters) => ReturnType { + return function v2FunctionsWrapper(original: T): (...args: OverloadedParameters) => ReturnType { + return function (this: FirebaseInstrumentation, ...args: OverloadedParameters): ReturnType { + const handler = typeof args[0] === 'function' ? args[0] : args[1]; + const documentOrOptions = typeof args[0] === 'function' ? undefined : args[0]; + + if (!handler) { + return original.call(this, ...args); + } + + const wrappedHandler = async function (this: unknown, ...handlerArgs: unknown[]): Promise { + const functionName = process.env.FUNCTION_TARGET || process.env.K_SERVICE || 'unknown'; + const span = tracer.startSpan(`firebase.function.${triggerType}`, { + kind: SpanKind.SERVER, + }); + + const attributes: SpanAttributes = { + '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; + } + + span.setAttributes(attributes); + functionsConfig?.requestHook?.(span); + + // Can be changed to safeExecuteInTheMiddleAsync once following is merged and released + // https://github.com/open-telemetry/opentelemetry-js/pull/6032 + return context.with(trace.setSpan(context.active(), span), async () => { + let error: Error | undefined; + let result: T | undefined; + + try { + result = await handler.apply(this, handlerArgs); + } catch (e) { + error = e as Error; + } + + functionsConfig?.responseHook?.(span, error); + + if (error) { + span.recordException(error); + } + + span.end(); + + if (error) { + await functionsConfig?.errorHook?.(span, error); + throw error; + } + + return result; + }); + }; + + if (documentOrOptions) { + return original.call(this, documentOrOptions, wrappedHandler); + } else { + return original.call(this, wrappedHandler); + } + }; + }; +} + +function wrapCommonFunctions( + moduleExports: AvailableFirebaseFunctions, + wrap: InstrumentationBase['_wrap'], + unwrap: InstrumentationBase['_unwrap'], + tracer: Tracer, + functionsConfig: FirebaseInstrumentationConfig['functions'], + triggerType: 'function' | 'firestore' | 'scheduler' | 'storage', +): AvailableFirebaseFunctions { + unwrapCommonFunctions(moduleExports, unwrap); + + switch (triggerType) { + case 'function': + wrap(moduleExports, 'onRequest', patchV2Functions(tracer, functionsConfig, 'http.request')); + wrap(moduleExports, 'onCall', patchV2Functions(tracer, functionsConfig, 'http.call')); + break; + + case 'firestore': + wrap(moduleExports, 'onDocumentCreated', patchV2Functions(tracer, functionsConfig, 'firestore.document.created')); + wrap(moduleExports, 'onDocumentUpdated', patchV2Functions(tracer, functionsConfig, 'firestore.document.updated')); + wrap(moduleExports, 'onDocumentDeleted', patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted')); + wrap(moduleExports, 'onDocumentWritten', patchV2Functions(tracer, functionsConfig, 'firestore.document.written')); + wrap( + moduleExports, + 'onDocumentCreatedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.created'), + ); + wrap( + moduleExports, + 'onDocumentUpdatedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.updated'), + ); + + wrap( + moduleExports, + 'onDocumentDeletedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted'), + ); + + wrap( + moduleExports, + 'onDocumentWrittenWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.written'), + ); + break; + + case 'scheduler': + wrap(moduleExports, 'onSchedule', patchV2Functions(tracer, functionsConfig, 'scheduler.scheduled')); + break; + + case 'storage': + wrap(moduleExports, 'onObjectFinalized', patchV2Functions(tracer, functionsConfig, 'storage.object.finalized')); + wrap(moduleExports, 'onObjectArchived', patchV2Functions(tracer, functionsConfig, 'storage.object.archived')); + wrap(moduleExports, 'onObjectDeleted', patchV2Functions(tracer, functionsConfig, 'storage.object.deleted')); + wrap( + moduleExports, + 'onObjectMetadataUpdated', + patchV2Functions(tracer, functionsConfig, 'storage.object.metadataUpdated'), + ); + break; + } + + return moduleExports; +} + +function unwrapCommonFunctions( + moduleExports: AvailableFirebaseFunctions, + unwrap: InstrumentationBase['_unwrap'], +): AvailableFirebaseFunctions { + const methods: (keyof AvailableFirebaseFunctions)[] = [ + 'onSchedule', + 'onRequest', + 'onCall', + 'onObjectFinalized', + 'onObjectArchived', + 'onObjectDeleted', + 'onObjectMetadataUpdated', + 'onDocumentCreated', + 'onDocumentUpdated', + 'onDocumentDeleted', + 'onDocumentWritten', + 'onDocumentCreatedWithAuthContext', + 'onDocumentUpdatedWithAuthContext', + 'onDocumentDeletedWithAuthContext', + 'onDocumentWrittenWithAuthContext', + ]; + + for (const method of methods) { + if (isWrapped(moduleExports[method])) { + unwrap(moduleExports, method); + } + } + return moduleExports; +} diff --git a/packages/node/src/integrations/tracing/firebase/otel/types.ts b/packages/node/src/integrations/tracing/firebase/otel/types.ts index ecc48bc09498..ead830fa2c1a 100644 --- a/packages/node/src/integrations/tracing/firebase/otel/types.ts +++ b/packages/node/src/integrations/tracing/firebase/otel/types.ts @@ -88,8 +88,19 @@ export interface FirestoreSettings { */ export interface FirebaseInstrumentationConfig extends InstrumentationConfig { firestoreSpanCreationHook?: FirestoreSpanCreationHook; + functions?: FunctionsConfig; } +export interface FunctionsConfig { + requestHook?: RequestHook; + responseHook?: ResponseHook; + errorHook?: ErrorHook; +} + +export type RequestHook = (span: Span) => void; +export type ResponseHook = (span: Span, error?: unknown) => void; +export type ErrorHook = (span: Span, error?: unknown) => Promise | void; + export interface FirestoreSpanCreationHook { (span: Span): void; } @@ -117,3 +128,40 @@ export type AddDocType = ( export type DeleteDocType = ( reference: DocumentReference, ) => Promise; + +export type OverloadedParameters = T extends { + (...args: infer A1): unknown; + (...args: infer A2): unknown; +} + ? A1 | A2 + : T extends (...args: infer A) => unknown + ? A + : unknown; + +/** + * A bare minimum of how Cloud Functions for Firebase (v2) are defined. + */ +export type FirebaseFunctions = + | ((handler: () => Promise | unknown) => (...args: unknown[]) => Promise | unknown) + | (( + documentOrOptions: string | string[] | Record, + handler: () => Promise | unknown, + ) => (...args: unknown[]) => Promise | unknown); + +export type AvailableFirebaseFunctions = { + onRequest: FirebaseFunctions; + onCall: FirebaseFunctions; + onDocumentCreated: FirebaseFunctions; + onDocumentUpdated: FirebaseFunctions; + onDocumentDeleted: FirebaseFunctions; + onDocumentWritten: FirebaseFunctions; + onDocumentCreatedWithAuthContext: FirebaseFunctions; + onDocumentUpdatedWithAuthContext: FirebaseFunctions; + onDocumentDeletedWithAuthContext: FirebaseFunctions; + onDocumentWrittenWithAuthContext: FirebaseFunctions; + onSchedule: FirebaseFunctions; + onObjectFinalized: FirebaseFunctions; + onObjectArchived: FirebaseFunctions; + onObjectDeleted: FirebaseFunctions; + onObjectMetadataUpdated: FirebaseFunctions; +};