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/createConfigFromEnv.js b/dev-packages/e2e-tests/test-applications/node-firebase/createConfigFromEnv.js new file mode 100644 index 000000000000..3345a9d868fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/createConfigFromEnv.js @@ -0,0 +1,14 @@ +const path = require('path'); +const dotent = require('dotenv'); +dotent.config({ path: path.resolve(__dirname, './docker/.env') }); + +const createConfigFromEnv = require('./docker/firebase/utils').createConfigFromEnv; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + try { + await createConfigFromEnv(); + } catch (e) { + console.error(e); + } +})(); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/createEnvFromConfig.js b/dev-packages/e2e-tests/test-applications/node-firebase/createEnvFromConfig.js new file mode 100644 index 000000000000..d19ea4cb1dc9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/createEnvFromConfig.js @@ -0,0 +1,10 @@ +const createEnvFromConfig = require('./docker/firebase/utils').createEnvFromConfig; + +(async () => { + try { + await createEnvFromConfig(); + } catch (e) { + console.error(e); + } +})(); + diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/.gitignore b/dev-packages/e2e-tests/test-applications/node-firebase/docker/.gitignore new file mode 100644 index 000000000000..b8b9f2089493 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/.gitignore @@ -0,0 +1,6 @@ +.config +cache +firebase/data +firebase/firebase-export-* +*-debug.log +.env diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/Dockerfile b/dev-packages/e2e-tests/test-applications/node-firebase/docker/Dockerfile new file mode 100644 index 000000000000..ce0fde56dbc7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +ARG FIREBASE_VERSION + +RUN apk --no-cache add openjdk11-jre bash curl openssl gettext nano nginx sudo && \ + npm cache clean --force && \ + npm i -g firebase-tools@$FIREBASE_VERSION + +COPY nginx.conf /etc/nginx/ +COPY serve.sh /usr/bin/ +RUN chmod +x /usr/bin/serve.sh + +WORKDIR /srv/firebase + +ENTRYPOINT ["/usr/bin/serve.sh"] diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/docker-compose.yml b/dev-packages/e2e-tests/test-applications/node-firebase/docker/docker-compose.yml new file mode 100644 index 000000000000..00b019398522 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/docker-compose.yml @@ -0,0 +1,60 @@ +services: + emulator: + container_name: sentry-firebase + build: + context: . + dockerfile: Dockerfile + args: + - FIREBASE_VERSION=13.19.0 + stop_grace_period: 1m + environment: + FIREBASE_AUTH_EMULATOR_HOST: 'localhost:5507' + FIRESTORE_EMULATOR_HOST: 'localhost:5504' + PUBSUB_EMULATOR_HOST: 'localhost:5505' + FUNCTIONS_EMULATOR_HOST: 'localhost:5503' + FIREBASE_PROJECT: 'sentry-15d85' + GCLOUD_PROJECT: 'sentry-15d85' + FORCE_COLOR: 'true' + DATA_DIRECTORY: 'data' + CHOKIDAR_USEPOLLING: 'true' + CONFIG_FIREBASE_TOOLS: ${CONFIG_FIREBASE_TOOLS} + CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS: ${CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS} + ports: + - '5500:4001' # ui + - '5501:4401' # hub + - '5502:4601' # logging + - '5503:5002' # functions + - '5504:8081' # firestore + - '5505:8086' # pubsub + - '5506:9001' # database + - '5507:9100' # auth + - '5508:9200' # Storage + - '5509:6001' # Hosting + - '5510:9081' # firestore (grpc) + - '5511:9230' # cloud_functions_debug + - '9005:9005' # to be able to authenticate using gmail and docker + volumes: + - type: bind + source: ./firebase + target: /srv/firebase + bind: + create_host_path: true + - type: bind + source: ./cache + target: /root/.cache + bind: + create_host_path: true + - type: bind + source: .config + target: /root/.config + bind: + create_host_path: true + - type: bind + source: ./firebase/data + target: /srv/firebase/data + bind: + create_host_path: true + +networks: + default: + driver: bridge diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/.firebaserc b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/.firebaserc new file mode 100644 index 000000000000..392290d3c931 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "sentry-project-436908" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/.gitignore b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/.gitignore new file mode 100644 index 000000000000..b17f63107554 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/createOrUpdateConfigIfPossible.js b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/createOrUpdateConfigIfPossible.js new file mode 100644 index 000000000000..3ac51191deab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/createOrUpdateConfigIfPossible.js @@ -0,0 +1,3 @@ +const createOrUpdateConfigIfPossible = require('./utils').createOrUpdateConfigIfPossible; + +createOrUpdateConfigIfPossible(); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/database.rules.json b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/database.rules.json new file mode 100644 index 000000000000..4335e236f6db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/database.rules.json @@ -0,0 +1,7 @@ +{ + /* Visit https://firebase.google.com/docs/database/security to learn more about security rules. */ + "rules": { + ".read": false, + ".write": false + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firebase.json b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firebase.json new file mode 100644 index 000000000000..714e003f7324 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firebase.json @@ -0,0 +1,25 @@ +{ + "database": { + "rules": "database.rules.json" + }, + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "emulators": { + "auth": { + "port": 5507 + }, + "firestore": { + "port": 5504 + }, + "database": { + "port": 5506 + }, + "ui": { + "enabled": true, + "port": 5500 + }, + "singleProjectMode": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firestore.indexes.json b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firestore.indexes.json new file mode 100644 index 000000000000..415027e5ddaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firestore.rules b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firestore.rules new file mode 100644 index 000000000000..c3c1733dcd8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/firestore.rules @@ -0,0 +1,8 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if true; + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/storage.rules b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/storage.rules new file mode 100644 index 000000000000..4cc806e7d390 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/storage.rules @@ -0,0 +1,8 @@ +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + match /{file=**} { + allow read, write: if true; + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/utils.js b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/utils.js new file mode 100644 index 000000000000..0fc6069c682e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/firebase/utils.js @@ -0,0 +1,145 @@ +const fs = require('fs'); +const path = require('path'); + +const firebaseToolsFileName = 'firebase-tools.json'; +const updateNotifierFirebaseToolsFileName = 'update-notifier-firebase-tools.json'; + +function createJsonFile(filePath, json) { + return new Promise((resolve, reject) => { + let content = JSON.stringify(json, null, 2); + + // replace spaces with tabs + content = content.replace(/[ ]{2}/g, '\t'); + + fs.mkdirSync(filePath.substring(0, filePath.lastIndexOf('/')), { recursive: true }); + fs.writeFile(filePath, content, function (err) { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +function createEnvironmentFile(filePath, content) { + return new Promise((resolve, reject) => { + fs.writeFile(filePath, content, function (err) { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} + +function readJsonFromFile(filePath) { + return new Promise((resolve, reject) => { + fs.readFile(filePath, 'utf8', (err, data) => { + if (err) { + reject(err); + return; + } + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + console.error(err); + } + }); + }); +} + +/** + * Creates firebase configuration based on .env file + */ +async function createConfigFromEnv() { + const dockerPath = path.resolve(__dirname, '..'); + const configPath = path.resolve(dockerPath, '.config/configstore'); + + let filePathFirebaseTools = process.env.CONFIG_FIREBASE_TOOLS; + let filePathUpdateNotifierFirebaseTools = process.env.CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS; + + if (typeof filePathFirebaseTools !== 'string') { + throw new Error('no CONFIG_FIREBASE_TOOLS environment'); + } + if (typeof filePathUpdateNotifierFirebaseTools !== 'string') { + throw new Error('no CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS environment'); + } + + try { + filePathFirebaseTools = JSON.parse(filePathFirebaseTools); + filePathUpdateNotifierFirebaseTools = JSON.parse(filePathUpdateNotifierFirebaseTools); + + await Promise.all([ + createJsonFile(path.resolve(configPath, firebaseToolsFileName), filePathFirebaseTools), + createJsonFile( + path.resolve(configPath, updateNotifierFirebaseToolsFileName), + filePathUpdateNotifierFirebaseTools, + ), + ]); + console.log('firebase config based on environment variables created successfully'); + } catch (e) { + console.error('firebase config creation error', e); + } +} + +/** + * Creates file .env based on firebase configuration + */ +async function createEnvFromConfig() { + const dockerPath = path.resolve(__dirname, '..'); + const configPath = path.resolve(dockerPath, '.config/configstore'); + const dockerFilePath = path.resolve(dockerPath, '.env'); + try { + const results = await Promise.all([ + readJsonFromFile(path.resolve(configPath, firebaseToolsFileName)), + readJsonFromFile(path.resolve(configPath, updateNotifierFirebaseToolsFileName)), + ]); + const filePathFirebaseTools = results[0]; + const filePathUpdateNotifierFirebaseTools = results[1]; + + if (!filePathFirebaseTools) { + throw new Error('Environment variable '); + } + + const content = []; + content.push('# This is autogenerated'); + content.push(`CONFIG_FIREBASE_TOOLS=${JSON.stringify(filePathFirebaseTools)}`); + content.push(`CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS=${JSON.stringify(filePathUpdateNotifierFirebaseTools)}`); + content.push(''); + await createEnvironmentFile(dockerFilePath, content.join('\n')); + + console.log(`environments variables saved in "${dockerFilePath}" based on firebase config`); + } catch (e) { + console.error(e); + } +} + +/** + * Creates or update the existing config whenever environment is defined. This is used by docker and will recreate + * the config each time the docker is run as long as the environment settings exist. + */ +function createOrUpdateConfigIfPossible() { + const filePathFirebaseTools = process.env.CONFIG_FIREBASE_TOOLS; + const filePathUpdateNotifierFirebaseTools = process.env.CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS; + if ( + typeof filePathFirebaseTools === 'string' && + typeof filePathUpdateNotifierFirebaseTools === 'string' && + filePathFirebaseTools !== '' && + filePathUpdateNotifierFirebaseTools !== '' + ) { + createConfigFromEnv(); + } else { + console.error('>>>>>>>>>>>>>>>> WARNING <<<<<<<<<<<<<<<<<<<<<'); + console.error('firebase config creation failed due to missing environment variables'); + console.error('>>>>>>>>>>>>>>>> WARNING <<<<<<<<<<<<<<<<<<<<<'); + } +} + +module.exports = { + createEnvFromConfig: createEnvFromConfig, + createConfigFromEnv: createConfigFromEnv, + createOrUpdateConfigIfPossible: createOrUpdateConfigIfPossible, +}; diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/nginx.conf b/dev-packages/e2e-tests/test-applications/node-firebase/docker/nginx.conf new file mode 100644 index 000000000000..d8189878c99a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/nginx.conf @@ -0,0 +1,150 @@ +daemon off; +worker_processes 1; + +# error_log /dev/stderr; + +events { + worker_connections 1024; +} + +http { + client_max_body_size 100M; + + map $http_upgrade $connection_upgrade { + default upgrade; '' close; + } + + server { + listen 0.0.0.0:4001; + server_name ui; + location / { + proxy_pass http://127.0.0.1:5500; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } + + server { + listen 0.0.0.0:4401; + server_name hub; + location / { + proxy_pass http://127.0.0.1:5501; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } + + server { + listen 0.0.0.0:4601; + server_name logging; + location / { + proxy_pass http://127.0.0.1:5502; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } + + server { + listen 0.0.0.0:5002; + server_name functions; + location / { + proxy_pass http://127.0.0.1:5503; + } + } + + server { + listen 0.0.0.0:8081; + server_name firestore; + location / { + proxy_pass http://127.0.0.1:5504; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } + + server { + listen 0.0.0.0:8086; + http2 on; + server_name pubsub; + location / { + grpc_pass grpc://127.0.0.1:5505; + } + } + + server { + listen 0.0.0.0:9001; + server_name database; + location / { + proxy_pass http://127.0.0.1:5506; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } + + server { + listen 0.0.0.0:9100; + server_name auth; + location / { + proxy_pass http://127.0.0.1:5507; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } + + server { + listen 0.0.0.0:9200; + server_name cloud_storage; + location / { + proxy_pass http://127.0.0.1:5508; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } + + server { + listen 0.0.0.0:6001; + server_name hosting; + location / { + proxy_pass http://127.0.0.1:5509; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } + + server { + listen 0.0.0.0:9081; + http2 on; + server_name firestore; + location / { + grpc_pass grpc://127.0.0.1:5510; + } + } + + server { + listen 0.0.0.0:9230; + server_name cloud_functions_debug; + location / { + proxy_pass http://127.0.0.1:5511; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/docker/serve.sh b/dev-packages/e2e-tests/test-applications/node-firebase/docker/serve.sh new file mode 100644 index 000000000000..23503e69109c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/docker/serve.sh @@ -0,0 +1,64 @@ +#!/bin/bash +set -eo pipefail + +# Function to print error messages and exit +error_exit() { + echo "$1" 1>&2 + exit 1 +} + +# Sanity checks +[[ -z "${DATA_DIRECTORY}" ]] && echo "DATA_DIRECTORY environment variable missing, will not export or import data to firebase" +[[ -z "${FIREBASE_PROJECT}" ]] && error_exit "FIREBASE_PROJECT environment variable missing" + +dirs=("/srv/firebase/functions" "/srv/firebase/firestore" "/srv/firebase/storage") + +for dir in "${dirs[@]}"; do + if [[ -d "$dir" ]]; then + echo "Installing npm packages in $dir" + npm install --prefix "$dir" || error_exit "npm install failed in $dir" + fi +done + +if [[ -z "${CONFIG_FIREBASE_TOOLS}" ]];then + echo "CONFIG_FIREBASE_TOOLS environment variable missing" +fi +if [[ -z "${CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS}" ]];then + echo "CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS environment variable missing" +fi + +node createOrUpdateConfigIfPossible.js +sleep 1 + +# Start Firebase emulators +emulator_cmd="firebase emulators:start --project=${FIREBASE_PROJECT}" +[[ -n "${DATA_DIRECTORY}" ]] && emulator_cmd+=" --import=./${DATA_DIRECTORY}/export --export-on-exit" +$emulator_cmd & +firebase_pid=$! + +# Start nginx and npm +echo "Starting nginx..." +nginx & +nginx_pid=$! + +cleanup() { + echo "Stopping services..." + # Gracefully stop background processes + echo "Terminating background services..." + if [[ -n "$firebase_pid" ]]; then + kill -SIGTERM "$firebase_pid" || echo "Failed to terminate Firebase process" + wait "$firebase_pid" 2>/dev/null + fi + if [[ -n "$nginx_pid" ]]; then + kill -SIGTERM "$nginx_pid" || echo "Failed to terminate Nginx process" + wait "$nginx_pid" 2>/dev/null + fi + if [[ -n "$npm_pid" ]]; then + kill -SIGTERM "$npm_pid" || echo "Failed to terminate NPM process" + wait "$npm_pid" 2>/dev/null + fi +} + +trap cleanup INT TERM SIGTERM SIGINT + +wait 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..6d1e93d917f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -0,0 +1,35 @@ +{ + "name": "node-firebase-e2e-test-app", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "tsc", + "createEnvFromConfig": "node createEnvFromConfig.js", + "createConfigFromEnv": "node createConfigFromEnv.js", + "dev": "tsc --build --watch", + "docker": "cd docker && docker compose up --build -d", + "proxy": "node start-event-proxy.mjs", + "start": "node ./dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm docker && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@firebase/app": "^0.13.1", + "@sentry/node": "latest || *", + "@sentry/core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "dotenv": "^16.4.5", + "firebase": "^11.0.1", + "tsconfig-paths": "^4.2.0", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "~1.50.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "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..be7a18b7bca6 --- /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'; // seems like "firebase/firestore" is trying to use grpc for connection and it + +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', 5504); +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..879606105a13 --- /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': '5504', + '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': '5504', + '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': '5504', + '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': '5504', + '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 83a135e71f21..15a7c4c6b81b 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -37,6 +37,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 f64ee53dc47c..71b2ba4bfd9b 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -86,6 +86,7 @@ export { connectIntegration, setupConnectErrorHandler, fastifyIntegration, + firebaseIntegration, fsIntegration, genericPoolIntegration, graphqlIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4a9d7fd9d71c..68b32f10dac0 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -104,6 +104,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 f0bed369acee..9e683deb23ea 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -87,6 +87,7 @@ export { connectIntegration, setupConnectErrorHandler, fastifyIntegration, + firebaseIntegration, genericPoolIntegration, graphqlIntegration, knexIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 1c02da9fff2e..9f1de49ec5ea 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -37,6 +37,7 @@ export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { childProcessIntegration } from './integrations/childProcess'; export { createSentryWinstonTransport } from './integrations/winston'; +export { firebaseIntegration } from './integrations/tracing/firebase'; export { SentryContextManager } from './otel/contextManager'; export { generateInstrumentOnce } from './otel/instrument'; 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..c2e701d24e9a --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/firebase.ts @@ -0,0 +1,29 @@ +import type { Span } from '@opentelemetry/api'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { generateInstrumentOnce } from '../../../otel/instrument'; +import { addOriginToSpan } from '../../../utils/addOriginToSpan'; +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..e4fa56cca4a5 --- /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 patchAddDoc( + reference: CollectionReference, + data: WithFieldValue, + ): Promise> { + const span = startSpan(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 patchDeleteDoc(reference: DocumentReference): Promise { + const span = startSpan(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 patchGetDocs( + reference: CollectionReference, + ): Promise> { + const span = startSpan(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 patchSetDoc( + reference: DocumentReference, + data: WithFieldValue & PartialWithFieldValue, + options?: SetOptions, + ): Promise { + const span = startSpan(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 startSpan( + 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..d6efadaa77e7 --- /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: string; // 'collection' or 'document' + 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 e7122562d619..242f6e1b9d63 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, instrumentExpressV5 } 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'; @@ -45,6 +46,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { amqplibIntegration(), lruMemoizerIntegration(), vercelAIIntegration(), + firebaseIntegration(), postgresJsIntegration(), ]; } @@ -77,6 +79,7 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentGenericPool, instrumentAmqplib, instrumentVercelAi, + instrumentFirebase, instrumentPostgresJs, ]; }