diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc b/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc new file mode 100644 index 000000000000..47e4665f6905 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "sentry-firebase-e2e-test-f4ed3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.gitignore b/dev-packages/e2e-tests/test-applications/node-firebase/.gitignore new file mode 100644 index 000000000000..48b1bd712db4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/.gitignore @@ -0,0 +1,58 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +test-results diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.npmrc b/dev-packages/e2e-tests/test-applications/node-firebase/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/README.md b/dev-packages/e2e-tests/test-applications/node-firebase/README.md new file mode 100644 index 000000000000..e44ee12f5268 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/README.md @@ -0,0 +1,64 @@ +## Assuming you already have installed docker desktop or orbstack etc. or any other docker software + +### Enabling / authorising firebase emulator through docker + +1. Run the docker + +```bash +pnpm docker +``` + +2. In new tab, enter the docker container by simply running + +```bash +docker exec -it sentry-firebase bash +``` + +3. Now inside docker container run + +```bash +firebase login +``` + +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 +``` + +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 + +### 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 +``` + +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. + +**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.** + +### Scripts - helpers + +- 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 + +Use these scripts when testing and updating the environment settings on CLI diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json new file mode 100644 index 000000000000..05203f1d6567 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json @@ -0,0 +1,20 @@ +{ + "firestore": { + "database": "(default)", + "location": "nam5", + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "emulators": { + "firestore": { + "port": 8080 + }, + "database": { + "port": 9000 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore.indexes.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.indexes.json new file mode 100644 index 000000000000..415027e5ddaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules new file mode 100644 index 000000000000..260e089a299b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules @@ -0,0 +1,18 @@ +rules_version='2' + +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + // This rule allows anyone with your database reference to view, edit, + // and delete all data in your database. It is useful for getting + // started, but it is configured to expire after 30 days because it + // leaves your app open to attackers. At that time, all client + // requests to your database will be denied. + // + // Make sure to write security rules for your app before that time, or + // else all client requests to your database will be denied until you + // update your rules. + allow read, write: if request.time < timestamp.date(2025, 8, 17); + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/package.json new file mode 100644 index 000000000000..0a23fbbeef92 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -0,0 +1,38 @@ +{ + "name": "node-firebase-e2e-test-app", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch", + "proxy": "node start-event-proxy.mjs", + "emulate": "firebase emulators:start &", + "start": "node ./dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm firebase emulators:exec 'pnpm test'" + }, + "dependencies": { + "@firebase/app": "^0.13.1", + "@sentry/node": "latest || *", + "@sentry/core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/node": "^18.19.1", + "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" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/express": "^4.17.13", + "firebase-tools": "^12.0.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-firebase/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts b/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts new file mode 100644 index 000000000000..486aa06b5ffc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts @@ -0,0 +1,71 @@ +import * as Sentry from '@sentry/node'; +import './init'; +import express from 'express'; +import type { FirebaseOptions } from '@firebase/app'; +import { initializeApp } from 'firebase/app'; +import { + addDoc, + collection, + connectFirestoreEmulator, + deleteDoc, + doc, + getDocs, + getFirestore, + setDoc, +} from 'firebase/firestore/lite'; + +const options: FirebaseOptions = { + projectId: 'sentry-15d85', + apiKey: 'sentry-fake-api-key', +}; + +const app = initializeApp(options); + +const db = getFirestore(app); +connectFirestoreEmulator(db, '127.0.0.1', 8080); +const citiesRef = collection(db, 'cities'); + +async function addCity(): Promise { + await addDoc(citiesRef, { + name: 'San Francisco', + }); +} + +async function getCities(): Promise { + const citySnapshot = await getDocs(citiesRef); + const cityList = citySnapshot.docs.map(doc => doc.data()); + return cityList; +} + +async function deleteCity(): Promise { + await deleteDoc(doc(citiesRef, 'SF')); +} + +async function setCity(): Promise { + await setDoc(doc(citiesRef, 'SF'), { + name: 'San Francisco', + state: 'CA', + country: 'USA', + capital: false, + population: 860000, + regions: ['west_coast', 'norcal'], + }); +} + +const expressApp = express(); +const port = 3030; + +expressApp.get('/test', async function (req, res) { + await Sentry.startSpan({ name: 'Test Transaction' }, async () => { + await addCity(); + await setCity(); + await getCities(); + await deleteCity(); + }); + await Sentry.flush(); + res.send({ version: 'v1' }); +}); + +expressApp.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts new file mode 100644 index 000000000000..23c3d2fa5974 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts @@ -0,0 +1,11 @@ +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/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-firebase/start-event-proxy.mjs new file mode 100644 index 000000000000..d935bf3dcc0b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-firebase', +}); 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 new file mode 100644 index 000000000000..1fcb2c8047f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const spanAddDoc = expect.objectContaining({ + description: 'addDoc cities', + data: expect.objectContaining({ + 'db.collection.name': 'cities', + 'db.namespace': '[DEFAULT]', + 'db.operation.name': 'addDoc', + '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 spanSetDocs = expect.objectContaining({ + description: 'setDocs cities', + data: expect.objectContaining({ + 'db.collection.name': 'cities', + 'db.namespace': '[DEFAULT]', + 'db.operation.name': 'setDocs', + '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', +}); + +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', +}); + +test('should add, set, get and delete document', async ({ baseURL, page }) => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'Test Transaction'; + }); + + await fetch(`${baseURL}/test`); + + const transactionEvent = await serverTransactionPromise; + + expect(transactionEvent.transaction).toEqual('Test Transaction'); + expect(transactionEvent.spans?.length).toEqual(4); + + expect(transactionEvent.spans).toEqual(expect.arrayContaining([spanAddDoc, spanSetDocs, spanGetDocs, spanDeleteDoc])); +}); 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 new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "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 new file mode 100644 index 000000000000..8cb64e989ed9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 0b92c8a4a6f8..a9e81aee7db5 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -39,6 +39,7 @@ export { expressIntegration, extraErrorDataIntegration, fastifyIntegration, + firebaseIntegration, flush, fsIntegration, functionToStringIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 7cf8e17f0dd7..b99c481fd1d3 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -89,6 +89,7 @@ export { connectIntegration, setupConnectErrorHandler, fastifyIntegration, + firebaseIntegration, fsIntegration, genericPoolIntegration, graphqlIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 024e3e3af5e8..b9af910eb0f1 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -107,6 +107,7 @@ export { setupExpressErrorHandler, fastifyIntegration, setupFastifyErrorHandler, + firebaseIntegration, koaIntegration, setupKoaErrorHandler, connectIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index ba6d9640a8b5..8339e95c77a3 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -90,6 +90,7 @@ export { connectIntegration, setupConnectErrorHandler, fastifyIntegration, + firebaseIntegration, genericPoolIntegration, graphqlIntegration, knexIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 4e7a8482c474..bba0f98bc75e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -32,6 +32,7 @@ export { statsigIntegration, unleashIntegration, } from './integrations/featureFlagShims'; +export { firebaseIntegration } from './integrations/tracing/firebase'; export { init, diff --git a/packages/node/src/integrations/tracing/firebase/README.md b/packages/node/src/integrations/tracing/firebase/README.md new file mode 100644 index 000000000000..6d839a4476f5 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/README.md @@ -0,0 +1 @@ +The structure inside OTEL is to be kept as close as possible to opentelemetry plugin. diff --git a/packages/node/src/integrations/tracing/firebase/firebase.ts b/packages/node/src/integrations/tracing/firebase/firebase.ts new file mode 100644 index 000000000000..9f2abbfe31fd --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/firebase.ts @@ -0,0 +1,28 @@ +import type { Span } from '@opentelemetry/api'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; +import { type FirebaseInstrumentationConfig, FirebaseInstrumentation } from './otel'; + +const INTEGRATION_NAME = 'Firebase'; + +const config: FirebaseInstrumentationConfig = { + firestoreSpanCreationHook: span => { + addOriginToSpan(span as Span, 'auto.firebase.otel.firestore'); + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'db.query'); + }, +}; + +export const instrumentFirebase = generateInstrumentOnce(INTEGRATION_NAME, () => new FirebaseInstrumentation(config)); + +const _firebaseIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentFirebase(); + }, + }; +}) satisfies IntegrationFn; + +export const firebaseIntegration = defineIntegration(_firebaseIntegration); diff --git a/packages/node/src/integrations/tracing/firebase/index.ts b/packages/node/src/integrations/tracing/firebase/index.ts new file mode 100644 index 000000000000..5588511bf303 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/index.ts @@ -0,0 +1 @@ +export * from './firebase'; diff --git a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts new file mode 100644 index 000000000000..ad67ea701079 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts @@ -0,0 +1,37 @@ +import { type InstrumentationNodeModuleDefinition, InstrumentationBase } from '@opentelemetry/instrumentation'; +import { SDK_VERSION } from '@sentry/core'; +import { patchFirestore } from './patches/firestore'; +import type { FirebaseInstrumentationConfig } from './types'; + +const DefaultFirebaseInstrumentationConfig: FirebaseInstrumentationConfig = {}; +const firestoreSupportedVersions = ['>=3.0.0 <5']; // firebase 9+ + +/** + * Instrumentation for Firebase services, specifically Firestore. + */ +export class FirebaseInstrumentation extends InstrumentationBase { + public constructor(config: FirebaseInstrumentationConfig = DefaultFirebaseInstrumentationConfig) { + super('@sentry/instrumentation-firebase', SDK_VERSION, config); + } + + /** + * sets config + * @param config + */ + public override setConfig(config: FirebaseInstrumentationConfig = {}): void { + super.setConfig({ ...DefaultFirebaseInstrumentationConfig, ...config }); + } + + /** + * + * @protected + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected init(): InstrumentationNodeModuleDefinition | InstrumentationNodeModuleDefinition[] | void { + const modules: InstrumentationNodeModuleDefinition[] = []; + + modules.push(patchFirestore(this.tracer, firestoreSupportedVersions, this._wrap, this._unwrap, this.getConfig())); + + return modules; + } +} diff --git a/packages/node/src/integrations/tracing/firebase/otel/index.ts b/packages/node/src/integrations/tracing/firebase/otel/index.ts new file mode 100644 index 000000000000..3b914e641ec0 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/otel/index.ts @@ -0,0 +1,2 @@ +export * from './firebaseInstrumentation'; +export * from './types'; diff --git a/packages/node/src/integrations/tracing/firebase/otel/patches/firestore.ts b/packages/node/src/integrations/tracing/firebase/otel/patches/firestore.ts new file mode 100644 index 000000000000..f13362995ae4 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/otel/patches/firestore.ts @@ -0,0 +1,291 @@ +import type { Span, Tracer } from '@opentelemetry/api'; +import { context, diag, SpanKind, trace } from '@opentelemetry/api'; +import { + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + isWrapped, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { + ATTR_DB_COLLECTION_NAME, + ATTR_DB_NAMESPACE, + ATTR_DB_OPERATION_NAME, + ATTR_DB_SYSTEM_NAME, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; +import type { SpanAttributes } from '@sentry/core'; +import type { unwrap as shimmerUnwrap, wrap as shimmerWrap } from 'shimmer'; +import type { FirebaseInstrumentation } from '../firebaseInstrumentation'; +import type { + AddDocType, + CollectionReference, + DeleteDocType, + DocumentData, + DocumentReference, + FirebaseApp, + FirebaseInstrumentationConfig, + FirebaseOptions, + FirestoreSettings, + FirestoreSpanCreationHook, + GetDocsType, + PartialWithFieldValue, + QuerySnapshot, + SetDocType, + SetOptions, + WithFieldValue, +} from '../types'; + +/** + * + * @param tracer - Opentelemetry Tracer + * @param firestoreSupportedVersions - supported version of firebase/firestore + * @param wrap - reference to native instrumentation wrap function + * @param unwrap - reference to native instrumentation wrap function + */ +export function patchFirestore( + tracer: Tracer, + firestoreSupportedVersions: string[], + wrap: typeof shimmerWrap, + unwrap: typeof shimmerUnwrap, + config: FirebaseInstrumentationConfig, +): InstrumentationNodeModuleDefinition { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const defaultFirestoreSpanCreationHook: FirestoreSpanCreationHook = () => {}; + + let firestoreSpanCreationHook: FirestoreSpanCreationHook = defaultFirestoreSpanCreationHook; + const configFirestoreSpanCreationHook = config.firestoreSpanCreationHook; + + if (typeof configFirestoreSpanCreationHook === 'function') { + firestoreSpanCreationHook = (span: Span) => { + safeExecuteInTheMiddle( + () => configFirestoreSpanCreationHook(span), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + + const moduleFirestoreCJS = new InstrumentationNodeModuleDefinition( + '@firebase/firestore', + firestoreSupportedVersions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (moduleExports: any) => wrapMethods(moduleExports, wrap, unwrap, tracer, firestoreSpanCreationHook), + ); + const files: string[] = [ + '@firebase/firestore/dist/lite/index.node.cjs.js', + '@firebase/firestore/dist/lite/index.node.mjs.js', + '@firebase/firestore/dist/lite/index.rn.esm2017.js', + '@firebase/firestore/dist/lite/index.cjs.js', + ]; + + for (const file of files) { + moduleFirestoreCJS.files.push( + new InstrumentationNodeModuleFile( + file, + firestoreSupportedVersions, + moduleExports => wrapMethods(moduleExports, wrap, unwrap, tracer, firestoreSpanCreationHook), + moduleExports => unwrapMethods(moduleExports, unwrap), + ), + ); + } + + return moduleFirestoreCJS; +} + +function wrapMethods( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + moduleExports: any, + wrap: typeof shimmerWrap, + unwrap: typeof shimmerUnwrap, + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any { + unwrapMethods(moduleExports, unwrap); + + wrap(moduleExports, 'addDoc', patchAddDoc(tracer, firestoreSpanCreationHook)); + wrap(moduleExports, 'getDocs', patchGetDocs(tracer, firestoreSpanCreationHook)); + wrap(moduleExports, 'setDoc', patchSetDoc(tracer, firestoreSpanCreationHook)); + wrap(moduleExports, 'deleteDoc', patchDeleteDoc(tracer, firestoreSpanCreationHook)); + + return moduleExports; +} + +function unwrapMethods( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + moduleExports: any, + unwrap: typeof shimmerUnwrap, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (isWrapped(moduleExports.addDoc)) { + unwrap(moduleExports, 'addDoc'); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (isWrapped(moduleExports.getDocs)) { + unwrap(moduleExports, 'getDocs'); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (isWrapped(moduleExports.setDoc)) { + unwrap(moduleExports, 'setDoc'); + } + + return moduleExports; +} + +function patchAddDoc( + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, +): ( + original: AddDocType, +) => ( + this: FirebaseInstrumentation, + reference: CollectionReference, + data: WithFieldValue, +) => Promise> { + return function addDoc(original: AddDocType) { + return function ( + reference: CollectionReference, + data: WithFieldValue, + ): Promise> { + const span = startDBSpan(tracer, 'addDoc', reference); + firestoreSpanCreationHook(span); + return executeContextWithSpan>>(span, () => { + return original(reference, data); + }); + }; + }; +} + +function patchDeleteDoc( + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, +): ( + original: DeleteDocType, +) => (this: FirebaseInstrumentation, reference: DocumentReference) => Promise { + return function deleteDoc(original: DeleteDocType) { + return function (reference: DocumentReference): Promise { + const span = startDBSpan(tracer, 'deleteDoc', reference.parent || reference); + firestoreSpanCreationHook(span); + return executeContextWithSpan>(span, () => { + return original(reference); + }); + }; + }; +} + +function patchGetDocs( + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, +): ( + original: GetDocsType, +) => ( + this: FirebaseInstrumentation, + reference: CollectionReference, +) => Promise> { + return function getDocs(original: GetDocsType) { + return function ( + reference: CollectionReference, + ): Promise> { + const span = startDBSpan(tracer, 'getDocs', reference); + firestoreSpanCreationHook(span); + return executeContextWithSpan>>(span, () => { + return original(reference); + }); + }; + }; +} + +function patchSetDoc( + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, +): ( + original: SetDocType, +) => ( + this: FirebaseInstrumentation, + reference: DocumentReference, + data: WithFieldValue & PartialWithFieldValue, + options?: SetOptions, +) => Promise { + return function setDoc(original: SetDocType) { + return function ( + reference: DocumentReference, + data: WithFieldValue & PartialWithFieldValue, + options?: SetOptions, + ): Promise { + const span = startDBSpan(tracer, 'setDocs', reference.parent || reference); + firestoreSpanCreationHook(span); + + return executeContextWithSpan>(span, () => { + return typeof options !== 'undefined' ? original(reference, data, options) : original(reference, data); + }); + }; + }; +} + +function executeContextWithSpan(span: Span, callback: () => T): T { + return context.with(trace.setSpan(context.active(), span), () => { + return safeExecuteInTheMiddle( + (): T => { + return callback(); + }, + err => { + if (err) { + span.recordException(err); + } + span.end(); + }, + true, + ); + }); +} + +function startDBSpan( + tracer: Tracer, + spanName: string, + reference: CollectionReference | DocumentReference, +): Span { + const span = tracer.startSpan(`${spanName} ${reference.path}`, { kind: SpanKind.CLIENT }); + addAttributes(span, reference); + span.setAttribute(ATTR_DB_OPERATION_NAME, spanName); + return span; +} + +function addAttributes( + span: Span, + reference: CollectionReference | DocumentReference, +): void { + 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 = { + [ATTR_DB_COLLECTION_NAME]: reference.path, + [ATTR_DB_NAMESPACE]: firestoreApp.name, + [ATTR_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, + }; + + if (typeof settings.host === 'string') { + const arr = settings.host.split(':'); + if (arr.length === 2) { + attributes[ATTR_SERVER_ADDRESS] = arr[0]; + attributes[ATTR_SERVER_PORT] = arr[1]; + } + } + + span.setAttributes(attributes); +} diff --git a/packages/node/src/integrations/tracing/firebase/otel/types.ts b/packages/node/src/integrations/tracing/firebase/otel/types.ts new file mode 100644 index 000000000000..ecc48bc09498 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/otel/types.ts @@ -0,0 +1,119 @@ +import type { Span } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; + +// Inlined types from 'firebase/app' +export interface FirebaseOptions { + [key: string]: any; + apiKey?: string; + authDomain?: string; + databaseURL?: string; + projectId?: string; + storageBucket?: string; + messagingSenderId?: string; + appId?: string; + measurementId?: string; +} + +export interface FirebaseApp { + name: string; + options: FirebaseOptions; + automaticDataCollectionEnabled: boolean; + delete(): Promise; +} + +// Inlined types from 'firebase/firestore' +export interface DocumentData { + [field: string]: any; +} + +export type WithFieldValue = T; + +export type PartialWithFieldValue = Partial; + +export interface SetOptions { + merge?: boolean; + mergeFields?: (string | number | symbol)[]; +} + +export interface DocumentReference { + id: string; + firestore: { + app: FirebaseApp; + settings: FirestoreSettings; + useEmulator: (host: string, port: number) => void; + toJSON: () => { + app: FirebaseApp; + settings: FirestoreSettings; + }; + }; + type: 'collection' | 'document' | string; + path: string; + parent: CollectionReference; +} + +export interface CollectionReference { + id: string; + firestore: { + app: FirebaseApp; + settings: FirestoreSettings; + useEmulator: (host: string, port: number) => void; + toJSON: () => { + app: FirebaseApp; + settings: FirestoreSettings; + }; + }; + type: string; // 'collection' or 'document' + path: string; + parent: DocumentReference | null; +} + +export interface QuerySnapshot { + docs: Array>; + size: number; + empty: boolean; +} + +export interface FirestoreSettings { + host?: string; + ssl?: boolean; + ignoreUndefinedProperties?: boolean; + cacheSizeBytes?: number; + experimentalForceLongPolling?: boolean; + experimentalAutoDetectLongPolling?: boolean; + useFetchStreams?: boolean; +} + +/** + * Firebase Auto Instrumentation + */ +export interface FirebaseInstrumentationConfig extends InstrumentationConfig { + firestoreSpanCreationHook?: FirestoreSpanCreationHook; +} + +export interface FirestoreSpanCreationHook { + (span: Span): void; +} + +// Function types (addDoc, getDocs, setDoc, deleteDoc) are defined below as types +export type GetDocsType = ( + query: CollectionReference, +) => Promise>; + +export type SetDocType = (( + reference: DocumentReference, + data: WithFieldValue, +) => Promise) & + (( + reference: DocumentReference, + data: PartialWithFieldValue, + options: SetOptions, + ) => Promise); + +export type AddDocType = ( + reference: CollectionReference, + data: WithFieldValue, +) => Promise>; + +export type DeleteDocType = ( + reference: DocumentReference, +) => Promise; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index f27b3cf615e8..6035cf3669f8 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -4,6 +4,7 @@ import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { connectIntegration, instrumentConnect } from './connect'; import { expressIntegration, instrumentExpress } from './express'; import { fastifyIntegration, instrumentFastify, instrumentFastifyV3 } from './fastify'; +import { firebaseIntegration, instrumentFirebase } from './firebase'; import { genericPoolIntegration, instrumentGenericPool } from './genericPool'; import { graphqlIntegration, instrumentGraphql } from './graphql'; import { hapiIntegration, instrumentHapi } from './hapi'; @@ -48,6 +49,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { vercelAIIntegration(), openAIIntegration(), postgresJsIntegration(), + firebaseIntegration(), ]; } @@ -80,5 +82,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentVercelAi, instrumentOpenAi, instrumentPostgresJs, + instrumentFirebase, ]; }