From ff46dce877216a786692d8efb982bc83dde8417d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 24 May 2024 14:28:19 +0200 Subject: [PATCH 1/4] feat(node): Add `@sentry/node/preload` hook --- .github/workflows/build.yml | 2 + .../node-express-cjs-preload/.npmrc | 2 + .../node-express-cjs-preload/package.json | 24 ++ .../playwright.config.mjs | 69 ++++++ .../node-express-cjs-preload/src/app.js | 46 ++++ .../start-event-proxy.mjs | 6 + .../tests/server.test.ts | 123 ++++++++++ .../node-express-esm-preload/.npmrc | 2 + .../node-express-esm-preload/package.json | 24 ++ .../playwright.config.mjs | 69 ++++++ .../node-express-esm-preload/src/app.mjs | 46 ++++ .../start-event-proxy.mjs | 6 + .../tests/server.test.ts | 123 ++++++++++ packages/node/package.json | 8 + packages/node/rollup.npm.config.mjs | 2 +- packages/node/src/index.ts | 4 +- packages/node/src/init.ts | 2 +- packages/node/src/integrations/http.ts | 216 ++++++++++-------- .../node/src/integrations/tracing/connect.ts | 10 +- .../node/src/integrations/tracing/express.ts | 80 ++++--- .../node/src/integrations/tracing/fastify.ts | 24 +- .../node/src/integrations/tracing/graphql.ts | 39 ++-- .../src/integrations/tracing/hapi/index.ts | 10 +- .../node/src/integrations/tracing/index.ts | 48 +++- packages/node/src/integrations/tracing/koa.ts | 83 +++---- .../node/src/integrations/tracing/mongo.ts | 24 +- .../node/src/integrations/tracing/mongoose.ts | 24 +- .../node/src/integrations/tracing/mysql.ts | 10 +- .../node/src/integrations/tracing/mysql2.ts | 24 +- .../node/src/integrations/tracing/nest.ts | 11 +- .../node/src/integrations/tracing/postgres.ts | 26 ++- .../node/src/integrations/tracing/prisma.ts | 25 +- .../node/src/integrations/tracing/redis.ts | 107 +++++---- packages/node/src/otel/instrument.ts | 31 +++ packages/node/src/preload.ts | 19 ++ packages/node/src/sdk/{init.ts => index.ts} | 32 +-- packages/node/src/sdk/initOtel.ts | 103 ++++++++- packages/node/test/helpers/mockSdkInit.ts | 2 +- packages/node/test/sdk/init.test.ts | 2 +- packages/node/test/sdk/preload.test.ts | 50 ++++ 40 files changed, 1195 insertions(+), 363 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-cjs-preload/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-express-cjs-preload/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-cjs-preload/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-cjs-preload/src/app.js create mode 100644 dev-packages/e2e-tests/test-applications/node-express-cjs-preload/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-cjs-preload/tests/server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-esm-preload/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-express-esm-preload/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-esm-preload/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-esm-preload/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts create mode 100644 packages/node/src/otel/instrument.ts create mode 100644 packages/node/src/preload.ts rename packages/node/src/sdk/{init.ts => index.ts} (87%) create mode 100644 packages/node/test/sdk/preload.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94cd5c28e07f..dd827c293a61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1003,7 +1003,9 @@ jobs: 'create-remix-app-express-vite-dev', 'debug-id-sourcemaps', 'node-express-esm-loader', + 'node-express-esm-preload', 'node-express-esm-without-loader', + 'node-express-cjs-preload', 'nextjs-app-dir', 'nextjs-14', 'nextjs-15', diff --git a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/.npmrc b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/.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-express-cjs-preload/package.json b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/package.json new file mode 100644 index 000000000000..8d98a54b8d7e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/package.json @@ -0,0 +1,24 @@ +{ + "name": "node-express-cjs-preload", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node --require @sentry/node/preload src/app.js", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "playwright test" + }, + "dependencies": { + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "express": "4.19.2" + }, + "devDependencies": { + "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server", + "@playwright/test": "^1.27.1" + }, + "volta": { + "extends": "../../package.json", + "node": "18.19.1" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/playwright.config.mjs new file mode 100644 index 000000000000..59b8f10d691b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/playwright.config.mjs @@ -0,0 +1,69 @@ +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const eventProxyPort = 3031; +const expressPort = 3030; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${expressPort}`, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'node start-event-proxy.mjs', + port: eventProxyPort, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: expressPort, + stdout: 'pipe', + stderr: 'pipe', + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/src/app.js b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/src/app.js new file mode 100644 index 000000000000..e6bbe57695c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/src/app.js @@ -0,0 +1,46 @@ +const Sentry = require('@sentry/node'); +const express = require('express'); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + setTimeout(() => { + res.status(200).end(); + }, 100); +}); + +app.get('/test-transaction/:param', function (req, res) { + setTimeout(() => { + res.status(200).end(); + }, 100); +}); + +app.get('/test-error', function (req, res) { + Sentry.captureException(new Error('This is an error')); + setTimeout(() => { + Sentry.flush(2000).then(() => { + res.status(200).end(); + }); + }, 100); +}); + +Sentry.setupExpressErrorHandler(app); + +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/start-event-proxy.mjs new file mode 100644 index 000000000000..e2b0f5436f3d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-cjs-preload', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/tests/server.test.ts new file mode 100644 index 000000000000..3ca97ad0b207 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/tests/server.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server'; + +test('Should record exceptions captured inside handlers', async ({ request }) => { + const errorEventPromise = waitForError('node-express-cjs-preload', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('This is an error'); + }); + + await request.get('/test-error'); + + await expect(errorEventPromise).resolves.toBeDefined(); +}); + +test('Should record a transaction for a parameterless route', async ({ request }) => { + const transactionEventPromise = waitForTransaction('node-express-cjs-preload', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-success'; + }); + + await request.get('/test-success'); + + await expect(transactionEventPromise).resolves.toBeDefined(); +}); + +test('Should record a transaction for route with parameters', async ({ request }) => { + const transactionEventPromise = waitForTransaction('node-express-cjs-preload', transactionEvent => { + return transactionEvent.contexts?.trace?.data?.['http.target'] === '/test-transaction/1'; + }); + + await request.get('/test-transaction/1'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent.transaction).toEqual('GET /test-transaction/:param'); + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.flavor': '1.1', + 'http.host': 'localhost:3030', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/test-transaction/:param', + 'http.scheme': 'http', + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/test-transaction/1', + 'http.url': 'http://localhost:3030/test-transaction/1', + 'http.user_agent': expect.any(String), + 'net.host.ip': expect.any(String), + 'net.host.name': 'localhost', + 'net.host.port': 3030, + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'net.transport': 'ip_tcp', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + url: 'http://localhost:3030/test-transaction/1', + }), + ); + + const spans = transactionEvent.spans || []; + expect(spans).toContainEqual({ + data: { + 'express.name': 'query', + 'express.type': 'middleware', + 'http.route': '/', + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + }, + op: 'middleware.express', + description: 'query', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'express.name': 'expressInit', + 'express.type': 'middleware', + 'http.route': '/', + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + }, + op: 'middleware.express', + description: 'expressInit', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'express.name': '/test-transaction/:param', + 'express.type': 'request_handler', + 'http.route': '/test-transaction/:param', + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + }, + op: 'request_handler.express', + description: '/test-transaction/:param', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/.npmrc b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/.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-express-esm-preload/package.json b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/package.json new file mode 100644 index 000000000000..20bda187d3a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/package.json @@ -0,0 +1,24 @@ +{ + "name": "node-express-esm-preload", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node --import @sentry/node/preload src/app.mjs", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "playwright test" + }, + "dependencies": { + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "express": "4.19.2" + }, + "devDependencies": { + "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server", + "@playwright/test": "^1.27.1" + }, + "volta": { + "extends": "../../package.json", + "node": "18.19.1" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/playwright.config.mjs new file mode 100644 index 000000000000..59b8f10d691b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/playwright.config.mjs @@ -0,0 +1,69 @@ +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const eventProxyPort = 3031; +const expressPort = 3030; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${expressPort}`, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'node start-event-proxy.mjs', + port: eventProxyPort, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: expressPort, + stdout: 'pipe', + stderr: 'pipe', + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs new file mode 100644 index 000000000000..f4740c7b4404 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + setTimeout(() => { + res.status(200).end(); + }, 100); +}); + +app.get('/test-transaction/:param', function (req, res) { + setTimeout(() => { + res.status(200).end(); + }, 100); +}); + +app.get('/test-error', function (req, res) { + Sentry.captureException(new Error('This is an error')); + setTimeout(() => { + Sentry.flush(2000).then(() => { + res.status(200).end(); + }); + }, 100); +}); + +Sentry.setupExpressErrorHandler(app); + +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/start-event-proxy.mjs new file mode 100644 index 000000000000..6b5d011dcb03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-esm-preload', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts new file mode 100644 index 000000000000..19803d7b3a7f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server'; + +test('Should record exceptions captured inside handlers', async ({ request }) => { + const errorEventPromise = waitForError('node-express-esm-preload', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('This is an error'); + }); + + await request.get('/test-error'); + + await expect(errorEventPromise).resolves.toBeDefined(); +}); + +test('Should record a transaction for a parameterless route', async ({ request }) => { + const transactionEventPromise = waitForTransaction('node-express-esm-preload', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-success'; + }); + + await request.get('/test-success'); + + await expect(transactionEventPromise).resolves.toBeDefined(); +}); + +test('Should record a transaction for route with parameters', async ({ request }) => { + const transactionEventPromise = waitForTransaction('node-express-esm-preload', transactionEvent => { + return transactionEvent.contexts?.trace?.data?.['http.target'] === '/test-transaction/1'; + }); + + await request.get('/test-transaction/1'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent.transaction).toEqual('GET /test-transaction/:param'); + expect(transactionEvent.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.flavor': '1.1', + 'http.host': 'localhost:3030', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/test-transaction/:param', + 'http.scheme': 'http', + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/test-transaction/1', + 'http.url': 'http://localhost:3030/test-transaction/1', + 'http.user_agent': expect.any(String), + 'net.host.ip': expect.any(String), + 'net.host.name': 'localhost', + 'net.host.port': 3030, + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'net.transport': 'ip_tcp', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + url: 'http://localhost:3030/test-transaction/1', + }), + ); + + const spans = transactionEvent.spans || []; + expect(spans).toContainEqual({ + data: { + 'express.name': 'query', + 'express.type': 'middleware', + 'http.route': '/', + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + }, + op: 'middleware.express', + description: 'query', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'express.name': 'expressInit', + 'express.type': 'middleware', + 'http.route': '/', + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + }, + op: 'middleware.express', + description: 'expressInit', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'express.name': '/test-transaction/:param', + 'express.type': 'request_handler', + 'http.route': '/test-transaction/:param', + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + }, + op: 'request_handler.express', + description: '/test-transaction/:param', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); +}); diff --git a/packages/node/package.json b/packages/node/package.json index c5cea8979ef3..4021baaa3fc8 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -49,6 +49,14 @@ "require": { "default": "./build/cjs/init.js" } + }, + "./preload": { + "import": { + "default": "./build/esm/preload.js" + }, + "require": { + "default": "./build/cjs/preload.js" + } } }, "typesVersions": { diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 8e18333836ef..e0483c673d1c 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -19,7 +19,7 @@ export default [ localVariablesWorkerConfig, ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/init.ts'], + entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 590ad7e82923..7f27e6abb3a2 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -33,8 +33,8 @@ export { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, validateOpenTelemetrySetup, -} from './sdk/init'; -export { initOpenTelemetry } from './sdk/initOtel'; +} from './sdk'; +export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; export { getSentryRelease, defaultStackParser } from './sdk/api'; export { createGetModuleFromFilename } from './utils/module'; diff --git a/packages/node/src/init.ts b/packages/node/src/init.ts index 245ae8573afa..3d4ba2ceff90 100644 --- a/packages/node/src/init.ts +++ b/packages/node/src/init.ts @@ -1,4 +1,4 @@ -import { init } from './sdk/init'; +import { init } from './sdk'; /** * The @sentry/node/init export can be used with the node --import and --require args to initialize the SDK entirely via diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 6854751fdcac..b807b28c4d61 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -22,6 +22,8 @@ import type { HTTPModuleRequestIncomingMessage } from '../transports/http-module import { addOriginToSpan } from '../utils/addOriginToSpan'; import { getRequestUrl } from '../utils/getRequestUrl'; +const INTEGRATION_NAME = 'Http'; + interface HttpOptions { /** * Whether breadcrumbs should be recorded for requests. @@ -45,106 +47,128 @@ interface HttpOptions { _instrumentation?: typeof HttpInstrumentation; } +let _httpOptions: HttpOptions = {}; +let _httpInstrumentation: HttpInstrumentation | undefined; + +/** + * Instrument the HTTP module. + * This can only be instrumented once! If this called again later, we just update the options. + */ +export const instrumentHttp = Object.assign( + function (): void { + if (_httpInstrumentation) { + return; + } + + const _InstrumentationClass = _httpOptions._instrumentation || HttpInstrumentation; + + _httpInstrumentation = new _InstrumentationClass({ + ignoreOutgoingRequestHook: request => { + const url = getRequestUrl(request); + + if (!url) { + return false; + } + + if (isSentryRequestUrl(url, getClient())) { + return true; + } + + const _ignoreOutgoingRequests = _httpOptions.ignoreOutgoingRequests; + if (_ignoreOutgoingRequests && _ignoreOutgoingRequests(url)) { + return true; + } + + return false; + }, + + ignoreIncomingRequestHook: request => { + const url = getRequestUrl(request); + + const method = request.method?.toUpperCase(); + // We do not capture OPTIONS/HEAD requests as transactions + if (method === 'OPTIONS' || method === 'HEAD') { + return true; + } + + const _ignoreIncomingRequests = _httpOptions.ignoreIncomingRequests; + if (_ignoreIncomingRequests && _ignoreIncomingRequests(url)) { + return true; + } + + return false; + }, + + requireParentforOutgoingSpans: false, + requireParentforIncomingSpans: false, + requestHook: (span, req) => { + addOriginToSpan(span, 'auto.http.otel.http'); + + // both, incoming requests and "client" requests made within the app trigger the requestHook + // we only want to isolate and further annotate incoming requests (IncomingMessage) + if (_isClientRequest(req)) { + return; + } + + const scopes = getCapturedScopesOnSpan(span); + + const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); + const scope = scopes.scope || getCurrentScope(); + + // Update the isolation scope, isolate this request + isolationScope.setSDKProcessingMetadata({ request: req }); + + const client = getClient(); + if (client && client.getOptions().autoSessionTracking) { + isolationScope.setRequestSession({ status: 'ok' }); + } + setIsolationScope(isolationScope); + setCapturedScopesOnSpan(span, scope, isolationScope); + + // attempt to update the scope's `transactionName` based on the request URL + // Ideally, framework instrumentations coming after the HttpInstrumentation + // update the transactionName once we get a parameterized route. + const httpMethod = (req.method || 'GET').toUpperCase(); + const httpTarget = stripUrlQueryAndFragment(req.url || '/'); + + const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; + + isolationScope.setTransactionName(bestEffortTransactionName); + }, + responseHook: () => { + const client = getClient(); + if (client && client.getOptions().autoSessionTracking) { + setImmediate(() => { + client['_captureRequestSession'](); + }); + } + }, + applyCustomAttributesOnSpan: ( + _span: Span, + request: ClientRequest | HTTPModuleRequestIncomingMessage, + response: HTTPModuleRequestIncomingMessage | ServerResponse, + ) => { + const _breadcrumbs = typeof _httpOptions.breadcrumbs === 'undefined' ? true : _httpOptions.breadcrumbs; + if (_breadcrumbs) { + _addRequestBreadcrumb(request, response); + } + }, + }); + + addOpenTelemetryInstrumentation(_httpInstrumentation); + }, + { + id: INTEGRATION_NAME, + }, +); + const _httpIntegration = ((options: HttpOptions = {}) => { - const _breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs; - const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; - const _ignoreIncomingRequests = options.ignoreIncomingRequests; - const _InstrumentationClass = options._instrumentation || HttpInstrumentation; + _httpOptions = options; return { - name: 'Http', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new _InstrumentationClass({ - ignoreOutgoingRequestHook: request => { - const url = getRequestUrl(request); - - if (!url) { - return false; - } - - if (isSentryRequestUrl(url, getClient())) { - return true; - } - - if (_ignoreOutgoingRequests && _ignoreOutgoingRequests(url)) { - return true; - } - - return false; - }, - - ignoreIncomingRequestHook: request => { - const url = getRequestUrl(request); - - const method = request.method?.toUpperCase(); - // We do not capture OPTIONS/HEAD requests as transactions - if (method === 'OPTIONS' || method === 'HEAD') { - return true; - } - - if (_ignoreIncomingRequests && _ignoreIncomingRequests(url)) { - return true; - } - - return false; - }, - - requireParentforOutgoingSpans: false, - requireParentforIncomingSpans: false, - requestHook: (span, req) => { - addOriginToSpan(span, 'auto.http.otel.http'); - - // both, incoming requests and "client" requests made within the app trigger the requestHook - // we only want to isolate and further annotate incoming requests (IncomingMessage) - if (_isClientRequest(req)) { - return; - } - - const scopes = getCapturedScopesOnSpan(span); - - const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); - const scope = scopes.scope || getCurrentScope(); - - // Update the isolation scope, isolate this request - isolationScope.setSDKProcessingMetadata({ request: req }); - - const client = getClient(); - if (client && client.getOptions().autoSessionTracking) { - isolationScope.setRequestSession({ status: 'ok' }); - } - setIsolationScope(isolationScope); - setCapturedScopesOnSpan(span, scope, isolationScope); - - // attempt to update the scope's `transactionName` based on the request URL - // Ideally, framework instrumentations coming after the HttpInstrumentation - // update the transactionName once we get a parameterized route. - const httpMethod = (req.method || 'GET').toUpperCase(); - const httpTarget = stripUrlQueryAndFragment(req.url || '/'); - - const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; - - isolationScope.setTransactionName(bestEffortTransactionName); - }, - responseHook: () => { - const client = getClient(); - if (client && client.getOptions().autoSessionTracking) { - setImmediate(() => { - client['_captureRequestSession'](); - }); - } - }, - applyCustomAttributesOnSpan: ( - _span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, - ) => { - if (_breadcrumbs) { - _addRequestBreadcrumb(request, response); - } - }, - }), - ); + instrumentHttp(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/connect.ts b/packages/node/src/integrations/tracing/connect.ts index 7d3e5a28137f..5ea6011c5257 100644 --- a/packages/node/src/integrations/tracing/connect.ts +++ b/packages/node/src/integrations/tracing/connect.ts @@ -7,8 +7,8 @@ import { getClient, spanToJSON, } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn, Span } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; import { ensureIsWrapped } from '../../utils/ensureIsWrapped'; type ConnectApp = { @@ -16,11 +16,15 @@ type ConnectApp = { use: (middleware: any) => void; }; +const INTEGRATION_NAME = 'Connect'; + +export const instrumentConnect = generateInstrumentOnce(INTEGRATION_NAME, () => new ConnectInstrumentation()); + const _connectIntegration = (() => { return { - name: 'Connect', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation(new ConnectInstrumentation({})); + instrumentConnect(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index cddb9bb7e0e5..00c5735207d4 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -2,53 +2,59 @@ import type * as http from 'node:http'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, defineIntegration, getDefaultIsolationScope, spanToJSON } from '@sentry/core'; import { captureException, getClient, getIsolationScope } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../../debug-build'; +import { generateInstrumentOnce } from '../../otel/instrument'; import type { NodeClient } from '../../sdk/client'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; import { ensureIsWrapped } from '../../utils/ensureIsWrapped'; +const INTEGRATION_NAME = 'Express'; + +export const instrumentExpress = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new ExpressInstrumentation({ + requestHook(span) { + addOriginToSpan(span, 'auto.http.otel.express'); + + const attributes = spanToJSON(span).data || {}; + // this is one of: middleware, request_handler, router + const type = attributes['express.type']; + + if (type) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.express`); + } + + // Also update the name, we don't need to "middleware - " prefix + const name = attributes['express.name']; + if (typeof name === 'string') { + span.updateName(name); + } + }, + spanNameHook(info, defaultName) { + if (getIsolationScope() === getDefaultIsolationScope()) { + DEBUG_BUILD && + logger.warn('Isolation scope is still default isolation scope - skipping setting transactionName'); + return defaultName; + } + if (info.layerType === 'request_handler') { + // type cast b/c Otel unfortunately types info.request as any :( + const req = info.request as { method?: string }; + const method = req.method ? req.method.toUpperCase() : 'GET'; + getIsolationScope().setTransactionName(`${method} ${info.route}`); + } + return defaultName; + }, + }), +); + const _expressIntegration = (() => { return { - name: 'Express', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new ExpressInstrumentation({ - requestHook(span) { - addOriginToSpan(span, 'auto.http.otel.express'); - - const attributes = spanToJSON(span).data || {}; - // this is one of: middleware, request_handler, router - const type = attributes['express.type']; - - if (type) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.express`); - } - - // Also update the name, we don't need to "middleware - " prefix - const name = attributes['express.name']; - if (typeof name === 'string') { - span.updateName(name); - } - }, - spanNameHook(info, defaultName) { - if (getIsolationScope() === getDefaultIsolationScope()) { - DEBUG_BUILD && - logger.warn('Isolation scope is still default isolation scope - skipping setting transactionName'); - return defaultName; - } - if (info.layerType === 'request_handler') { - // type cast b/c Otel unfortunately types info.request as any :( - const req = info.request as { method?: string }; - const method = req.method ? req.method.toUpperCase() : 'GET'; - getIsolationScope().setTransactionName(`${method} ${info.route}`); - } - return defaultName; - }, - }), - ); + instrumentExpress(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/fastify.ts b/packages/node/src/integrations/tracing/fastify.ts index 6286bd8a0f97..27657d94d3d3 100644 --- a/packages/node/src/integrations/tracing/fastify.ts +++ b/packages/node/src/integrations/tracing/fastify.ts @@ -8,8 +8,8 @@ import { getIsolationScope, spanToJSON, } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn, Span } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; import { ensureIsWrapped } from '../../utils/ensureIsWrapped'; // We inline the types we care about here @@ -33,17 +33,23 @@ interface FastifyRequestRouteInfo { routerPath?: string; } +const INTEGRATION_NAME = 'Fastify'; + +export const instrumentFastify = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new FastifyInstrumentation({ + requestHook(span) { + addFastifySpanAttributes(span); + }, + }), +); + const _fastifyIntegration = (() => { return { - name: 'Fastify', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new FastifyInstrumentation({ - requestHook(span) { - addFastifySpanAttributes(span); - }, - }), - ); + instrumentFastify(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/graphql.ts b/packages/node/src/integrations/tracing/graphql.ts index 4f4fdc93dac9..097ee3ba43f8 100644 --- a/packages/node/src/integrations/tracing/graphql.ts +++ b/packages/node/src/integrations/tracing/graphql.ts @@ -1,7 +1,7 @@ import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql'; import { defineIntegration } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; @@ -20,24 +20,31 @@ interface GraphqlOptions { ignoreTrivalResolveSpans?: boolean; } -const _graphqlIntegration = ((_options: GraphqlOptions = {}) => { - const options = { - ignoreResolveSpans: true, - ignoreTrivialResolveSpans: true, - ..._options, - }; +const INTEGRATION_NAME = 'Graphql'; + +export const instrumentGraphql = generateInstrumentOnce( + INTEGRATION_NAME, + (_options: GraphqlOptions = {}) => { + const options = { + ignoreResolveSpans: true, + ignoreTrivialResolveSpans: true, + ..._options, + }; + + return new GraphQLInstrumentation({ + ...options, + responseHook(span) { + addOriginToSpan(span, 'auto.graphql.otel.graphql'); + }, + }); + }, +); +const _graphqlIntegration = ((options: GraphqlOptions = {}) => { return { - name: 'Graphql', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new GraphQLInstrumentation({ - ...options, - responseHook(span) { - addOriginToSpan(span, 'auto.graphql.otel.graphql'); - }, - }), - ); + instrumentGraphql(options); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/hapi/index.ts b/packages/node/src/integrations/tracing/hapi/index.ts index ee03cfc34ac6..d197fbed0b2d 100644 --- a/packages/node/src/integrations/tracing/hapi/index.ts +++ b/packages/node/src/integrations/tracing/hapi/index.ts @@ -13,18 +13,22 @@ import { getRootSpan, spanToJSON, } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn, Span } from '@sentry/types'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../../../debug-build'; +import { generateInstrumentOnce } from '../../../otel/instrument'; import { ensureIsWrapped } from '../../../utils/ensureIsWrapped'; import type { Boom, RequestEvent, ResponseObject, Server } from './types'; +const INTEGRATION_NAME = 'Hapi'; + +export const instrumentHapi = generateInstrumentOnce(INTEGRATION_NAME, () => new HapiInstrumentation()); + const _hapiIntegration = (() => { return { - name: 'Hapi', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation(new HapiInstrumentation()); + instrumentHapi(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index ec71ec7b8b60..55a01ba13651 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -1,17 +1,18 @@ import type { Integration } from '@sentry/types'; +import { instrumentHttp } from '../http'; -import { connectIntegration } from './connect'; -import { expressIntegration } from './express'; -import { fastifyIntegration } from './fastify'; -import { graphqlIntegration } from './graphql'; -import { hapiIntegration } from './hapi'; -import { koaIntegration } from './koa'; -import { mongoIntegration } from './mongo'; -import { mongooseIntegration } from './mongoose'; -import { mysqlIntegration } from './mysql'; -import { mysql2Integration } from './mysql2'; -import { nestIntegration } from './nest'; -import { postgresIntegration } from './postgres'; +import { connectIntegration, instrumentConnect } from './connect'; +import { expressIntegration, instrumentExpress } from './express'; +import { fastifyIntegration, instrumentFastify } from './fastify'; +import { graphqlIntegration, instrumentGraphql } from './graphql'; +import { hapiIntegration, instrumentHapi } from './hapi'; +import { instrumentKoa, koaIntegration } from './koa'; +import { instrumentMongo, mongoIntegration } from './mongo'; +import { instrumentMongoose, mongooseIntegration } from './mongoose'; +import { instrumentMysql, mysqlIntegration } from './mysql'; +import { instrumentMysql2, mysql2Integration } from './mysql2'; +import { instrumentNest, nestIntegration } from './nest'; +import { instrumentPostgres, postgresIntegration } from './postgres'; import { redisIntegration } from './redis'; /** @@ -38,3 +39,26 @@ export function getAutoPerformanceIntegrations(): Integration[] { connectIntegration(), ]; } + +/** + * Get a list of methods to instrument OTEL, when preload instrumentation. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => void) & { id: string })[] { + return [ + instrumentHttp, + instrumentExpress, + instrumentConnect, + instrumentFastify, + instrumentHapi, + instrumentKoa, + instrumentNest, + instrumentMongo, + instrumentMongoose, + instrumentMysql, + instrumentMysql2, + instrumentPostgres, + instrumentHapi, + instrumentGraphql, + ]; +} diff --git a/packages/node/src/integrations/tracing/koa.ts b/packages/node/src/integrations/tracing/koa.ts index 7d68afb19efe..1fc85234fb76 100644 --- a/packages/node/src/integrations/tracing/koa.ts +++ b/packages/node/src/integrations/tracing/koa.ts @@ -9,56 +9,40 @@ import { getIsolationScope, spanToJSON, } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn, Span } from '@sentry/types'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../../debug-build'; +import { generateInstrumentOnce } from '../../otel/instrument'; import { ensureIsWrapped } from '../../utils/ensureIsWrapped'; -function addKoaSpanAttributes(span: Span): void { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.http.otel.koa'); - - const attributes = spanToJSON(span).data || {}; - - // this is one of: middleware, router - const type = attributes['koa.type']; +const INTEGRATION_NAME = 'Koa'; - if (type) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.koa`); - } +export const instrumentKoa = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new KoaInstrumentation({ + requestHook(span, info) { + addKoaSpanAttributes(span); - // Also update the name - const name = attributes['koa.name']; - if (typeof name === 'string') { - // Somehow, name is sometimes `''` for middleware spans - // See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220 - span.updateName(name || '< unknown >'); - } -} + if (getIsolationScope() === getDefaultIsolationScope()) { + DEBUG_BUILD && logger.warn('Isolation scope is default isolation scope - skipping setting transactionName'); + return; + } + const attributes = spanToJSON(span).data; + const route = attributes && attributes[SEMATTRS_HTTP_ROUTE]; + const method = info.context.request.method.toUpperCase() || 'GET'; + if (route) { + getIsolationScope().setTransactionName(`${method} ${route}`); + } + }, + }), +); const _koaIntegration = (() => { return { - name: 'Koa', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new KoaInstrumentation({ - requestHook(span, info) { - addKoaSpanAttributes(span); - - if (getIsolationScope() === getDefaultIsolationScope()) { - DEBUG_BUILD && - logger.warn('Isolation scope is default isolation scope - skipping setting transactionName'); - return; - } - const attributes = spanToJSON(span).data; - const route = attributes && attributes[SEMATTRS_HTTP_ROUTE]; - const method = info.context.request.method.toUpperCase() || 'GET'; - if (route) { - getIsolationScope().setTransactionName(`${method} ${route}`); - } - }, - }), - ); + instrumentKoa(); }, }; }) satisfies IntegrationFn; @@ -77,3 +61,24 @@ export const setupKoaErrorHandler = (app: { use: (arg0: (ctx: any, next: any) => ensureIsWrapped(app.use, 'koa'); }; + +function addKoaSpanAttributes(span: Span): void { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.http.otel.koa'); + + const attributes = spanToJSON(span).data || {}; + + // this is one of: middleware, router + const type = attributes['koa.type']; + + if (type) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.koa`); + } + + // Also update the name + const name = attributes['koa.name']; + if (typeof name === 'string') { + // Somehow, name is sometimes `''` for middleware spans + // See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220 + span.updateName(name || '< unknown >'); + } +} diff --git a/packages/node/src/integrations/tracing/mongo.ts b/packages/node/src/integrations/tracing/mongo.ts index 03442df058a6..143c7bf99a6d 100644 --- a/packages/node/src/integrations/tracing/mongo.ts +++ b/packages/node/src/integrations/tracing/mongo.ts @@ -1,21 +1,27 @@ import { MongoDBInstrumentation } from '@opentelemetry/instrumentation-mongodb'; import { defineIntegration } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; +const INTEGRATION_NAME = 'Mongo'; + +export const instrumentMongo = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new MongoDBInstrumentation({ + responseHook(span) { + addOriginToSpan(span, 'auto.db.otel.mongo'); + }, + }), +); + const _mongoIntegration = (() => { return { - name: 'Mongo', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new MongoDBInstrumentation({ - responseHook(span) { - addOriginToSpan(span, 'auto.db.otel.mongo'); - }, - }), - ); + instrumentMongo(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/mongoose.ts b/packages/node/src/integrations/tracing/mongoose.ts index 13a11ca46937..4a4566fa98da 100644 --- a/packages/node/src/integrations/tracing/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose.ts @@ -1,21 +1,27 @@ import { MongooseInstrumentation } from '@opentelemetry/instrumentation-mongoose'; import { defineIntegration } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; +const INTEGRATION_NAME = 'Mongoose'; + +export const instrumentMongoose = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new MongooseInstrumentation({ + responseHook(span) { + addOriginToSpan(span, 'auto.db.otel.mongoose'); + }, + }), +); + const _mongooseIntegration = (() => { return { - name: 'Mongoose', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new MongooseInstrumentation({ - responseHook(span) { - addOriginToSpan(span, 'auto.db.otel.mongoose'); - }, - }), - ); + instrumentMongoose(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/mysql.ts b/packages/node/src/integrations/tracing/mysql.ts index 4ad0daca2a8b..67b46ae9bdcf 100644 --- a/packages/node/src/integrations/tracing/mysql.ts +++ b/packages/node/src/integrations/tracing/mysql.ts @@ -1,13 +1,17 @@ import { MySQLInstrumentation } from '@opentelemetry/instrumentation-mysql'; import { defineIntegration } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; + +const INTEGRATION_NAME = 'Mysql'; + +export const instrumentMysql = generateInstrumentOnce(INTEGRATION_NAME, () => new MySQLInstrumentation({})); const _mysqlIntegration = (() => { return { - name: 'Mysql', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation(new MySQLInstrumentation({})); + instrumentMysql(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/mysql2.ts b/packages/node/src/integrations/tracing/mysql2.ts index 332560c1d5a1..b3c36435979c 100644 --- a/packages/node/src/integrations/tracing/mysql2.ts +++ b/packages/node/src/integrations/tracing/mysql2.ts @@ -1,21 +1,27 @@ import { MySQL2Instrumentation } from '@opentelemetry/instrumentation-mysql2'; import { defineIntegration } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; +const INTEGRATION_NAME = 'Mysql2'; + +export const instrumentMysql2 = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new MySQL2Instrumentation({ + responseHook(span) { + addOriginToSpan(span, 'auto.db.otel.mysql2'); + }, + }), +); + const _mysql2Integration = (() => { return { - name: 'Mysql2', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new MySQL2Instrumentation({ - responseHook(span) { - addOriginToSpan(span, 'auto.db.otel.mysql2'); - }, - }), - ); + instrumentMysql2(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/nest.ts b/packages/node/src/integrations/tracing/nest.ts index cc66e745da1d..bbb658318946 100644 --- a/packages/node/src/integrations/tracing/nest.ts +++ b/packages/node/src/integrations/tracing/nest.ts @@ -9,9 +9,9 @@ import { getIsolationScope, spanToJSON, } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn, Span } from '@sentry/types'; import { logger } from '@sentry/utils'; +import { generateInstrumentOnce } from '../../otel/instrument'; interface MinimalNestJsExecutionContext { getType: () => string; @@ -37,15 +37,20 @@ interface NestJsErrorFilter { interface MinimalNestJsApp { useGlobalFilters: (arg0: NestJsErrorFilter) => void; useGlobalInterceptors: (interceptor: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any intercept: (context: MinimalNestJsExecutionContext, next: { handle: () => any }) => any; }) => void; } +const INTEGRATION_NAME = 'Nest'; + +export const instrumentNest = generateInstrumentOnce(INTEGRATION_NAME, () => new NestInstrumentation()); + const _nestIntegration = (() => { return { - name: 'Nest', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation(new NestInstrumentation({})); + instrumentNest(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/postgres.ts b/packages/node/src/integrations/tracing/postgres.ts index ad662d123845..05b56d9152ff 100644 --- a/packages/node/src/integrations/tracing/postgres.ts +++ b/packages/node/src/integrations/tracing/postgres.ts @@ -1,22 +1,28 @@ import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; import { defineIntegration } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; +const INTEGRATION_NAME = 'Postgres'; + +export const instrumentPostgres = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new PgInstrumentation({ + requireParentSpan: true, + requestHook(span) { + addOriginToSpan(span, 'auto.db.otel.postgres'); + }, + }), +); + const _postgresIntegration = (() => { return { - name: 'Postgres', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation( - new PgInstrumentation({ - requireParentSpan: true, - requestHook(span) { - addOriginToSpan(span, 'auto.db.otel.postgres'); - }, - }), - ); + instrumentPostgres(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/prisma.ts b/packages/node/src/integrations/tracing/prisma.ts index c2874a89f19b..e5d9e61a0229 100644 --- a/packages/node/src/integrations/tracing/prisma.ts +++ b/packages/node/src/integrations/tracing/prisma.ts @@ -1,22 +1,25 @@ // When importing CJS modules into an ESM module, we cannot import the named exports directly. import * as prismaInstrumentation from '@prisma/instrumentation'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration, spanToJSON } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; + +const INTEGRATION_NAME = 'Prisma'; + +export const instrumentPrisma = generateInstrumentOnce(INTEGRATION_NAME, () => { + const EsmInteropPrismaInstrumentation: typeof prismaInstrumentation.PrismaInstrumentation = + // @ts-expect-error We need to do the following for interop reasons + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + prismaInstrumentation.default?.PrismaInstrumentation || prismaInstrumentation.PrismaInstrumentation; + + return new EsmInteropPrismaInstrumentation({}); +}); const _prismaIntegration = (() => { return { - name: 'Prisma', + name: INTEGRATION_NAME, setupOnce() { - const EsmInteropPrismaInstrumentation: typeof prismaInstrumentation.PrismaInstrumentation = - // @ts-expect-error We need to do the following for interop reasons - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - prismaInstrumentation.default?.PrismaInstrumentation || prismaInstrumentation.PrismaInstrumentation; - - addOpenTelemetryInstrumentation( - // does not have a hook to adjust spans & add origin - new EsmInteropPrismaInstrumentation({}), - ); + instrumentPrisma(); }, setup(client) { diff --git a/packages/node/src/integrations/tracing/redis.ts b/packages/node/src/integrations/tracing/redis.ts index a734d7cb864f..c919e3382e10 100644 --- a/packages/node/src/integrations/tracing/redis.ts +++ b/packages/node/src/integrations/tracing/redis.ts @@ -8,8 +8,8 @@ import { defineIntegration, spanToJSON, } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; function keyHasPrefix(key: string, prefixes: string[]): boolean { return prefixes.some(prefix => key.startsWith(prefix)); @@ -41,56 +41,65 @@ interface RedisOptions { cachePrefixes?: string[]; } -const _redisIntegration = ((options?: RedisOptions) => { +const INTEGRATION_NAME = 'Redis'; + +let _redisOptions: RedisOptions = {}; + +export const instrumentRedis = generateInstrumentOnce(INTEGRATION_NAME, () => { + return new IORedisInstrumentation({ + responseHook: (span, redisCommand, cmdArgs, response) => { + const key = cmdArgs[0]; + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); + + if (!_redisOptions?.cachePrefixes || !shouldConsiderForCache(redisCommand, key, _redisOptions.cachePrefixes)) { + // not relevant for cache + return; + } + + // otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199 + // We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/ + const networkPeerAddress = spanToJSON(span).data?.['net.peer.name']; + const networkPeerPort = spanToJSON(span).data?.['net.peer.port']; + if (networkPeerPort && networkPeerAddress) { + span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort }); + } + + const cacheItemSize = calculateCacheItemSize(response); + if (cacheItemSize) span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize); + + if (typeof key === 'string') { + switch (redisCommand) { + case 'get': + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', // todo: will be changed to cache.get + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: key, + }); + if (cacheItemSize !== undefined) span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0); + break; + case 'set': + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: key, + }); + break; + } + } + }, + }); +}); + +const _redisIntegration = ((options: RedisOptions = {}) => { + _redisOptions = options; + return { - name: 'Redis', + name: INTEGRATION_NAME, setupOnce() { - addOpenTelemetryInstrumentation([ - new IORedisInstrumentation({ - responseHook: (span, redisCommand, cmdArgs, response) => { - const key = cmdArgs[0]; - - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); - - if (!options?.cachePrefixes || !shouldConsiderForCache(redisCommand, key, options.cachePrefixes)) { - // not relevant for cache - return; - } - - // otel/ioredis seems to be using the old standard, as there was a change to those params: https://github.com/open-telemetry/opentelemetry-specification/issues/3199 - // We are using params based on the docs: https://opentelemetry.io/docs/specs/semconv/attributes-registry/network/ - const networkPeerAddress = spanToJSON(span).data?.['net.peer.name']; - const networkPeerPort = spanToJSON(span).data?.['net.peer.port']; - if (networkPeerPort && networkPeerAddress) { - span.setAttributes({ 'network.peer.address': networkPeerAddress, 'network.peer.port': networkPeerPort }); - } - - const cacheItemSize = calculateCacheItemSize(response); - if (cacheItemSize) span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize); - - if (typeof key === 'string') { - switch (redisCommand) { - case 'get': - span.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', // todo: will be changed to cache.get - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: key, - }); - if (cacheItemSize !== undefined) span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0); - break; - case 'set': - span.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: key, - }); - break; - } - } - }, - }), - // todo: implement them gradually - // new LegacyRedisInstrumentation({}), - // new RedisInstrumentation({}), - ]); + instrumentRedis(); + + // todo: implement them gradually + // new LegacyRedisInstrumentation({}), + // new RedisInstrumentation({}), }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/otel/instrument.ts b/packages/node/src/otel/instrument.ts new file mode 100644 index 000000000000..71cc28a24915 --- /dev/null +++ b/packages/node/src/otel/instrument.ts @@ -0,0 +1,31 @@ +import type { Instrumentation } from '@opentelemetry/instrumentation'; +import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; + +const INSTRUMENTED: Record = {}; + +/** + * Instrument an OpenTelemetry instrumentation once. + * This will skip running instrumentation again if it was already instrumented. + */ +export function generateInstrumentOnce( + name: string, + creator: (options?: Options) => Instrumentation, +): ((options?: Options) => void) & { id: string } { + return Object.assign( + (options?: Options) => { + if (INSTRUMENTED[name]) { + // If options are provided, ensure we update them + if (options) { + INSTRUMENTED[name].setConfig(options); + } + return; + } + + const instrumentation = creator(options); + INSTRUMENTED[name] = instrumentation; + + addOpenTelemetryInstrumentation(instrumentation); + }, + { id: name }, + ); +} diff --git a/packages/node/src/preload.ts b/packages/node/src/preload.ts new file mode 100644 index 000000000000..0d62b28d9c91 --- /dev/null +++ b/packages/node/src/preload.ts @@ -0,0 +1,19 @@ +import { preloadOpenTelemetry } from './sdk/initOtel'; + +const debug = !!process.env.SENTRY_DEBUG; +const integrationsStr = process.env.SENTRY_PRELOAD_INTEGRATIONS; + +const integrations = integrationsStr ? integrationsStr.split(',').map(integration => integration.trim()) : undefined; + +/** + * The @sentry/node/preload export can be used with the node --import and --require args to preload the OTEL instrumentation, + * without initializing the Sentry SDK. + * + * This is useful if you cannot initialize the SDK immediately, but still want to preload the instrumentation, + * e.g. if you have to load the DSN from somewhere else. + * + * You can configure this in two ways via environment variables: + * - `SENTRY_DEBUG` to enable debug logging + * - `SENTRY_PRELOAD_INTEGRATIONS` to preload specific integrations - e.g. `SENTRY_PRELOAD_INTEGRATIONS="Http,Express"` + */ +preloadOpenTelemetry({ debug, integrations }); diff --git a/packages/node/src/sdk/init.ts b/packages/node/src/sdk/index.ts similarity index 87% rename from packages/node/src/sdk/init.ts rename to packages/node/src/sdk/index.ts index 83533842e76c..f149a44c06a0 100644 --- a/packages/node/src/sdk/init.ts +++ b/packages/node/src/sdk/index.ts @@ -18,7 +18,6 @@ import { } from '@sentry/opentelemetry'; import type { Client, Integration, Options } from '@sentry/types'; import { - GLOBAL_OBJ, consoleSandbox, dropUndefinedKeys, logger, @@ -30,7 +29,6 @@ import { consoleIntegration } from '../integrations/console'; import { nodeContextIntegration } from '../integrations/context'; import { contextLinesIntegration } from '../integrations/contextlines'; -import moduleModule from 'module'; import { httpIntegration } from '../integrations/http'; import { localVariablesIntegration } from '../integrations/local-variables'; import { modulesIntegration } from '../integrations/modules'; @@ -44,7 +42,7 @@ import type { NodeClientOptions, NodeOptions } from '../types'; import { isCjs } from '../utils/commonjs'; import { defaultStackParser, getSentryRelease } from './api'; import { NodeClient } from './client'; -import { initOpenTelemetry } from './initOtel'; +import { initOpenTelemetry, maybeInitializeEsmLoader } from './initOtel'; function getCjsOnlyIntegrations(): Integration[] { return isCjs() ? [modulesIntegration()] : []; @@ -96,8 +94,6 @@ function shouldAddPerformanceIntegrations(options: Options): boolean { return options.enableTracing || options.tracesSampleRate != null || 'tracesSampler' in options; } -declare const __IMPORT_META_URL_REPLACEMENT__: string; - /** * Initialize Sentry for Node. */ @@ -134,31 +130,7 @@ function _init( } if (!isCjs()) { - const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number); - - // Register hook was added in v20.6.0 and v18.19.0 - if (nodeMajor >= 22 || (nodeMajor === 20 && nodeMinor >= 6) || (nodeMajor === 18 && nodeMinor >= 19)) { - // We need to work around using import.meta.url directly because jest complains about it. - const importMetaUrl = - typeof __IMPORT_META_URL_REPLACEMENT__ !== 'undefined' ? __IMPORT_META_URL_REPLACEMENT__ : undefined; - - if (!GLOBAL_OBJ._sentryEsmLoaderHookRegistered && importMetaUrl) { - try { - // @ts-expect-error register is available in these versions - moduleModule.register('@opentelemetry/instrumentation/hook.mjs', importMetaUrl); - GLOBAL_OBJ._sentryEsmLoaderHookRegistered = true; - } catch (error) { - logger.warn('Failed to register ESM hook', error); - } - } - } else { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - '[Sentry] You are using Node.js in ESM mode ("import syntax"). The Sentry Node.js SDK is not compatible with ESM in Node.js versions before 18.19.0 or before 20.6.0. Please either build your application with CommonJS ("require() syntax"), or use version 7.x of the Sentry Node.js SDK.', - ); - }); - } + maybeInitializeEsmLoader(); } setOpenTelemetryContextAsyncContextStrategy(); diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 2556b86162b5..26a2f34e0901 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -1,3 +1,4 @@ +import moduleModule from 'module'; import { DiagLogLevel, diag } from '@opentelemetry/api'; import { Resource } from '@opentelemetry/resources'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; @@ -8,30 +9,98 @@ import { } from '@opentelemetry/semantic-conventions'; import { SDK_VERSION } from '@sentry/core'; import { SentryPropagator, SentrySampler, SentrySpanProcessor } from '@sentry/opentelemetry'; -import { logger } from '@sentry/utils'; +import { GLOBAL_OBJ, consoleSandbox, logger } from '@sentry/utils'; +import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracing'; import { SentryContextManager } from '../otel/contextManager'; +import { isCjs } from '../utils/commonjs'; import type { NodeClient } from './client'; +declare const __IMPORT_META_URL_REPLACEMENT__: string; + /** * Initialize OpenTelemetry for Node. */ export function initOpenTelemetry(client: NodeClient): void { if (client.getOptions().debug) { - const otelLogger = new Proxy(logger as typeof logger & { verbose: (typeof logger)['debug'] }, { - get(target, prop, receiver) { - const actualProp = prop === 'verbose' ? 'debug' : prop; - return Reflect.get(target, actualProp, receiver); - }, - }); - - diag.setLogger(otelLogger, DiagLogLevel.DEBUG); + setupOpenTelemetryLogger(); } const provider = setupOtel(client); client.traceProvider = provider; } +/** Initialize the ESM loader. */ +export function maybeInitializeEsmLoader(): void { + const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number); + + // Register hook was added in v20.6.0 and v18.19.0 + if (nodeMajor >= 22 || (nodeMajor === 20 && nodeMinor >= 6) || (nodeMajor === 18 && nodeMinor >= 19)) { + // We need to work around using import.meta.url directly because jest complains about it. + const importMetaUrl = + typeof __IMPORT_META_URL_REPLACEMENT__ !== 'undefined' ? __IMPORT_META_URL_REPLACEMENT__ : undefined; + + if (!GLOBAL_OBJ._sentryEsmLoaderHookRegistered && importMetaUrl) { + try { + // @ts-expect-error register is available in these versions + moduleModule.register('@opentelemetry/instrumentation/hook.mjs', importMetaUrl); + GLOBAL_OBJ._sentryEsmLoaderHookRegistered = true; + } catch (error) { + logger.warn('Failed to register ESM hook', error); + } + } + } else { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry] You are using Node.js in ESM mode ("import syntax"). The Sentry Node.js SDK is not compatible with ESM in Node.js versions before 18.19.0 or before 20.6.0. Please either build your application with CommonJS ("require() syntax"), or use version 7.x of the Sentry Node.js SDK.', + ); + }); + } +} + +interface NodePreloadOptions { + debug?: boolean; + integrations?: string[]; +} + +/** + * Preload OpenTelemetry for Node. + * This can be used to preload instrumentation early, but set up Sentry later. + * By preloading the OTEL instrumentation wrapping still happens early enough that everything works. + */ +export function preloadOpenTelemetry(options: NodePreloadOptions = {}): void { + const { debug } = options; + + if (debug) { + logger.enable(); + setupOpenTelemetryLogger(); + } + + if (!isCjs()) { + maybeInitializeEsmLoader(); + } + + // These are all integrations that we need to pre-load to ensure they are set up before any other code runs + getPreloadMethods(options.integrations).forEach(fn => { + fn(); + + if (debug) { + logger.log(`[Sentry] Preloaded ${fn.id} instrumentation`); + } + }); +} + +function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: string })[] { + const instruments = getOpenTelemetryInstrumentationToPreload(); + + if (!integrationNames) { + return instruments; + } + + return instruments.filter(instrumentation => integrationNames.includes(instrumentation.id)); +} + /** Just exported for tests. */ export function setupOtel(client: NodeClient): BasicTracerProvider { // Create and configure NodeTracerProvider @@ -54,3 +123,19 @@ export function setupOtel(client: NodeClient): BasicTracerProvider { return provider; } + +/** + * Setup the OTEL logger to use our own logger. + */ +function setupOpenTelemetryLogger(): void { + const otelLogger = new Proxy(logger as typeof logger & { verbose: (typeof logger)['debug'] }, { + get(target, prop, receiver) { + const actualProp = prop === 'verbose' ? 'debug' : prop; + return Reflect.get(target, actualProp, receiver); + }, + }); + + // Disable diag, to ensure this works even if called multiple times + diag.disable(); + diag.setLogger(otelLogger, DiagLogLevel.DEBUG); +} diff --git a/packages/node/test/helpers/mockSdkInit.ts b/packages/node/test/helpers/mockSdkInit.ts index 845721868d76..0e1d23cfc73c 100644 --- a/packages/node/test/helpers/mockSdkInit.ts +++ b/packages/node/test/helpers/mockSdkInit.ts @@ -3,7 +3,7 @@ import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; import type { NodeClient } from '../../src'; -import { init } from '../../src/sdk/init'; +import { init } from '../../src/sdk'; import type { NodeClientOptions } from '../../src/types'; const PUBLIC_DSN = 'https://username@domain/123'; diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index d1c3788caa2f..5592acfaa897 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -2,8 +2,8 @@ import type { Integration } from '@sentry/types'; import { getClient } from '../../src/'; import * as auto from '../../src/integrations/tracing'; +import { init } from '../../src/sdk'; import type { NodeClient } from '../../src/sdk/client'; -import { init } from '../../src/sdk/init'; import { cleanupOtel } from '../helpers/mockSdkInit'; // eslint-disable-next-line no-var diff --git a/packages/node/test/sdk/preload.test.ts b/packages/node/test/sdk/preload.test.ts new file mode 100644 index 000000000000..fedba139b0f6 --- /dev/null +++ b/packages/node/test/sdk/preload.test.ts @@ -0,0 +1,50 @@ +import { logger } from '@sentry/utils'; + +describe('preload', () => { + afterEach(() => { + jest.resetAllMocks(); + logger.disable(); + + delete process.env.SENTRY_DEBUG; + delete process.env.SENTRY_PRELOAD_INTEGRATIONS; + + jest.resetModules(); + }); + + it('works without env vars', async () => { + const logSpy = jest.spyOn(console, 'log'); + + await import('../../src/preload'); + + expect(logSpy).toHaveBeenCalledTimes(0); + }); + + it('works with SENTRY_DEBUG set', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + // We want to swallow these logs + jest.spyOn(console, 'debug').mockImplementation(() => {}); + + process.env.SENTRY_DEBUG = '1'; + + await import('../../src/preload'); + + expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Http instrumentation'); + expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Express instrumentation'); + expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Graphql instrumentation'); + }); + + it('works with SENTRY_DEBUG & SENTRY_PRELOAD_INTEGRATIONS set', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + // We want to swallow these logs + jest.spyOn(console, 'debug').mockImplementation(() => {}); + + process.env.SENTRY_DEBUG = '1'; + process.env.SENTRY_PRELOAD_INTEGRATIONS = 'Http,Express'; + + await import('../../src/preload'); + + expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Http instrumentation'); + expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Express instrumentation'); + expect(logSpy).not.toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Graphql instrumentation'); + }); +}); From e7d5bf442321885d91e60c7a14fffd951dffd6f1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 24 May 2024 15:29:22 +0200 Subject: [PATCH 2/4] better e2e tests --- .../node-express-cjs-preload/src/app.js | 26 ++++++++++++------- .../node-express-esm-preload/src/app.mjs | 26 ++++++++++++------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/src/app.js b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/src/app.js index e6bbe57695c9..b41d99ab6440 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/src/app.js +++ b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/src/app.js @@ -1,13 +1,6 @@ const Sentry = require('@sentry/node'); const express = require('express'); -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1, -}); - const app = express(); const port = 3030; @@ -41,6 +34,19 @@ app.use(function onError(err, req, res, next) { res.end(res.sentry + '\n'); }); -app.listen(port, () => { - console.log(`Example app listening on port ${port}`); -}); +async function run() { + await new Promise(resolve => setTimeout(resolve, 1000)); + + Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + }); + + app.listen(port, () => { + console.log(`Example app listening on port ${port}`); + }); +} + +run(); diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs index f4740c7b4404..abb70111543d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/src/app.mjs @@ -1,13 +1,6 @@ import * as Sentry from '@sentry/node'; import express from 'express'; -Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1, -}); - const app = express(); const port = 3030; @@ -41,6 +34,19 @@ app.use(function onError(err, req, res, next) { res.end(res.sentry + '\n'); }); -app.listen(port, () => { - console.log(`Example app listening on port ${port}`); -}); +async function run() { + await new Promise(resolve => setTimeout(resolve, 1000)); + + Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + }); + + app.listen(port, () => { + console.log(`Example app listening on port ${port}`); + }); +} + +run(); From 0b77a5e79d32db8fbdfade4f03e57f689b55b6bb Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 24 May 2024 16:03:37 +0200 Subject: [PATCH 3/4] fix stuff --- .../node-exports-test-app/scripts/consistentExports.ts | 1 + .../suites/tracing/redis-cache/test.ts | 2 +- packages/node/src/integrations/http.ts | 3 +-- packages/node/src/integrations/tracing/redis.ts | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 138ea18b5e3d..a35bf4657c64 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -20,6 +20,7 @@ const NODE_EXPORTS_IGNORE = [ 'initWithoutDefaultIntegrations', 'SentryContextManager', 'validateOpenTelemetrySetup', + 'preloadOpenTelemetry', ]; const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e)); diff --git a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts index de1f2eff3a53..3ad860bb72f4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/redis-cache/test.ts @@ -1,6 +1,6 @@ import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -describe('redis auto instrumentation', () => { +describe('redis cache auto instrumentation', () => { afterAll(() => { cleanupChildProcesses(); }); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index b807b28c4d61..c135c32816a2 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -163,11 +163,10 @@ export const instrumentHttp = Object.assign( ); const _httpIntegration = ((options: HttpOptions = {}) => { - _httpOptions = options; - return { name: INTEGRATION_NAME, setupOnce() { + _httpOptions = options; instrumentHttp(); }, }; diff --git a/packages/node/src/integrations/tracing/redis.ts b/packages/node/src/integrations/tracing/redis.ts index c919e3382e10..5c4cd1bf45be 100644 --- a/packages/node/src/integrations/tracing/redis.ts +++ b/packages/node/src/integrations/tracing/redis.ts @@ -50,6 +50,8 @@ export const instrumentRedis = generateInstrumentOnce(INTEGRATION_NAME, () => { responseHook: (span, redisCommand, cmdArgs, response) => { const key = cmdArgs[0]; + console.log('AHA', JSON.stringify(_redisOptions)); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); if (!_redisOptions?.cachePrefixes || !shouldConsiderForCache(redisCommand, key, _redisOptions.cachePrefixes)) { @@ -90,11 +92,10 @@ export const instrumentRedis = generateInstrumentOnce(INTEGRATION_NAME, () => { }); const _redisIntegration = ((options: RedisOptions = {}) => { - _redisOptions = options; - return { name: INTEGRATION_NAME, setupOnce() { + _redisOptions = options; instrumentRedis(); // todo: implement them gradually From e5c29d6e0d5564ab6da513b07ae4eb86ec2d90ce Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 24 May 2024 16:14:04 +0200 Subject: [PATCH 4/4] oops --- packages/node/src/integrations/tracing/redis.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/node/src/integrations/tracing/redis.ts b/packages/node/src/integrations/tracing/redis.ts index 5c4cd1bf45be..1379336412f6 100644 --- a/packages/node/src/integrations/tracing/redis.ts +++ b/packages/node/src/integrations/tracing/redis.ts @@ -50,8 +50,6 @@ export const instrumentRedis = generateInstrumentOnce(INTEGRATION_NAME, () => { responseHook: (span, redisCommand, cmdArgs, response) => { const key = cmdArgs[0]; - console.log('AHA', JSON.stringify(_redisOptions)); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.redis'); if (!_redisOptions?.cachePrefixes || !shouldConsiderForCache(redisCommand, key, _redisOptions.cachePrefixes)) {