diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/.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-core-express-otel-v1/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json new file mode 100644 index 000000000000..67351f3ab187 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json @@ -0,0 +1,39 @@ +{ + "name": "node-core-express-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1", + "@opentelemetry/core": "^1.30.1", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/instrumentation-http": "^0.57.1", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-node": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.30.0", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.50.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "resolutions": { + "@types/qs": "6.9.17" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/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-core-express-otel-v1/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/src/app.ts new file mode 100644 index 000000000000..d5bf40067de0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/src/app.ts @@ -0,0 +1,57 @@ +// Import this first! +import './instrument'; + +// Now import other modules +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-transaction', function (req, res) { + Sentry.withActiveSpan(null, async () => { + Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + }); + + await Sentry.flush(); + + res.send({ + transactionIds: global.transactionIds || [], + }); + }); +}); + +app.get('/test-exception/:id', function (req, _res) { + try { + throw new Error(`This is an exception with id ${req.params.id}`); + } catch (e) { + Sentry.captureException(e); + throw e; + } +}); + +app.get('/test-local-variables-caught', function (req, res) { + const randomVariableToRecord = Math.random(); + + let exceptionId: string; + try { + throw new Error('Local Variable Error'); + } catch (e) { + exceptionId = Sentry.captureException(e); + } + + res.send({ exceptionId, randomVariableToRecord }); +}); + +// @ts-ignore +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-core-express-otel-v1/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/src/instrument.ts new file mode 100644 index 000000000000..a3969933ea64 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/src/instrument.ts @@ -0,0 +1,46 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as Sentry from '@sentry/node-core'; +import { SentrySpanProcessor, SentryPropagator, SentrySampler } from '@sentry/opentelemetry'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; + +declare global { + namespace globalThis { + var transactionIds: string[]; + } +} + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + openTelemetryInstrumentations: [new HttpInstrumentation()], +}); + +const provider = new NodeTracerProvider({ + sampler: sentryClient ? new SentrySampler(sentryClient) : undefined, + spanProcessors: [new SentrySpanProcessor()], +}); + +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +Sentry.validateOpenTelemetrySetup(); + +Sentry.addEventProcessor(event => { + global.transactionIds = global.transactionIds || []; + + if (event.type === 'transaction') { + const eventId = event.event_id; + + if (eventId) { + global.transactionIds.push(eventId); + } + } + + return event; +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/start-event-proxy.mjs new file mode 100644 index 000000000000..161017eab5ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v1', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/errors.test.ts new file mode 100644 index 000000000000..013c622f125a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/errors.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v1', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/123'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Should record caught exceptions with local variable', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v1', event => { + return event.transaction === 'GET /test-local-variables-caught'; + }); + + await fetch(`${baseURL}/test-local-variables-caught`); + + const errorEvent = await errorEventPromise; + + const frames = errorEvent.exception?.values?.[0].stacktrace?.frames; + expect(frames?.[frames.length - 1].vars?.randomVariableToRecord).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts new file mode 100644 index 000000000000..1628a9a03ada --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tests/transactions.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-core-express-otel-v1', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'url', + 'sentry.origin': 'manual', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'url', + }, + }), + ); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-core-express-otel-v1', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/777' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/777'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tsconfig.json new file mode 100644 index 000000000000..0060abd94682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/.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-core-express-otel-v2/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json new file mode 100644 index 000000000000..58dcd720e5b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json @@ -0,0 +1,39 @@ +{ + "name": "node-core-express-otel-v2-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.200.0", + "@opentelemetry/instrumentation-http": "^0.200.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.30.0", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.50.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "resolutions": { + "@types/qs": "6.9.17" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/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-core-express-otel-v2/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/src/app.ts new file mode 100644 index 000000000000..d5bf40067de0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/src/app.ts @@ -0,0 +1,57 @@ +// Import this first! +import './instrument'; + +// Now import other modules +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-transaction', function (req, res) { + Sentry.withActiveSpan(null, async () => { + Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + }); + + await Sentry.flush(); + + res.send({ + transactionIds: global.transactionIds || [], + }); + }); +}); + +app.get('/test-exception/:id', function (req, _res) { + try { + throw new Error(`This is an exception with id ${req.params.id}`); + } catch (e) { + Sentry.captureException(e); + throw e; + } +}); + +app.get('/test-local-variables-caught', function (req, res) { + const randomVariableToRecord = Math.random(); + + let exceptionId: string; + try { + throw new Error('Local Variable Error'); + } catch (e) { + exceptionId = Sentry.captureException(e); + } + + res.send({ exceptionId, randomVariableToRecord }); +}); + +// @ts-ignore +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-core-express-otel-v2/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/src/instrument.ts new file mode 100644 index 000000000000..a3969933ea64 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/src/instrument.ts @@ -0,0 +1,46 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as Sentry from '@sentry/node-core'; +import { SentrySpanProcessor, SentryPropagator, SentrySampler } from '@sentry/opentelemetry'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; + +declare global { + namespace globalThis { + var transactionIds: string[]; + } +} + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + openTelemetryInstrumentations: [new HttpInstrumentation()], +}); + +const provider = new NodeTracerProvider({ + sampler: sentryClient ? new SentrySampler(sentryClient) : undefined, + spanProcessors: [new SentrySpanProcessor()], +}); + +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +Sentry.validateOpenTelemetrySetup(); + +Sentry.addEventProcessor(event => { + global.transactionIds = global.transactionIds || []; + + if (event.type === 'transaction') { + const eventId = event.event_id; + + if (eventId) { + global.transactionIds.push(eventId); + } + } + + return event; +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/start-event-proxy.mjs new file mode 100644 index 000000000000..23a724872457 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v2', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/errors.test.ts new file mode 100644 index 000000000000..f4832729b899 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/errors.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v2', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/123'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Should record caught exceptions with local variable', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v2', event => { + return event.transaction === 'GET /test-local-variables-caught'; + }); + + await fetch(`${baseURL}/test-local-variables-caught`); + + const errorEvent = await errorEventPromise; + + const frames = errorEvent.exception?.values?.[0].stacktrace?.frames; + expect(frames?.[frames.length - 1].vars?.randomVariableToRecord).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts new file mode 100644 index 000000000000..f3b1b680f2e9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tests/transactions.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-core-express-otel-v2', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'url', + 'sentry.origin': 'manual', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'url', + }, + }), + ); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-core-express-otel-v2', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/777' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/777'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tsconfig.json new file mode 100644 index 000000000000..0060abd94682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index 8535c5898175..c2ad7907bb20 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -104,6 +104,12 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/node-core': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/opentelemetry': access: $all publish: $all diff --git a/dev-packages/node-core-integration-tests/.eslintrc.js b/dev-packages/node-core-integration-tests/.eslintrc.js new file mode 100644 index 000000000000..0598ba3f5ca1 --- /dev/null +++ b/dev-packages/node-core-integration-tests/.eslintrc.js @@ -0,0 +1,40 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], + overrides: [ + { + files: ['utils/**/*.ts', 'src/**/*.ts'], + parserOptions: { + project: ['tsconfig.json'], + sourceType: 'module', + }, + }, + { + files: ['suites/**/*.ts', 'suites/**/*.mjs'], + parserOptions: { + project: ['tsconfig.test.json'], + sourceType: 'module', + ecmaVersion: 'latest', + }, + globals: { + fetch: 'readonly', + }, + rules: { + '@typescript-eslint/typedef': 'off', + // Explicitly allow ts-ignore with description for Node integration tests + // Reason: We run these tests on TS3.8 which doesn't support `@ts-expect-error` + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-ignore': 'allow-with-description', + 'ts-expect-error': true, + }, + ], + // We rely on having imports after init() is called for OTEL + 'import/first': 'off', + }, + }, + ], +}; diff --git a/dev-packages/node-core-integration-tests/.gitignore b/dev-packages/node-core-integration-tests/.gitignore new file mode 100644 index 000000000000..365cb959a94c --- /dev/null +++ b/dev-packages/node-core-integration-tests/.gitignore @@ -0,0 +1 @@ +suites/**/tmp_* diff --git a/dev-packages/node-core-integration-tests/README.md b/dev-packages/node-core-integration-tests/README.md new file mode 100644 index 000000000000..cf4cf93e9d52 --- /dev/null +++ b/dev-packages/node-core-integration-tests/README.md @@ -0,0 +1,49 @@ +# Integration Tests for Sentry Node.JS Core SDK with OpenTelemetry v2 dependencies + +## Structure + +``` +suites/ +|---- public-api/ + |---- captureMessage/ + |---- test.ts [assertions] + |---- scenario.ts [Sentry initialization and test subject] + |---- customTest/ + |---- test.ts [assertions] + |---- scenario_1.ts [optional extra test scenario] + |---- scenario_2.ts [optional extra test scenario] + |---- server_with_mongo.ts [optional custom server] + |---- server_with_postgres.ts [optional custom server] +``` + +The tests are grouped by their scopes, such as `public-api` or `tracing`. In every group of tests, there are multiple +folders containing test scenarios and assertions. + +`scenario.ts` contains the initialization logic and the test subject. By default, `{TEST_DIR}/scenario.ts` is used, but +`runServer` also accepts an optional `scenarioPath` argument for non-standard usage. + +`test.ts` is required for each test case, and contains the server runner logic, request interceptors for Sentry +requests, and assertions. Test server, interceptors and assertions are all run on the same Vitest thread. + +### Utilities + +`utils/` contains helpers and Sentry-specific assertions that can be used in (`test.ts`). + +Nock interceptors are internally used to capture envelope requests by `getEnvelopeRequest` and +`getMultipleEnvelopeRequest` helpers. After capturing required requests, the interceptors are removed. Nock can manually +be used inside the test cases to intercept requests but should be removed before the test ends, as not to cause +flakiness. + +## Running Tests Locally + +Tests can be run locally with: + +`yarn test` + +To run tests with Vitest's watch mode: + +`yarn test:watch` + +To filter tests by their title: + +`yarn test -t "set different properties of a scope"` diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json new file mode 100644 index 000000000000..c7b984c7ce5b --- /dev/null +++ b/dev-packages/node-core-integration-tests/package.json @@ -0,0 +1,61 @@ +{ + "name": "@sentry-internal/node-core-integration-tests", + "version": "9.22.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "private": true, + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/src/index.d.ts", + "scripts": { + "build": "run-s build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "clean": "rimraf -g **/node_modules && run-p clean:script", + "clean:script": "node scripts/clean.js", + "lint": "eslint . --format stylish", + "fix": "eslint . --format stylish --fix", + "type-check": "tsc", + "test": "vitest run", + "test:watch": "yarn test --watch" + }, + "dependencies": { + "@nestjs/common": "11.0.16", + "@nestjs/core": "10.4.6", + "@nestjs/platform-express": "10.4.6", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1", + "@opentelemetry/core": "^1.30.1", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/instrumentation-http": "0.57.2", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.30.0", + "@sentry/core": "9.22.0", + "@sentry/node": "9.22.0", + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "cron": "^3.1.6", + "express": "^4.21.1", + "http-terminator": "^3.2.0", + "nock": "^13.5.5", + "node-cron": "^3.0.3", + "node-schedule": "^2.1.1", + "proxy": "^2.1.1", + "reflect-metadata": "0.2.1", + "rxjs": "^7.8.1", + "winston": "^3.17.0", + "yargs": "^16.2.0" + }, + "devDependencies": { + "@types/node-cron": "^3.0.11", + "@types/node-schedule": "^2.1.7", + "globby": "11" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/node-core-integration-tests/rollup.npm.config.mjs b/dev-packages/node-core-integration-tests/rollup.npm.config.mjs new file mode 100644 index 000000000000..84a06f2fb64a --- /dev/null +++ b/dev-packages/node-core-integration-tests/rollup.npm.config.mjs @@ -0,0 +1,3 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants(makeBaseNPMConfig()); diff --git a/dev-packages/node-core-integration-tests/scripts/clean.js b/dev-packages/node-core-integration-tests/scripts/clean.js new file mode 100644 index 000000000000..0610e39f92d4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/scripts/clean.js @@ -0,0 +1,19 @@ +const { execSync } = require('child_process'); +const globby = require('globby'); +const { dirname, join } = require('path'); + +const cwd = join(__dirname, '..'); +const paths = globby.sync(['suites/**/docker-compose.yml'], { cwd }).map(path => join(cwd, dirname(path))); + +// eslint-disable-next-line no-console +console.log('Cleaning up docker containers and volumes...'); + +for (const path of paths) { + try { + // eslint-disable-next-line no-console + console.log(`docker compose down @ ${path}`); + execSync('docker compose down --volumes', { stdio: 'inherit', cwd: path }); + } catch (_) { + // + } +} diff --git a/dev-packages/node-core-integration-tests/scripts/use-ts-3_8.js b/dev-packages/node-core-integration-tests/scripts/use-ts-3_8.js new file mode 100644 index 000000000000..d759179f8e06 --- /dev/null +++ b/dev-packages/node-core-integration-tests/scripts/use-ts-3_8.js @@ -0,0 +1,39 @@ +/* eslint-disable no-console */ +const { execSync } = require('child_process'); +const { join } = require('path'); +const { readFileSync, writeFileSync } = require('fs'); + +const cwd = join(__dirname, '../../..'); + +// Newer versions of the Express types use syntax that isn't supported by TypeScript 3.8. +// We'll pin to the last version of those types that are compatible. +console.log('Pinning Express types to old versions...'); + +const packageJsonPath = join(cwd, 'package.json'); +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + +if (!packageJson.resolutions) packageJson.resolutions = {}; +packageJson.resolutions['@types/express'] = '4.17.13'; +packageJson.resolutions['@types/express-serve-static-core'] = '4.17.30'; + +writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + +const tsVersion = '3.8'; + +console.log(`Installing typescript@${tsVersion}, and @types/node@14...`); + +execSync(`yarn add --dev --ignore-workspace-root-check typescript@${tsVersion} @types/node@^14`, { + stdio: 'inherit', + cwd, +}); + +console.log('Removing unsupported tsconfig options...'); + +const baseTscConfigPath = join(cwd, 'packages/typescript/tsconfig.json'); + +const tsConfig = require(baseTscConfigPath); + +// TS 3.8 fails build when it encounters a config option it does not understand, so we remove it :( +delete tsConfig.compilerOptions.noUncheckedIndexedAccess; + +writeFileSync(baseTscConfigPath, JSON.stringify(tsConfig, null, 2)); diff --git a/dev-packages/node-core-integration-tests/src/index.ts b/dev-packages/node-core-integration-tests/src/index.ts new file mode 100644 index 000000000000..ed6a150bd8d6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/src/index.ts @@ -0,0 +1,57 @@ +import type { BaseTransportOptions, Envelope, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import type { Express } from 'express'; +import type { AddressInfo } from 'net'; + +/** + * Debug logging transport + */ +export function loggingTransport(_options: BaseTransportOptions): Transport { + return { + send(request: Envelope): Promise { + // eslint-disable-next-line no-console + console.log(JSON.stringify(request)); + return Promise.resolve({ statusCode: 200 }); + }, + flush(): PromiseLike { + return new Promise(resolve => setTimeout(() => resolve(true), 1000)); + }, + }; +} + +/** + * Starts an express server and sends the port to the runner + * @param app Express app + * @param port Port to start the app on. USE WITH CAUTION! By default a random port will be chosen. + * Setting this port to something specific is useful for local debugging but dangerous for + * CI/CD environments where port collisions can cause flakes! + */ +export function startExpressServerAndSendPortToRunner( + app: Pick, + port: number | undefined = undefined, +): void { + const server = app.listen(port || 0, () => { + const address = server.address() as AddressInfo; + + // @ts-expect-error If we write the port to the app we can read it within route handlers in tests + app.port = port || address.port; + + // eslint-disable-next-line no-console + console.log(`{"port":${port || address.port}}`); + }); +} + +/** + * Sends the port to the runner + */ +export function sendPortToRunner(port: number): void { + // eslint-disable-next-line no-console + console.log(`{"port":${port}}`); +} + +/** + * Can be used to get the port of a running app, so requests can be sent to a server from within the server. + */ +export function getPortAppIsRunningOn(app: Express): number | undefined { + // @ts-expect-error It's not defined in the types but we'd like to read it. + return app.port; +} diff --git a/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/app.mjs b/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/app.mjs new file mode 100644 index 000000000000..7169b4824532 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/app.mjs @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { spawn } from 'child_process'; +import { join } from 'path'; +import { Worker } from 'worker_threads'; +import { setupOtel } from '../../../utils/setupOtel.js'; + +const __dirname = new URL('.', import.meta.url).pathname; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + integrations: [Sentry.childProcessIntegration({ captureWorkerErrors: false })], + transport: loggingTransport, +}); + +setupOtel(client); + +(async () => { + await new Promise(resolve => { + const child = spawn('sleep', ['a']); + child.on('error', resolve); + child.on('exit', resolve); + }); + + await new Promise(resolve => { + const worker = new Worker(join(__dirname, 'worker.mjs')); + worker.on('error', resolve); + worker.on('exit', resolve); + }); + + throw new Error('This is a test error'); +})(); diff --git a/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/test.ts b/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/test.ts new file mode 100644 index 000000000000..a3ae49da4808 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/test.ts @@ -0,0 +1,50 @@ +import type { Event } from '@sentry/core'; +import { afterAll, expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +const EVENT = { + // and an exception that is our ANR + exception: { + values: [ + { + type: 'Error', + value: 'This is a test error', + }, + ], + }, + breadcrumbs: [ + { + timestamp: expect.any(Number), + category: 'child_process', + message: "Child process exited with code '1'", + level: 'warning', + data: { + spawnfile: 'sleep', + }, + }, + { + timestamp: expect.any(Number), + category: 'worker_thread', + message: "Worker thread errored with 'Worker error'", + level: 'error', + data: { + threadId: expect.any(Number), + }, + }, + ], +}; + +conditionalTest({ min: 20 })('should capture process and thread breadcrumbs', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('ESM', async () => { + await createRunner(__dirname, 'app.mjs') + .withMockSentryServer() + .expect({ event: EVENT as Event }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/worker.mjs b/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/worker.mjs new file mode 100644 index 000000000000..049063bd26b4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/breadcrumbs/process-thread/worker.mjs @@ -0,0 +1 @@ +throw new Error('Worker error'); diff --git a/dev-packages/node-core-integration-tests/suites/child-process/child.js b/dev-packages/node-core-integration-tests/suites/child-process/child.js new file mode 100644 index 000000000000..cb1937007297 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/child-process/child.js @@ -0,0 +1,3 @@ +setTimeout(() => { + throw new Error('Test error'); +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/child-process/child.mjs b/dev-packages/node-core-integration-tests/suites/child-process/child.mjs new file mode 100644 index 000000000000..cb1937007297 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/child-process/child.mjs @@ -0,0 +1,3 @@ +setTimeout(() => { + throw new Error('Test error'); +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/child-process/fork.js b/dev-packages/node-core-integration-tests/suites/child-process/fork.js new file mode 100644 index 000000000000..8fa2b26983fc --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/child-process/fork.js @@ -0,0 +1,20 @@ +const Sentry = require('@sentry/node-core'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const path = require('path'); +const { fork } = require('child_process'); +const { setupOtel } = require('../../utils/setupOtel.js'); + +const client = Sentry.init({ + debug: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +fork(path.join(__dirname, 'child.mjs')); + +setTimeout(() => { + throw new Error('Exiting main process'); +}, 3000); diff --git a/dev-packages/node-core-integration-tests/suites/child-process/fork.mjs b/dev-packages/node-core-integration-tests/suites/child-process/fork.mjs new file mode 100644 index 000000000000..7aab2c78e6b6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/child-process/fork.mjs @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { fork } from 'child_process'; +import * as path from 'path'; +import { setupOtel } from '../../utils/setupOtel.js'; + +const __dirname = new URL('.', import.meta.url).pathname; + +const client = Sentry.init({ + debug: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +fork(path.join(__dirname, 'child.mjs')); + +setTimeout(() => { + throw new Error('Exiting main process'); +}, 3000); diff --git a/dev-packages/node-core-integration-tests/suites/child-process/test.ts b/dev-packages/node-core-integration-tests/suites/child-process/test.ts new file mode 100644 index 000000000000..798cd48d86d0 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/child-process/test.ts @@ -0,0 +1,66 @@ +import type { Event } from '@sentry/core'; +import { afterAll, describe, expect, test } from 'vitest'; +import { conditionalTest } from '../../utils'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +const WORKER_EVENT: Event = { + exception: { + values: [ + { + type: 'Error', + value: 'Test error', + mechanism: { + type: 'instrument', + handled: false, + data: { + threadId: expect.any(String), + }, + }, + }, + ], + }, +}; + +const CHILD_EVENT: Event = { + exception: { + values: [ + { + type: 'Error', + value: 'Exiting main process', + }, + ], + }, + breadcrumbs: [ + { + category: 'child_process', + message: "Child process exited with code '1'", + level: 'warning', + }, + ], +}; + +describe('should capture child process events', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + conditionalTest({ min: 20 })('worker', () => { + test('ESM', async () => { + await createRunner(__dirname, 'worker.mjs').expect({ event: WORKER_EVENT }).start().completed(); + }); + + test('CJS', async () => { + await createRunner(__dirname, 'worker.js').expect({ event: WORKER_EVENT }).start().completed(); + }); + }); + + conditionalTest({ min: 20 })('fork', () => { + test('ESM', async () => { + await createRunner(__dirname, 'fork.mjs').expect({ event: CHILD_EVENT }).start().completed(); + }); + + test('CJS', async () => { + await createRunner(__dirname, 'fork.js').expect({ event: CHILD_EVENT }).start().completed(); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/child-process/worker.js b/dev-packages/node-core-integration-tests/suites/child-process/worker.js new file mode 100644 index 000000000000..e18074c781e5 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/child-process/worker.js @@ -0,0 +1,20 @@ +const Sentry = require('@sentry/node-core'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const path = require('path'); +const { Worker } = require('worker_threads'); +const { setupOtel } = require('../../utils/setupOtel.js'); + +const client = Sentry.init({ + debug: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +new Worker(path.join(__dirname, 'child.js')); + +setTimeout(() => { + process.exit(); +}, 3000); diff --git a/dev-packages/node-core-integration-tests/suites/child-process/worker.mjs b/dev-packages/node-core-integration-tests/suites/child-process/worker.mjs new file mode 100644 index 000000000000..1c2037ba79e0 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/child-process/worker.mjs @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as path from 'path'; +import { Worker } from 'worker_threads'; +import { setupOtel } from '../../utils/setupOtel.js'; + +const __dirname = new URL('.', import.meta.url).pathname; + +const client = Sentry.init({ + debug: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +new Worker(path.join(__dirname, 'child.mjs')); + +setTimeout(() => { + process.exit(); +}, 3000); diff --git a/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/before-send/scenario.ts b/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/before-send/scenario.ts new file mode 100644 index 000000000000..1d0cc44eb5c7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/before-send/scenario.ts @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + beforeSend(event) { + return !event.type ? null : event; + }, + }); + + setupOtel(client); + + Sentry.captureException(new Error('this should get dropped by the event processor')); + + await Sentry.flush(); + + Sentry.captureException(new Error('this should get dropped by the event processor')); + Sentry.captureException(new Error('this should get dropped by the event processor')); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.flush(); +})(); diff --git a/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/before-send/test.ts b/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/before-send/test.ts new file mode 100644 index 000000000000..73a40fd88d17 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/before-send/test.ts @@ -0,0 +1,35 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should record client report for beforeSend', async () => { + await createRunner(__dirname, 'scenario.ts') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'before_send', + }, + ], + }, + }) + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 2, + reason: 'before_send', + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/event-processors/scenario.ts b/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/event-processors/scenario.ts new file mode 100644 index 000000000000..740cc82e183d --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/event-processors/scenario.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + }); + + setupOtel(client); + + Sentry.addEventProcessor(event => { + return !event.type ? null : event; + }); + + Sentry.captureException(new Error('this should get dropped by the event processor')); + + await Sentry.flush(); + + Sentry.captureException(new Error('this should get dropped by the event processor')); + Sentry.captureException(new Error('this should get dropped by the event processor')); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.flush(); +})(); diff --git a/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/event-processors/test.ts b/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/event-processors/test.ts new file mode 100644 index 000000000000..4e236e375c40 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/client-reports/drop-reasons/event-processors/test.ts @@ -0,0 +1,35 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should record client report for event processors', async () => { + await createRunner(__dirname, 'scenario.ts') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'event_processor', + }, + ], + }, + }) + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 2, + reason: 'event_processor', + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/client-reports/periodic-send/scenario.ts b/dev-packages/node-core-integration-tests/suites/client-reports/periodic-send/scenario.ts new file mode 100644 index 000000000000..272bf9a5305f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/client-reports/periodic-send/scenario.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + clientReportFlushInterval: 5000, + beforeSend(event) { + return !event.type ? null : event; + }, +}); + +setupOtel(client); + +Sentry.captureException(new Error('this should get dropped by before send')); diff --git a/dev-packages/node-core-integration-tests/suites/client-reports/periodic-send/test.ts b/dev-packages/node-core-integration-tests/suites/client-reports/periodic-send/test.ts new file mode 100644 index 000000000000..69775219b784 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/client-reports/periodic-send/test.ts @@ -0,0 +1,24 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should flush client reports automatically after the timeout interval', async () => { + await createRunner(__dirname, 'scenario.ts') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'before_send', + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/instrument.mjs b/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/instrument.mjs new file mode 100644 index 000000000000..0aade82dbf23 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/scenario with space.cjs b/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/scenario with space.cjs new file mode 100644 index 000000000000..7ba7dd225b7e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/scenario with space.cjs @@ -0,0 +1,15 @@ +const Sentry = require('@sentry/node-core'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.captureException(new Error('Test Error')); + +// some more post context diff --git a/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/scenario with space.mjs b/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/scenario with space.mjs new file mode 100644 index 000000000000..e3139401e5e2 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/scenario with space.mjs @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/node-core'; + +Sentry.captureException(new Error('Test Error')); + +// some more post context diff --git a/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/test.ts b/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/test.ts new file mode 100644 index 000000000000..85e6b28a6eb8 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/contextLines/filename-with-spaces/test.ts @@ -0,0 +1,85 @@ +import { join } from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRunner } from '../../../utils/runner'; + +describe('ContextLines integration in ESM', () => { + test('reads encoded context lines from filenames with spaces', async () => { + expect.assertions(1); + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario with space.mjs') + .withInstrument(instrumentPath) + .expect({ + event: { + exception: { + values: [ + { + value: 'Test Error', + stacktrace: { + frames: expect.arrayContaining([ + { + filename: expect.stringMatching(/\/scenario with space.mjs$/), + context_line: "Sentry.captureException(new Error('Test Error'));", + pre_context: ["import * as Sentry from '@sentry/node-core';", ''], + post_context: ['', '// some more post context'], + colno: 25, + lineno: 3, + function: '?', + in_app: true, + module: 'scenario with space', + }, + ]), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); +}); + +describe('ContextLines integration in CJS', () => { + test('reads context lines from filenames with spaces', async () => { + expect.assertions(1); + + await createRunner(__dirname, 'scenario with space.cjs') + .expect({ + event: { + exception: { + values: [ + { + value: 'Test Error', + stacktrace: { + frames: expect.arrayContaining([ + { + filename: expect.stringMatching(/\/scenario with space.cjs$/), + context_line: "Sentry.captureException(new Error('Test Error'));", + pre_context: [ + " dsn: 'https://public@dsn.ingest.sentry.io/1337',", + " release: '1.0',", + ' transport: loggingTransport,', + '});', + '', + 'setupOtel(client);', + '', + ], + post_context: ['', '// some more post context'], + colno: 25, + lineno: 13, + function: 'Object.?', + in_app: true, + module: 'scenario with space', + }, + ]), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/nested-file.ts b/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/nested-file.ts new file mode 100644 index 000000000000..bd76720a6285 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/nested-file.ts @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/node-core'; + +export function captureException(i: number): void { + Sentry.captureException(new Error(`error in loop ${i}`)); +} diff --git a/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/other-file.ts b/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/other-file.ts new file mode 100644 index 000000000000..c48fae3e2e2e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/other-file.ts @@ -0,0 +1,7 @@ +import { captureException } from './nested-file'; + +export function runSentry(): void { + for (let i = 0; i < 10; i++) { + captureException(i); + } +} diff --git a/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/scenario.ts b/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/scenario.ts new file mode 100644 index 000000000000..e19ef8fa16d4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/scenario.ts @@ -0,0 +1,32 @@ +import { execSync } from 'node:child_process'; +import * as path from 'node:path'; +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +import { runSentry } from './other-file'; + +runSentry(); + +const lsofOutput = execSync(`lsof -p ${process.pid}`, { encoding: 'utf8' }); +const lsofTable = lsofOutput.split('\n'); +const mainPath = __dirname.replace(`${path.sep}suites${path.sep}contextLines${path.sep}memory-leak`, ''); +const numberOfLsofEntriesWithMainPath = lsofTable.filter(entry => entry.includes(mainPath)); + +// There should only be a single entry with the main path, otherwise we are leaking file handles from the +// context lines integration. +if (numberOfLsofEntriesWithMainPath.length > 1) { + // eslint-disable-next-line no-console + console.error('Leaked file handles detected'); + // eslint-disable-next-line no-console + console.error(lsofTable); + process.exit(1); +} diff --git a/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/test.ts b/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/test.ts new file mode 100644 index 000000000000..1a5170c05fe7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/contextLines/memory-leak/test.ts @@ -0,0 +1,18 @@ +import { afterAll, describe, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('ContextLines integration in CJS', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // Regression test for: https://github.com/getsentry/sentry-javascript/issues/14892 + test('does not leak open file handles', async () => { + await createRunner(__dirname, 'scenario.ts') + .expectN(10, { + event: {}, + }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts new file mode 100644 index 000000000000..bcbe375dbe31 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/cron/scenario.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { CronJob } from 'cron'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const CronJobWithCheckIn = Sentry.cron.instrumentCron(CronJob, 'my-cron-job'); + +let closeNext = false; + +const cron = new CronJobWithCheckIn('* * * * * *', () => { + if (closeNext) { + cron.stop(); + throw new Error('Error in cron job'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext = true; +}); + +cron.start(); + +setTimeout(() => { + process.exit(); +}, 5000); diff --git a/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts new file mode 100644 index 000000000000..8b9fdfd5c593 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/cron/test.ts @@ -0,0 +1,77 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('cron instrumentation', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'in_progress', + release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '* * * * * *' } }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'ok', + release: '1.0', + duration: expect.any(Number), + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'in_progress', + release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '* * * * * *' } }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'error', + release: '1.0', + duration: expect.any(Number), + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + event: { + exception: { values: [{ type: 'Error', value: 'Error in cron job' }] }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts new file mode 100644 index 000000000000..eee62bc1a94a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import * as cron from 'node-cron'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const cronWithCheckIn = Sentry.cron.instrumentNodeCron(cron); + +let closeNext = false; + +const task = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task.stop(); + }); + + throw new Error('Error in cron job'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext = true; + }, + { name: 'my-cron-job' }, +); + +setTimeout(() => { + process.exit(); +}, 5000); diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts new file mode 100644 index 000000000000..1c5fa515e208 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts @@ -0,0 +1,77 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('node-cron instrumentation', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'in_progress', + release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '* * * * * *' } }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'ok', + release: '1.0', + duration: expect.any(Number), + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'in_progress', + release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '* * * * * *' } }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'error', + release: '1.0', + duration: expect.any(Number), + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + event: { + exception: { values: [{ type: 'Error', value: 'Error in cron job' }] }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-schedule/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/node-schedule/scenario.ts new file mode 100644 index 000000000000..d48d7a2926d2 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/node-schedule/scenario.ts @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import * as schedule from 'node-schedule'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const scheduleWithCheckIn = Sentry.cron.instrumentNodeSchedule(schedule); + +let closeNext = false; + +const job = scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * * * *', () => { + if (closeNext) { + job.cancel(); + throw new Error('Error in cron job'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext = true; +}); + +setTimeout(() => { + process.exit(); +}, 5000); diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-schedule/test.ts b/dev-packages/node-core-integration-tests/suites/cron/node-schedule/test.ts new file mode 100644 index 000000000000..a2019253203f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/node-schedule/test.ts @@ -0,0 +1,77 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('node-schedule instrumentation', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'in_progress', + release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '* * * * * *' } }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'ok', + release: '1.0', + duration: expect.any(Number), + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'in_progress', + release: '1.0', + monitor_config: { schedule: { type: 'crontab', value: '* * * * * *' } }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + check_in: { + check_in_id: expect.any(String), + monitor_slug: 'my-cron-job', + status: 'error', + release: '1.0', + duration: expect.any(Number), + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .expect({ + event: { + exception: { values: [{ type: 'Error', value: 'Error in cron job' }] }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/app.mjs b/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/app.mjs new file mode 100644 index 000000000000..180eedbab9a5 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/app.mjs @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as iitm from 'import-in-the-middle'; +import { setupOtel } from '../../../utils/setupOtel.js'; + +new iitm.Hook((_, name) => { + if (name !== 'http') { + throw new Error(`'http' should be the only hooked modules but we just hooked '${name}'`); + } +}); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +(async () => { + await import('./sub-module.mjs'); + await import('http'); + await import('os'); +})(); diff --git a/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/sub-module.mjs b/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/sub-module.mjs new file mode 100644 index 000000000000..9940c57857eb --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/sub-module.mjs @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-console +console.assert(true); diff --git a/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/test.ts b/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/test.ts new file mode 100644 index 000000000000..99dea0e9193a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/esm/import-in-the-middle/test.ts @@ -0,0 +1,15 @@ +import { spawnSync } from 'child_process'; +import { join } from 'path'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe('import-in-the-middle', () => { + test('should only instrument modules that we have instrumentation for', () => { + const result = spawnSync('node', [join(__dirname, 'app.mjs')], { encoding: 'utf-8' }); + expect(result.stderr).not.toMatch('should be the only hooked modules but we just hooked'); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/esm/modules-integration/app.mjs b/dev-packages/node-core-integration-tests/suites/esm/modules-integration/app.mjs new file mode 100644 index 000000000000..ab1566c7b139 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/esm/modules-integration/app.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + integrations: [Sentry.modulesIntegration()], + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/esm/modules-integration/test.ts b/dev-packages/node-core-integration-tests/suites/esm/modules-integration/test.ts new file mode 100644 index 000000000000..94995aedb91f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/esm/modules-integration/test.ts @@ -0,0 +1,12 @@ +import { afterAll, describe, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe('modulesIntegration', () => { + test('does not crash ESM setups', async () => { + await createRunner(__dirname, 'app.mjs').ensureNoErrorOutput().start().completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/esm/setupOtel.mjs b/dev-packages/node-core-integration-tests/suites/esm/setupOtel.mjs new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/dev-packages/node-core-integration-tests/suites/esm/warn-esm/server.js b/dev-packages/node-core-integration-tests/suites/esm/warn-esm/server.js new file mode 100644 index 000000000000..fc6c1aaa75f4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/esm/warn-esm/server.js @@ -0,0 +1,22 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); +const express = require('express'); + +const app = express(); + +app.get('/test/success', (req, res) => { + res.send({ response: 'response 3' }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/esm/warn-esm/server.mjs b/dev-packages/node-core-integration-tests/suites/esm/warn-esm/server.mjs new file mode 100644 index 000000000000..b02456a34f4e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/esm/warn-esm/server.mjs @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; +import { setupOtel } from '../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const app = express(); + +app.get('/test/success', (req, res) => { + res.send({ response: 'response 3' }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/esm/warn-esm/test.ts b/dev-packages/node-core-integration-tests/suites/esm/warn-esm/test.ts new file mode 100644 index 000000000000..18eebdab6e85 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/esm/warn-esm/test.ts @@ -0,0 +1,42 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +const esmWarning = `[Sentry] You are using Node.js v${process.versions.node} 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 upgrade your Node.js version.`; + +test("warns if using ESM on Node.js versions that don't support `register()`", async () => { + const nodeMajorVersion = Number(process.versions.node.split('.')[0]); + if (nodeMajorVersion >= 18) { + return; + } + + const runner = createRunner(__dirname, 'server.mjs').ignore('event').start(); + + await runner.makeRequest('get', '/test/success'); + + expect(runner.getLogs()).toContain(esmWarning); +}); + +test('does not warn if using ESM on Node.js versions that support `register()`', async () => { + const nodeMajorVersion = Number(process.versions.node.split('.')[0]); + if (nodeMajorVersion < 18) { + return; + } + + const runner = createRunner(__dirname, 'server.mjs').ignore('event').start(); + + await runner.makeRequest('get', '/test/success'); + + expect(runner.getLogs()).not.toContain(esmWarning); +}); + +test('does not warn if using CJS', async () => { + const runner = createRunner(__dirname, 'server.js').ignore('event').start(); + + await runner.makeRequest('get', '/test/success'); + + expect(runner.getLogs()).not.toContain(esmWarning); +}); diff --git a/dev-packages/node-core-integration-tests/suites/no-code/app.js b/dev-packages/node-core-integration-tests/suites/no-code/app.js new file mode 100644 index 000000000000..cb1937007297 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/no-code/app.js @@ -0,0 +1,3 @@ +setTimeout(() => { + throw new Error('Test error'); +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/no-code/app.mjs b/dev-packages/node-core-integration-tests/suites/no-code/app.mjs new file mode 100644 index 000000000000..cb1937007297 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/no-code/app.mjs @@ -0,0 +1,3 @@ +setTimeout(() => { + throw new Error('Test error'); +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/no-code/test.ts b/dev-packages/node-core-integration-tests/suites/no-code/test.ts new file mode 100644 index 000000000000..dfe58ff03f72 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/no-code/test.ts @@ -0,0 +1,39 @@ +import { afterAll, describe, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +const EVENT = { + exception: { + values: [ + { + type: 'Error', + value: 'Test error', + }, + ], + }, +}; + +describe('no-code init', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('CJS', async () => { + await createRunner(__dirname, 'app.js') + .withFlags('--require=@sentry/node/init') + .withMockSentryServer() + .expect({ event: EVENT }) + .start() + .completed(); + }); + + describe('--import', () => { + test('ESM', async () => { + await createRunner(__dirname, 'app.mjs') + .withFlags('--import=@sentry/node/init') + .withMockSentryServer() + .expect({ event: EVENT }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/proxy/basic.js b/dev-packages/node-core-integration-tests/suites/proxy/basic.js new file mode 100644 index 000000000000..00709a26de91 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/proxy/basic.js @@ -0,0 +1,20 @@ +const http = require('http'); +const Sentry = require('@sentry/node-core'); +const { createProxy } = require('proxy'); +const { setupOtel } = require('../../utils/setupOtel.js'); + +const proxy = createProxy(http.createServer()); +proxy.listen(0, () => { + const proxyPort = proxy.address().port; + + const client = Sentry.init({ + dsn: process.env.SENTRY_DSN, + transportOptions: { + proxy: `http://localhost:${proxyPort}`, + }, + }); + + setupOtel(client); + + Sentry.captureMessage('Hello, via proxy!'); +}); diff --git a/dev-packages/node-core-integration-tests/suites/proxy/test.ts b/dev-packages/node-core-integration-tests/suites/proxy/test.ts new file mode 100644 index 000000000000..805b913d4814 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/proxy/test.ts @@ -0,0 +1,18 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('proxies sentry requests', async () => { + await createRunner(__dirname, 'basic.js') + .withMockSentryServer() + .expect({ + event: { + message: 'Hello, via proxy!', + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/deny-inspector.mjs b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/deny-inspector.mjs new file mode 100644 index 000000000000..08db0bf96acb --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/deny-inspector.mjs @@ -0,0 +1,22 @@ +import { register } from 'node:module'; + +register( + new URL(`data:application/javascript, +export async function resolve(specifier, context, nextResolve) { + if (specifier === 'node:inspector' || specifier === 'inspector') { + throw new Error('Should not use node:inspector module'); + } + + return nextResolve(specifier); +}`), + import.meta.url, +); + +(async () => { + const Sentry = await import('@sentry/node-core'); + const { setupOtel } = await import('../../../utils/setupOtel.js'); + + const client = Sentry.init({}); + + setupOtel(client); +})(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js new file mode 100644 index 000000000000..70f9377470de --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js @@ -0,0 +1,43 @@ +/* eslint-disable no-unused-vars */ +const Sentry = require('@sentry/node-core'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + includeLocalVariables: true, + transport: loggingTransport, +}); + +setupOtel(client); + +class Some { + two(name) { + throw new Error('Enough!'); + } +} + +function one(name) { + const arr = [1, '2', null]; + const obj = { + name, + num: 5, + }; + const bool = false; + const num = 0; + const str = ''; + const something = undefined; + const somethingElse = null; + + const ty = new Some(); + + ty.two(name); +} + +setTimeout(() => { + try { + one('some name'); + } catch (e) { + Sentry.captureException(e); + } +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs new file mode 100644 index 000000000000..3df12c70382b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs @@ -0,0 +1,47 @@ +/* eslint-disable no-unused-vars */ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + includeLocalVariables: true, + transport: loggingTransport, +}); + +setupOtel(client); + +class Some { + async two(name) { + return new Promise((_, reject) => { + reject(new Error('Enough!')); + }); + } +} + +async function one(name) { + const arr = [1, '2', null]; + const obj = { + name, + num: 5, + functionsShouldNotBeIncluded: () => {}, + functionsShouldNotBeIncluded2() {}, + }; + const bool = false; + const num = 0; + const str = ''; + const something = undefined; + const somethingElse = null; + + const ty = new Some(); + + await ty.two(name); +} + +setTimeout(async () => { + try { + await one('some name'); + } catch (e) { + Sentry.captureException(e); + } +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-instrument.js b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-instrument.js new file mode 100644 index 000000000000..c14403135848 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-instrument.js @@ -0,0 +1,11 @@ +const Sentry = require('@sentry/node-core'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + includeLocalVariables: true, + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-no-sentry.js b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-no-sentry.js new file mode 100644 index 000000000000..08636175fa7b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-no-sentry.js @@ -0,0 +1,31 @@ +/* eslint-disable no-unused-vars */ +process.on('uncaughtException', () => { + // do nothing - this will prevent the Error below from closing this process +}); + +class Some { + two(name) { + throw new Error('Enough!'); + } +} + +function one(name) { + const arr = [1, '2', null]; + const obj = { + name, + num: 5, + }; + const bool = false; + const num = 0; + const str = ''; + const something = undefined; + const somethingElse = null; + + const ty = new Some(); + + ty.two(name); +} + +setTimeout(() => { + one('some name'); +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-rethrow.js b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-rethrow.js new file mode 100644 index 000000000000..2b7eb65b1582 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables-rethrow.js @@ -0,0 +1,48 @@ +/* eslint-disable no-unused-vars */ +const Sentry = require('@sentry/node-core'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + includeLocalVariables: true, + transport: loggingTransport, +}); + +setupOtel(client); + +class Some { + two(name) { + throw new Error('Enough!'); + } +} + +function one(name) { + const arr = [1, '2', null]; + const obj = { + name, + num: 5, + }; + const bool = false; + const num = 0; + const str = ''; + const something = undefined; + const somethingElse = null; + + const ty = new Some(); + + ty.two(name); +} + +setTimeout(() => { + try { + try { + one('some name'); + } catch (e) { + const more = 'here'; + throw e; + } + } catch (e) { + Sentry.captureException(e); + } +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables.js b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables.js new file mode 100644 index 000000000000..27eaa228b497 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/local-variables.js @@ -0,0 +1,43 @@ +/* eslint-disable no-unused-vars */ +const Sentry = require('@sentry/node-core'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + includeLocalVariables: true, + transport: loggingTransport, +}); + +setupOtel(client); + +process.on('uncaughtException', () => { + // do nothing - this will prevent the Error below from closing this process +}); + +class Some { + two(name) { + throw new Error('Enough!'); + } +} + +function one(name) { + const arr = [1, '2', null]; + const obj = { + name, + num: 5, + }; + const bool = false; + const num = 0; + const str = ''; + const something = undefined; + const somethingElse = null; + + const ty = new Some(); + + ty.two(name); +} + +setTimeout(() => { + one('some name'); +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/no-local-variables.js b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/no-local-variables.js new file mode 100644 index 000000000000..3be190c23260 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/no-local-variables.js @@ -0,0 +1,42 @@ +/* eslint-disable no-unused-vars */ +const Sentry = require('@sentry/node-core'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, +}); + +setupOtel(client); + +process.on('uncaughtException', () => { + // do nothing - this will prevent the Error below from closing this process +}); + +class Some { + two(name) { + throw new Error('Enough!'); + } +} + +function one(name) { + const arr = [1, '2', null]; + const obj = { + name, + num: 5, + }; + const bool = false; + const num = 0; + const str = ''; + const something = undefined; + const somethingElse = null; + + const ty = new Some(); + + ty.two(name); +} + +setTimeout(() => { + one('some name'); +}, 1000); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/test.ts new file mode 100644 index 000000000000..e95e5a9e3767 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/LocalVariables/test.ts @@ -0,0 +1,104 @@ +import * as path from 'path'; +import { afterAll, describe, expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +// This test takes some time because it connects the debugger etc. +// So we increase the timeout here +// vi.setTimeout(45_000); + +const EXPECTED_LOCAL_VARIABLES_EVENT = { + exception: { + values: [ + { + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: 'one', + vars: { + name: 'some name', + arr: [1, '2', null], + obj: { name: 'some name', num: 5 }, + ty: '', + bool: false, + num: 0, + str: '', + something: '', + somethingElse: '', + }, + }), + expect.objectContaining({ + function: 'Some.two', + vars: { name: 'some name' }, + }), + ]), + }, + }, + ], + }, +}; + +describe('LocalVariables integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('Should not include local variables by default', async () => { + await createRunner(__dirname, 'no-local-variables.js') + .expect({ + event: event => { + for (const frame of event.exception?.values?.[0]?.stacktrace?.frames || []) { + expect(frame.vars).toBeUndefined(); + } + }, + }) + .start() + .completed(); + }); + + test('Should include local variables when enabled', async () => { + await createRunner(__dirname, 'local-variables.js') + .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) + .start() + .completed(); + }); + + test('Should include local variables when instrumenting via --require', async () => { + const requirePath = path.resolve(__dirname, 'local-variables-instrument.js'); + + await createRunner(__dirname, 'local-variables-no-sentry.js') + .withFlags(`--require=${requirePath}`) + .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) + .start() + .completed(); + }); + + test('Should include local variables with ESM', async () => { + await createRunner(__dirname, 'local-variables-caught.mjs') + .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) + .start() + .completed(); + }); + + conditionalTest({ min: 19 })('Node v19+', () => { + test('Should not import inspector when not in use', async () => { + await createRunner(__dirname, 'deny-inspector.mjs').ensureNoErrorOutput().start().completed(); + }); + }); + + conditionalTest({ min: 20 })('Node v20+', () => { + test('Should retain original local variables when error is re-thrown', async () => { + await createRunner(__dirname, 'local-variables-rethrow.js') + .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) + .start() + .completed(); + }); + }); + + test('Includes local variables for caught exceptions when enabled', async () => { + await createRunner(__dirname, 'local-variables-caught.js') + .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/additional-listener-test-script.js b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/additional-listener-test-script.js new file mode 100644 index 000000000000..745b744da467 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/additional-listener-test-script.js @@ -0,0 +1,19 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +setupOtel(client); + +process.on('uncaughtException', () => { + // do nothing - this will prevent the Error below from closing this process before the timeout resolves +}); + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +throw new Error(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/log-entire-error-to-console.js b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/log-entire-error-to-console.js new file mode 100644 index 000000000000..467eb127f7d1 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/log-entire-error-to-console.js @@ -0,0 +1,10 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +setupOtel(client); + +throw new Error('foo', { cause: 'bar' }); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/mimic-native-behaviour-additional-listener-test-script.js b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/mimic-native-behaviour-additional-listener-test-script.js new file mode 100644 index 000000000000..dcc2a0166964 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/mimic-native-behaviour-additional-listener-test-script.js @@ -0,0 +1,24 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.onUncaughtExceptionIntegration({ + exitEvenIfOtherHandlersAreRegistered: false, + }), + ], +}); + +setupOtel(client); + +process.on('uncaughtException', () => { + // do nothing - this will prevent the Error below from closing this process before the timeout resolves +}); + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +throw new Error(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/mimic-native-behaviour-no-additional-listener-test-script.js b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/mimic-native-behaviour-no-additional-listener-test-script.js new file mode 100644 index 000000000000..bc8fc1f8c898 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/mimic-native-behaviour-no-additional-listener-test-script.js @@ -0,0 +1,21 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.onUncaughtExceptionIntegration({ + exitEvenIfOtherHandlersAreRegistered: false, + }), + ], +}); + +setupOtel(client); + +setTimeout(() => { + // This should not be called because the script throws before this resolves + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +throw new Error(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/no-additional-listener-test-script.js b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/no-additional-listener-test-script.js new file mode 100644 index 000000000000..513eb3abc7cb --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/no-additional-listener-test-script.js @@ -0,0 +1,16 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +setupOtel(client); + +setTimeout(() => { + // This should not be called because the script throws before this resolves + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +throw new Error(); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/test.ts new file mode 100644 index 000000000000..d27b08f152be --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/OnUncaughtException/test.ts @@ -0,0 +1,77 @@ +import * as childProcess from 'child_process'; +import * as path from 'path'; +import { describe, expect, test } from 'vitest'; + +describe('OnUncaughtException integration', () => { + test('should close process on uncaught error with no additional listeners registered', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'no-additional-listener-test-script.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => { + expect(err).not.toBeNull(); + expect(err?.code).toBe(1); + expect(stdout).not.toBe("I'm alive!"); + done(); + }); + })); + + test('should not close process on uncaught error when additional listeners are registered', () => + new Promise(done => { + expect.assertions(2); + + const testScriptPath = path.resolve(__dirname, 'additional-listener-test-script.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + done(); + }); + })); + + test('should log entire error object to console stderr', () => + new Promise(done => { + expect.assertions(2); + + const testScriptPath = path.resolve(__dirname, 'log-entire-error-to-console.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stderr) => { + expect(err).not.toBeNull(); + const errString = err?.toString() || ''; + + expect(errString).toContain(stderr); + + done(); + }); + })); + + describe('with `exitEvenIfOtherHandlersAreRegistered` set to false', () => { + test('should close process on uncaught error with no additional listeners registered', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'mimic-native-behaviour-no-additional-listener-test-script.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => { + expect(err).not.toBeNull(); + expect(err?.code).toBe(1); + expect(stdout).not.toBe("I'm alive!"); + done(); + }); + })); + + test('should not close process on uncaught error when additional listeners are registered', () => + new Promise(done => { + expect.assertions(2); + + const testScriptPath = path.resolve(__dirname, 'mimic-native-behaviour-additional-listener-test-script.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + done(); + }); + })); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/empty-obj/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/empty-obj/scenario.ts new file mode 100644 index 000000000000..b67cd8865747 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/empty-obj/scenario.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.addBreadcrumb({}); +Sentry.captureMessage('test-empty-obj'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/empty-obj/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/empty-obj/test.ts new file mode 100644 index 000000000000..c4f5145a8bbf --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/empty-obj/test.ts @@ -0,0 +1,17 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should add an empty breadcrumb, when an empty object is given', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'test-empty-obj', + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/multiple_breadcrumbs/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/multiple_breadcrumbs/scenario.ts new file mode 100644 index 000000000000..3f7c663dc2b6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/multiple_breadcrumbs/scenario.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.addBreadcrumb({ + category: 'foo', + message: 'bar', + level: 'fatal', +}); + +Sentry.addBreadcrumb({ + category: 'qux', +}); + +Sentry.captureMessage('test_multi_breadcrumbs'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/multiple_breadcrumbs/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/multiple_breadcrumbs/test.ts new file mode 100644 index 000000000000..13dba000a823 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/multiple_breadcrumbs/test.ts @@ -0,0 +1,27 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should add multiple breadcrumbs', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'test_multi_breadcrumbs', + breadcrumbs: [ + { + category: 'foo', + message: 'bar', + level: 'fatal', + }, + { + category: 'qux', + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/simple_breadcrumb/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/simple_breadcrumb/scenario.ts new file mode 100644 index 000000000000..c7a78699c689 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/simple_breadcrumb/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.addBreadcrumb({ + category: 'foo', + message: 'bar', + level: 'fatal', +}); + +Sentry.captureMessage('test_simple'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/simple_breadcrumb/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/simple_breadcrumb/test.ts new file mode 100644 index 000000000000..9708e00201ae --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/addBreadcrumb/simple_breadcrumb/test.ts @@ -0,0 +1,20 @@ +import { test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('should add a simple breadcrumb', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'test_simple', + breadcrumbs: [ + { + category: 'foo', + message: 'bar', + level: 'fatal', + }, + ], + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureException/catched-error/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureException/catched-error/scenario.ts new file mode 100644 index 000000000000..f09a9b971e6f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureException/catched-error/scenario.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +try { + throw new Error('catched_error'); +} catch (err) { + Sentry.captureException(err); +} diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureException/catched-error/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureException/catched-error/test.ts new file mode 100644 index 000000000000..31a5bf3d6b2e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureException/catched-error/test.ts @@ -0,0 +1,45 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should work inside catch block', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'catched_error', + mechanism: { + type: 'generic', + handled: true, + }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + context_line: " throw new Error('catched_error');", + pre_context: [ + " release: '1.0',", + ' transport: loggingTransport,', + '});', + '', + 'setupOtel(client);', + '', + 'try {', + ], + post_context: ['} catch (err) {', ' Sentry.captureException(err);', '}'], + }), + ]), + }, + }, + ], + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureException/empty-obj/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureException/empty-obj/scenario.ts new file mode 100644 index 000000000000..50e651ff3f71 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureException/empty-obj/scenario.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +Sentry.captureException({}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureException/empty-obj/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureException/empty-obj/test.ts new file mode 100644 index 000000000000..b8a6fe4f85e2 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureException/empty-obj/test.ts @@ -0,0 +1,28 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture an empty object', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Object captured as exception with keys: [object has no keys]', + mechanism: { + type: 'generic', + handled: true, + }, + }, + ], + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureException/simple-error/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureException/simple-error/scenario.ts new file mode 100644 index 000000000000..8fd8955e6df4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureException/simple-error/scenario.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.captureException(new Error('test_simple_error')); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureException/simple-error/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureException/simple-error/test.ts new file mode 100644 index 000000000000..3afe450398e3 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureException/simple-error/test.ts @@ -0,0 +1,31 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture a simple error with message', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'test_simple_error', + mechanism: { + type: 'generic', + handled: true, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/parameterized_message/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/parameterized_message/scenario.ts new file mode 100644 index 000000000000..61cb8f6d54fc --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/parameterized_message/scenario.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const x = 'first'; +const y = 'second'; + +Sentry.captureMessage(Sentry.parameterize`This is a log statement with ${x} and ${y} params`); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts new file mode 100644 index 000000000000..15e6e76306fe --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/parameterized_message/test.ts @@ -0,0 +1,20 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture a parameterized representation of the message', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + logentry: { + message: 'This is a log statement with %s and %s params', + params: ['first', 'second'], + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message/scenario.ts new file mode 100644 index 000000000000..2ca74b70083e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message/scenario.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.captureMessage('Message'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message/test.ts new file mode 100644 index 000000000000..e32081747f28 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message/test.ts @@ -0,0 +1,18 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture a simple message string', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'Message', + level: 'info', + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message_attachStackTrace/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message_attachStackTrace/scenario.ts new file mode 100644 index 000000000000..c868e03c5ce7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message_attachStackTrace/scenario.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + attachStacktrace: true, +}); + +setupOtel(client); + +Sentry.captureMessage('Message'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message_attachStackTrace/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message_attachStackTrace/test.ts new file mode 100644 index 000000000000..8c79687b2bc4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/simple_message_attachStackTrace/test.ts @@ -0,0 +1,27 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('capture a simple message string with a stack trace if `attachStackTrace` is `true`', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'Message', + level: 'info', + exception: { + values: [ + { + mechanism: { synthetic: true, type: 'generic', handled: true }, + value: 'Message', + stacktrace: { frames: expect.any(Array) }, + }, + ], + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/with_level/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/with_level/scenario.ts new file mode 100644 index 000000000000..ea0691019b27 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/with_level/scenario.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.captureMessage('debug_message', 'debug'); +Sentry.captureMessage('info_message', 'info'); +Sentry.captureMessage('warning_message', 'warning'); +Sentry.captureMessage('error_message', 'error'); +Sentry.captureMessage('fatal_message', 'fatal'); +Sentry.captureMessage('log_message', 'log'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/with_level/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/with_level/test.ts new file mode 100644 index 000000000000..a44af6931d1f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/captureMessage/with_level/test.ts @@ -0,0 +1,18 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture with different severity levels', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ event: { message: 'debug_message', level: 'debug' } }) + .expect({ event: { message: 'info_message', level: 'info' } }) + .expect({ event: { message: 'warning_message', level: 'warning' } }) + .expect({ event: { message: 'error_message', level: 'error' } }) + .expect({ event: { message: 'fatal_message', level: 'fatal' } }) + .expect({ event: { message: 'log_message', level: 'log' } }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/configureScope/clear_scope/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/configureScope/clear_scope/scenario.ts new file mode 100644 index 000000000000..0fd3e4c10e88 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/configureScope/clear_scope/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const scope = Sentry.getCurrentScope(); +scope.setTag('foo', 'bar'); +scope.setUser({ id: 'baz' }); +scope.setExtra('qux', 'quux'); +scope.clear(); + +Sentry.captureMessage('cleared_scope'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/configureScope/clear_scope/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/configureScope/clear_scope/test.ts new file mode 100644 index 000000000000..19f16417bb50 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/configureScope/clear_scope/test.ts @@ -0,0 +1,17 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should clear previously set properties of a scope', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'cleared_scope', + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/configureScope/set_properties/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/configureScope/set_properties/scenario.ts new file mode 100644 index 000000000000..040107d7e98b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/configureScope/set_properties/scenario.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const scope = Sentry.getCurrentScope(); +scope.setTag('foo', 'bar'); +scope.setUser({ id: 'baz' }); +scope.setExtra('qux', 'quux'); + +Sentry.captureMessage('configured_scope'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/configureScope/set_properties/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/configureScope/set_properties/test.ts new file mode 100644 index 000000000000..ecfb83c3a4a3 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/configureScope/set_properties/test.ts @@ -0,0 +1,26 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should set different properties of a scope', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'configured_scope', + tags: { + foo: 'bar', + }, + extra: { + qux: 'quux', + }, + user: { + id: 'baz', + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-none.js b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-none.js new file mode 100644 index 000000000000..7974601a0ddd --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-none.js @@ -0,0 +1,16 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.onUnhandledRejectionIntegration({ mode: 'none' })], +}); + +setupOtel(client); + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +Promise.reject('test rejection'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-strict.js b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-strict.js new file mode 100644 index 000000000000..5a0919adabce --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-strict.js @@ -0,0 +1,17 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.onUnhandledRejectionIntegration({ mode: 'strict' })], +}); + +setupOtel(client); + +setTimeout(() => { + // should not be called + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +Promise.reject('test rejection'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-error.js b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-error.js new file mode 100644 index 000000000000..1e4e209a610a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-error.js @@ -0,0 +1,15 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +setupOtel(client); + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +Promise.reject(new Error('test rejection')); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-string.js b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-string.js new file mode 100644 index 000000000000..a80cada0d039 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-string.js @@ -0,0 +1,15 @@ +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +setupOtel(client); + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +Promise.reject('test rejection'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-strict.ts b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-strict.ts new file mode 100644 index 000000000000..8565e6e65985 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-strict.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + integrations: [Sentry.onUnhandledRejectionIntegration({ mode: 'strict' })], +}); + +setupOtel(client); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Promise.reject('test rejection'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-warn.ts b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-warn.ts new file mode 100644 index 000000000000..fb47769bc243 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-warn.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Promise.reject('test rejection'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts new file mode 100644 index 000000000000..2f4a22c835a4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts @@ -0,0 +1,126 @@ +import * as childProcess from 'child_process'; +import * as path from 'path'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('onUnhandledRejectionIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should show string-type promise rejection warnings by default', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'mode-warn-string.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr.trim()) + .toBe(`This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason: +test rejection`); + done(); + }); + })); + + test('should show error-type promise rejection warnings by default', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'mode-warn-error.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr) + .toContain(`This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason: +Error: test rejection + at Object.`); + done(); + }); + })); + + test('should not close process on unhandled rejection in strict mode', () => + new Promise(done => { + expect.assertions(4); + + const testScriptPath = path.resolve(__dirname, 'mode-strict.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).not.toBeNull(); + expect(err?.code).toBe(1); + expect(stdout).not.toBe("I'm alive!"); + expect(stderr) + .toContain(`This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason: +test rejection`); + done(); + }); + })); + + test('should not close process or warn on unhandled rejection in none mode', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'mode-none.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr).toBe(''); + done(); + }); + })); + + test('captures exceptions for unhandled rejections', async () => { + await createRunner(__dirname, 'scenario-warn.ts') + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'test rejection', + mechanism: { + type: 'onunhandledrejection', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + + test('captures exceptions for unhandled rejections in strict mode', async () => { + await createRunner(__dirname, 'scenario-strict.ts') + .expect({ + event: { + level: 'fatal', + exception: { + values: [ + { + type: 'Error', + value: 'test rejection', + mechanism: { + type: 'onunhandledrejection', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/scopes/initialScopes/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/scopes/initialScopes/scenario.ts new file mode 100644 index 000000000000..00cd252e81fe --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/scopes/initialScopes/scenario.ts @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const globalScope = Sentry.getGlobalScope(); +const isolationScope = Sentry.getIsolationScope(); +const currentScope = Sentry.getCurrentScope(); + +globalScope.setExtra('aa', 'aa'); +isolationScope.setExtra('bb', 'bb'); +currentScope.setExtra('cc', 'cc'); + +Sentry.captureMessage('outer_before'); + +Sentry.withScope(scope => { + scope.setExtra('dd', 'dd'); + Sentry.captureMessage('inner'); +}); + +Sentry.captureMessage('outer_after'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/scopes/initialScopes/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/scopes/initialScopes/test.ts new file mode 100644 index 000000000000..8f16958cc1c9 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/scopes/initialScopes/test.ts @@ -0,0 +1,40 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should apply scopes correctly', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'outer_before', + extra: { + aa: 'aa', + bb: 'bb', + }, + }, + }) + .expect({ + event: { + message: 'inner', + extra: { + aa: 'aa', + bb: 'bb', + cc: 'cc', + }, + }, + }) + .expect({ + event: { + message: 'outer_after', + extra: { + aa: 'aa', + bb: 'bb', + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/scopes/isolationScope/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/scopes/isolationScope/scenario.ts new file mode 100644 index 000000000000..d89bf872b36c --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/scopes/isolationScope/scenario.ts @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const globalScope = Sentry.getGlobalScope(); +const isolationScope = Sentry.getIsolationScope(); +const currentScope = Sentry.getCurrentScope(); + +globalScope.setExtra('aa', 'aa'); +isolationScope.setExtra('bb', 'bb'); +currentScope.setExtra('cc', 'cc'); + +Sentry.captureMessage('outer_before'); + +Sentry.withScope(scope => { + Sentry.getIsolationScope().setExtra('dd', 'dd'); + scope.setExtra('ee', 'ee'); + Sentry.captureMessage('inner'); +}); + +Sentry.withIsolationScope(() => { + Sentry.getIsolationScope().setExtra('ff', 'ff'); + Sentry.getCurrentScope().setExtra('gg', 'gg'); + Sentry.captureMessage('inner_async_context'); +}); + +Sentry.captureMessage('outer_after'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/scopes/isolationScope/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/scopes/isolationScope/test.ts new file mode 100644 index 000000000000..eb926423ef58 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/scopes/isolationScope/test.ts @@ -0,0 +1,57 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should apply scopes correctly', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'outer_before', + extra: { + aa: 'aa', + bb: 'bb', + }, + }, + }) + .expect({ + event: { + message: 'inner', + extra: { + aa: 'aa', + bb: 'bb', + cc: 'cc', + dd: 'dd', + ee: 'ee', + }, + }, + }) + .expect({ + event: { + message: 'inner_async_context', + extra: { + aa: 'aa', + bb: 'bb', + cc: 'cc', + dd: 'dd', + ff: 'ff', + gg: 'gg', + }, + }, + }) + .expect({ + event: { + message: 'outer_after', + extra: { + aa: 'aa', + bb: 'bb', + cc: 'cc', + dd: 'dd', + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setContext/multiple-contexts/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setContext/multiple-contexts/scenario.ts new file mode 100644 index 000000000000..985bfb2d3c15 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setContext/multiple-contexts/scenario.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setContext('context_1', { + foo: 'bar', + baz: { + qux: 'quux', + }, +}); + +Sentry.setContext('context_2', { + 1: 'foo', + bar: false, +}); + +Sentry.setContext('context_3', null); + +Sentry.captureMessage('multiple_contexts'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setContext/multiple-contexts/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setContext/multiple-contexts/test.ts new file mode 100644 index 000000000000..1cf8342e2f29 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setContext/multiple-contexts/test.ts @@ -0,0 +1,24 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should record multiple contexts', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'multiple_contexts', + contexts: { + context_1: { + foo: 'bar', + baz: { qux: 'quux' }, + }, + context_2: { 1: 'foo', bar: false }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setContext/non-serializable-context/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setContext/non-serializable-context/scenario.ts new file mode 100644 index 000000000000..9063235c08eb --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setContext/non-serializable-context/scenario.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +type Circular = { + self?: Circular; +}; + +const objCircular: Circular = {}; +objCircular.self = objCircular; + +Sentry.setContext('non_serializable', objCircular); + +Sentry.captureMessage('non_serializable'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setContext/non-serializable-context/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setContext/non-serializable-context/test.ts new file mode 100644 index 000000000000..34c962e5e216 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setContext/non-serializable-context/test.ts @@ -0,0 +1,13 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should normalize non-serializable context', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ event: { message: 'non_serializable', contexts: {} } }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setContext/simple-context/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setContext/simple-context/scenario.ts new file mode 100644 index 000000000000..d6a1af92257c --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setContext/simple-context/scenario.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setContext('foo', { bar: 'baz' }); +Sentry.captureMessage('simple_context_object'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setContext/simple-context/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setContext/simple-context/test.ts new file mode 100644 index 000000000000..3c28a109130b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setContext/simple-context/test.ts @@ -0,0 +1,22 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should set a simple context', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'simple_context_object', + contexts: { + foo: { + bar: 'baz', + }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtra/multiple-extras/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/multiple-extras/scenario.ts new file mode 100644 index 000000000000..399938dcfc07 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/multiple-extras/scenario.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setExtra('extra_1', { + foo: 'bar', + baz: { + qux: 'quux', + }, +}); + +Sentry.setExtra('extra_2', false); + +Sentry.captureMessage('multiple_extras'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtra/multiple-extras/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/multiple-extras/test.ts new file mode 100644 index 000000000000..f40d56af6579 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/multiple-extras/test.ts @@ -0,0 +1,21 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should record multiple extras of different types', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'multiple_extras', + extra: { + extra_1: { foo: 'bar', baz: { qux: 'quux' } }, + extra_2: false, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtra/non-serializable-extra/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/non-serializable-extra/scenario.ts new file mode 100644 index 000000000000..58126e60a10e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/non-serializable-extra/scenario.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +type Circular = { + self?: Circular; +}; + +const objCircular: Circular = {}; +objCircular.self = objCircular; + +Sentry.setExtra('non_serializable', objCircular); + +Sentry.captureMessage('non_serializable'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtra/non-serializable-extra/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/non-serializable-extra/test.ts new file mode 100644 index 000000000000..113c99883f32 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/non-serializable-extra/test.ts @@ -0,0 +1,18 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should normalize non-serializable extra', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'non_serializable', + extra: {}, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtra/simple-extra/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/simple-extra/scenario.ts new file mode 100644 index 000000000000..6440a956635f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/simple-extra/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setExtra('foo', { + foo: 'bar', + baz: { + qux: 'quux', + }, +}); +Sentry.captureMessage('simple_extra'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtra/simple-extra/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/simple-extra/test.ts new file mode 100644 index 000000000000..115d4ca064a4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtra/simple-extra/test.ts @@ -0,0 +1,25 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should set a simple extra', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'simple_extra', + extra: { + foo: { + foo: 'bar', + baz: { + qux: 'quux', + }, + }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtras/consecutive-calls/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtras/consecutive-calls/scenario.ts new file mode 100644 index 000000000000..f472bf06e4a8 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtras/consecutive-calls/scenario.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setExtras({ extra: [] }); +Sentry.setExtras({ null: 0 }); +Sentry.setExtras({ + obj: { + foo: ['bar', 'baz', 1], + }, +}); +Sentry.setExtras({ [Infinity]: 2 }); + +Sentry.captureMessage('consecutive_calls'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtras/consecutive-calls/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtras/consecutive-calls/test.ts new file mode 100644 index 000000000000..da5dc31e9fea --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtras/consecutive-calls/test.ts @@ -0,0 +1,18 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should set extras from multiple consecutive calls', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'consecutive_calls', + extra: { extra: [], Infinity: 2, null: 0, obj: { foo: ['bar', 'baz', 1] } }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtras/multiple-extras/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtras/multiple-extras/scenario.ts new file mode 100644 index 000000000000..4815fd36f258 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtras/multiple-extras/scenario.ts @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setExtras({ + extra_1: [1, ['foo'], 'bar'], + extra_2: 'baz', + extra_3: Math.PI, + extra_4: { + qux: { + quux: false, + }, + }, +}); + +Sentry.captureMessage('multiple_extras'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setExtras/multiple-extras/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setExtras/multiple-extras/test.ts new file mode 100644 index 000000000000..614a157fed14 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setExtras/multiple-extras/test.ts @@ -0,0 +1,23 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should record an extras object', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'multiple_extras', + extra: { + extra_1: [1, ['foo'], 'bar'], + extra_2: 'baz', + extra_3: 3.141592653589793, + extra_4: { qux: { quux: false } }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setMeasurement/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setMeasurement/scenario.ts new file mode 100644 index 000000000000..3dd0a4c7ebb3 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setMeasurement/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan({ name: 'some_transaction' }, () => { + Sentry.setMeasurement('metric.foo', 42, 'ms'); + Sentry.setMeasurement('metric.bar', 1337, 'nanoseconds'); + Sentry.setMeasurement('metric.baz', 99, 's'); + Sentry.setMeasurement('metric.baz', 1, ''); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setMeasurement/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setMeasurement/test.ts new file mode 100644 index 000000000000..829e6a7ed3da --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setMeasurement/test.ts @@ -0,0 +1,22 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should attach measurement to transaction', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'some_transaction', + measurements: { + 'metric.foo': { value: 42, unit: 'ms' }, + 'metric.bar': { value: 1337, unit: 'nanoseconds' }, + 'metric.baz': { value: 1, unit: '' }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setTag/with-primitives/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setTag/with-primitives/scenario.ts new file mode 100644 index 000000000000..df278cb4d94a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setTag/with-primitives/scenario.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setTag('tag_1', 'foo'); +Sentry.setTag('tag_2', Math.PI); +Sentry.setTag('tag_3', false); +Sentry.setTag('tag_4', null); +Sentry.setTag('tag_5', undefined); +Sentry.setTag('tag_6', -1); + +Sentry.captureMessage('primitive_tags'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setTag/with-primitives/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setTag/with-primitives/test.ts new file mode 100644 index 000000000000..23e22402c666 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setTag/with-primitives/test.ts @@ -0,0 +1,24 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should set primitive tags', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'primitive_tags', + tags: { + tag_1: 'foo', + tag_2: 3.141592653589793, + tag_3: false, + tag_4: null, + tag_6: -1, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setTags/with-primitives/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setTags/with-primitives/scenario.ts new file mode 100644 index 000000000000..df278cb4d94a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setTags/with-primitives/scenario.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setTag('tag_1', 'foo'); +Sentry.setTag('tag_2', Math.PI); +Sentry.setTag('tag_3', false); +Sentry.setTag('tag_4', null); +Sentry.setTag('tag_5', undefined); +Sentry.setTag('tag_6', -1); + +Sentry.captureMessage('primitive_tags'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setTags/with-primitives/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setTags/with-primitives/test.ts new file mode 100644 index 000000000000..23e22402c666 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setTags/with-primitives/test.ts @@ -0,0 +1,24 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should set primitive tags', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'primitive_tags', + tags: { + tag_1: 'foo', + tag_2: 3.141592653589793, + tag_3: false, + tag_4: null, + tag_6: -1, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setUser/unset_user/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setUser/unset_user/scenario.ts new file mode 100644 index 000000000000..bdbb712b2f95 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setUser/unset_user/scenario.ts @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.captureMessage('no_user'); + +Sentry.setUser({ + id: 'foo', + ip_address: 'bar', + other_key: 'baz', +}); + +Sentry.captureMessage('user'); + +Sentry.setUser(null); + +Sentry.captureMessage('unset_user'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setUser/unset_user/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setUser/unset_user/test.ts new file mode 100644 index 000000000000..9b7f3e2c23be --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setUser/unset_user/test.ts @@ -0,0 +1,24 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should unset user', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ event: { message: 'no_user' } }) + .expect({ + event: { + message: 'user', + user: { + id: 'foo', + ip_address: 'bar', + other_key: 'baz', + }, + }, + }) + .expect({ event: { message: 'unset_user' } }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setUser/update_user/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/setUser/update_user/scenario.ts new file mode 100644 index 000000000000..6c001274f64b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setUser/update_user/scenario.ts @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setUser({ + id: 'foo', + ip_address: 'bar', +}); + +Sentry.captureMessage('first_user'); + +Sentry.setUser({ + id: 'baz', +}); + +Sentry.captureMessage('second_user'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/setUser/update_user/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/setUser/update_user/test.ts new file mode 100644 index 000000000000..7a6c89f4c213 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/setUser/update_user/test.ts @@ -0,0 +1,29 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should update user', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'first_user', + user: { + id: 'foo', + ip_address: 'bar', + }, + }, + }) + .expect({ + event: { + message: 'second_user', + user: { + id: 'baz', + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage/scenario.ts new file mode 100644 index 000000000000..bb81e0d70c81 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage/scenario.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan({ name: 'test_span' }, () => undefined); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage/test.ts new file mode 100644 index 000000000000..8a72ecd7c8b3 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage/test.ts @@ -0,0 +1,45 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node-core'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('sends a manually started root span with source custom', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'test_span', + transaction_info: { source: 'custom' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }, + }, + }, + }) + .start() + .completed(); +}); + +test("doesn't change the name for manually started spans even if attributes triggering inference are set", async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'test_span', + transaction_info: { source: 'custom' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans/scenario.ts new file mode 100644 index 000000000000..22855b1960da --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans/scenario.ts @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.getCurrentScope().setPropagationContext({ + parentSpanId: '1234567890123456', + traceId: '12345678901234567890123456789012', + sampleRand: Math.random(), +}); + +const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, +); + +Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, +); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts new file mode 100644 index 000000000000..e1b8f793d9b6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts @@ -0,0 +1,31 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should send manually started parallel root spans in root context', async () => { + expect.assertions(7); + + await createRunner(__dirname, 'scenario.ts') + .expect({ transaction: { transaction: 'test_span_1' } }) + .expect({ + transaction: transaction => { + expect(transaction).toBeDefined(); + const traceId = transaction.contexts?.trace?.trace_id; + expect(traceId).toBeDefined(); + + // It ignores propagation context of the root context + expect(traceId).not.toBe('12345678901234567890123456789012'); + expect(transaction.contexts?.trace?.parent_span_id).toBeUndefined(); + + // Different trace ID than the first span + const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; + expect(trace1Id).toBeDefined(); + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/scenario.ts new file mode 100644 index 000000000000..933de3455202 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/scenario.ts @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.withScope(scope => { + scope.setPropagationContext({ + parentSpanId: '1234567890123456', + traceId: '12345678901234567890123456789012', + sampleRand: Math.random(), + }); + + const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, + ); + + Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, + ); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts new file mode 100644 index 000000000000..e10a1210a0c9 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts @@ -0,0 +1,27 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should send manually started parallel root spans outside of root context with parentSpanId', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ transaction: { transaction: 'test_span_1' } }) + .expect({ + transaction: transaction => { + expect(transaction).toBeDefined(); + const traceId = transaction.contexts?.trace?.trace_id; + expect(traceId).toBeDefined(); + expect(transaction.contexts?.trace?.parent_span_id).toBeUndefined(); + + const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; + expect(trace1Id).toBeDefined(); + + // Different trace ID as the first span + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/scenario.ts new file mode 100644 index 000000000000..e48b803edf51 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/scenario.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.withScope(() => { + const spanIdTraceId = Sentry.startSpan( + { + name: 'test_span_1', + }, + span1 => span1.spanContext().traceId, + ); + + Sentry.startSpan( + { + name: 'test_span_2', + attributes: { spanIdTraceId }, + }, + () => undefined, + ); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts new file mode 100644 index 000000000000..69fc2bc2774a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts @@ -0,0 +1,29 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should send manually started parallel root spans outside of root context', async () => { + expect.assertions(6); + + await createRunner(__dirname, 'scenario.ts') + .expect({ transaction: { transaction: 'test_span_1' } }) + .expect({ + transaction: transaction => { + expect(transaction).toBeDefined(); + const traceId = transaction.contexts?.trace?.trace_id; + expect(traceId).toBeDefined(); + expect(transaction.contexts?.trace?.parent_span_id).toBeUndefined(); + + const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; + expect(trace1Id).toBeDefined(); + + // Different trace ID as the first span + expect(trace1Id).not.toBe(traceId); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method/scenario.ts new file mode 100644 index 000000000000..24dc5861e3fd --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan( + { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + (span: Sentry.Span) => { + span.updateName('new name'); + }, +); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method/test.ts new file mode 100644 index 000000000000..f54cbeb84895 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateName-method/test.ts @@ -0,0 +1,26 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node-core'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('updates the span name when calling `span.updateName`', async () => { + createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'new name', + transaction_info: { source: 'url' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }, + }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function/scenario.ts new file mode 100644 index 000000000000..ae73ee16fd82 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan( + { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + (span: Sentry.Span) => { + Sentry.updateSpanName(span, 'new name'); + }, +); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function/test.ts new file mode 100644 index 000000000000..faa6a674bfc6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/updateSpanName-function/test.ts @@ -0,0 +1,26 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node-core'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('updates the span name and source when calling `updateSpanName`', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'new name', + transaction_info: { source: 'custom' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/with-nested-spans/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/with-nested-spans/scenario.ts new file mode 100644 index 000000000000..8ec9384616c3 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/with-nested-spans/scenario.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan({ name: 'root_span' }, () => { + Sentry.startSpan( + { + name: 'span_1', + attributes: { + foo: 'bar', + baz: [1, 2, 3], + }, + }, + () => undefined, + ); + + // span_2 doesn't finish + Sentry.startInactiveSpan({ name: 'span_2' }); + + Sentry.startSpan({ name: 'span_3' }, () => { + // span_4 is the child of span_3 but doesn't finish. + Sentry.startInactiveSpan({ name: 'span_4', attributes: { qux: 'quux' } }); + + // span_5 is another child of span_3 but finishes. + Sentry.startSpan({ name: 'span_5' }, () => undefined); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/with-nested-spans/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/with-nested-spans/test.ts new file mode 100644 index 000000000000..c01b837db5f7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/with-nested-spans/test.ts @@ -0,0 +1,47 @@ +import type { SpanJSON } from '@sentry/core'; +import { afterAll, expect, test } from 'vitest'; +import { assertSentryTransaction } from '../../../../utils/assertions'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should report finished spans as children of the root transaction.', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: transaction => { + const rootSpanId = transaction.contexts?.trace?.span_id; + const span3Id = transaction.spans?.[1]?.span_id; + + expect(rootSpanId).toEqual(expect.any(String)); + expect(span3Id).toEqual(expect.any(String)); + + assertSentryTransaction(transaction, { + transaction: 'root_span', + spans: [ + { + description: 'span_1', + data: { + foo: 'bar', + baz: [1, 2, 3], + }, + parent_span_id: rootSpanId, + }, + { + description: 'span_3', + parent_span_id: rootSpanId, + data: {}, + }, + { + description: 'span_5', + parent_span_id: span3Id, + data: {}, + }, + ] as SpanJSON[], + }); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/withScope/nested-scopes/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/withScope/nested-scopes/scenario.ts new file mode 100644 index 000000000000..2d11f822f60e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/withScope/nested-scopes/scenario.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.setUser({ id: 'qux' }); +Sentry.captureMessage('root_before'); + +Sentry.withScope(scope => { + scope.setTag('foo', false); + Sentry.captureMessage('outer_before'); + + Sentry.withScope(scope => { + scope.setTag('bar', 10); + scope.setUser(null); + Sentry.captureMessage('inner'); + }); + + scope.setUser({ id: 'baz' }); + Sentry.captureMessage('outer_after'); +}); + +Sentry.captureMessage('root_after'); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/withScope/nested-scopes/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/withScope/nested-scopes/test.ts new file mode 100644 index 000000000000..4e646a233443 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/public-api/withScope/nested-scopes/test.ts @@ -0,0 +1,59 @@ +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should allow nested scoping', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + message: 'root_before', + user: { + id: 'qux', + }, + }, + }) + .expect({ + event: { + message: 'outer_before', + user: { + id: 'qux', + }, + tags: { + foo: false, + }, + }, + }) + .expect({ + event: { + message: 'inner', + tags: { + foo: false, + bar: 10, + }, + }, + }) + .expect({ + event: { + message: 'outer_after', + user: { + id: 'baz', + }, + tags: { + foo: false, + }, + }, + }) + .expect({ + event: { + message: 'root_after', + user: { + id: 'qux', + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/sessions/errored-session-aggregate/test.ts b/dev-packages/node-core-integration-tests/suites/sessions/errored-session-aggregate/test.ts new file mode 100644 index 000000000000..ba8110a62675 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/sessions/errored-session-aggregate/test.ts @@ -0,0 +1,29 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should aggregate successful, crashed and erroneous sessions', async () => { + const runner = createRunner(__dirname, '..', 'server.ts') + .ignore('transaction', 'event') + .unignore('sessions') + .expect({ + sessions: { + aggregates: [ + { + started: expect.any(String), + exited: 2, + errored: 1, + }, + ], + }, + }) + .start(); + + runner.makeRequest('get', '/test/success'); + runner.makeRequest('get', '/test/error_handled'); + runner.makeRequest('get', '/test/error_unhandled', { expectError: true }); + await runner.completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/sessions/exited-session-aggregate/test.ts b/dev-packages/node-core-integration-tests/suites/sessions/exited-session-aggregate/test.ts new file mode 100644 index 000000000000..228ee9a98643 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/sessions/exited-session-aggregate/test.ts @@ -0,0 +1,28 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should aggregate successful sessions', async () => { + const runner = createRunner(__dirname, '..', 'server.ts') + .ignore('transaction', 'event') + .unignore('sessions') + .expect({ + sessions: { + aggregates: [ + { + started: expect.any(String), + exited: 3, + }, + ], + }, + }) + .start(); + + runner.makeRequest('get', '/test/success'); + runner.makeRequest('get', '/test/success_next'); + runner.makeRequest('get', '/test/success_slow'); + await runner.completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/sessions/server.ts b/dev-packages/node-core-integration-tests/suites/sessions/server.ts new file mode 100644 index 000000000000..5638b0946d73 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/sessions/server.ts @@ -0,0 +1,51 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + integrations: [ + Sentry.httpIntegration({ + // Flush after 2 seconds (to avoid waiting for the default 60s) + sessionFlushingDelayMS: 2_000, + }), + ], +}); + +setupOtel(client); + +import express from 'express'; + +const app = express(); + +app.get('/test/success', (_req, res) => { + res.send('Success!'); +}); + +app.get('/test/success_next', (_req, res, next) => { + res.send('Success!'); + next(); +}); + +app.get('/test/success_slow', async (_req, res) => { + await new Promise(res => setTimeout(res, 50)); + + res.send('Success!'); +}); + +app.get('/test/error_unhandled', () => { + throw new Error('Crash!'); +}); + +app.get('/test/error_handled', (_req, res) => { + try { + throw new Error('Crash!'); + } catch (e) { + Sentry.captureException(e); + } + res.send('Crash!'); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/scenario-events.ts b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/scenario-events.ts new file mode 100644 index 000000000000..f8466723e9b7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/scenario-events.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan( + { name: 'initial-name', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + async span => { + Sentry.captureMessage('message-1'); + + span.updateName('updated-name-1'); + span.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + Sentry.captureMessage('message-2'); + + span.updateName('updated-name-2'); + span.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'custom'); + + Sentry.captureMessage('message-3'); + + span.end(); + }, +); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/scenario-headers.ts b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/scenario-headers.ts new file mode 100644 index 000000000000..0b33f219bb23 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/scenario-headers.ts @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + tracePropagationTargets: [/\/api\/v0/, /\/api\/v1/, /\/api\/v2/], + transport: loggingTransport, +}); + +setupOtel(client); + +import * as http from 'http'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan( + { name: 'initial-name', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + async span => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + + span.updateName('updated-name-1'); + span.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + + span.updateName('updated-name-2'); + span.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'custom'); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + + span.end(); + }, +); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts new file mode 100644 index 000000000000..3f8c2edd3a6f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts @@ -0,0 +1,141 @@ +import { expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { createRunner } from '../../../utils/runner'; +import { createTestServer } from '../../../utils/server'; + +// This test requires Node.js 22+ because it depends on the 'http.client.request.created' +// diagnostic channel for baggage header propagation, which only exists since Node 22.12.0+ and 23.2.0+ +conditionalTest({ min: 22 })('node >=22', () => { + test('adds current transaction name to baggage when the txn name is high-quality', async () => { + expect.assertions(5); + + let traceId: string | undefined; + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + const baggageItems = getBaggageHeaderItems(headers); + traceId = baggageItems.find(item => item.startsWith('sentry-trace_id='))?.split('=')[1] as string; + + expect(traceId).toMatch(/^[0-9a-f]{32}$/); + + expect(baggageItems).toEqual([ + 'sentry-environment=production', + 'sentry-public_key=public', + 'sentry-release=1.0', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + 'sentry-sample_rate=1', + 'sentry-sampled=true', + `sentry-trace_id=${traceId}`, + ]); + }) + .get('/api/v1', headers => { + expect(getBaggageHeaderItems(headers)).toEqual([ + 'sentry-environment=production', + 'sentry-public_key=public', + 'sentry-release=1.0', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + 'sentry-sample_rate=1', + 'sentry-sampled=true', + `sentry-trace_id=${traceId}`, + 'sentry-transaction=updated-name-1', + ]); + }) + .get('/api/v2', headers => { + expect(getBaggageHeaderItems(headers)).toEqual([ + 'sentry-environment=production', + 'sentry-public_key=public', + 'sentry-release=1.0', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + 'sentry-sample_rate=1', + 'sentry-sampled=true', + `sentry-trace_id=${traceId}`, + 'sentry-transaction=updated-name-2', + ]); + }) + .start(); + + await createRunner(__dirname, 'scenario-headers.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: {}, + }) + .start() + .completed(); + closeTestServer(); + }); +}); + +test('adds current transaction name to trace envelope header when the txn name is high-quality', async () => { + expect.assertions(4); + + await createRunner(__dirname, 'scenario-events.ts') + .expectHeader({ + event: { + trace: { + environment: 'production', + public_key: 'public', + release: '1.0', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + sample_rand: expect.any(String), + }, + }, + }) + .expectHeader({ + event: { + trace: { + environment: 'production', + public_key: 'public', + release: '1.0', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + transaction: 'updated-name-1', + sample_rand: expect.any(String), + }, + }, + }) + .expectHeader({ + event: { + trace: { + environment: 'production', + public_key: 'public', + release: '1.0', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + transaction: 'updated-name-2', + sample_rand: expect.any(String), + }, + }, + }) + .expectHeader({ + transaction: { + trace: { + environment: 'production', + public_key: 'public', + release: '1.0', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + transaction: 'updated-name-2', + sample_rand: expect.any(String), + }, + }, + }) + .start() + .completed(); +}); + +function getBaggageHeaderItems(headers: Record) { + const baggage = headers['baggage'] as string; + if (!baggage) { + return []; + } + const baggageItems = baggage + .split(',') + .map(b => b.trim()) + .sort(); + return baggageItems; +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/scenario.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/scenario.ts new file mode 100644 index 000000000000..ee86f615d220 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/scenario.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan({ name: 'test span' }, () => { + Sentry.captureException(new Error('foo')); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/test.ts new file mode 100644 index 000000000000..bba04c788282 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('envelope header for error event during active unsampled span is correct', async () => { + await createRunner(__dirname, 'scenario.ts') + .ignore('transaction') + .expectHeader({ + event: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + public_key: 'public', + environment: 'production', + release: '1.0', + sample_rate: '0', + sampled: 'false', + sample_rand: expect.any(String), + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span/scenario.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span/scenario.ts new file mode 100644 index 000000000000..72ef4f49a2f6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span/scenario.ts @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 1, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan({ name: 'test span' }, () => { + Sentry.startSpan({ name: 'test inner span' }, () => { + Sentry.captureException(new Error('foo')); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span/test.ts new file mode 100644 index 000000000000..f11defc490c8 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error-active-span/test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('envelope header for error event during active span is correct', async () => { + await createRunner(__dirname, 'scenario.ts') + .ignore('transaction') + .expectHeader({ + event: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + public_key: 'public', + environment: 'production', + release: '1.0', + sample_rate: '1', + sampled: 'true', + transaction: 'test span', + sample_rand: expect.any(String), + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error/scenario.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error/scenario.ts new file mode 100644 index 000000000000..edeee6176370 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error/scenario.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.captureException(new Error('foo')); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error/test.ts new file mode 100644 index 000000000000..9d39209d456f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/error/test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('envelope header for error events is correct', async () => { + await createRunner(__dirname, 'scenario.ts') + .expectHeader({ + event: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + environment: 'production', + public_key: 'public', + release: '1.0', + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/server.js b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/server.js new file mode 100644 index 000000000000..ca751ec38123 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/server.js @@ -0,0 +1,38 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + // disable attaching headers to /test/* endpoints + tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const bodyParser = require('body-parser'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); +app.use(bodyParser.json()); +app.use(bodyParser.text()); +app.use(bodyParser.raw()); + +app.get('/test', (req, res) => { + // Create a transaction to trigger trace continuation from headers + // because node-core doesn't create spans for http requests due to + // the lack of @opentelemetry/instrumentation-http + Sentry.startSpan({ name: 'test-transaction', op: 'http.server' }, () => { + res.send({ headers: req.headers }); + }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/test.ts new file mode 100644 index 000000000000..63db6ff4e820 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/test.ts @@ -0,0 +1,33 @@ +import { afterAll, describe, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('tracesSampleRate propagation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const traceId = '12345678123456781234567812345678'; + + test('uses sample rate from incoming baggage header in trace envelope item', async () => { + const runner = createRunner(__dirname, 'server.js') + .expectHeader({ + transaction: { + trace: { + sample_rate: '0.05', + sampled: 'true', + trace_id: traceId, + transaction: 'myTransaction', + sample_rand: '0.42', + }, + }, + }) + .start(); + runner.makeRequest('get', '/test', { + headers: { + 'sentry-trace': `${traceId}-1234567812345678-1`, + baggage: `sentry-sample_rate=0.05,sentry-trace_id=${traceId},sentry-sampled=true,sentry-transaction=myTransaction,sentry-sample_rand=0.42`, + }, + }); + await runner.completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-route/scenario.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-route/scenario.ts new file mode 100644 index 000000000000..cd28c63fc4b7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-route/scenario.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 1, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan( + { + name: 'GET /route', + attributes: { + 'http.method': 'GET', + 'http.route': '/route', + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }, + }, + () => { + // noop + }, +); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-route/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-route/test.ts new file mode 100644 index 000000000000..f4bb6e2b4293 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-route/test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('envelope header for transaction event of route correct', async () => { + await createRunner(__dirname, 'scenario.ts') + .expectHeader({ + transaction: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + public_key: 'public', + transaction: 'GET /route', + environment: 'production', + release: '1.0', + sample_rate: '1', + sampled: 'true', + sample_rand: expect.any(String), + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-url/scenario.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-url/scenario.ts new file mode 100644 index 000000000000..b47f9cfc73dc --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-url/scenario.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 1, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan( + { + name: 'GET /route/1', + attributes: { + 'http.method': 'GET', + 'http.route': '/route', + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }, + () => { + // noop + }, +); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-url/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-url/test.ts new file mode 100644 index 000000000000..c4ed5ae4983f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction-url/test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('envelope header for transaction event with source=url correct', async () => { + await createRunner(__dirname, 'scenario.ts') + .expectHeader({ + transaction: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + public_key: 'public', + environment: 'production', + release: '1.0', + sample_rate: '1', + sampled: 'true', + sample_rand: expect.any(String), + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction/scenario.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction/scenario.ts new file mode 100644 index 000000000000..0bec8720f01b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction/scenario.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 1, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan({ name: 'test span' }, () => { + // noop +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction/test.ts new file mode 100644 index 000000000000..104761d52c86 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/envelope-header/transaction/test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; + +test('envelope header for transaction event is correct', async () => { + await createRunner(__dirname, 'scenario.ts') + .expectHeader({ + transaction: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + public_key: 'public', + environment: 'production', + release: '1.0', + sample_rate: '1', + sampled: 'true', + transaction: 'test span', + sample_rand: expect.any(String), + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLink-nested.ts b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLink-nested.ts new file mode 100644 index 000000000000..2923e2d3414f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLink-nested.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'parent1' }, async parentSpan1 => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child1.1' }, async childSpan1 => { + childSpan1.addLink({ + context: parentSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); + + childSpan1.end(); + }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child1.2' }, async childSpan2 => { + childSpan2.addLink({ + context: parentSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); + + childSpan2.end(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLink.ts b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLink.ts new file mode 100644 index 000000000000..d12fb52ad748 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLink.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +const span1 = Sentry.startInactiveSpan({ name: 'span1' }); +span1.end(); + +Sentry.startSpan({ name: 'rootSpan' }, rootSpan => { + rootSpan.addLink({ + context: span1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLinks-nested.ts b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLinks-nested.ts new file mode 100644 index 000000000000..5a1d45845f85 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLinks-nested.ts @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'parent1' }, async parentSpan1 => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child1.1' }, async childSpan1 => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child2.1' }, async childSpan2 => { + childSpan2.addLinks([ + { context: parentSpan1.spanContext() }, + { + context: childSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ]); + + childSpan2.end(); + }); + + childSpan1.end(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLinks.ts b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLinks.ts new file mode 100644 index 000000000000..c2c1e765b3d2 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-addLinks.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +const span1 = Sentry.startInactiveSpan({ name: 'span1' }); +span1.end(); + +const span2 = Sentry.startInactiveSpan({ name: 'span2' }); +span2.end(); + +Sentry.startSpan({ name: 'rootSpan' }, rootSpan => { + rootSpan.addLinks([ + { context: span1.spanContext() }, + { + context: span2.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ]); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-span-options.ts b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-span-options.ts new file mode 100644 index 000000000000..0488758a03df --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/linking/scenario-span-options.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +const parentSpan1 = Sentry.startInactiveSpan({ name: 'parent1' }); +parentSpan1.end(); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan( + { + name: 'parent2', + links: [{ context: parentSpan1.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }], + }, + async () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child2.1' }, async childSpan1 => { + childSpan1.end(); + }); + }, +); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/linking/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/linking/test.ts new file mode 100644 index 000000000000..a0874274d2bd --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/linking/test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from 'vitest'; +import { createRunner } from '../../../utils/runner'; + +describe('span links', () => { + test('should link spans by adding "links" to span options', async () => { + let span1_traceId: string, span1_spanId: string; + + await createRunner(__dirname, 'scenario-span-options.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('parent1'); + + const traceContext = event.contexts?.trace; + span1_traceId = traceContext?.trace_id as string; + span1_spanId = traceContext?.span_id as string; + }, + }) + .expect({ + transaction: event => { + expect(event.transaction).toBe('parent2'); + + const traceContext = event.contexts?.trace; + expect(traceContext).toBeDefined(); + expect(traceContext?.links).toEqual([ + expect.objectContaining({ + trace_id: expect.stringMatching(span1_traceId), + span_id: expect.stringMatching(span1_spanId), + }), + ]); + }, + }) + .start() + .completed(); + }); + + test('should link spans with addLink() in trace context', async () => { + let span1_traceId: string, span1_spanId: string; + + await createRunner(__dirname, 'scenario-addLink.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('span1'); + + span1_traceId = event.contexts?.trace?.trace_id as string; + span1_spanId = event.contexts?.trace?.span_id as string; + + expect(event.spans).toEqual([]); + }, + }) + .expect({ + transaction: event => { + expect(event.transaction).toBe('rootSpan'); + + expect(event.contexts?.trace?.links).toEqual([ + expect.objectContaining({ + trace_id: expect.stringMatching(span1_traceId), + span_id: expect.stringMatching(span1_spanId), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + }, + }) + .start() + .completed(); + }); + + test('should link spans with addLinks() in trace context', async () => { + let span1_traceId: string, span1_spanId: string, span2_traceId: string, span2_spanId: string; + + await createRunner(__dirname, 'scenario-addLinks.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('span1'); + + span1_traceId = event.contexts?.trace?.trace_id as string; + span1_spanId = event.contexts?.trace?.span_id as string; + + expect(event.spans).toEqual([]); + }, + }) + .expect({ + transaction: event => { + expect(event.transaction).toBe('span2'); + + span2_traceId = event.contexts?.trace?.trace_id as string; + span2_spanId = event.contexts?.trace?.span_id as string; + + expect(event.spans).toEqual([]); + }, + }) + .expect({ + transaction: event => { + expect(event.transaction).toBe('rootSpan'); + + expect(event.contexts?.trace?.links).toEqual([ + expect.not.objectContaining({ attributes: expect.anything() }) && + expect.objectContaining({ + trace_id: expect.stringMatching(span1_traceId), + span_id: expect.stringMatching(span1_spanId), + }), + expect.objectContaining({ + trace_id: expect.stringMatching(span2_traceId), + span_id: expect.stringMatching(span2_spanId), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + }, + }) + .start() + .completed(); + }); + + test('should link spans with addLink() in nested startSpan() calls', async () => { + await createRunner(__dirname, 'scenario-addLink-nested.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('parent1'); + + const parent1_traceId = event.contexts?.trace?.trace_id as string; + const parent1_spanId = event.contexts?.trace?.span_id as string; + + const spans = event.spans || []; + const child1_1 = spans.find(span => span.description === 'child1.1'); + const child1_2 = spans.find(span => span.description === 'child1.2'); + + expect(child1_1).toBeDefined(); + expect(child1_1?.links).toEqual([ + expect.objectContaining({ + trace_id: expect.stringMatching(parent1_traceId), + span_id: expect.stringMatching(parent1_spanId), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + + expect(child1_2).toBeDefined(); + expect(child1_2?.links).toEqual([ + expect.objectContaining({ + trace_id: expect.stringMatching(parent1_traceId), + span_id: expect.stringMatching(parent1_spanId), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + }, + }) + .start() + .completed(); + }); + + test('should link spans with addLinks() in nested startSpan() calls', async () => { + await createRunner(__dirname, 'scenario-addLinks-nested.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('parent1'); + + const parent1_traceId = event.contexts?.trace?.trace_id as string; + const parent1_spanId = event.contexts?.trace?.span_id as string; + + const spans = event.spans || []; + const child1_1 = spans.find(span => span.description === 'child1.1'); + const child2_1 = spans.find(span => span.description === 'child2.1'); + + expect(child1_1).toBeDefined(); + + expect(child2_1).toBeDefined(); + + expect(child2_1?.links).toEqual([ + expect.not.objectContaining({ attributes: expect.anything() }) && + expect.objectContaining({ + trace_id: expect.stringMatching(parent1_traceId), + span_id: expect.stringMatching(parent1_spanId), + }), + expect.objectContaining({ + trace_id: expect.stringMatching(child1_1?.trace_id || 'non-existent-id-fallback'), + span_id: expect.stringMatching(child1_1?.span_id || 'non-existent-id-fallback'), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + }, + }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/maxSpans/scenario.ts b/dev-packages/node-core-integration-tests/suites/tracing/maxSpans/scenario.ts new file mode 100644 index 000000000000..0241785b0535 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/maxSpans/scenario.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +Sentry.startSpan({ name: 'parent' }, () => { + for (let i = 0; i < 5000; i++) { + Sentry.startInactiveSpan({ name: `child ${i}` }).end(); + } +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/maxSpans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/maxSpans/test.ts new file mode 100644 index 000000000000..31b0af762d9a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/maxSpans/test.ts @@ -0,0 +1,20 @@ +import type { SpanJSON } from '@sentry/core'; +import { expect, test } from 'vitest'; +import { createRunner } from '../../../utils/runner'; + +test('it limits spans to 1000', async () => { + const expectedSpans: SpanJSON[] = []; + for (let i = 0; i < 1000; i++) { + expectedSpans.push(expect.objectContaining({ description: `child ${i}` })); + } + + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'parent', + spans: expectedSpans, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/no-server.js b/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/no-server.js new file mode 100644 index 000000000000..c2d3c3664406 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/no-server.js @@ -0,0 +1,23 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + beforeSend(event) { + event.contexts = { + ...event.contexts, + traceData: { + ...Sentry.getTraceData(), + metaTags: Sentry.getTraceMetaTags(), + }, + }; + return event; + }, +}); + +setupOtel(client); + +Sentry.captureException(new Error('test error')); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/server.js b/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/server.js new file mode 100644 index 000000000000..2fdda31684e6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/server.js @@ -0,0 +1,28 @@ +const { loggingTransport, startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, +}); + +setupOtel(client); + +// express must be required after Sentry is initialized +const express = require('express'); + +const app = express(); + +app.get('/test', (_req, res) => { + Sentry.withScope(scope => { + scope.setContext('traceData', { + ...Sentry.getTraceData(), + metaTags: Sentry.getTraceMetaTags(), + }); + Sentry.captureException(new Error('test error 2')); + }); + res.status(200).send(); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts new file mode 100644 index 000000000000..7c6612a0f4f7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts @@ -0,0 +1,67 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('errors in TwP mode have same trace in trace context and getTraceData()', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // In a request handler, the spanId is consistent inside of the request + test('in incoming request', async () => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + event: event => { + const { contexts } = event; + const { trace_id, span_id } = contexts?.trace || {}; + expect(trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(span_id).toMatch(/^[a-f0-9]{16}$/); + + const traceData = contexts?.traceData || {}; + + expect(traceData['sentry-trace']).toEqual(`${trace_id}-${span_id}`); + + expect(traceData.baggage).toContain(`sentry-trace_id=${trace_id}`); + expect(traceData.baggage).not.toContain('sentry-sampled='); + + expect(traceData.metaTags).toContain(``); + expect(traceData.metaTags).toContain(`sentry-trace_id=${trace_id}`); + expect(traceData.metaTags).not.toContain('sentry-sampled='); + }, + }) + .start(); + runner.makeRequest('get', '/test'); + await runner.completed(); + }); + + // Outside of a request handler, the spanId is random + test('outside of a request handler', async () => { + await createRunner(__dirname, 'no-server.js') + .expect({ + event: event => { + const { contexts } = event; + const { trace_id, span_id } = contexts?.trace || {}; + expect(trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(span_id).toMatch(/^[a-f0-9]{16}$/); + + const traceData = contexts?.traceData || {}; + + expect(traceData['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}$/); + expect(traceData['sentry-trace']).toContain(`${trace_id}-`); + // span_id is a random span ID + expect(traceData['sentry-trace']).not.toContain(span_id); + + expect(traceData.baggage).toContain(`sentry-trace_id=${trace_id}`); + expect(traceData.baggage).not.toContain('sentry-sampled='); + + expect(traceData.metaTags).toMatch(//); + expect(traceData.metaTags).toContain(`/); + expect(html).toContain(''); + }); + + test('injects tags with new trace if no incoming headers', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + + const response = await runner.makeRequest<{ response: string }>('get', '/test'); + + const html = response?.response; + + const traceId = html?.match(//)?.[1]; + expect(traceId).not.toBeUndefined(); + + expect(html).toContain(' tags if SDK is disabled", async () => { + const traceId = 'cd7ee7a6fe3ebe7ab9c3271559bc203c'; + const parentSpanId = '100ff0980e7a4ead'; + + const runner = createRunner(__dirname, 'server-sdk-disabled.js').start(); + + const response = await runner.makeRequest<{ response: string }>('get', '/test', { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: 'sentry-environment=production', + }, + }); + + const html = response?.response; + + expect(html).not.toContain('"sentry-trace"'); + expect(html).not.toContain('"baggage"'); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/instrument.mjs new file mode 100644 index 000000000000..8834d9742502 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/instrument.mjs @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, + tracesSampleRate: 0.0, + // Ensure this gets a correct hint + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + breadcrumb.data.ADDED_PATH = req?.path; + return breadcrumb; + }, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs new file mode 100644 index 000000000000..50d1391ee577 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node-core'; + +async function run() { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + await Sentry.suppressTracing(() => fetch(`${process.env.SERVER_URL}/api/v4`).then(res => res.text())); + + Sentry.captureException(new Error('foo')); +} + +run(); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts new file mode 100644 index 000000000000..0d1d33bb5fc9 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts @@ -0,0 +1,83 @@ +import { describe, expect } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing fetch', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + conditionalTest({ min: 22 })('node >=22', () => { + test('outgoing fetch requests create breadcrumbs', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer().start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 404, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 404, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 404, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 404, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start() + .completed(); + + closeTestServer(); + }); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/instrument.mjs new file mode 100644 index 000000000000..687969d7ec1b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/instrument.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [Sentry.nativeNodeFetchIntegration({ spans: false })], + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.mjs new file mode 100644 index 000000000000..dce36bdb9262 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node-core'; + +async function run() { + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +run(); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts new file mode 100644 index 000000000000..f61532d9de8b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts @@ -0,0 +1,50 @@ +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing fetch', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing fetch requests are correctly instrumented with tracing & spans are disabled', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start() + .completed(); + closeTestServer; + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/instrument.mjs new file mode 100644 index 000000000000..b2c76f80e13a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/instrument.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.mjs new file mode 100644 index 000000000000..dce36bdb9262 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node-core'; + +async function run() { + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +run(); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts new file mode 100644 index 000000000000..b4594c4d9c41 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts @@ -0,0 +1,50 @@ +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing fetch', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing fetch requests are correctly instrumented with tracing disabled', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/instrument.mjs new file mode 100644 index 000000000000..fea0bfd36c11 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/instrument.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.mjs new file mode 100644 index 000000000000..dce36bdb9262 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node-core'; + +async function run() { + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +run(); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts new file mode 100644 index 000000000000..32f24517b3f6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -0,0 +1,50 @@ +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing fetch', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing sampled fetch requests without active span are correctly instrumented', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/instrument.mjs new file mode 100644 index 000000000000..0c77fb8702b7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/instrument.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.mjs new file mode 100644 index 000000000000..38735e01aaa8 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node-core'; + +async function run() { + // Wrap in span that is not sampled + await Sentry.startSpan({ name: 'outer' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + }); + + Sentry.captureException(new Error('foo')); +} + +run(); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts new file mode 100644 index 000000000000..097236ba4e7f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts @@ -0,0 +1,50 @@ +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing fetch', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing fetch requests are correctly instrumented when not sampled', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/instrument.mjs new file mode 100644 index 000000000000..1465fc45ca46 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/instrument.mjs @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, + // Ensure this gets a correct hint + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + breadcrumb.data.ADDED_PATH = req?.path; + return breadcrumb; + }, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs new file mode 100644 index 000000000000..746e6487281a --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/node-core'; +import * as http from 'http'; + +async function run() { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpGet(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + + await Sentry.suppressTracing(() => makeHttpRequest(`${process.env.SERVER_URL}/api/v4`)); + + Sentry.captureException(new Error('foo')); +} + +run(); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} + +function makeHttpGet(url) { + return new Promise(resolve => { + http.get(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }); + }); +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts new file mode 100644 index 000000000000..318d4628453b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts @@ -0,0 +1,79 @@ +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing http requests create breadcrumbs', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer().start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 404, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 404, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 404, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 404, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/instrument.mjs new file mode 100644 index 000000000000..61706a36eca6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/instrument.mjs @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [Sentry.httpIntegration({ spans: false })], + transport: loggingTransport, + // Ensure this gets a correct hint + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + breadcrumb.data.ADDED_PATH = req?.path; + return breadcrumb; + }, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/scenario.mjs new file mode 100644 index 000000000000..861d6c29bd2f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/scenario.mjs @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/node-core'; +import * as http from 'http'; + +async function run() { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpGet(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + + Sentry.captureException(new Error('foo')); +} + +run(); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} + +function makeHttpGet(url) { + return new Promise(resolve => { + http.get(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }); + }); +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts new file mode 100644 index 000000000000..fe9cba032344 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -0,0 +1,202 @@ +import { describe, expect } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing http requests with tracing & spans disabled', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + conditionalTest({ min: 22 })('node >=22', () => { + test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); + + closeTestServer(); + }); + }); + + // On older node versions, outgoing requests do not get trace-headers injected, sadly + // This is because the necessary diagnostics channel hook is not available yet + conditionalTest({ max: 21 })('node <22', () => { + test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => { + expect.assertions(9); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + // This is not instrumented, sadly + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v1', headers => { + // This is not instrumented, sadly + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); + + closeTestServer(); + }); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/instrument.mjs new file mode 100644 index 000000000000..1465fc45ca46 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/instrument.mjs @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, + // Ensure this gets a correct hint + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + breadcrumb.data.ADDED_PATH = req?.path; + return breadcrumb; + }, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/scenario.mjs new file mode 100644 index 000000000000..861d6c29bd2f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/scenario.mjs @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/node-core'; +import * as http from 'http'; + +async function run() { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpGet(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + + Sentry.captureException(new Error('foo')); +} + +run(); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} + +function makeHttpGet(url) { + return new Promise(resolve => { + http.get(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }); + }); +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts new file mode 100644 index 000000000000..8727f1cad0de --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -0,0 +1,103 @@ +import { describe, expect } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + conditionalTest({ min: 22 })('node >=22', () => { + test('outgoing http requests are correctly instrumented with tracing disabled', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/instrument.mjs new file mode 100644 index 000000000000..33213733efef --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/instrument.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.mjs new file mode 100644 index 000000000000..f1603c6dcd8b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.mjs @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node-core'; +import * as http from 'http'; + +async function run() { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + + Sentry.captureException(new Error('foo')); +} + +run(); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts new file mode 100644 index 000000000000..d89992dd362e --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts @@ -0,0 +1,53 @@ +import { describe, expect } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + conditionalTest({ min: 22 })('node >=22', () => { + test('outgoing sampled http requests without active span are correctly instrumented', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/instrument.mjs new file mode 100644 index 000000000000..33213733efef --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/instrument.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/scenario.mjs new file mode 100644 index 000000000000..18f508d309a2 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/scenario.mjs @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node-core'; +import * as http from 'http'; + +Sentry.startSpan({ name: 'test_span' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); +}); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts new file mode 100644 index 000000000000..1189afc502e5 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts @@ -0,0 +1,46 @@ +import { describe, expect } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + conditionalTest({ min: 22 })('node >=22', () => { + test('outgoing sampled http requests are correctly instrumented', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + // we're not too concerned with the actual transaction here since this is tested elsewhere + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/instrument.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/instrument.mjs new file mode 100644 index 000000000000..0c77fb8702b7 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/instrument.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel.js'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 0, + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/scenario.mjs b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/scenario.mjs new file mode 100644 index 000000000000..e470ae986985 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/scenario.mjs @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/node-core'; +import * as http from 'http'; + +async function run() { + // Wrap in span that is not sampled + await Sentry.startSpan({ name: 'outer' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + }); + + Sentry.captureException(new Error('foo')); +} + +run(); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts new file mode 100644 index 000000000000..60d3345dcb51 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts @@ -0,0 +1,53 @@ +import { describe, expect } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + conditionalTest({ min: 22 })('node >=22', () => { + test('outgoing http requests are correctly instrumented when not sampled', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rand-propagation/server.js b/dev-packages/node-core-integration-tests/suites/tracing/sample-rand-propagation/server.js new file mode 100644 index 000000000000..b65a5fe11ac0 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rand-propagation/server.js @@ -0,0 +1,41 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + tracesSampleRate: 0.00000001, // It's important that this is not 1, so that we also check logic for NonRecordingSpans, which is usually the edge-case +}); + +setupOtel(client); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const { + startExpressServerAndSendPortToRunner, + getPortAppIsRunningOn, +} = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/check', (req, res) => { + const appPort = getPortAppIsRunningOn(app); + // eslint-disable-next-line no-undef + fetch(`http://localhost:${appPort}/bounce`) + .then(r => r.json()) + .then(bounceRes => { + res.json({ propagatedData: bounceRes }); + }); +}); + +app.get('/bounce', (req, res) => { + res.json({ + baggage: req.headers['baggage'], + }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rand-propagation/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/sample-rand-propagation/test.ts new file mode 100644 index 000000000000..40001a9f62f5 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rand-propagation/test.ts @@ -0,0 +1,82 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('sample_rand propagation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('propagates a sample rand when there is a sentry-trace header and incoming sentry baggage', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: 'sentry-release=foo,sentry-sample_rand=0.424242', + }, + }); + expect(response).toEqual({ + propagatedData: { + baggage: expect.stringMatching(/sentry-sample_rand=0\.424242/), + }, + }); + }); + + test('does not propagate a sample rand when there is an incoming sentry-trace header but no baggage header', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + }, + }); + expect(response).toEqual({ + propagatedData: { + baggage: expect.not.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + }, + }); + }); + + test('propagates a sample_rand that would lead to a positive sampling decision when there is an incoming positive sampling decision but no sample_rand in the baggage header', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: 'sentry-sample_rate=0.25', + }, + }); + + const sampleRand = Number((response as any).propagatedData.baggage.match(/sentry-sample_rand=(0\.[0-9]+)/)[1]); + + expect(sampleRand).toStrictEqual(expect.any(Number)); + expect(sampleRand).not.toBeNaN(); + expect(sampleRand).toBeLessThan(0.25); + expect(sampleRand).toBeGreaterThanOrEqual(0); + }); + + test('propagates a sample_rand that would lead to a negative sampling decision when there is an incoming negative sampling decision but no sample_rand in the baggage header', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', + baggage: 'sentry-sample_rate=0.75', + }, + }); + + const sampleRand = Number((response as any).propagatedData.baggage.match(/sentry-sample_rand=(0\.[0-9]+)/)[1]); + + expect(sampleRand).toStrictEqual(expect.any(Number)); + expect(sampleRand).not.toBeNaN(); + expect(sampleRand).toBeGreaterThanOrEqual(0.75); + expect(sampleRand).toBeLessThan(1); + }); + + test('no sample_rand when there is no sentry-trace header but a baggage header with sample_rand', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + baggage: 'sentry-sample_rate=0.75,sentry-sample_rand=0.5', + }, + }); + + expect((response as any).propagatedData.baggage).not.toMatch(/sentry-sample_rand=0\.[0-9]+/); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/server.js b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/server.js new file mode 100644 index 000000000000..3ceb59573390 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/server.js @@ -0,0 +1,40 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, +}); + +setupOtel(client); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const { + startExpressServerAndSendPortToRunner, + getPortAppIsRunningOn, +} = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/check', (req, res) => { + const appPort = getPortAppIsRunningOn(app); + // eslint-disable-next-line no-undef + fetch(`http://localhost:${appPort}/bounce`) + .then(r => r.json()) + .then(bounceRes => { + res.json({ propagatedData: bounceRes }); + }); +}); + +app.get('/bounce', (req, res) => { + res.json({ + baggage: req.headers['baggage'], + }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/test.ts new file mode 100644 index 000000000000..b3040dc0cfa4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/no-tracing-enabled/test.ts @@ -0,0 +1,26 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('parentSampleRate propagation with no tracing enabled', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should propagate an incoming sample rate', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: 'sentry-sample_rate=0.1337', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); + }); + + test('should not propagate a sample rate for root traces', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check'); + expect((response as any).propagatedData.baggage).not.toMatch(/sentry-sample_rate/); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/server.js b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/server.js new file mode 100644 index 000000000000..bae259c5b74d --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/server.js @@ -0,0 +1,41 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + tracesSampleRate: 0, +}); + +setupOtel(client); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const { + startExpressServerAndSendPortToRunner, + getPortAppIsRunningOn, +} = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/check', (req, res) => { + const appPort = getPortAppIsRunningOn(app); + // eslint-disable-next-line no-undef + fetch(`http://localhost:${appPort}/bounce`) + .then(r => r.json()) + .then(bounceRes => { + res.json({ propagatedData: bounceRes }); + }); +}); + +app.get('/bounce', (req, res) => { + res.json({ + baggage: req.headers['baggage'], + }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/test.ts new file mode 100644 index 000000000000..219e82dfeb12 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate-0/test.ts @@ -0,0 +1,62 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('parentSampleRate propagation with tracesSampleRate=0', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should propagate incoming sample rate when inheriting a positive sampling decision', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: 'sentry-sample_rate=0.1337', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); + }); + + test('should propagate incoming sample rate when inheriting a negative sampling decision', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', + baggage: 'sentry-sample_rate=0.1337', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); + }); + + test('should not propagate a sample rate when receiving a trace without sampling decision and sample rate', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac', + baggage: '', + }, + }); + + expect((response as any).propagatedData.baggage).not.toMatch(/sentry-sample_rate=0/); + }); + + test('should propagate configured sample rate when receiving a trace without sampling decision, but with sample rate', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac', + baggage: 'sentry-sample_rate=0.1337', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0/); + }); + + test('should not propagate configured sample rate when there is no incoming trace', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check'); + expect((response as any).propagatedData.baggage).not.toMatch(/sentry-sample_rate=0/); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/server.js b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/server.js new file mode 100644 index 000000000000..c62e3bec4c45 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/server.js @@ -0,0 +1,41 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + tracesSampleRate: 0.69, +}); + +setupOtel(client); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const { + startExpressServerAndSendPortToRunner, + getPortAppIsRunningOn, +} = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/check', (req, res) => { + const appPort = getPortAppIsRunningOn(app); + // eslint-disable-next-line no-undef + fetch(`http://localhost:${appPort}/bounce`) + .then(r => r.json()) + .then(bounceRes => { + res.json({ propagatedData: bounceRes }); + }); +}); + +app.get('/bounce', (req, res) => { + res.json({ + baggage: req.headers['baggage'], + }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/test.ts new file mode 100644 index 000000000000..147b4c13a1e1 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampleRate/test.ts @@ -0,0 +1,62 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('parentSampleRate propagation with tracesSampleRate', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should propagate incoming sample rate when inheriting a positive sampling decision', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: 'sentry-sample_rate=0.1337', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); + }); + + test('should propagate incoming sample rate when inheriting a negative sampling decision', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', + baggage: 'sentry-sample_rate=0.1337', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); + }); + + test('should not propagate configured sample rate when receiving a trace without sampling decision and sample rate', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac', + baggage: '', + }, + }); + + expect((response as any).propagatedData.baggage).not.toMatch(/sentry-sample_rate=0\.69/); + }); + + test('should not propagate configured sample rate when receiving a trace without sampling decision, but with sample rate', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac', + baggage: 'sentry-sample_rate=0.1337', + }, + }); + + expect((response as any).propagatedData.baggage).not.toMatch(/sentry-sample_rate=0\.69/); + }); + + test('should not propagate configured sample rate when there is no incoming trace', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check'); + expect((response as any).propagatedData.baggage).not.toMatch(/sentry-sample_rate=0\.69/); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler-with-otel-http-instrumentation/server.js b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler-with-otel-http-instrumentation/server.js new file mode 100644 index 000000000000..f58cb4af35a2 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler-with-otel-http-instrumentation/server.js @@ -0,0 +1,45 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); +const Sentry = require('@sentry/node-core'); +const { setupOtel } = require('../../../../utils/setupOtel.js'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + openTelemetryInstrumentations: [new HttpInstrumentation()], + tracesSampler: ({ inheritOrSampleWith }) => { + return inheritOrSampleWith(0.69); + }, +}); + +setupOtel(client); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const { + startExpressServerAndSendPortToRunner, + getPortAppIsRunningOn, +} = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/check', (req, res) => { + const appPort = getPortAppIsRunningOn(app); + // eslint-disable-next-line no-undef + fetch(`http://localhost:${appPort}/bounce`) + .then(r => r.json()) + .then(bounceRes => { + res.json({ propagatedData: bounceRes }); + }); +}); + +app.get('/bounce', (req, res) => { + res.json({ + baggage: req.headers['baggage'], + }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler-with-otel-http-instrumentation/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler-with-otel-http-instrumentation/test.ts new file mode 100644 index 000000000000..ffab071bbc26 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/sample-rate-propagation/tracesSampler-with-otel-http-instrumentation/test.ts @@ -0,0 +1,62 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('parentSampleRate propagation with tracesSampler and OpenTelemetry HTTP instrumentation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming trace', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check'); + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.69/); + }); + + test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (1 -> because there is a positive sampling decision and inheritOrSampleWith was used)', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: '', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=1/); + }); + + test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (0 -> because there is a negative sampling decision and inheritOrSampleWith was used)', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-0', + baggage: '', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0/); + }); + + test('should propagate sample_rate equivalent to sample rate returned by tracesSampler when there is no incoming sample rate (the fallback value -> because there is no sampling decision and inheritOrSampleWith was used)', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac', + baggage: '', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.69/); + }); + + test('should propagate sample_rate equivalent to incoming sample_rate (because tracesSampler is configured that way)', async () => { + const runner = createRunner(__dirname, 'server.js').start(); + const response = await runner.makeRequest('get', '/check', { + headers: { + 'sentry-trace': '530699e319cc067ce440315d74acb312-414dc2a08d5d1dac-1', + baggage: 'sentry-sample_rate=0.1337', + }, + }); + + expect((response as any).propagatedData.baggage).toMatch(/sentry-sample_rate=0\.1337/); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts new file mode 100644 index 000000000000..2676a2f77bef --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +const client = Sentry.init({ + dsn: 'https://public@o01234987.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +import cors from 'cors'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const headers = http + .get({ + hostname: 'example.com', + }) + .getHeaders(); + + res.send({ test_data: headers }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts new file mode 100644 index 000000000000..e291ab122ba1 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +const client = Sentry.init({ + dsn: 'https://public@public.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +import cors from 'cors'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const headers = http + .get({ + hostname: 'example.com', + }) + .getHeaders(); + + res.send({ test_data: headers }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts new file mode 100644 index 000000000000..5e4c9a7ea3dc --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../../utils/setupOtel'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +const client = Sentry.init({ + dsn: 'https://public@o0000987.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + orgId: '01234987', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +setupOtel(client); + +import cors from 'cors'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const headers = http + .get({ + hostname: 'example.com', + }) + .getHeaders(); + + res.send({ test_data: headers }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts new file mode 100644 index 000000000000..1c1fa4e6cf5f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts @@ -0,0 +1,42 @@ +import { afterAll, expect, test } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from './server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +// This test requires Node.js 22+ because it depends on the 'http.client.request.created' +// diagnostic channel for baggage header propagation, which only exists since Node 22.12.0+ and 23.2.0+ +conditionalTest({ min: 22 })('node >=22', () => { + test('should include explicitly set org_id in the baggage header', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage; + expect(baggage).toContain('sentry-org_id=01234987'); + }); + + test('should extract org_id from DSN host when not explicitly set', async () => { + const runner = createRunner(__dirname, 'server-no-explicit-org-id.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage; + expect(baggage).toContain('sentry-org_id=01234987'); + }); + + test('should set undefined org_id when it cannot be extracted', async () => { + const runner = createRunner(__dirname, 'server-no-org-id.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage; + expect(baggage).not.toContain('sentry-org_id'); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts new file mode 100644 index 000000000000..52fc94f0496f --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import { setupOtel } from '../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); + +setupOtel(client); + +import * as http from 'http'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_span' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); +}); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts new file mode 100644 index 000000000000..8ae06f883b38 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts @@ -0,0 +1,44 @@ +import { expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { createRunner } from '../../../utils/runner'; +import { createTestServer } from '../../../utils/server'; + +// This test requires Node.js 22+ because it depends on the 'http.client.request.created' +// diagnostic channel for baggage header propagation, which only exists since Node 22.12.0+ and 23.2.0+ +conditionalTest({ min: 22 })('node >=22', () => { + test('SentryHttpIntegration should instrument correct requests when tracePropagationTargets option is provided', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + // we're not too concerned with the actual transaction here since this is tested elsewhere + }, + }) + .start() + .completed(); + closeTestServer(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/suites/tsconfig.json b/dev-packages/node-core-integration-tests/suites/tsconfig.json new file mode 100644 index 000000000000..38ca0b13bcdd --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.test.json" +} diff --git a/dev-packages/node-core-integration-tests/suites/winston/subject.ts b/dev-packages/node-core-integration-tests/suites/winston/subject.ts new file mode 100644 index 000000000000..07979ab05524 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/winston/subject.ts @@ -0,0 +1,76 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-core-integration-tests'; +import winston from 'winston'; +import Transport from 'winston-transport'; +import { setupOtel } from '../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + _experiments: { + enableLogs: true, + }, + transport: loggingTransport, +}); + +setupOtel(client); + +async function run(): Promise { + // Create a custom transport that extends winston-transport + const SentryWinstonTransport = Sentry.createSentryWinstonTransport(Transport); + + // Create logger with default levels + const logger = winston.createLogger({ + transports: [new SentryWinstonTransport()], + }); + + // Test basic logging + logger.info('Test info message'); + logger.error('Test error message'); + + // If custom levels are requested + if (process.env.CUSTOM_LEVELS === 'true') { + const customLevels = { + levels: { + error: 0, + warn: 1, + info: 2, + http: 3, + verbose: 4, + debug: 5, + silly: 6, + }, + colors: { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + verbose: 'cyan', + debug: 'blue', + silly: 'grey', + }, + }; + + const customLogger = winston.createLogger({ + levels: customLevels.levels, + transports: [new SentryWinstonTransport()], + }); + + customLogger.info('Test info message'); + customLogger.error('Test error message'); + } + + // If metadata is requested + if (process.env.WITH_METADATA === 'true') { + logger.info('Test message with metadata', { + foo: 'bar', + number: 42, + }); + } + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +void run(); diff --git a/dev-packages/node-core-integration-tests/suites/winston/test.ts b/dev-packages/node-core-integration-tests/suites/winston/test.ts new file mode 100644 index 000000000000..034210f8690b --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/winston/test.ts @@ -0,0 +1,186 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +describe('winston integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should capture winston logs with default levels', async () => { + const runner = createRunner(__dirname, 'subject.ts') + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test info message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Test error message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); + + test('should capture winston logs with custom levels', async () => { + const runner = createRunner(__dirname, 'subject.ts') + .withEnv({ CUSTOM_LEVELS: 'true' }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test info message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Test error message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test info message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Test error message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); + + test('should capture winston logs with metadata', async () => { + const runner = createRunner(__dirname, 'subject.ts') + .withEnv({ WITH_METADATA: 'true' }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test info message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Test error message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test message with metadata', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + foo: { value: 'bar', type: 'string' }, + number: { value: 42, type: 'integer' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-core-integration-tests/test.txt b/dev-packages/node-core-integration-tests/test.txt new file mode 100644 index 000000000000..0a0fa7f94de9 --- /dev/null +++ b/dev-packages/node-core-integration-tests/test.txt @@ -0,0 +1,213 @@ +yarn run v1.22.22 +$ /Users/abhijeetprasad/workspace/sentry-javascript/node_modules/.bin/jest contextLines/memory-leak + console.log + starting scenario /Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts [ '-r', 'ts-node/register' ] undefined + + at log (utils/runner.ts:462:11) + + console.log + line COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad cwd DIR 1,16 608 107673020 /Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad txt REG 1,16 88074480 114479727 /Users/abhijeetprasad/.volta/tools/image/node/18.20.5/bin/node + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 0u unix 0x6a083c8cc83ea8db 0t0 ->0xf2cacdd1d3a0ebec + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 1u unix 0xd99cc422a76ba47f 0t0 ->0x542148981a0b9ef2 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 2u unix 0x97e70527ed5803f8 0t0 ->0xbafdaf00ef20de83 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 3u KQUEUE count=0, state=0 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 4 PIPE 0x271836c29e42bc67 16384 ->0x16ac23fcfd4fe1a3 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 5 PIPE 0x16ac23fcfd4fe1a3 16384 ->0x271836c29e42bc67 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 6 PIPE 0xd76fcd4ca2a35fcf 16384 ->0x30d26cd4f0e069b2 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 7 PIPE 0x30d26cd4f0e069b2 16384 ->0xd76fcd4ca2a35fcf + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 8 PIPE 0x37691847717c3d6 16384 ->0x966eedd79d018252 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 9 PIPE 0x966eedd79d018252 16384 ->0x37691847717c3d6 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 10u KQUEUE count=0, state=0xa + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 11 PIPE 0x99c1186f14b865be 16384 ->0xe88675eb1eefb2b + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 12 PIPE 0xe88675eb1eefb2b 16384 ->0x99c1186f14b865be + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 13 PIPE 0x52173210451cdda9 16384 ->0x50bbc31a0f1cc1af + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 14 PIPE 0x50bbc31a0f1cc1af 16384 ->0x52173210451cdda9 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 15u KQUEUE count=0, state=0 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 16 PIPE 0xa115aa0653327e72 16384 ->0x100525c465ee1eb0 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 17 PIPE 0x100525c465ee1eb0 16384 ->0xa115aa0653327e72 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 18 PIPE 0x41945cf9fe740277 16384 ->0x8791d18eade5b1e0 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 19 PIPE 0x8791d18eade5b1e0 16384 ->0x41945cf9fe740277 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 20r CHR 3,2 0t0 333 /dev/null + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 21u KQUEUE count=0, state=0xa + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 22 PIPE 0xf4c6a2f47fb0bff5 16384 ->0xa00185e1c59cedbe + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 23 PIPE 0xa00185e1c59cedbe 16384 ->0xf4c6a2f47fb0bff5 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 24 PIPE 0x4ac25a99f45f7ca4 16384 ->0x2032aef840c94700 + + at log (utils/runner.ts:462:11) + + console.log + line node 90932 abhijeetprasad 25 PIPE 0x2032aef840c94700 16384 ->0x4ac25a99f45f7ca4 + + at log (utils/runner.ts:462:11) + + console.log + line null + + at log (utils/runner.ts:462:11) + + console.log + line [{"sent_at":"2025-01-13T21:47:47.663Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"}},[[{"type":"session"},{"sid":"0ae9ef2ac2ba49dd92b6dab9d81444ac","init":true,"started":"2025-01-13T21:47:47.502Z","timestamp":"2025-01-13T21:47:47.663Z","status":"ok","errors":1,"duration":0.16146087646484375,"attrs":{"release":"1.0","environment":"production"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"2626269e3c634fc289338c441e76412c","sent_at":"2025-01-13T21:47:47.663Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 0","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"2626269e3c634fc289338c441e76412c","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"b1e1b8a0d410ef14"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.528,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"f58236bf0a7f4a999f7daf5283f0400f","sent_at":"2025-01-13T21:47:47.664Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 1","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"f58236bf0a7f4a999f7daf5283f0400f","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"9b6ccaf59536bcb4"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.531,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"d4d1b66dc41b44b98df2d2ff5d5370a2","sent_at":"2025-01-13T21:47:47.665Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 2","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"d4d1b66dc41b44b98df2d2ff5d5370a2","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"82d56f443d3f01f9"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.532,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"293d7c8c731c48eca30735b41efd40ba","sent_at":"2025-01-13T21:47:47.665Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 3","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"293d7c8c731c48eca30735b41efd40ba","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"8be46494d3555ddb"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.533,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"e9273b56624d4261b00f5431852da167","sent_at":"2025-01-13T21:47:47.666Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 4","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"e9273b56624d4261b00f5431852da167","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"9a067a8906c8c147"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.533,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"cf92173285aa49b8bdb3fe31a5de6c90","sent_at":"2025-01-13T21:47:47.667Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 5","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"cf92173285aa49b8bdb3fe31a5de6c90","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"ac2ad9041812f9d9"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.534,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"65224267e02049daadbc577de86960f3","sent_at":"2025-01-13T21:47:47.667Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 6","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"65224267e02049daadbc577de86960f3","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"b12818330e05cd2f"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.535,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"b9e96b480e1a4e74a2ecebde9f0400a9","sent_at":"2025-01-13T21:47:47.668Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 7","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"b9e96b480e1a4e74a2ecebde9f0400a9","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"83cb86896d96bbf6"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.536,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"c541f2c0a31345b78f93f69ffe5e0fc6","sent_at":"2025-01-13T21:47:47.668Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 8","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"c541f2c0a31345b78f93f69ffe5e0fc6","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"a0e8e199fcf05714"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270073856},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.536,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + + console.log + line [{"event_id":"dc08b3fe26e94759817c7b5e95469727","sent_at":"2025-01-13T21:47:47.669Z","sdk":{"name":"sentry.javascript.node","version":"8.45.0"},"trace":{"environment":"production","release":"1.0","public_key":"public","trace_id":"efdb9350effb47959d48bd0aaf395824"}},[[{"type":"event"},{"exception":{"values":[{"type":"Error","value":"error in loop 9","stacktrace":{"frames":[{"filename":"node:internal/main/run_main_module","module":"run_main_module","function":"?","lineno":28,"colno":49,"in_app":false},{"filename":"node:internal/modules/run_main","module":"run_main","function":"Function.executeUserEntryPoint [as runMain]","lineno":128,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._load","lineno":1019,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module.load","lineno":1203,"colno":32,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Object.require.extensions. [as .ts]","lineno":1621,"colno":12,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._extensions..js","lineno":1422,"colno":10,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/node_modules/ts-node/src/index.ts","module":"ts-node.src:index.ts","function":"Module.m._compile","lineno":1618,"colno":23,"in_app":false},{"filename":"node:internal/modules/cjs/loader","module":"loader","function":"Module._compile","lineno":1364,"colno":14,"in_app":false},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/scenario.ts","module":"scenario.ts","function":"Object.?","lineno":14,"colno":10,"in_app":true,"pre_context":[" dsn: 'https://public@dsn.ingest.sentry.io/1337',"," release: '1.0',"," transport: loggingTransport,","});","","import { runSentry } from './other-file';",""],"context_line":"runSentry();","post_context":["","console.log(execSync(`lsof -p ${process.pid}`, { stdio: 'inherit', cwd: process.cwd() }));"]},{"filename":"/Users/abhijeetprasad/workspace/sentry-javascript/dev-packages/node-integration-tests/suites/contextLines/memory-leak/other-file.ts","module":"other-file.ts","function":"runSentry","lineno":5,"colno":29,"in_app":true,"pre_context":["import * as Sentry from '@sentry/node';","","export function runSentry(): void {"," for (let i = 0; i < 10; i++) {"],"context_line":" Sentry.captureException(new Error(`error in loop ${i}`));","post_context":[" }","}"]}]},"mechanism":{"type":"generic","handled":true}}]},"event_id":"dc08b3fe26e94759817c7b5e95469727","level":"error","platform":"node","contexts":{"trace":{"trace_id":"efdb9350effb47959d48bd0aaf395824","span_id":"8ec7d145c5362df0"},"runtime":{"name":"node","version":"v18.20.5"},"app":{"app_start_time":"2025-01-13T21:47:46.327Z","app_memory":270106624},"os":{"kernel_version":"23.6.0","name":"macOS","version":"14.7","build":"23H124"},"device":{"boot_time":"2024-12-23T16:56:50.637Z","arch":"arm64","memory_size":34359738368,"free_memory":355794944,"processor_count":10,"cpu_description":"Apple M1 Pro","processor_frequency":24},"culture":{"locale":"en-CA","timezone":"America/Toronto"},"cloud_resource":{}},"server_name":"GT9RQ02WW5.local","timestamp":1736804867.537,"environment":"production","release":"1.0","sdk":{"integrations":["InboundFilters","FunctionToString","LinkedErrors","RequestData","Console","Http","NodeFetch","OnUncaughtException","OnUnhandledRejection","ContextLines","LocalVariables","Context","ChildProcess","Modules"],"name":"sentry.javascript.node","version":"8.45.0","packages":[{"name":"npm:@sentry/node","version":"8.45.0"}]},"modules":{"ts-node":"10.9.1","make-error":"1.3.6","yn":"3.1.1","arg":"4.1.3","v8-compile-cache-lib":"3.0.1","typescript":"5.0.4","tslib":"2.7.0","semver":"7.6.3","shimmer":"1.2.1","require-in-the-middle":"7.2.0","resolve":"1.22.1","is-core-module":"2.11.0","has":"1.0.3","function-bind":"1.1.1","debug":"4.3.4","supports-color":"7.2.0","has-flag":"4.0.0","module-details-from-path":"1.0.3","import-in-the-middle":"1.12.0","forwarded-parse":"2.1.2"}}]]] + + at log (utils/runner.ts:462:11) + +Done in 4.21s. diff --git a/dev-packages/node-core-integration-tests/tsconfig.json b/dev-packages/node-core-integration-tests/tsconfig.json new file mode 100644 index 000000000000..1cd6c0aec734 --- /dev/null +++ b/dev-packages/node-core-integration-tests/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["utils/**/*.ts", "src/**/*.ts"], + + "compilerOptions": { + // Although this seems wrong to include `DOM` here, it's necessary to make + // global fetch available in tests in lower Node versions. + "lib": ["DOM", "ES2018"], + // package-specific options + "esModuleInterop": true, + "types": ["node"] + } +} diff --git a/dev-packages/node-core-integration-tests/tsconfig.test.json b/dev-packages/node-core-integration-tests/tsconfig.test.json new file mode 100644 index 000000000000..45a6e39b0054 --- /dev/null +++ b/dev-packages/node-core-integration-tests/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + + "include": ["suites/**/*.ts", "vite.config.ts"], + + "compilerOptions": { + // Although this seems wrong to include `DOM` here, it's necessary to make + // global fetch available in tests in lower Node versions. + "lib": ["DOM", "ES2018"], + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node"] + + // other package-specific, test-specific options + } +} diff --git a/dev-packages/node-core-integration-tests/tsconfig.types.json b/dev-packages/node-core-integration-tests/tsconfig.types.json new file mode 100644 index 000000000000..65455f66bd75 --- /dev/null +++ b/dev-packages/node-core-integration-tests/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/dev-packages/node-core-integration-tests/utils/assertions.ts b/dev-packages/node-core-integration-tests/utils/assertions.ts new file mode 100644 index 000000000000..296bdc608bb4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/utils/assertions.ts @@ -0,0 +1,89 @@ +import type { + ClientReport, + Envelope, + Event, + SerializedCheckIn, + SerializedLogContainer, + SerializedSession, + SessionAggregates, + TransactionEvent, +} from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import { expect } from 'vitest'; + +/** + * Asserts against a Sentry Event ignoring non-deterministic properties + * + * @param {Record} actual + * @param {Record} expected + */ +export const assertSentryEvent = (actual: Event, expected: Record): void => { + expect(actual).toMatchObject({ + event_id: expect.any(String), + ...expected, + }); +}; + +/** + * Asserts against a Sentry Transaction ignoring non-deterministic properties + * + * @param {Record} actual + * @param {Record} expected + */ +export const assertSentryTransaction = (actual: TransactionEvent, expected: Record): void => { + expect(actual).toMatchObject({ + event_id: expect.any(String), + timestamp: expect.anything(), + start_timestamp: expect.anything(), + spans: expect.any(Array), + type: 'transaction', + ...expected, + }); +}; + +export function assertSentrySession(actual: SerializedSession, expected: Partial): void { + expect(actual).toMatchObject({ + sid: expect.any(String), + ...expected, + }); +} + +export function assertSentrySessions(actual: SessionAggregates, expected: Partial): void { + expect(actual).toMatchObject({ + ...expected, + }); +} + +export function assertSentryCheckIn(actual: SerializedCheckIn, expected: Partial): void { + expect(actual).toMatchObject({ + check_in_id: expect.any(String), + ...expected, + }); +} + +export function assertSentryClientReport(actual: ClientReport, expected: Partial): void { + expect(actual).toMatchObject({ + ...expected, + }); +} + +export function assertSentryLogContainer( + actual: SerializedLogContainer, + expected: Partial, +): void { + expect(actual).toMatchObject({ + ...expected, + }); +} + +export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void { + expect(actual).toEqual({ + event_id: expect.any(String), + sent_at: expect.any(String), + sdk: { + name: 'sentry.javascript.node', + version: SDK_VERSION, + }, + ...expected, + }); +} diff --git a/dev-packages/node-core-integration-tests/utils/index.ts b/dev-packages/node-core-integration-tests/utils/index.ts new file mode 100644 index 000000000000..e08d89a92131 --- /dev/null +++ b/dev-packages/node-core-integration-tests/utils/index.ts @@ -0,0 +1,57 @@ +import type { EnvelopeItemType } from '@sentry/core'; +import { parseSemver } from '@sentry/core'; +import type * as http from 'http'; +import { describe } from 'vitest'; + +const NODE_VERSION = parseSemver(process.versions.node).major; + +export type TestServerConfig = { + url: string; + server: http.Server; +}; + +export type DataCollectorOptions = { + // Optional custom URL + url?: string; + + // The expected amount of requests to the envelope endpoint. + // If the amount of sent requests is lower than `count`, this function will not resolve. + count?: number; + + // The method of the request. + method?: 'get' | 'post'; + + // Whether to stop the server after the requests have been intercepted + endServer?: boolean; + + // Type(s) of the envelopes to capture + envelopeType?: EnvelopeItemType | EnvelopeItemType[]; +}; + +/** + * Returns`describe` or `describe.skip` depending on allowed major versions of Node. + * + * @param {{ min?: number; max?: number }} allowedVersion + */ +export function conditionalTest(allowedVersion: { + min?: number; + max?: number; +}): typeof describe | typeof describe.skip { + if (!NODE_VERSION) { + return describe.skip; + } + + return NODE_VERSION < (allowedVersion.min || -Infinity) || NODE_VERSION > (allowedVersion.max || Infinity) + ? describe.skip + : describe; +} + +/** + * Parses response body containing an Envelope + * + * @param {string} body + * @return {*} {Array>} + */ +export const parseEnvelope = (body: string): Array> => { + return body.split('\n').map(e => JSON.parse(e)); +}; diff --git a/dev-packages/node-core-integration-tests/utils/runner.ts b/dev-packages/node-core-integration-tests/utils/runner.ts new file mode 100644 index 000000000000..97b1efa2dbb4 --- /dev/null +++ b/dev-packages/node-core-integration-tests/utils/runner.ts @@ -0,0 +1,683 @@ +/* eslint-disable max-lines */ +import type { + ClientReport, + Envelope, + EnvelopeItemType, + Event, + EventEnvelope, + SerializedCheckIn, + SerializedLogContainer, + SerializedSession, + SessionAggregates, + TransactionEvent, +} from '@sentry/core'; +import { normalize } from '@sentry/core'; +import { execSync, spawn, spawnSync } from 'child_process'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { afterAll, beforeAll, describe, test } from 'vitest'; +import { + assertEnvelopeHeader, + assertSentryCheckIn, + assertSentryClientReport, + assertSentryEvent, + assertSentryLogContainer, + assertSentrySession, + assertSentrySessions, + assertSentryTransaction, +} from './assertions'; +import { createBasicSentryServer } from './server'; + +const CLEANUP_STEPS = new Set(); + +export function cleanupChildProcesses(): void { + for (const step of CLEANUP_STEPS) { + step(); + } + CLEANUP_STEPS.clear(); +} + +process.on('exit', cleanupChildProcesses); + +/** Promise only resolves when fn returns true */ +async function waitFor(fn: () => boolean, timeout = 10_000, message = 'Timed out waiting'): Promise { + let remaining = timeout; + while (fn() === false) { + await new Promise(resolve => setTimeout(resolve, 100)); + remaining -= 100; + if (remaining < 0) { + throw new Error(message); + } + } +} + +type VoidFunction = () => void; + +interface DockerOptions { + /** + * The working directory to run docker compose in + */ + workingDirectory: string[]; + /** + * The strings to look for in the output to know that the docker compose is ready for the test to be run + */ + readyMatches: string[]; + /** + * The command to run after docker compose is up + */ + setupCommand?: string; +} + +/** + * Runs docker compose up and waits for the readyMatches to appear in the output + * + * Returns a function that can be called to docker compose down + */ +async function runDockerCompose(options: DockerOptions): Promise { + return new Promise((resolve, reject) => { + const cwd = join(...options.workingDirectory); + const close = (): void => { + spawnSync('docker', ['compose', 'down', '--volumes'], { + cwd, + stdio: process.env.DEBUG ? 'inherit' : undefined, + }); + }; + + // ensure we're starting fresh + close(); + + const child = spawn('docker', ['compose', 'up'], { cwd }); + + const timeout = setTimeout(() => { + close(); + reject(new Error('Timed out waiting for docker-compose')); + }, 75_000); + + function newData(data: Buffer): void { + const text = data.toString('utf8'); + + if (process.env.DEBUG) log(text); + + for (const match of options.readyMatches) { + if (text.includes(match)) { + child.stdout.removeAllListeners(); + clearTimeout(timeout); + if (options.setupCommand) { + execSync(options.setupCommand, { cwd, stdio: 'inherit' }); + } + resolve(close); + } + } + } + + child.stdout.on('data', newData); + child.stderr.on('data', newData); + }); +} + +type ExpectedEvent = Partial | ((event: Event) => void); +type ExpectedTransaction = Partial | ((event: TransactionEvent) => void); +type ExpectedSession = Partial | ((event: SerializedSession) => void); +type ExpectedSessions = Partial | ((event: SessionAggregates) => void); +type ExpectedCheckIn = Partial | ((event: SerializedCheckIn) => void); +type ExpectedClientReport = Partial | ((event: ClientReport) => void); +type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void); + +type Expected = + | { + event: ExpectedEvent; + } + | { + transaction: ExpectedTransaction; + } + | { + session: ExpectedSession; + } + | { + sessions: ExpectedSessions; + } + | { + check_in: ExpectedCheckIn; + } + | { + client_report: ExpectedClientReport; + } + | { + log: ExpectedLogContainer; + }; + +type ExpectedEnvelopeHeader = + | { event: Partial } + | { transaction: Partial } + | { session: Partial } + | { sessions: Partial } + | { log: Partial }; + +type StartResult = { + completed(): Promise; + childHasExited(): boolean; + getLogs(): string[]; + makeRequest( + method: 'get' | 'post', + path: string, + options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, + ): Promise; +}; + +export function createEsmAndCjsTests( + cwd: string, + scenarioPath: string, + instrumentPath: string, + callback: ( + createTestRunner: () => ReturnType, + testFn: typeof test | typeof test.fails, + mode: 'esm' | 'cjs', + ) => void, + options?: { failsOnCjs?: boolean; failsOnEsm?: boolean }, +): void { + const mjsScenarioPath = join(cwd, scenarioPath); + const mjsInstrumentPath = join(cwd, instrumentPath); + + if (!mjsScenarioPath.endsWith('.mjs')) { + throw new Error(`Scenario path must end with .mjs: ${scenarioPath}`); + } + + if (!existsSync(mjsInstrumentPath)) { + throw new Error(`Instrument file not found: ${mjsInstrumentPath}`); + } + + const cjsScenarioPath = join(cwd, `tmp_${scenarioPath.replace('.mjs', '.cjs')}`); + const cjsInstrumentPath = join(cwd, `tmp_${instrumentPath.replace('.mjs', '.cjs')}`); + + describe('esm', () => { + const testFn = options?.failsOnEsm ? test.fails : test; + callback(() => createRunner(mjsScenarioPath).withFlags('--import', mjsInstrumentPath), testFn, 'esm'); + }); + + describe('cjs', () => { + beforeAll(() => { + // For the CJS runner, we create some temporary files... + convertEsmFileToCjs(mjsScenarioPath, cjsScenarioPath); + convertEsmFileToCjs(mjsInstrumentPath, cjsInstrumentPath); + }); + + afterAll(() => { + try { + unlinkSync(cjsInstrumentPath); + } catch { + // Ignore errors here + } + try { + unlinkSync(cjsScenarioPath); + } catch { + // Ignore errors here + } + }); + + const testFn = options?.failsOnCjs ? test.fails : test; + callback(() => createRunner(cjsScenarioPath).withFlags('--require', cjsInstrumentPath), testFn, 'cjs'); + }); +} + +function convertEsmFileToCjs(inputPath: string, outputPath: string): void { + const cjsFileContent = readFileSync(inputPath, 'utf8'); + const cjsFileContentConverted = convertEsmToCjs(cjsFileContent); + writeFileSync(outputPath, cjsFileContentConverted); +} + +/** Creates a test runner */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createRunner(...paths: string[]) { + const testPath = join(...paths); + + if (!existsSync(testPath)) { + throw new Error(`Test scenario not found: ${testPath}`); + } + + const expectedEnvelopes: Expected[] = []; + let expectedEnvelopeHeaders: ExpectedEnvelopeHeader[] | undefined = undefined; + const flags: string[] = []; + // By default, we ignore session & sessions + const ignored: Set = new Set(['session', 'sessions', 'client_report']); + let withEnv: Record = {}; + let withSentryServer = false; + let dockerOptions: DockerOptions | undefined; + let ensureNoErrorOutput = false; + const logs: string[] = []; + + if (testPath.endsWith('.ts')) { + flags.push('-r', 'ts-node/register'); + } + + return { + expect: function (expected: Expected) { + if (ensureNoErrorOutput) { + throw new Error('You should not use `ensureNoErrorOutput` when using `expect`!'); + } + expectedEnvelopes.push(expected); + return this; + }, + expectN: function (n: number, expected: Expected) { + for (let i = 0; i < n; i++) { + expectedEnvelopes.push(expected); + } + return this; + }, + expectHeader: function (expected: ExpectedEnvelopeHeader) { + if (!expectedEnvelopeHeaders) { + expectedEnvelopeHeaders = []; + } + + expectedEnvelopeHeaders.push(expected); + return this; + }, + withEnv: function (env: Record) { + withEnv = env; + return this; + }, + withFlags: function (...args: string[]) { + flags.push(...args); + return this; + }, + withInstrument: function (instrumentPath: string) { + flags.push('--import', instrumentPath); + return this; + }, + withMockSentryServer: function () { + withSentryServer = true; + return this; + }, + ignore: function (...types: EnvelopeItemType[]) { + types.forEach(t => ignored.add(t)); + return this; + }, + unignore: function (...types: EnvelopeItemType[]) { + for (const t of types) { + ignored.delete(t); + } + return this; + }, + withDockerCompose: function (options: DockerOptions) { + dockerOptions = options; + return this; + }, + ensureNoErrorOutput: function () { + if (expectedEnvelopes.length > 0) { + throw new Error('You should not use `ensureNoErrorOutput` when using `expect`!'); + } + ensureNoErrorOutput = true; + return this; + }, + start: function (): StartResult { + let isComplete = false; + let completeError: Error | undefined; + + const expectedEnvelopeCount = Math.max(expectedEnvelopes.length, (expectedEnvelopeHeaders || []).length); + + let envelopeCount = 0; + let scenarioServerPort: number | undefined; + let hasExited = false; + let child: ReturnType | undefined; + + function complete(error?: Error): void { + if (isComplete) { + return; + } + + isComplete = true; + completeError = error || undefined; + child?.kill(); + } + + /** Called after each expect callback to check if we're complete */ + function expectCallbackCalled(): void { + envelopeCount++; + if (envelopeCount === expectedEnvelopeCount) { + complete(); + } + } + + function newEnvelope(envelope: Envelope): void { + for (const item of envelope[1]) { + const envelopeItemType = item[0].type; + + if (ignored.has(envelopeItemType)) { + continue; + } + + if (expectedEnvelopeHeaders) { + const header = envelope[0]; + const expected = expectedEnvelopeHeaders.shift()?.[envelopeItemType as keyof ExpectedEnvelopeHeader]; + + try { + if (!expected) { + return; + } + + assertEnvelopeHeader(header, expected); + + expectCallbackCalled(); + } catch (e) { + complete(e as Error); + } + + return; + } + + const expected = expectedEnvelopes.shift(); + + // Catch any error or failed assertions and pass them to done to end the test quickly + try { + if (!expected) { + return; + } + + const expectedType = Object.keys(expected)[0]; + + if (expectedType !== envelopeItemType) { + throw new Error( + `Expected envelope item type '${expectedType}' but got '${envelopeItemType}'. \nItem: ${JSON.stringify( + item, + )}`, + ); + } + + if ('event' in expected) { + expectErrorEvent(item[1] as Event, expected.event); + expectCallbackCalled(); + } else if ('transaction' in expected) { + expectTransactionEvent(item[1] as TransactionEvent, expected.transaction); + expectCallbackCalled(); + } else if ('session' in expected) { + expectSessionEvent(item[1] as SerializedSession, expected.session); + expectCallbackCalled(); + } else if ('sessions' in expected) { + expectSessionsEvent(item[1] as SessionAggregates, expected.sessions); + expectCallbackCalled(); + } else if ('check_in' in expected) { + expectCheckInEvent(item[1] as SerializedCheckIn, expected.check_in); + expectCallbackCalled(); + } else if ('client_report' in expected) { + expectClientReport(item[1] as ClientReport, expected.client_report); + expectCallbackCalled(); + } else if ('log' in expected) { + expectLog(item[1] as SerializedLogContainer, expected.log); + expectCallbackCalled(); + } else { + throw new Error( + `Unhandled expected envelope item type: ${JSON.stringify(expected)}\nItem: ${JSON.stringify(item)}`, + ); + } + } catch (e) { + complete(e as Error); + } + } + } + + // We need to properly define & pass these types around for TS 3.8, + // which otherwise fails to infer these correctly :( + type ServerStartup = [number | undefined, (() => void) | undefined]; + type DockerStartup = VoidFunction | undefined; + + const serverStartup: Promise = withSentryServer + ? createBasicSentryServer(newEnvelope) + : Promise.resolve([undefined, undefined]); + + const dockerStartup: Promise = dockerOptions + ? runDockerCompose(dockerOptions) + : Promise.resolve(undefined); + + const startup = Promise.all([dockerStartup, serverStartup]) as Promise<[DockerStartup, ServerStartup]>; + + startup + .then(([dockerChild, [mockServerPort, mockServerClose]]) => { + if (mockServerClose) { + CLEANUP_STEPS.add(() => { + mockServerClose(); + }); + } + + if (dockerChild) { + CLEANUP_STEPS.add(dockerChild); + } + + const env = mockServerPort + ? { ...process.env, ...withEnv, SENTRY_DSN: `http://public@localhost:${mockServerPort}/1337` } + : { ...process.env, ...withEnv }; + + if (process.env.DEBUG) log('starting scenario', testPath, flags, env.SENTRY_DSN); + + child = spawn('node', [...flags, testPath], { env }); + + CLEANUP_STEPS.add(() => { + child?.kill(); + }); + + child.stderr?.on('data', (data: Buffer) => { + const output = data.toString(); + logs.push(output.trim()); + + if (process.env.DEBUG) log('stderr line', output); + + if (ensureNoErrorOutput) { + complete(new Error(`Expected no error output but got: '${output}'`)); + } + }); + + child.on('close', () => { + hasExited = true; + + if (ensureNoErrorOutput) { + complete(); + } + }); + + // Pass error to done to end the test quickly + child.on('error', e => { + if (process.env.DEBUG) log('scenario error', e); + complete(e); + }); + + function tryParseEnvelopeFromStdoutLine(line: string): void { + // Lines can have leading '[something] [{' which we need to remove + const cleanedLine = line.replace(/^.*?] \[{"/, '[{"'); + + // See if we have a port message + if (cleanedLine.startsWith('{"port":')) { + const { port } = JSON.parse(cleanedLine) as { port: number }; + scenarioServerPort = port; + return; + } + + // Skip any lines that don't start with envelope JSON + if (!cleanedLine.startsWith('[{')) { + return; + } + + try { + const envelope = JSON.parse(cleanedLine) as Envelope; + newEnvelope(envelope); + } catch (_) { + // + } + } + + let buffer = Buffer.alloc(0); + child.stdout?.on('data', (data: Buffer) => { + // This is horribly memory inefficient but it's only for tests + buffer = Buffer.concat([buffer, data]); + + let splitIndex = -1; + while ((splitIndex = buffer.indexOf(0xa)) >= 0) { + const line = buffer.subarray(0, splitIndex).toString(); + logs.push(line.trim()); + + buffer = Buffer.from(buffer.subarray(splitIndex + 1)); + if (process.env.DEBUG) log('line', line); + tryParseEnvelopeFromStdoutLine(line); + } + }); + }) + .catch(e => complete(e)); + + return { + completed: async function (): Promise { + await waitFor(() => isComplete, 120_000, 'Timed out waiting for test to complete'); + + if (completeError) { + throw completeError; + } + }, + childHasExited: function (): boolean { + return hasExited; + }, + getLogs(): string[] { + return logs; + }, + makeRequest: async function ( + method: 'get' | 'post', + path: string, + options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, + ): Promise { + try { + await waitFor(() => scenarioServerPort !== undefined, 10_000, 'Timed out waiting for server port'); + } catch (e) { + complete(e as Error); + return; + } + + const url = `http://localhost:${scenarioServerPort}${path}`; + const body = options.data; + const headers = options.headers || {}; + const expectError = options.expectError || false; + + if (process.env.DEBUG) log('making request', method, url, headers, body); + + try { + const res = await fetch(url, { headers, method, body }); + + if (!res.ok) { + if (!expectError) { + complete(new Error(`Expected request to "${path}" to succeed, but got a ${res.status} response`)); + } + + return; + } + + if (expectError) { + complete(new Error(`Expected request to "${path}" to fail, but got a ${res.status} response`)); + return; + } + + if (res.headers.get('content-type')?.includes('application/json')) { + return await res.json(); + } + + return (await res.text()) as T; + } catch (e) { + if (expectError) { + return; + } + + complete(e as Error); + return; + } + }, + }; + }, + }; +} + +function log(...args: unknown[]): void { + // eslint-disable-next-line no-console + console.log(...args.map(arg => normalize(arg))); +} + +function expectErrorEvent(item: Event, expected: ExpectedEvent): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentryEvent(item, expected); + } +} + +function expectTransactionEvent(item: TransactionEvent, expected: ExpectedTransaction): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentryTransaction(item, expected); + } +} + +function expectSessionEvent(item: SerializedSession, expected: ExpectedSession): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentrySession(item, expected); + } +} + +function expectSessionsEvent(item: SessionAggregates, expected: ExpectedSessions): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentrySessions(item, expected); + } +} + +function expectCheckInEvent(item: SerializedCheckIn, expected: ExpectedCheckIn): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentryCheckIn(item, expected); + } +} + +function expectClientReport(item: ClientReport, expected: ExpectedClientReport): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentryClientReport(item, expected); + } +} + +function expectLog(item: SerializedLogContainer, expected: ExpectedLogContainer): void { + if (typeof expected === 'function') { + expected(item); + } else { + assertSentryLogContainer(item, expected); + } +} + +/** + * Converts ESM import statements to CommonJS require statements + * @param content The content of an ESM file + * @returns The content with require statements instead of imports + */ +function convertEsmToCjs(content: string): string { + let newContent = content; + + // Handle default imports: import x from 'y' -> const x = require('y') + newContent = newContent.replace( + /import\s+([\w*{}\s,]+)\s+from\s+['"]([^'"]+)['"]/g, + (_, imports: string, module: string) => { + if (imports.includes('* as')) { + // Handle namespace imports: import * as x from 'y' -> const x = require('y') + return `const ${imports.replace('* as', '').trim()} = require('${module}')`; + } else if (imports.includes('{')) { + // Handle named imports: import {x, y} from 'z' -> const {x, y} = require('z') + return `const ${imports} = require('${module}')`; + } else { + // Handle default imports: import x from 'y' -> const x = require('y') + return `const ${imports} = require('${module}')`; + } + }, + ); + + // Handle side-effect imports: import 'x' -> require('x') + newContent = newContent.replace(/import\s+['"]([^'"]+)['"]/g, (_, module) => { + return `require('${module}')`; + }); + + return newContent; +} diff --git a/dev-packages/node-core-integration-tests/utils/server.ts b/dev-packages/node-core-integration-tests/utils/server.ts new file mode 100644 index 000000000000..92e0477c845c --- /dev/null +++ b/dev-packages/node-core-integration-tests/utils/server.ts @@ -0,0 +1,85 @@ +import type { Envelope } from '@sentry/core'; +import { parseEnvelope } from '@sentry/core'; +import express from 'express'; +import type { AddressInfo } from 'net'; + +/** + * Creates a basic Sentry server that accepts POST to the envelope endpoint + * + * This does no checks on the envelope, it just calls the callback if it managed to parse an envelope from the raw POST + * body data. + */ +export function createBasicSentryServer(onEnvelope: (env: Envelope) => void): Promise<[number, () => void]> { + const app = express(); + + app.use(express.raw({ type: () => true, inflate: true, limit: '100mb' })); + app.post('/api/:id/envelope/', (req, res) => { + try { + const env = parseEnvelope(req.body as Buffer); + onEnvelope(env); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + + res.status(200).send(); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + const address = server.address() as AddressInfo; + resolve([ + address.port, + () => { + server.close(); + }, + ]); + }); + }); +} + +type HeaderAssertCallback = (headers: Record) => void; + +/** Creates a test server that can be used to check headers */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createTestServer() { + const gets: Array<[string, HeaderAssertCallback, number]> = []; + let error: unknown | undefined; + + return { + get: function (path: string, callback: HeaderAssertCallback, result = 200) { + gets.push([path, callback, result]); + return this; + }, + start: async (): Promise<[string, () => void]> => { + const app = express(); + + for (const [path, callback, result] of gets) { + app.get(path, (req, res) => { + try { + callback(req.headers); + } catch (e) { + error = e; + } + + res.status(result).send(); + }); + } + + return new Promise(resolve => { + const server = app.listen(0, () => { + const address = server.address() as AddressInfo; + resolve([ + `http://localhost:${address.port}`, + () => { + server.close(); + if (error) { + throw error; + } + }, + ]); + }); + }); + }, + }; +} diff --git a/dev-packages/node-core-integration-tests/utils/setup-tests.ts b/dev-packages/node-core-integration-tests/utils/setup-tests.ts new file mode 100644 index 000000000000..6f7bb2bec369 --- /dev/null +++ b/dev-packages/node-core-integration-tests/utils/setup-tests.ts @@ -0,0 +1,12 @@ +import EventEmitter from 'events'; + +const setup = async (): Promise => { + // Node warns about a potential memory leak + // when more than 10 event listeners are assigned inside a single thread. + // Initializing Sentry for each test triggers these warnings after 10th test inside Jest thread. + // As we know that it's not a memory leak and number of listeners are limited to the number of tests, + // removing the limit on listener count here. + EventEmitter.defaultMaxListeners = 0; +}; + +export default setup; diff --git a/dev-packages/node-core-integration-tests/utils/setupOtel.js b/dev-packages/node-core-integration-tests/utils/setupOtel.js new file mode 100644 index 000000000000..aa2e06aaf8a1 --- /dev/null +++ b/dev-packages/node-core-integration-tests/utils/setupOtel.js @@ -0,0 +1,17 @@ +const { trace, propagation, context } = require('@opentelemetry/api'); +const { BasicTracerProvider } = require('@opentelemetry/sdk-trace-base'); +const Sentry = require('@sentry/node-core'); +const { SentryPropagator, SentrySampler, SentrySpanProcessor } = require('@sentry/opentelemetry'); + +exports.setupOtel = function setupOtel(client) { + const provider = new BasicTracerProvider({ + sampler: new SentrySampler(client), + spanProcessors: [new SentrySpanProcessor()], + }); + + trace.setGlobalTracerProvider(provider); + propagation.setGlobalPropagator(new SentryPropagator()); + context.setGlobalContextManager(new Sentry.SentryContextManager()); + + Sentry.validateOpenTelemetrySetup(); +}; diff --git a/dev-packages/node-core-integration-tests/utils/setupOtel.ts b/dev-packages/node-core-integration-tests/utils/setupOtel.ts new file mode 100644 index 000000000000..2ece99a5d785 --- /dev/null +++ b/dev-packages/node-core-integration-tests/utils/setupOtel.ts @@ -0,0 +1,34 @@ +import { context, propagation, trace } from '@opentelemetry/api'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { Client } from '@sentry/core'; +import * as Sentry from '@sentry/node-core'; +import { SentryPropagator, SentrySampler, SentrySpanProcessor } from '@sentry/opentelemetry'; + +export function setupOtel(client: Client): BasicTracerProvider { + const provider = new BasicTracerProvider({ + sampler: new SentrySampler(client), + spanProcessors: [new SentrySpanProcessor()], + }); + + trace.setGlobalTracerProvider(provider); + propagation.setGlobalPropagator(new SentryPropagator()); + context.setGlobalContextManager(new Sentry.SentryContextManager()); + + Sentry.validateOpenTelemetrySetup(); + + return provider; +} + +export function cleanupOtel(provider: BasicTracerProvider): void { + void provider.forceFlush().catch(() => { + // no-op + }); + void provider.shutdown().catch(() => { + // no-op + }); + + // Disable all globally registered APIs + trace.disable(); + context.disable(); + propagation.disable(); +} diff --git a/dev-packages/node-core-integration-tests/vite.config.ts b/dev-packages/node-core-integration-tests/vite.config.ts new file mode 100644 index 000000000000..38d4abb0b16a --- /dev/null +++ b/dev-packages/node-core-integration-tests/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + isolate: false, + coverage: { + enabled: false, + }, + include: ['./**/test.ts'], + testTimeout: 15000, + }, +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts index 3bdf6c113555..0bbb77296a58 100644 --- a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts +++ b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts @@ -548,57 +548,57 @@ describe('Integration | Transactions', () => { expect(finishedSpans.length).toBe(0); }); -it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { - const timeout = 5 * 60 * 1000; - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); + it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { + const timeout = 5 * 60 * 1000; + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); - const logs: unknown[] = []; - vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); - const transactions: Event[] = []; + const transactions: Event[] = []; - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); - const provider = getProvider(); - const spanProcessor = getSpanProcessor(); + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - span.end(); + span.end(); - setTimeout(() => { - subSpan2.end(); - }, timeout - 2); - }); + setTimeout(() => { + subSpan2.end(); + }, timeout - 2); + }); - vi.advanceTimersByTime(timeout - 1); + vi.advanceTimersByTime(timeout - 1); - expect(transactions).toHaveLength(2); - expect(transactions[0]?.spans).toHaveLength(1); + expect(transactions).toHaveLength(2); + expect(transactions[0]?.spans).toHaveLength(1); - const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => - bucket ? Array.from(bucket.spans) : [], - ); - expect(finishedSpans.length).toBe(0); -}); + const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => + bucket ? Array.from(bucket.spans) : [], + ); + expect(finishedSpans.length).toBe(0); + }); it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => { const timeout = 5 * 60 * 1000; diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 7cd41344e596..6ef09192eef1 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -152,9 +152,14 @@ export function makeOtelLoaders(outputFolder, hookVariant) { } const requiredDep = hookVariant === 'otel' ? '@opentelemetry/instrumentation' : '@sentry/node'; - const foundImportInTheMiddleDep = Object.keys(packageDotJSON.dependencies ?? {}).some(key => { - return key === requiredDep; - }); + const foundImportInTheMiddleDep = + Object.keys(packageDotJSON.dependencies ?? {}).some(key => { + return key === requiredDep; + }) || + Object.keys(packageDotJSON.devDependencies ?? {}).some(key => { + return key === requiredDep; + }); + if (!foundImportInTheMiddleDep) { throw new Error( `You used the makeOtelLoaders() rollup utility but didn't specify the "${requiredDep}" dependency in ${path.resolve( diff --git a/package.json b/package.json index 13e1a600e83d..09f0bcfdf8b1 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "dedupe-deps:check": "yarn-deduplicate yarn.lock --list --fail", "dedupe-deps:fix": "yarn-deduplicate yarn.lock", "postpublish": "lerna run --stream --concurrency 1 postpublish", - "test": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests}\" test", - "test:unit": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests}\" test:unit", + "test": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests}\" test", + "test:unit": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests}\" test:unit", "test:update-snapshots": "lerna run test:update-snapshots", "test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests}\"", "test:pr:browser": "UNIT_TEST_ENV=browser ts-node ./scripts/ci-unit-tests.ts --affected", @@ -68,6 +68,7 @@ "packages/nestjs", "packages/nextjs", "packages/node", + "packages/node-core", "packages/nuxt", "packages/opentelemetry", "packages/profiling-node", @@ -92,6 +93,7 @@ "dev-packages/bundle-analyzer-scenarios", "dev-packages/e2e-tests", "dev-packages/node-integration-tests", + "dev-packages/node-core-integration-tests", "dev-packages/test-utils", "dev-packages/size-limit-gh-action", "dev-packages/clear-cache-gh-action", diff --git a/packages/node-core/.eslintrc.js b/packages/node-core/.eslintrc.js new file mode 100644 index 000000000000..6da218bd8641 --- /dev/null +++ b/packages/node-core/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-class-field-initializers': 'off', + }, +}; diff --git a/packages/node-core/LICENSE b/packages/node-core/LICENSE new file mode 100644 index 000000000000..b3c4b18a6317 --- /dev/null +++ b/packages/node-core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/node-core/README.md b/packages/node-core/README.md new file mode 100644 index 000000000000..fa3459e212dd --- /dev/null +++ b/packages/node-core/README.md @@ -0,0 +1,115 @@ +

+ + Sentry + +

+ +# Sentry JavaScript SDK Node Core + +[![npm version](https://img.shields.io/npm/v/@sentry/node.svg)](https://www.npmjs.com/package/@sentry/node-core) +[![npm dm](https://img.shields.io/npm/dm/@sentry/node.svg)](https://www.npmjs.com/package/@sentry/node-core) +[![npm dt](https://img.shields.io/npm/dt/@sentry/node.svg)](https://www.npmjs.com/package/@sentry/node-core) + +> [!CAUTION] +> This package is in alpha state and may be subject to breaking changes. + +Unlike the `@sentry/node` SDK, this SDK comes with no OpenTelemetry auto-instrumentation out of the box. It requires the following OpenTelemetry dependencies and supports both v1 and v2 of OpenTelemetry: + +- `@opentelemetry/api` +- `@opentelemetry/context-async-hooks` +- `@opentelemetry/core` +- `@opentelemetry/instrumentation` +- `@opentelemetry/resources` +- `@opentelemetry/sdk-trace-base` +- `@opentelemetry/semantic-conventions`. + +## Installation + +```bash +npm install @sentry/node-core @sentry/opentelemetry @opentelemetry/api @opentelemetry/core @opentelemetry/context-async-hooks @opentelemetry/instrumentation @opentelemetry/resources @opentelemetry/sdk-trace-base @opentelemetry/semantic-conventions + +# Or yarn +yarn add @sentry/node-core @sentry/opentelemetry @opentelemetry/api @opentelemetry/core @opentelemetry/context-async-hooks @opentelemetry/instrumentation @opentelemetry/resources @opentelemetry/sdk-trace-base @opentelemetry/semantic-conventions +``` + +## Usage + +Sentry should be initialized as early in your app as possible. It is essential that you call `Sentry.init` before you +require any other modules in your application, otherwise any auto-instrumentation will **not** work. +You also need to set up OpenTelemetry, if you prefer not to, consider using the `@sentry/node` SDK instead. + +You need to create a file named `instrument.js` that imports and initializes Sentry: + +```js +// CJS Syntax +const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node'); +const Sentry = require('@sentry/node-core'); +const { SentrySpanProcessor, SentryPropagator, SentrySampler } = require('@sentry/opentelemetry'); +// ESM Syntax +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as Sentry from '@sentry/node-core'; +import { SentrySpanProcessor, SentryPropagator, SentrySampler } from '@sentry/opentelemetry'; + +const sentryClient = Sentry.init({ + dsn: '__DSN__', + // ... +}); + +// Note: This could be BasicTracerProvider or any other provider depending on how you want to use the +// OpenTelemetry SDK +const provider = new NodeTracerProvider({ + // Ensure the correct subset of traces is sent to Sentry + // This also ensures trace propagation works as expected + sampler: sentryClient ? new SentrySampler(sentryClient) : undefined, + spanProcessors: [ + // Ensure spans are correctly linked & sent to Sentry + new SentrySpanProcessor(), + // Add additional processors here + ], +}); + +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +Sentry.validateOpenTelemetrySetup(); +``` + +You need to require or import the `instrument.js` file before importing any other modules in your application. This is +necessary to ensure that Sentry can automatically instrument all modules in your application: + +```js +// Import this first! +import './instrument'; + +// Now import other modules +import http from 'http'; + +// Your application code goes here +``` + +### ESM Support + +When running your application in ESM mode, you should use the Node.js +[`--import`](https://nodejs.org/api/cli.html#--importmodule) command line option to ensure that Sentry is loaded before +the application code is evaluated. + +Adjust the Node.js call for your application to use the `--import` parameter and point it at `instrument.js`, which +contains your `Sentry.init`() code: + +```bash +# Note: This is only available for Node v18.19.0 onwards. +node --import ./instrument.mjs app.mjs +``` + +If it is not possible for you to pass the `--import` flag to the Node.js binary, you can alternatively use the +`NODE_OPTIONS` environment variable as follows: + +```bash +NODE_OPTIONS="--import ./instrument.mjs" npm run start +``` + +## Links + +- [Official SDK Docs](https://docs.sentry.io/quickstart/) diff --git a/packages/node-core/package.json b/packages/node-core/package.json new file mode 100644 index 000000000000..3fb0c7be8427 --- /dev/null +++ b/packages/node-core/package.json @@ -0,0 +1,109 @@ +{ + "name": "@sentry/node-core", + "version": "9.27.0", + "description": "Sentry Node SDK using OpenTelemetry for performance instrumentation", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + }, + "require": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.js" + } + }, + "./import": { + "import": { + "default": "./build/import-hook.mjs" + } + }, + "./loader": { + "import": { + "default": "./build/loader-hook.mjs" + } + }, + "./init": { + "import": { + "default": "./build/esm/init.js" + }, + "require": { + "default": "./build/cjs/init.js" + } + } + }, + "typesVersions": { + "<5.0": { + "build/types/index.d.ts": [ + "build/types-ts3.8/index.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", + "@opentelemetry/core": "^1.30.1 || ^2.0.0", + "@opentelemetry/instrumentation": "^0.57.1 || ^0.200.0", + "@opentelemetry/resources": "^1.30.1 || ^2.0.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1", + "@opentelemetry/core": "^1.30.1", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.30.0", + "@types/node": "^18.19.1" + }, + "dependencies": { + "@sentry/core": "9.27.0", + "@sentry/opentelemetry": "9.27.0", + "import-in-the-middle": "^1.13.1" + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch build:types:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "npm pack", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build coverage sentry-node-*.tgz", + "fix": "eslint . --format stylish --fix", + "lint": "eslint . --format stylish", + "lint:es-compatibility": "es-check es2022 ./build/cjs/*.js && es-check es2022 ./build/esm/*.js --module", + "test": "yarn test:unit", + "test:unit": "vitest run", + "test:watch": "vitest --watch", + "yalc:publish": "yalc publish --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false +} diff --git a/packages/node-core/rollup.anr-worker.config.mjs b/packages/node-core/rollup.anr-worker.config.mjs new file mode 100644 index 000000000000..4ef40909503f --- /dev/null +++ b/packages/node-core/rollup.anr-worker.config.mjs @@ -0,0 +1,31 @@ +import { makeBaseBundleConfig } from '@sentry-internal/rollup-utils'; + +export function createWorkerCodeBuilder(entry, outDir) { + let base64Code; + + return [ + makeBaseBundleConfig({ + bundleType: 'node-worker', + entrypoints: [entry], + licenseTitle: '@sentry/node', + outputFileBase: () => 'worker-script.js', + packageSpecificConfig: { + output: { + dir: outDir, + sourcemap: false, + }, + plugins: [ + { + name: 'output-base64-worker-script', + renderChunk(code) { + base64Code = Buffer.from(code).toString('base64'); + }, + }, + ], + }, + }), + () => { + return base64Code; + }, + ]; +} diff --git a/packages/node-core/rollup.npm.config.mjs b/packages/node-core/rollup.npm.config.mjs new file mode 100644 index 000000000000..8e18333836ef --- /dev/null +++ b/packages/node-core/rollup.npm.config.mjs @@ -0,0 +1,43 @@ +import replace from '@rollup/plugin-replace'; +import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; +import { createWorkerCodeBuilder } from './rollup.anr-worker.config.mjs'; + +const [anrWorkerConfig, getAnrBase64Code] = createWorkerCodeBuilder( + 'src/integrations/anr/worker.ts', + 'build/esm/integrations/anr', +); + +const [localVariablesWorkerConfig, getLocalVariablesBase64Code] = createWorkerCodeBuilder( + 'src/integrations/local-variables/worker.ts', + 'build/esm/integrations/local-variables', +); + +export default [ + ...makeOtelLoaders('./build', 'otel'), + // The workers needs to be built first since it's their output is copied in the main bundle. + anrWorkerConfig, + localVariablesWorkerConfig, + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/init.ts'], + packageSpecificConfig: { + output: { + // set exports to 'named' or 'auto' so that rollup doesn't warn + exports: 'named', + preserveModules: true, + }, + plugins: [ + replace({ + delimiters: ['###', '###'], + // removes some rollup warnings + preventAssignment: true, + values: { + AnrWorkerScript: () => getAnrBase64Code(), + LocalVariablesWorkerScript: () => getLocalVariablesBase64Code(), + }, + }), + ], + }, + }), + ), +]; diff --git a/packages/node-core/src/cron/common.ts b/packages/node-core/src/cron/common.ts new file mode 100644 index 000000000000..0fa8c1c18d23 --- /dev/null +++ b/packages/node-core/src/cron/common.ts @@ -0,0 +1,51 @@ +const replacements: [string, string][] = [ + ['january', '1'], + ['february', '2'], + ['march', '3'], + ['april', '4'], + ['may', '5'], + ['june', '6'], + ['july', '7'], + ['august', '8'], + ['september', '9'], + ['october', '10'], + ['november', '11'], + ['december', '12'], + ['jan', '1'], + ['feb', '2'], + ['mar', '3'], + ['apr', '4'], + ['may', '5'], + ['jun', '6'], + ['jul', '7'], + ['aug', '8'], + ['sep', '9'], + ['oct', '10'], + ['nov', '11'], + ['dec', '12'], + ['sunday', '0'], + ['monday', '1'], + ['tuesday', '2'], + ['wednesday', '3'], + ['thursday', '4'], + ['friday', '5'], + ['saturday', '6'], + ['sun', '0'], + ['mon', '1'], + ['tue', '2'], + ['wed', '3'], + ['thu', '4'], + ['fri', '5'], + ['sat', '6'], +]; + +/** + * Replaces names in cron expressions + */ +export function replaceCronNames(cronExpression: string): string { + return replacements.reduce( + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor + (acc, [name, replacement]) => acc.replace(new RegExp(name, 'gi'), replacement), + cronExpression, + ); +} diff --git a/packages/node-core/src/cron/cron.ts b/packages/node-core/src/cron/cron.ts new file mode 100644 index 000000000000..ce6225ced2fa --- /dev/null +++ b/packages/node-core/src/cron/cron.ts @@ -0,0 +1,157 @@ +import { captureException, withMonitor } from '@sentry/core'; +import { replaceCronNames } from './common'; + +export type CronJobParams = { + cronTime: string | Date; + onTick: (context: unknown, onComplete?: unknown) => void | Promise; + onComplete?: () => void | Promise; + start?: boolean | null; + context?: unknown; + runOnInit?: boolean | null; + unrefTimeout?: boolean | null; +} & ( + | { + timeZone?: string | null; + utcOffset?: never; + } + | { + timeZone?: never; + utcOffset?: number | null; + } +); + +export type CronJob = { + // +}; + +export type CronJobConstructor = { + from: (param: CronJobParams) => CronJob; + + new ( + cronTime: CronJobParams['cronTime'], + onTick: CronJobParams['onTick'], + onComplete?: CronJobParams['onComplete'], + start?: CronJobParams['start'], + timeZone?: CronJobParams['timeZone'], + context?: CronJobParams['context'], + runOnInit?: CronJobParams['runOnInit'], + utcOffset?: null, + unrefTimeout?: CronJobParams['unrefTimeout'], + ): CronJob; + new ( + cronTime: CronJobParams['cronTime'], + onTick: CronJobParams['onTick'], + onComplete?: CronJobParams['onComplete'], + start?: CronJobParams['start'], + timeZone?: null, + context?: CronJobParams['context'], + runOnInit?: CronJobParams['runOnInit'], + utcOffset?: CronJobParams['utcOffset'], + unrefTimeout?: CronJobParams['unrefTimeout'], + ): CronJob; +}; + +const ERROR_TEXT = 'Automatic instrumentation of CronJob only supports crontab string'; + +/** + * Instruments the `cron` library to send a check-in event to Sentry for each job execution. + * + * ```ts + * import * as Sentry from '@sentry/node'; + * import { CronJob } from 'cron'; + * + * const CronJobWithCheckIn = Sentry.cron.instrumentCron(CronJob, 'my-cron-job'); + * + * // use the constructor + * const job = new CronJobWithCheckIn('* * * * *', () => { + * console.log('You will see this message every minute'); + * }); + * + * // or from + * const job = CronJobWithCheckIn.from({ cronTime: '* * * * *', onTick: () => { + * console.log('You will see this message every minute'); + * }); + * ``` + */ +export function instrumentCron(lib: T & CronJobConstructor, monitorSlug: string): T { + let jobScheduled = false; + + return new Proxy(lib, { + construct(target, args: ConstructorParameters) { + const [cronTime, onTick, onComplete, start, timeZone, ...rest] = args; + + if (typeof cronTime !== 'string') { + throw new Error(ERROR_TEXT); + } + + if (jobScheduled) { + throw new Error(`A job named '${monitorSlug}' has already been scheduled`); + } + + jobScheduled = true; + + const cronString = replaceCronNames(cronTime); + + async function monitoredTick(context: unknown, onComplete?: unknown): Promise { + return withMonitor( + monitorSlug, + async () => { + try { + await onTick(context, onComplete); + } catch (e) { + captureException(e); + throw e; + } + }, + { + schedule: { type: 'crontab', value: cronString }, + timezone: timeZone || undefined, + }, + ); + } + + return new target(cronTime, monitoredTick, onComplete, start, timeZone, ...rest); + }, + get(target, prop: keyof CronJobConstructor) { + if (prop === 'from') { + return (param: CronJobParams) => { + const { cronTime, onTick, timeZone } = param; + + if (typeof cronTime !== 'string') { + throw new Error(ERROR_TEXT); + } + + if (jobScheduled) { + throw new Error(`A job named '${monitorSlug}' has already been scheduled`); + } + + jobScheduled = true; + + const cronString = replaceCronNames(cronTime); + + param.onTick = async (context: unknown, onComplete?: unknown) => { + return withMonitor( + monitorSlug, + async () => { + try { + await onTick(context, onComplete); + } catch (e) { + captureException(e); + throw e; + } + }, + { + schedule: { type: 'crontab', value: cronString }, + timezone: timeZone || undefined, + }, + ); + }; + + return target.from(param); + }; + } else { + return target[prop]; + } + }, + }); +} diff --git a/packages/node-core/src/cron/index.ts b/packages/node-core/src/cron/index.ts new file mode 100644 index 000000000000..eb4b915cff66 --- /dev/null +++ b/packages/node-core/src/cron/index.ts @@ -0,0 +1,10 @@ +import { instrumentCron } from './cron'; +import { instrumentNodeCron } from './node-cron'; +import { instrumentNodeSchedule } from './node-schedule'; + +/** Methods to instrument cron libraries for Sentry check-ins */ +export const cron = { + instrumentCron, + instrumentNodeCron, + instrumentNodeSchedule, +}; diff --git a/packages/node-core/src/cron/node-cron.ts b/packages/node-core/src/cron/node-cron.ts new file mode 100644 index 000000000000..5b9e48900287 --- /dev/null +++ b/packages/node-core/src/cron/node-cron.ts @@ -0,0 +1,75 @@ +import { captureException, withMonitor } from '@sentry/core'; +import { replaceCronNames } from './common'; + +export interface NodeCronOptions { + name: string; + timezone?: string; +} + +export interface NodeCron { + schedule: (cronExpression: string, callback: () => void, options: NodeCronOptions | undefined) => unknown; +} + +/** + * Wraps the `node-cron` library with check-in monitoring. + * + * ```ts + * import * as Sentry from "@sentry/node"; + * import * as cron from "node-cron"; + * + * const cronWithCheckIn = Sentry.cron.instrumentNodeCron(cron); + * + * cronWithCheckIn.schedule( + * "* * * * *", + * () => { + * console.log("running a task every minute"); + * }, + * { name: "my-cron-job" }, + * ); + * ``` + */ +export function instrumentNodeCron(lib: Partial & T): T { + return new Proxy(lib, { + get(target, prop) { + if (prop === 'schedule' && target.schedule) { + // When 'get' is called for schedule, return a proxied version of the schedule function + return new Proxy(target.schedule, { + apply(target, thisArg, argArray: Parameters) { + const [expression, callback, options] = argArray; + + const name = options?.name; + const timezone = options?.timezone; + + if (!name) { + throw new Error('Missing "name" for scheduled job. A name is required for Sentry check-in monitoring.'); + } + + const monitoredCallback = async (): Promise => { + return withMonitor( + name, + async () => { + // We have to manually catch here and capture the exception because node-cron swallows errors + // https://github.com/node-cron/node-cron/issues/399 + try { + return await callback(); + } catch (e) { + captureException(e); + throw e; + } + }, + { + schedule: { type: 'crontab', value: replaceCronNames(expression) }, + timezone, + }, + ); + }; + + return target.apply(thisArg, [expression, monitoredCallback, options]); + }, + }); + } else { + return target[prop as keyof T]; + } + }, + }); +} diff --git a/packages/node-core/src/cron/node-schedule.ts b/packages/node-core/src/cron/node-schedule.ts new file mode 100644 index 000000000000..35db51618b9a --- /dev/null +++ b/packages/node-core/src/cron/node-schedule.ts @@ -0,0 +1,68 @@ +import { withMonitor } from '@sentry/core'; +import { replaceCronNames } from './common'; + +export interface NodeSchedule { + scheduleJob( + nameOrExpression: string | Date | object, + expressionOrCallback: string | Date | object | (() => void), + callback?: () => void, + ): unknown; +} + +/** + * Instruments the `node-schedule` library to send a check-in event to Sentry for each job execution. + * + * ```ts + * import * as Sentry from '@sentry/node'; + * import * as schedule from 'node-schedule'; + * + * const scheduleWithCheckIn = Sentry.cron.instrumentNodeSchedule(schedule); + * + * const job = scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * * *', () => { + * console.log('You will see this message every minute'); + * }); + * ``` + */ +export function instrumentNodeSchedule(lib: T & NodeSchedule): T { + return new Proxy(lib, { + get(target, prop: keyof NodeSchedule) { + if (prop === 'scheduleJob') { + // eslint-disable-next-line @typescript-eslint/unbound-method + return new Proxy(target.scheduleJob, { + apply(target, thisArg, argArray: Parameters) { + const [nameOrExpression, expressionOrCallback, callback] = argArray; + + if ( + typeof nameOrExpression !== 'string' || + typeof expressionOrCallback !== 'string' || + typeof callback !== 'function' + ) { + throw new Error( + "Automatic instrumentation of 'node-schedule' requires the first parameter of 'scheduleJob' to be a job name string and the second parameter to be a crontab string", + ); + } + + const monitorSlug = nameOrExpression; + const expression = expressionOrCallback; + + async function monitoredCallback(): Promise { + return withMonitor( + monitorSlug, + async () => { + await callback?.(); + }, + { + schedule: { type: 'crontab', value: replaceCronNames(expression) }, + }, + ); + } + + return target.apply(thisArg, [monitorSlug, expression, monitoredCallback]); + }, + }); + } + + return target[prop]; + }, + }); +} diff --git a/packages/node-core/src/debug-build.ts b/packages/node-core/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/node-core/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts new file mode 100644 index 000000000000..084e6f7ae9f6 --- /dev/null +++ b/packages/node-core/src/index.ts @@ -0,0 +1,131 @@ +import * as logger from './logs/exports'; + +export { httpIntegration } from './integrations/http'; +export { nativeNodeFetchIntegration } from './integrations/node-fetch'; + +export { nodeContextIntegration } from './integrations/context'; +export { contextLinesIntegration } from './integrations/contextlines'; +export { localVariablesIntegration } from './integrations/local-variables'; +export { modulesIntegration } from './integrations/modules'; +export { onUncaughtExceptionIntegration } from './integrations/onuncaughtexception'; +export { onUnhandledRejectionIntegration } from './integrations/onunhandledrejection'; +export { anrIntegration, disableAnrDetectionForCallback } from './integrations/anr'; + +export { spotlightIntegration } from './integrations/spotlight'; +export { childProcessIntegration } from './integrations/childProcess'; +export { createSentryWinstonTransport } from './integrations/winston'; + +export { SentryContextManager } from './otel/contextManager'; +export { generateInstrumentOnce } from './otel/instrument'; + +export { init, getDefaultIntegrations, initWithoutDefaultIntegrations, validateOpenTelemetrySetup } from './sdk'; +export { getSentryRelease, defaultStackParser } from './sdk/api'; +export { createGetModuleFromFilename } from './utils/module'; +export { makeNodeTransport } from './transports'; +export { NodeClient } from './sdk/client'; +export { cron } from './cron'; + +export type { NodeOptions } from './types'; + +export { + // This needs exporting so the NodeClient can be used without calling init + setOpenTelemetryContextAsyncContextStrategy as setNodeAsyncContextStrategy, +} from '@sentry/opentelemetry'; + +export { + addBreadcrumb, + isInitialized, + getGlobalScope, + lastEventId, + close, + createTransport, + flush, + SDK_VERSION, + getSpanStatusFromHttpCode, + setHttpStatus, + captureCheckIn, + withMonitor, + requestDataIntegration, + functionToStringIntegration, + // eslint-disable-next-line deprecation/deprecation + inboundFiltersIntegration, + eventFiltersIntegration, + linkedErrorsIntegration, + addEventProcessor, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + setCurrentClient, + Scope, + setMeasurement, + getSpanDescendants, + parameterize, + getClient, + getCurrentScope, + getIsolationScope, + getTraceData, + getTraceMetaTags, + continueTrace, + withScope, + withIsolationScope, + captureException, + captureEvent, + captureMessage, + captureFeedback, + captureConsoleIntegration, + dedupeIntegration, + extraErrorDataIntegration, + rewriteFramesIntegration, + startSession, + captureSession, + endSession, + addIntegration, + startSpan, + startSpanManual, + startInactiveSpan, + startNewTrace, + suppressTracing, + getActiveSpan, + withActiveSpan, + getRootSpan, + spanToJSON, + spanToTraceHeader, + spanToBaggageHeader, + trpcMiddleware, + updateSpanName, + supabaseIntegration, + instrumentSupabaseClient, + zodErrorsIntegration, + profiler, + consoleLoggingIntegration, + consoleIntegration, + wrapMcpServerWithSentry, +} from '@sentry/core'; + +export type { + Breadcrumb, + BreadcrumbHint, + PolymorphicRequest, + RequestEventData, + SdkInfo, + Event, + EventHint, + ErrorEvent, + Exception, + Session, + SeverityLevel, + StackFrame, + Stacktrace, + Thread, + User, + Span, +} from '@sentry/core'; + +export { logger }; diff --git a/packages/node-core/src/init.ts b/packages/node-core/src/init.ts new file mode 100644 index 000000000000..3d4ba2ceff90 --- /dev/null +++ b/packages/node-core/src/init.ts @@ -0,0 +1,9 @@ +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 + * environment variables. + * + * > SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 SENTRY_TRACES_SAMPLE_RATE=1.0 node --import=@sentry/node/init app.mjs + */ +init(); diff --git a/packages/node-core/src/integrations/anr/common.ts b/packages/node-core/src/integrations/anr/common.ts new file mode 100644 index 000000000000..fc1b23e35b1d --- /dev/null +++ b/packages/node-core/src/integrations/anr/common.ts @@ -0,0 +1,51 @@ +import type { Contexts, DsnComponents, Primitive, SdkMetadata } from '@sentry/core'; + +export interface AnrIntegrationOptions { + /** + * Interval to send heartbeat messages to the ANR worker. + * + * Defaults to 50ms. + */ + pollInterval: number; + /** + * Threshold in milliseconds to trigger an ANR event. + * + * Defaults to 5000ms. + */ + anrThreshold: number; + /** + * Whether to capture a stack trace when the ANR event is triggered. + * + * Defaults to `false`. + * + * This uses the node debugger which enables the inspector API and opens the required ports. + */ + captureStackTrace: boolean; + /** + * Maximum number of ANR events to send. + * + * Defaults to 1. + */ + maxAnrEvents: number; + /** + * Tags to include with ANR events. + */ + staticTags: { [key: string]: Primitive }; + /** + * @ignore Internal use only. + * + * If this is supplied, stack frame filenames will be rewritten to be relative to this path. + */ + appRootPath: string | undefined; +} + +export interface WorkerStartData extends AnrIntegrationOptions { + debug: boolean; + sdkMetadata: SdkMetadata; + dsn: DsnComponents; + tunnel: string | undefined; + release: string | undefined; + environment: string; + dist: string | undefined; + contexts: Contexts; +} diff --git a/packages/node-core/src/integrations/anr/index.ts b/packages/node-core/src/integrations/anr/index.ts new file mode 100644 index 000000000000..5d5cc2daa5f6 --- /dev/null +++ b/packages/node-core/src/integrations/anr/index.ts @@ -0,0 +1,250 @@ +import { types } from 'node:util'; +import { Worker } from 'node:worker_threads'; +import type { Contexts, Event, EventHint, Integration, IntegrationFn, ScopeData } from '@sentry/core'; +import { + defineIntegration, + getClient, + getCurrentScope, + getFilenameToDebugIdMap, + getGlobalScope, + getIsolationScope, + GLOBAL_OBJ, + logger, + mergeScopeData, +} from '@sentry/core'; +import { NODE_VERSION } from '../../nodeVersion'; +import type { NodeClient } from '../../sdk/client'; +import { isDebuggerEnabled } from '../../utils/debug'; +import type { AnrIntegrationOptions, WorkerStartData } from './common'; + +const { isPromise } = types; + +// This string is a placeholder that gets overwritten with the worker code. +export const base64WorkerScript = '###AnrWorkerScript###'; + +const DEFAULT_INTERVAL = 50; +const DEFAULT_HANG_THRESHOLD = 5000; + +function log(message: string, ...args: unknown[]): void { + logger.log(`[ANR] ${message}`, ...args); +} + +function globalWithScopeFetchFn(): typeof GLOBAL_OBJ & { __SENTRY_GET_SCOPES__?: () => ScopeData } { + return GLOBAL_OBJ; +} + +/** Fetches merged scope data */ +function getScopeData(): ScopeData { + const scope = getGlobalScope().getScopeData(); + mergeScopeData(scope, getIsolationScope().getScopeData()); + mergeScopeData(scope, getCurrentScope().getScopeData()); + + // We remove attachments because they likely won't serialize well as json + scope.attachments = []; + // We can't serialize event processor functions + scope.eventProcessors = []; + + return scope; +} + +/** + * Gets contexts by calling all event processors. This shouldn't be called until all integrations are setup + */ +async function getContexts(client: NodeClient): Promise { + let event: Event | null = { message: 'ANR' }; + const eventHint: EventHint = {}; + + for (const processor of client.getEventProcessors()) { + if (event === null) break; + event = await processor(event, eventHint); + } + + return event?.contexts || {}; +} + +const INTEGRATION_NAME = 'Anr'; + +type AnrInternal = { startWorker: () => void; stopWorker: () => void }; + +const _anrIntegration = ((options: Partial = {}) => { + if (NODE_VERSION.major < 16 || (NODE_VERSION.major === 16 && NODE_VERSION.minor < 17)) { + throw new Error('ANR detection requires Node 16.17.0 or later'); + } + + let worker: Promise<() => void> | undefined; + let client: NodeClient | undefined; + + // Hookup the scope fetch function to the global object so that it can be called from the worker thread via the + // debugger when it pauses + const gbl = globalWithScopeFetchFn(); + gbl.__SENTRY_GET_SCOPES__ = getScopeData; + + return { + name: INTEGRATION_NAME, + startWorker: () => { + if (worker) { + return; + } + + if (client) { + worker = _startWorker(client, options); + } + }, + stopWorker: () => { + if (worker) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + worker.then(stop => { + stop(); + worker = undefined; + }); + } + }, + async setup(initClient: NodeClient) { + client = initClient; + + if (options.captureStackTrace && (await isDebuggerEnabled())) { + logger.warn('ANR captureStackTrace has been disabled because the debugger was already enabled'); + options.captureStackTrace = false; + } + + // setImmediate is used to ensure that all other integrations have had their setup called first. + // This allows us to call into all integrations to fetch the full context + setImmediate(() => this.startWorker()); + }, + } as Integration & AnrInternal; +}) satisfies IntegrationFn; + +type AnrReturn = (options?: Partial) => Integration & AnrInternal; + +export const anrIntegration = defineIntegration(_anrIntegration) as AnrReturn; + +/** + * Starts the ANR worker thread + * + * @returns A function to stop the worker + */ +async function _startWorker( + client: NodeClient, + integrationOptions: Partial, +): Promise<() => void> { + const dsn = client.getDsn(); + + if (!dsn) { + return () => { + // + }; + } + + const contexts = await getContexts(client); + + // These will not be accurate if sent later from the worker thread + delete contexts.app?.app_memory; + delete contexts.device?.free_memory; + + const initOptions = client.getOptions(); + + const sdkMetadata = client.getSdkMetadata() || {}; + if (sdkMetadata.sdk) { + sdkMetadata.sdk.integrations = initOptions.integrations.map(i => i.name); + } + + const options: WorkerStartData = { + debug: logger.isEnabled(), + dsn, + tunnel: initOptions.tunnel, + environment: initOptions.environment || 'production', + release: initOptions.release, + dist: initOptions.dist, + sdkMetadata, + appRootPath: integrationOptions.appRootPath, + pollInterval: integrationOptions.pollInterval || DEFAULT_INTERVAL, + anrThreshold: integrationOptions.anrThreshold || DEFAULT_HANG_THRESHOLD, + captureStackTrace: !!integrationOptions.captureStackTrace, + maxAnrEvents: integrationOptions.maxAnrEvents || 1, + staticTags: integrationOptions.staticTags || {}, + contexts, + }; + + if (options.captureStackTrace) { + const inspector = await import('node:inspector'); + if (!inspector.url()) { + inspector.open(0); + } + } + + const worker = new Worker(new URL(`data:application/javascript;base64,${base64WorkerScript}`), { + workerData: options, + // We don't want any Node args to be passed to the worker + execArgv: [], + env: { ...process.env, NODE_OPTIONS: undefined }, + }); + + process.on('exit', () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + worker.terminate(); + }); + + const timer = setInterval(() => { + try { + const currentSession = getIsolationScope().getSession(); + // We need to copy the session object and remove the toJSON method so it can be sent to the worker + // serialized without making it a SerializedSession + const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; + // message the worker to tell it the main event loop is still running + worker.postMessage({ session, debugImages: getFilenameToDebugIdMap(initOptions.stackParser) }); + } catch (_) { + // + } + }, options.pollInterval); + // Timer should not block exit + timer.unref(); + + worker.on('message', (msg: string) => { + if (msg === 'session-ended') { + log('ANR event sent from ANR worker. Clearing session in this thread.'); + getIsolationScope().setSession(undefined); + } + }); + + worker.once('error', (err: Error) => { + clearInterval(timer); + log('ANR worker error', err); + }); + + worker.once('exit', (code: number) => { + clearInterval(timer); + log('ANR worker exit', code); + }); + + // Ensure this thread can't block app exit + worker.unref(); + + return () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + worker.terminate(); + clearInterval(timer); + }; +} + +export function disableAnrDetectionForCallback(callback: () => T): T; +export function disableAnrDetectionForCallback(callback: () => Promise): Promise; +/** + * Disables ANR detection for the duration of the callback + */ +export function disableAnrDetectionForCallback(callback: () => T | Promise): T | Promise { + const integration = getClient()?.getIntegrationByName(INTEGRATION_NAME) as AnrInternal | undefined; + + if (!integration) { + return callback(); + } + + integration.stopWorker(); + + const result = callback(); + if (isPromise(result)) { + return result.finally(() => integration.startWorker()); + } + + integration.startWorker(); + return result; +} diff --git a/packages/node-core/src/integrations/anr/worker.ts b/packages/node-core/src/integrations/anr/worker.ts new file mode 100644 index 000000000000..dae062b4df7c --- /dev/null +++ b/packages/node-core/src/integrations/anr/worker.ts @@ -0,0 +1,330 @@ +import { Session as InspectorSession } from 'node:inspector'; +import { parentPort, workerData } from 'node:worker_threads'; +import type { DebugImage, Event, ScopeData, Session, StackFrame } from '@sentry/core'; +import { + applyScopeDataToEvent, + callFrameToStackFrame, + createEventEnvelope, + createSessionEnvelope, + generateSpanId, + getEnvelopeEndpointWithUrlEncodedAuth, + makeSession, + normalizeUrlToBase, + stripSentryFramesAndReverse, + updateSession, + uuid4, + watchdogTimer, +} from '@sentry/core'; +import { makeNodeTransport } from '../../transports'; +import { createGetModuleFromFilename } from '../../utils/module'; +import type { WorkerStartData } from './common'; + +type VoidFunction = () => void; + +const options: WorkerStartData = workerData; +let session: Session | undefined; +let sentAnrEvents = 0; +let mainDebugImages: Record = {}; + +function log(msg: string): void { + if (options.debug) { + // eslint-disable-next-line no-console + console.log(`[ANR Worker] ${msg}`); + } +} + +const url = getEnvelopeEndpointWithUrlEncodedAuth(options.dsn, options.tunnel, options.sdkMetadata.sdk); +const transport = makeNodeTransport({ + url, + recordDroppedEvent: () => { + // + }, +}); + +async function sendAbnormalSession(): Promise { + // of we have an existing session passed from the main thread, send it as abnormal + if (session) { + log('Sending abnormal session'); + + updateSession(session, { + status: 'abnormal', + abnormal_mechanism: 'anr_foreground', + release: options.release, + environment: options.environment, + }); + + const envelope = createSessionEnvelope(session, options.dsn, options.sdkMetadata, options.tunnel); + // Log the envelope so to aid in testing + log(JSON.stringify(envelope)); + + await transport.send(envelope); + + try { + // Notify the main process that the session has ended so the session can be cleared from the scope + parentPort?.postMessage('session-ended'); + } catch (_) { + // ignore + } + } +} + +log('Started'); + +function prepareStackFrames(stackFrames: StackFrame[] | undefined): StackFrame[] | undefined { + if (!stackFrames) { + return undefined; + } + + // Strip Sentry frames and reverse the stack frames so they are in the correct order + const strippedFrames = stripSentryFramesAndReverse(stackFrames); + + // If we have an app root path, rewrite the filenames to be relative to the app root + if (options.appRootPath) { + for (const frame of strippedFrames) { + if (!frame.filename) { + continue; + } + + frame.filename = normalizeUrlToBase(frame.filename, options.appRootPath); + } + } + + return strippedFrames; +} + +function applyDebugMeta(event: Event): void { + if (Object.keys(mainDebugImages).length === 0) { + return; + } + + const normalisedDebugImages = options.appRootPath ? {} : mainDebugImages; + if (options.appRootPath) { + for (const [path, debugId] of Object.entries(mainDebugImages)) { + normalisedDebugImages[normalizeUrlToBase(path, options.appRootPath)] = debugId; + } + } + + const filenameToDebugId = new Map(); + + for (const exception of event.exception?.values || []) { + for (const frame of exception.stacktrace?.frames || []) { + const filename = frame.abs_path || frame.filename; + if (filename && normalisedDebugImages[filename]) { + filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + } + } + } + + if (filenameToDebugId.size > 0) { + const images: DebugImage[] = []; + for (const [code_file, debug_id] of filenameToDebugId.entries()) { + images.push({ + type: 'sourcemap', + code_file, + debug_id, + }); + } + event.debug_meta = { images }; + } +} + +function applyScopeToEvent(event: Event, scope: ScopeData): void { + applyScopeDataToEvent(event, scope); + + if (!event.contexts?.trace) { + const { traceId, parentSpanId, propagationSpanId } = scope.propagationContext; + event.contexts = { + trace: { + trace_id: traceId, + span_id: propagationSpanId || generateSpanId(), + parent_span_id: parentSpanId, + }, + ...event.contexts, + }; + } +} + +async function sendAnrEvent(frames?: StackFrame[], scope?: ScopeData): Promise { + if (sentAnrEvents >= options.maxAnrEvents) { + return; + } + + sentAnrEvents += 1; + + await sendAbnormalSession(); + + log('Sending event'); + + const event: Event = { + event_id: uuid4(), + contexts: options.contexts, + release: options.release, + environment: options.environment, + dist: options.dist, + platform: 'node', + level: 'error', + exception: { + values: [ + { + type: 'ApplicationNotResponding', + value: `Application Not Responding for at least ${options.anrThreshold} ms`, + stacktrace: { frames: prepareStackFrames(frames) }, + // This ensures the UI doesn't say 'Crashed in' for the stack trace + mechanism: { type: 'ANR' }, + }, + ], + }, + tags: options.staticTags, + }; + + if (scope) { + applyScopeToEvent(event, scope); + } + + applyDebugMeta(event); + + const envelope = createEventEnvelope(event, options.dsn, options.sdkMetadata, options.tunnel); + // Log the envelope to aid in testing + log(JSON.stringify(envelope)); + + await transport.send(envelope); + await transport.flush(2000); + + if (sentAnrEvents >= options.maxAnrEvents) { + // Delay for 5 seconds so that stdio can flush if the main event loop ever restarts. + // This is mainly for the benefit of logging or debugging. + setTimeout(() => { + process.exit(0); + }, 5_000); + } +} + +let debuggerPause: VoidFunction | undefined; + +if (options.captureStackTrace) { + log('Connecting to debugger'); + + const session = new InspectorSession(); + session.connectToMainThread(); + + log('Connected to debugger'); + + // Collect scriptId -> url map so we can look up the filenames later + const scripts = new Map(); + + session.on('Debugger.scriptParsed', event => { + scripts.set(event.params.scriptId, event.params.url); + }); + + session.on('Debugger.paused', event => { + if (event.params.reason !== 'other') { + return; + } + + try { + log('Debugger paused'); + + // copy the frames + const callFrames = [...event.params.callFrames]; + + const getModuleName = options.appRootPath ? createGetModuleFromFilename(options.appRootPath) : () => undefined; + const stackFrames = callFrames.map(frame => + callFrameToStackFrame(frame, scripts.get(frame.location.scriptId), getModuleName), + ); + + // Runtime.evaluate may never return if the event loop is blocked indefinitely + // In that case, we want to send the event anyway + const getScopeTimeout = setTimeout(() => { + sendAnrEvent(stackFrames).then(null, () => { + log('Sending ANR event failed.'); + }); + }, 5_000); + + // Evaluate a script in the currently paused context + session.post( + 'Runtime.evaluate', + { + // Grab the trace context from the current scope + expression: 'global.__SENTRY_GET_SCOPES__();', + // Don't re-trigger the debugger if this causes an error + silent: true, + // Serialize the result to json otherwise only primitives are supported + returnByValue: true, + }, + (err, param) => { + if (err) { + log(`Error executing script: '${err.message}'`); + } + + clearTimeout(getScopeTimeout); + + const scopes = param?.result ? (param.result.value as ScopeData) : undefined; + + session.post('Debugger.resume'); + session.post('Debugger.disable'); + + sendAnrEvent(stackFrames, scopes).then(null, () => { + log('Sending ANR event failed.'); + }); + }, + ); + } catch (e) { + session.post('Debugger.resume'); + session.post('Debugger.disable'); + throw e; + } + }); + + debuggerPause = () => { + try { + session.post('Debugger.enable', () => { + session.post('Debugger.pause'); + }); + } catch (_) { + // + } + }; +} + +function createHrTimer(): { getTimeMs: () => number; reset: VoidFunction } { + // TODO (v8): We can use process.hrtime.bigint() after we drop node v8 + let lastPoll = process.hrtime(); + + return { + getTimeMs: (): number => { + const [seconds, nanoSeconds] = process.hrtime(lastPoll); + return Math.floor(seconds * 1e3 + nanoSeconds / 1e6); + }, + reset: (): void => { + lastPoll = process.hrtime(); + }, + }; +} + +function watchdogTimeout(): void { + log('Watchdog timeout'); + + if (debuggerPause) { + log('Pausing debugger to capture stack trace'); + debuggerPause(); + } else { + log('Capturing event without a stack trace'); + sendAnrEvent().then(null, () => { + log('Sending ANR event failed on watchdog timeout.'); + }); + } +} + +const { poll } = watchdogTimer(createHrTimer, options.pollInterval, options.anrThreshold, watchdogTimeout); + +parentPort?.on('message', (msg: { session: Session | undefined; debugImages?: Record }) => { + if (msg.session) { + session = makeSession(msg.session); + } + + if (msg.debugImages) { + mainDebugImages = msg.debugImages; + } + + poll(); +}); diff --git a/packages/node-core/src/integrations/childProcess.ts b/packages/node-core/src/integrations/childProcess.ts new file mode 100644 index 000000000000..6fc6046c7e2f --- /dev/null +++ b/packages/node-core/src/integrations/childProcess.ts @@ -0,0 +1,113 @@ +import type { ChildProcess } from 'node:child_process'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { Worker } from 'node:worker_threads'; +import { addBreadcrumb, captureException, defineIntegration } from '@sentry/core'; + +interface Options { + /** + * Whether to include child process arguments in breadcrumbs data. + * + * @default false + */ + includeChildProcessArgs?: boolean; + + /** + * Whether to capture errors from worker threads. + * + * @default true + */ + captureWorkerErrors?: boolean; +} + +const INTEGRATION_NAME = 'ChildProcess'; + +/** + * Capture breadcrumbs and events for child processes and worker threads. + */ +export const childProcessIntegration = defineIntegration((options: Options = {}) => { + return { + name: INTEGRATION_NAME, + setup() { + diagnosticsChannel.channel('child_process').subscribe((event: unknown) => { + if (event && typeof event === 'object' && 'process' in event) { + captureChildProcessEvents(event.process as ChildProcess, options); + } + }); + + diagnosticsChannel.channel('worker_threads').subscribe((event: unknown) => { + if (event && typeof event === 'object' && 'worker' in event) { + captureWorkerThreadEvents(event.worker as Worker, options); + } + }); + }, + }; +}); + +function captureChildProcessEvents(child: ChildProcess, options: Options): void { + let hasExited = false; + let data: Record | undefined; + + child + .on('spawn', () => { + // This is Sentry getting macOS OS context + if (child.spawnfile === '/usr/bin/sw_vers') { + hasExited = true; + return; + } + + data = { spawnfile: child.spawnfile }; + if (options.includeChildProcessArgs) { + data.spawnargs = child.spawnargs; + } + }) + .on('exit', code => { + if (!hasExited) { + hasExited = true; + + // Only log for non-zero exit codes + if (code !== null && code !== 0) { + addBreadcrumb({ + category: 'child_process', + message: `Child process exited with code '${code}'`, + level: code === 0 ? 'info' : 'warning', + data, + }); + } + } + }) + .on('error', error => { + if (!hasExited) { + hasExited = true; + + addBreadcrumb({ + category: 'child_process', + message: `Child process errored with '${error.message}'`, + level: 'error', + data, + }); + } + }); +} + +function captureWorkerThreadEvents(worker: Worker, options: Options): void { + let threadId: number | undefined; + + worker + .on('online', () => { + threadId = worker.threadId; + }) + .on('error', error => { + if (options.captureWorkerErrors !== false) { + captureException(error, { + mechanism: { type: 'instrument', handled: false, data: { threadId: String(threadId) } }, + }); + } else { + addBreadcrumb({ + category: 'worker_thread', + message: `Worker thread errored with '${error.message}'`, + level: 'error', + data: { threadId }, + }); + } + }); +} diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts new file mode 100644 index 000000000000..b8376ab0ada8 --- /dev/null +++ b/packages/node-core/src/integrations/context.ts @@ -0,0 +1,479 @@ +/* eslint-disable max-lines */ + +import { execFile } from 'node:child_process'; +import { readdir, readFile } from 'node:fs'; +import * as os from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import type { + AppContext, + CloudResourceContext, + Contexts, + CultureContext, + DeviceContext, + Event, + IntegrationFn, + OsContext, +} from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; + +export const readFileAsync = promisify(readFile); +export const readDirAsync = promisify(readdir); + +// Process enhanced with methods from Node 18, 20, 22 as @types/node +// is on `14.18.0` to match minimum version requirements of the SDK +interface ProcessWithCurrentValues extends NodeJS.Process { + availableMemory?(): number; +} + +const INTEGRATION_NAME = 'Context'; + +interface DeviceContextOptions { + cpu?: boolean; + memory?: boolean; +} + +interface ContextOptions { + app?: boolean; + os?: boolean; + device?: DeviceContextOptions | boolean; + culture?: boolean; + cloudResource?: boolean; +} + +const _nodeContextIntegration = ((options: ContextOptions = {}) => { + let cachedContext: Promise | undefined; + + const _options = { + app: true, + os: true, + device: true, + culture: true, + cloudResource: true, + ...options, + }; + + /** Add contexts to the event. Caches the context so we only look it up once. */ + async function addContext(event: Event): Promise { + if (cachedContext === undefined) { + cachedContext = _getContexts(); + } + + const updatedContext = _updateContext(await cachedContext); + + // TODO(v10): conditional with `sendDefaultPii` here? + event.contexts = { + ...event.contexts, + app: { ...updatedContext.app, ...event.contexts?.app }, + os: { ...updatedContext.os, ...event.contexts?.os }, + device: { ...updatedContext.device, ...event.contexts?.device }, + culture: { ...updatedContext.culture, ...event.contexts?.culture }, + cloud_resource: { ...updatedContext.cloud_resource, ...event.contexts?.cloud_resource }, + }; + + return event; + } + + /** Get the contexts from node. */ + async function _getContexts(): Promise { + const contexts: Contexts = {}; + + if (_options.os) { + contexts.os = await getOsContext(); + } + + if (_options.app) { + contexts.app = getAppContext(); + } + + if (_options.device) { + contexts.device = getDeviceContext(_options.device); + } + + if (_options.culture) { + const culture = getCultureContext(); + + if (culture) { + contexts.culture = culture; + } + } + + if (_options.cloudResource) { + contexts.cloud_resource = getCloudResourceContext(); + } + + return contexts; + } + + return { + name: INTEGRATION_NAME, + processEvent(event) { + return addContext(event); + }, + }; +}) satisfies IntegrationFn; + +/** + * Capture context about the environment and the device that the client is running on, to events. + */ +export const nodeContextIntegration = defineIntegration(_nodeContextIntegration); + +/** + * Updates the context with dynamic values that can change + */ +function _updateContext(contexts: Contexts): Contexts { + // Only update properties if they exist + + if (contexts.app?.app_memory) { + contexts.app.app_memory = process.memoryUsage().rss; + } + + if (contexts.app?.free_memory && typeof (process as ProcessWithCurrentValues).availableMemory === 'function') { + const freeMemory = (process as ProcessWithCurrentValues).availableMemory?.(); + if (freeMemory != null) { + contexts.app.free_memory = freeMemory; + } + } + + if (contexts.device?.free_memory) { + contexts.device.free_memory = os.freemem(); + } + + return contexts; +} + +/** + * Returns the operating system context. + * + * Based on the current platform, this uses a different strategy to provide the + * most accurate OS information. Since this might involve spawning subprocesses + * or accessing the file system, this should only be executed lazily and cached. + * + * - On macOS (Darwin), this will execute the `sw_vers` utility. The context + * has a `name`, `version`, `build` and `kernel_version` set. + * - On Linux, this will try to load a distribution release from `/etc` and set + * the `name`, `version` and `kernel_version` fields. + * - On all other platforms, only a `name` and `version` will be returned. Note + * that `version` might actually be the kernel version. + */ +async function getOsContext(): Promise { + const platformId = os.platform(); + switch (platformId) { + case 'darwin': + return getDarwinInfo(); + case 'linux': + return getLinuxInfo(); + default: + return { + name: PLATFORM_NAMES[platformId] || platformId, + version: os.release(), + }; + } +} + +function getCultureContext(): CultureContext | undefined { + try { + if (typeof process.versions.icu !== 'string') { + // Node was built without ICU support + return; + } + + // Check that node was built with full Intl support. Its possible it was built without support for non-English + // locales which will make resolvedOptions inaccurate + // + // https://nodejs.org/api/intl.html#detecting-internationalization-support + const january = new Date(9e8); + const spanish = new Intl.DateTimeFormat('es', { month: 'long' }); + if (spanish.format(january) === 'enero') { + const options = Intl.DateTimeFormat().resolvedOptions(); + + return { + locale: options.locale, + timezone: options.timeZone, + }; + } + } catch (err) { + // + } + + return; +} + +/** + * Get app context information from process + */ +export function getAppContext(): AppContext { + const app_memory = process.memoryUsage().rss; + const app_start_time = new Date(Date.now() - process.uptime() * 1000).toISOString(); + // https://nodejs.org/api/process.html#processavailablememory + const appContext: AppContext = { app_start_time, app_memory }; + + if (typeof (process as ProcessWithCurrentValues).availableMemory === 'function') { + const freeMemory = (process as ProcessWithCurrentValues).availableMemory?.(); + if (freeMemory != null) { + appContext.free_memory = freeMemory; + } + } + + return appContext; +} + +/** + * Gets device information from os + */ +export function getDeviceContext(deviceOpt: DeviceContextOptions | true): DeviceContext { + const device: DeviceContext = {}; + + // Sometimes os.uptime() throws due to lacking permissions: https://github.com/getsentry/sentry-javascript/issues/8202 + let uptime; + try { + uptime = os.uptime(); + } catch (e) { + // noop + } + + // os.uptime or its return value seem to be undefined in certain environments (e.g. Azure functions). + // Hence, we only set boot time, if we get a valid uptime value. + // @see https://github.com/getsentry/sentry-javascript/issues/5856 + if (typeof uptime === 'number') { + device.boot_time = new Date(Date.now() - uptime * 1000).toISOString(); + } + + device.arch = os.arch(); + + if (deviceOpt === true || deviceOpt.memory) { + device.memory_size = os.totalmem(); + device.free_memory = os.freemem(); + } + + if (deviceOpt === true || deviceOpt.cpu) { + const cpuInfo = os.cpus() as os.CpuInfo[] | undefined; + const firstCpu = cpuInfo?.[0]; + if (firstCpu) { + device.processor_count = cpuInfo.length; + device.cpu_description = firstCpu.model; + device.processor_frequency = firstCpu.speed; + } + } + + return device; +} + +/** Mapping of Node's platform names to actual OS names. */ +const PLATFORM_NAMES: { [platform: string]: string } = { + aix: 'IBM AIX', + freebsd: 'FreeBSD', + openbsd: 'OpenBSD', + sunos: 'SunOS', + win32: 'Windows', +}; + +/** Linux version file to check for a distribution. */ +interface DistroFile { + /** The file name, located in `/etc`. */ + name: string; + /** Potential distributions to check. */ + distros: [string, ...string[]]; +} + +/** Mapping of linux release files located in /etc to distributions. */ +const LINUX_DISTROS: DistroFile[] = [ + { name: 'fedora-release', distros: ['Fedora'] }, + { name: 'redhat-release', distros: ['Red Hat Linux', 'Centos'] }, + { name: 'redhat_version', distros: ['Red Hat Linux'] }, + { name: 'SuSE-release', distros: ['SUSE Linux'] }, + { name: 'lsb-release', distros: ['Ubuntu Linux', 'Arch Linux'] }, + { name: 'debian_version', distros: ['Debian'] }, + { name: 'debian_release', distros: ['Debian'] }, + { name: 'arch-release', distros: ['Arch Linux'] }, + { name: 'gentoo-release', distros: ['Gentoo Linux'] }, + { name: 'novell-release', distros: ['SUSE Linux'] }, + { name: 'alpine-release', distros: ['Alpine Linux'] }, +]; + +/** Functions to extract the OS version from Linux release files. */ +const LINUX_VERSIONS: { + [identifier: string]: (content: string) => string | undefined; +} = { + alpine: content => content, + arch: content => matchFirst(/distrib_release=(.*)/, content), + centos: content => matchFirst(/release ([^ ]+)/, content), + debian: content => content, + fedora: content => matchFirst(/release (..)/, content), + mint: content => matchFirst(/distrib_release=(.*)/, content), + red: content => matchFirst(/release ([^ ]+)/, content), + suse: content => matchFirst(/VERSION = (.*)\n/, content), + ubuntu: content => matchFirst(/distrib_release=(.*)/, content), +}; + +/** + * Executes a regular expression with one capture group. + * + * @param regex A regular expression to execute. + * @param text Content to execute the RegEx on. + * @returns The captured string if matched; otherwise undefined. + */ +function matchFirst(regex: RegExp, text: string): string | undefined { + const match = regex.exec(text); + return match ? match[1] : undefined; +} + +/** Loads the macOS operating system context. */ +async function getDarwinInfo(): Promise { + // Default values that will be used in case no operating system information + // can be loaded. The default version is computed via heuristics from the + // kernel version, but the build ID is missing. + const darwinInfo: OsContext = { + kernel_version: os.release(), + name: 'Mac OS X', + version: `10.${Number(os.release().split('.')[0]) - 4}`, + }; + + try { + // We try to load the actual macOS version by executing the `sw_vers` tool. + // This tool should be available on every standard macOS installation. In + // case this fails, we stick with the values computed above. + + const output = await new Promise((resolve, reject) => { + execFile('/usr/bin/sw_vers', (error: Error | null, stdout: string) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }); + }); + + darwinInfo.name = matchFirst(/^ProductName:\s+(.*)$/m, output); + darwinInfo.version = matchFirst(/^ProductVersion:\s+(.*)$/m, output); + darwinInfo.build = matchFirst(/^BuildVersion:\s+(.*)$/m, output); + } catch (e) { + // ignore + } + + return darwinInfo; +} + +/** Returns a distribution identifier to look up version callbacks. */ +function getLinuxDistroId(name: string): string { + return (name.split(' ') as [string])[0].toLowerCase(); +} + +/** Loads the Linux operating system context. */ +async function getLinuxInfo(): Promise { + // By default, we cannot assume anything about the distribution or Linux + // version. `os.release()` returns the kernel version and we assume a generic + // "Linux" name, which will be replaced down below. + const linuxInfo: OsContext = { + kernel_version: os.release(), + name: 'Linux', + }; + + try { + // We start guessing the distribution by listing files in the /etc + // directory. This is were most Linux distributions (except Knoppix) store + // release files with certain distribution-dependent meta data. We search + // for exactly one known file defined in `LINUX_DISTROS` and exit if none + // are found. In case there are more than one file, we just stick with the + // first one. + const etcFiles = await readDirAsync('/etc'); + const distroFile = LINUX_DISTROS.find(file => etcFiles.includes(file.name)); + if (!distroFile) { + return linuxInfo; + } + + // Once that file is known, load its contents. To make searching in those + // files easier, we lowercase the file contents. Since these files are + // usually quite small, this should not allocate too much memory and we only + // hold on to it for a very short amount of time. + const distroPath = join('/etc', distroFile.name); + const contents = ((await readFileAsync(distroPath, { encoding: 'utf-8' })) as string).toLowerCase(); + + // Some Linux distributions store their release information in the same file + // (e.g. RHEL and Centos). In those cases, we scan the file for an + // identifier, that basically consists of the first word of the linux + // distribution name (e.g. "red" for Red Hat). In case there is no match, we + // just assume the first distribution in our list. + const { distros } = distroFile; + linuxInfo.name = distros.find(d => contents.indexOf(getLinuxDistroId(d)) >= 0) || distros[0]; + + // Based on the found distribution, we can now compute the actual version + // number. This is different for every distribution, so several strategies + // are computed in `LINUX_VERSIONS`. + const id = getLinuxDistroId(linuxInfo.name); + linuxInfo.version = LINUX_VERSIONS[id]?.(contents); + } catch (e) { + // ignore + } + + return linuxInfo; +} + +/** + * Grabs some information about hosting provider based on best effort. + */ +function getCloudResourceContext(): CloudResourceContext | undefined { + if (process.env.VERCEL) { + // https://vercel.com/docs/concepts/projects/environment-variables/system-environment-variables#system-environment-variables + return { + 'cloud.provider': 'vercel', + 'cloud.region': process.env.VERCEL_REGION, + }; + } else if (process.env.AWS_REGION) { + // https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html + return { + 'cloud.provider': 'aws', + 'cloud.region': process.env.AWS_REGION, + 'cloud.platform': process.env.AWS_EXECUTION_ENV, + }; + } else if (process.env.GCP_PROJECT) { + // https://cloud.google.com/composer/docs/how-to/managing/environment-variables#reserved_variables + return { + 'cloud.provider': 'gcp', + }; + } else if (process.env.ALIYUN_REGION_ID) { + // TODO: find where I found these environment variables - at least gc.github.com returns something + return { + 'cloud.provider': 'alibaba_cloud', + 'cloud.region': process.env.ALIYUN_REGION_ID, + }; + } else if (process.env.WEBSITE_SITE_NAME && process.env.REGION_NAME) { + // https://learn.microsoft.com/en-us/azure/app-service/reference-app-settings?tabs=kudu%2Cdotnet#app-environment + return { + 'cloud.provider': 'azure', + 'cloud.region': process.env.REGION_NAME, + }; + } else if (process.env.IBM_CLOUD_REGION) { + // TODO: find where I found these environment variables - at least gc.github.com returns something + return { + 'cloud.provider': 'ibm_cloud', + 'cloud.region': process.env.IBM_CLOUD_REGION, + }; + } else if (process.env.TENCENTCLOUD_REGION) { + // https://www.tencentcloud.com/document/product/583/32748 + return { + 'cloud.provider': 'tencent_cloud', + 'cloud.region': process.env.TENCENTCLOUD_REGION, + 'cloud.account.id': process.env.TENCENTCLOUD_APPID, + 'cloud.availability_zone': process.env.TENCENTCLOUD_ZONE, + }; + } else if (process.env.NETLIFY) { + // https://docs.netlify.com/configure-builds/environment-variables/#read-only-variables + return { + 'cloud.provider': 'netlify', + }; + } else if (process.env.FLY_REGION) { + // https://fly.io/docs/reference/runtime-environment/ + return { + 'cloud.provider': 'fly.io', + 'cloud.region': process.env.FLY_REGION, + }; + } else if (process.env.DYNO) { + // https://devcenter.heroku.com/articles/dynos#local-environment-variables + return { + 'cloud.provider': 'heroku', + }; + } else { + return undefined; + } +} diff --git a/packages/node-core/src/integrations/contextlines.ts b/packages/node-core/src/integrations/contextlines.ts new file mode 100644 index 000000000000..6667bed80e28 --- /dev/null +++ b/packages/node-core/src/integrations/contextlines.ts @@ -0,0 +1,414 @@ +import { createReadStream } from 'node:fs'; +import { createInterface } from 'node:readline'; +import type { Event, IntegrationFn, StackFrame } from '@sentry/core'; +import { defineIntegration, logger, LRUMap, snipLine } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +const LRU_FILE_CONTENTS_CACHE = new LRUMap>(10); +const LRU_FILE_CONTENTS_FS_READ_FAILED = new LRUMap(20); +const DEFAULT_LINES_OF_CONTEXT = 7; +const INTEGRATION_NAME = 'ContextLines'; +// Determines the upper bound of lineno/colno that we will attempt to read. Large colno values are likely to be +// minified code while large lineno values are likely to be bundled code. +// Exported for testing purposes. +export const MAX_CONTEXTLINES_COLNO: number = 1000; +export const MAX_CONTEXTLINES_LINENO: number = 10000; + +interface ContextLinesOptions { + /** + * Sets the number of context lines for each frame when loading a file. + * Defaults to 7. + * + * Set to 0 to disable loading and inclusion of source files. + **/ + frameContextLines?: number; +} + +/** + * Exported for testing purposes. + */ +export function resetFileContentCache(): void { + LRU_FILE_CONTENTS_CACHE.clear(); +} + +/** + * Get or init map value + */ +function emplace, K extends string, V>(map: T, key: K, contents: V): V { + const value = map.get(key); + + if (value === undefined) { + map.set(key, contents); + return contents; + } + + return value; +} + +/** + * Determines if context lines should be skipped for a file. + * - .min.(mjs|cjs|js) files are and not useful since they dont point to the original source + * - node: prefixed modules are part of the runtime and cannot be resolved to a file + * - data: skip json, wasm and inline js https://nodejs.org/api/esm.html#data-imports + */ +function shouldSkipContextLinesForFile(path: string): boolean { + // Test the most common prefix and extension first. These are the ones we + // are most likely to see in user applications and are the ones we can break out of first. + if (path.startsWith('node:')) return true; + if (path.endsWith('.min.js')) return true; + if (path.endsWith('.min.cjs')) return true; + if (path.endsWith('.min.mjs')) return true; + if (path.startsWith('data:')) return true; + return false; +} + +/** + * Determines if we should skip contextlines based off the max lineno and colno values. + */ +function shouldSkipContextLinesForFrame(frame: StackFrame): boolean { + if (frame.lineno !== undefined && frame.lineno > MAX_CONTEXTLINES_LINENO) return true; + if (frame.colno !== undefined && frame.colno > MAX_CONTEXTLINES_COLNO) return true; + return false; +} +/** + * Checks if we have all the contents that we need in the cache. + */ +function rangeExistsInContentCache(file: string, range: ReadlineRange): boolean { + const contents = LRU_FILE_CONTENTS_CACHE.get(file); + if (contents === undefined) return false; + + for (let i = range[0]; i <= range[1]; i++) { + if (contents[i] === undefined) { + return false; + } + } + + return true; +} + +/** + * Creates contiguous ranges of lines to read from a file. In the case where context lines overlap, + * the ranges are merged to create a single range. + */ +function makeLineReaderRanges(lines: number[], linecontext: number): ReadlineRange[] { + if (!lines.length) { + return []; + } + + let i = 0; + const line = lines[0]; + + if (typeof line !== 'number') { + return []; + } + + let current = makeContextRange(line, linecontext); + const out: ReadlineRange[] = []; + // eslint-disable-next-line no-constant-condition + while (true) { + if (i === lines.length - 1) { + out.push(current); + break; + } + + // If the next line falls into the current range, extend the current range to lineno + linecontext. + const next = lines[i + 1]; + if (typeof next !== 'number') { + break; + } + if (next <= current[1]) { + current[1] = next + linecontext; + } else { + out.push(current); + current = makeContextRange(next, linecontext); + } + + i++; + } + + return out; +} + +/** + * Extracts lines from a file and stores them in a cache. + */ +function getContextLinesFromFile(path: string, ranges: ReadlineRange[], output: Record): Promise { + return new Promise((resolve, _reject) => { + // It is important *not* to have any async code between createInterface and the 'line' event listener + // as it will cause the 'line' event to + // be emitted before the listener is attached. + const stream = createReadStream(path); + const lineReaded = createInterface({ + input: stream, + }); + + // We need to explicitly destroy the stream to prevent memory leaks, + // removing the listeners on the readline interface is not enough. + // See: https://github.com/nodejs/node/issues/9002 and https://github.com/getsentry/sentry-javascript/issues/14892 + function destroyStreamAndResolve(): void { + stream.destroy(); + resolve(); + } + + // Init at zero and increment at the start of the loop because lines are 1 indexed. + let lineNumber = 0; + let currentRangeIndex = 0; + const range = ranges[currentRangeIndex]; + if (range === undefined) { + // We should never reach this point, but if we do, we should resolve the promise to prevent it from hanging. + destroyStreamAndResolve(); + return; + } + let rangeStart = range[0]; + let rangeEnd = range[1]; + + // We use this inside Promise.all, so we need to resolve the promise even if there is an error + // to prevent Promise.all from short circuiting the rest. + function onStreamError(e: Error): void { + // Mark file path as failed to read and prevent multiple read attempts. + LRU_FILE_CONTENTS_FS_READ_FAILED.set(path, 1); + DEBUG_BUILD && logger.error(`Failed to read file: ${path}. Error: ${e}`); + lineReaded.close(); + lineReaded.removeAllListeners(); + destroyStreamAndResolve(); + } + + // We need to handle the error event to prevent the process from crashing in < Node 16 + // https://github.com/nodejs/node/pull/31603 + stream.on('error', onStreamError); + lineReaded.on('error', onStreamError); + lineReaded.on('close', destroyStreamAndResolve); + + lineReaded.on('line', line => { + lineNumber++; + if (lineNumber < rangeStart) return; + + // !Warning: This mutates the cache by storing the snipped line into the cache. + output[lineNumber] = snipLine(line, 0); + + if (lineNumber >= rangeEnd) { + if (currentRangeIndex === ranges.length - 1) { + // We need to close the file stream and remove listeners, else the reader will continue to run our listener; + lineReaded.close(); + lineReaded.removeAllListeners(); + return; + } + currentRangeIndex++; + const range = ranges[currentRangeIndex]; + if (range === undefined) { + // This should never happen as it means we have a bug in the context. + lineReaded.close(); + lineReaded.removeAllListeners(); + return; + } + rangeStart = range[0]; + rangeEnd = range[1]; + } + }); + }); +} + +/** + * Adds surrounding (context) lines of the line that an exception occurred on to the event. + * This is done by reading the file line by line and extracting the lines. The extracted lines are stored in + * a cache to prevent multiple reads of the same file. Failures to read a file are similarly cached to prevent multiple + * failing reads from happening. + */ +/* eslint-disable complexity */ +async function addSourceContext(event: Event, contextLines: number): Promise { + // keep a lookup map of which files we've already enqueued to read, + // so we don't enqueue the same file multiple times which would cause multiple i/o reads + const filesToLines: Record = {}; + + if (contextLines > 0 && event.exception?.values) { + for (const exception of event.exception.values) { + if (!exception.stacktrace?.frames?.length) { + continue; + } + + // Maps preserve insertion order, so we iterate in reverse, starting at the + // outermost frame and closer to where the exception has occurred (poor mans priority) + for (let i = exception.stacktrace.frames.length - 1; i >= 0; i--) { + const frame: StackFrame | undefined = exception.stacktrace.frames[i]; + const filename = frame?.filename; + + if ( + !frame || + typeof filename !== 'string' || + typeof frame.lineno !== 'number' || + shouldSkipContextLinesForFile(filename) || + shouldSkipContextLinesForFrame(frame) + ) { + continue; + } + + const filesToLinesOutput = filesToLines[filename]; + if (!filesToLinesOutput) filesToLines[filename] = []; + // @ts-expect-error this is defined above + filesToLines[filename].push(frame.lineno); + } + } + } + + const files = Object.keys(filesToLines); + if (files.length == 0) { + return event; + } + + const readlinePromises: Promise[] = []; + for (const file of files) { + // If we failed to read this before, dont try reading it again. + if (LRU_FILE_CONTENTS_FS_READ_FAILED.get(file)) { + continue; + } + + const filesToLineRanges = filesToLines[file]; + if (!filesToLineRanges) { + continue; + } + + // Sort ranges so that they are sorted by line increasing order and match how the file is read. + filesToLineRanges.sort((a, b) => a - b); + // Check if the contents are already in the cache and if we can avoid reading the file again. + const ranges = makeLineReaderRanges(filesToLineRanges, contextLines); + if (ranges.every(r => rangeExistsInContentCache(file, r))) { + continue; + } + + const cache = emplace(LRU_FILE_CONTENTS_CACHE, file, {}); + readlinePromises.push(getContextLinesFromFile(file, ranges, cache)); + } + + // The promise rejections are caught in order to prevent them from short circuiting Promise.all + await Promise.all(readlinePromises).catch(() => { + DEBUG_BUILD && logger.log('Failed to read one or more source files and resolve context lines'); + }); + + // Perform the same loop as above, but this time we can assume all files are in the cache + // and attempt to add source context to frames. + if (contextLines > 0 && event.exception?.values) { + for (const exception of event.exception.values) { + if (exception.stacktrace?.frames && exception.stacktrace.frames.length > 0) { + addSourceContextToFrames(exception.stacktrace.frames, contextLines, LRU_FILE_CONTENTS_CACHE); + } + } + } + + return event; +} +/* eslint-enable complexity */ + +/** Adds context lines to frames */ +function addSourceContextToFrames( + frames: StackFrame[], + contextLines: number, + cache: LRUMap>, +): void { + for (const frame of frames) { + // Only add context if we have a filename and it hasn't already been added + if (frame.filename && frame.context_line === undefined && typeof frame.lineno === 'number') { + const contents = cache.get(frame.filename); + if (contents === undefined) { + continue; + } + + addContextToFrame(frame.lineno, frame, contextLines, contents); + } + } +} + +/** + * Clears the context lines from a frame, used to reset a frame to its original state + * if we fail to resolve all context lines for it. + */ +function clearLineContext(frame: StackFrame): void { + delete frame.pre_context; + delete frame.context_line; + delete frame.post_context; +} + +/** + * Resolves context lines before and after the given line number and appends them to the frame; + */ +export function addContextToFrame( + lineno: number, + frame: StackFrame, + contextLines: number, + contents: Record | undefined, +): void { + // When there is no line number in the frame, attaching context is nonsensical and will even break grouping. + // We already check for lineno before calling this, but since StackFrame lineno ism optional, we check it again. + if (frame.lineno === undefined || contents === undefined) { + DEBUG_BUILD && logger.error('Cannot resolve context for frame with no lineno or file contents'); + return; + } + + frame.pre_context = []; + for (let i = makeRangeStart(lineno, contextLines); i < lineno; i++) { + // We always expect the start context as line numbers cannot be negative. If we dont find a line, then + // something went wrong somewhere. Clear the context and return without adding any linecontext. + const line = contents[i]; + if (line === undefined) { + clearLineContext(frame); + DEBUG_BUILD && logger.error(`Could not find line ${i} in file ${frame.filename}`); + return; + } + + frame.pre_context.push(line); + } + + // We should always have the context line. If we dont, something went wrong, so we clear the context and return + // without adding any linecontext. + if (contents[lineno] === undefined) { + clearLineContext(frame); + DEBUG_BUILD && logger.error(`Could not find line ${lineno} in file ${frame.filename}`); + return; + } + + frame.context_line = contents[lineno]; + + const end = makeRangeEnd(lineno, contextLines); + frame.post_context = []; + for (let i = lineno + 1; i <= end; i++) { + // Since we dont track when the file ends, we cant clear the context if we dont find a line as it could + // just be that we reached the end of the file. + const line = contents[i]; + if (line === undefined) { + break; + } + frame.post_context.push(line); + } +} + +// Helper functions for generating line context ranges. They take a line number and the number of lines of context to +// include before and after the line and generate an inclusive range of indices. +type ReadlineRange = [start: number, end: number]; +// Compute inclusive end context range +function makeRangeStart(line: number, linecontext: number): number { + return Math.max(1, line - linecontext); +} +// Compute inclusive start context range +function makeRangeEnd(line: number, linecontext: number): number { + return line + linecontext; +} +// Determine start and end indices for context range (inclusive); +function makeContextRange(line: number, linecontext: number): [start: number, end: number] { + return [makeRangeStart(line, linecontext), makeRangeEnd(line, linecontext)]; +} + +/** Exported only for tests, as a type-safe variant. */ +export const _contextLinesIntegration = ((options: ContextLinesOptions = {}) => { + const contextLines = options.frameContextLines !== undefined ? options.frameContextLines : DEFAULT_LINES_OF_CONTEXT; + + return { + name: INTEGRATION_NAME, + processEvent(event) { + return addSourceContext(event, contextLines); + }, + }; +}) satisfies IntegrationFn; + +/** + * Capture the lines before and after the frame's context. + */ +export const contextLinesIntegration = defineIntegration(_contextLinesIntegration); diff --git a/packages/node-core/src/integrations/diagnostic_channel.d.ts b/packages/node-core/src/integrations/diagnostic_channel.d.ts new file mode 100644 index 000000000000..abf3649a617f --- /dev/null +++ b/packages/node-core/src/integrations/diagnostic_channel.d.ts @@ -0,0 +1,556 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ + +/** + * The `node:diagnostics_channel` module provides an API to create named channels + * to report arbitrary message data for diagnostics purposes. + * + * It can be accessed using: + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * ``` + * + * It is intended that a module writer wanting to report diagnostics messages + * will create one or many top-level channels to report messages through. + * Channels may also be acquired at runtime but it is not encouraged + * due to the additional overhead of doing so. Channels may be exported for + * convenience, but as long as the name is known it can be acquired anywhere. + * + * If you intend for your module to produce diagnostics data for others to + * consume it is recommended that you include documentation of what named + * channels are used along with the shape of the message data. Channel names + * should generally include the module name to avoid collisions with data from + * other modules. + * @since v15.1.0, v14.17.0 + * @see [source](https://github.com/nodejs/node/blob/v22.x/lib/diagnostics_channel.js) + */ +declare module 'diagnostics_channel' { + import type { AsyncLocalStorage } from 'node:async_hooks'; + /** + * Check if there are active subscribers to the named channel. This is helpful if + * the message you want to send might be expensive to prepare. + * + * This API is optional but helpful when trying to publish messages from very + * performance-sensitive code. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * if (diagnostics_channel.hasSubscribers('my-channel')) { + * // There are subscribers, prepare and publish message + * } + * ``` + * @since v15.1.0, v14.17.0 + * @param name The channel name + * @return If there are active subscribers + */ + function hasSubscribers(name: string | symbol): boolean; + /** + * This is the primary entry-point for anyone wanting to publish to a named + * channel. It produces a channel object which is optimized to reduce overhead at + * publish time as much as possible. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * ``` + * @since v15.1.0, v14.17.0 + * @param name The channel name + * @return The named channel object + */ + function channel(name: string | symbol): Channel; + type ChannelListener = (message: unknown, name: string | symbol) => void; + /** + * Register a message handler to subscribe to this channel. This message handler + * will be run synchronously whenever a message is published to the channel. Any + * errors thrown in the message handler will trigger an `'uncaughtException'`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * diagnostics_channel.subscribe('my-channel', (message, name) => { + * // Received data + * }); + * ``` + * @since v18.7.0, v16.17.0 + * @param name The channel name + * @param onMessage The handler to receive channel messages + */ + function subscribe(name: string | symbol, onMessage: ChannelListener): void; + /** + * Remove a message handler previously registered to this channel with {@link subscribe}. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * function onMessage(message, name) { + * // Received data + * } + * + * diagnostics_channel.subscribe('my-channel', onMessage); + * + * diagnostics_channel.unsubscribe('my-channel', onMessage); + * ``` + * @since v18.7.0, v16.17.0 + * @param name The channel name + * @param onMessage The previous subscribed handler to remove + * @return `true` if the handler was found, `false` otherwise. + */ + function unsubscribe(name: string | symbol, onMessage: ChannelListener): boolean; + /** + * Creates a `TracingChannel` wrapper for the given `TracingChannel Channels`. If a name is given, the corresponding tracing + * channels will be created in the form of `tracing:${name}:${eventType}` where `eventType` corresponds to the types of `TracingChannel Channels`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channelsByName = diagnostics_channel.tracingChannel('my-channel'); + * + * // or... + * + * const channelsByCollection = diagnostics_channel.tracingChannel({ + * start: diagnostics_channel.channel('tracing:my-channel:start'), + * end: diagnostics_channel.channel('tracing:my-channel:end'), + * asyncStart: diagnostics_channel.channel('tracing:my-channel:asyncStart'), + * asyncEnd: diagnostics_channel.channel('tracing:my-channel:asyncEnd'), + * error: diagnostics_channel.channel('tracing:my-channel:error'), + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param nameOrChannels Channel name or object containing all the `TracingChannel Channels` + * @return Collection of channels to trace with + */ + function tracingChannel< + StoreType = unknown, + ContextType extends object = StoreType extends object ? StoreType : object, + >(nameOrChannels: string | TracingChannelCollection): TracingChannel; + /** + * The class `Channel` represents an individual named channel within the data + * pipeline. It is used to track subscribers and to publish messages when there + * are subscribers present. It exists as a separate object to avoid channel + * lookups at publish time, enabling very fast publish speeds and allowing + * for heavy use while incurring very minimal cost. Channels are created with {@link channel}, constructing a channel directly + * with `new Channel(name)` is not supported. + * @since v15.1.0, v14.17.0 + */ + class Channel { + readonly name: string | symbol; + /** + * Check if there are active subscribers to this channel. This is helpful if + * the message you want to send might be expensive to prepare. + * + * This API is optional but helpful when trying to publish messages from very + * performance-sensitive code. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * if (channel.hasSubscribers) { + * // There are subscribers, prepare and publish message + * } + * ``` + * @since v15.1.0, v14.17.0 + */ + readonly hasSubscribers: boolean; + private constructor(name: string | symbol); + /** + * Publish a message to any subscribers to the channel. This will trigger + * message handlers synchronously so they will execute within the same context. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.publish({ + * some: 'message', + * }); + * ``` + * @since v15.1.0, v14.17.0 + * @param message The message to send to the channel subscribers + */ + publish(message: unknown): void; + /** + * Register a message handler to subscribe to this channel. This message handler + * will be run synchronously whenever a message is published to the channel. Any + * errors thrown in the message handler will trigger an `'uncaughtException'`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.subscribe((message, name) => { + * // Received data + * }); + * ``` + * @since v15.1.0, v14.17.0 + * @deprecated Since v18.7.0,v16.17.0 - Use {@link subscribe(name, onMessage)} + * @param onMessage The handler to receive channel messages + */ + subscribe(onMessage: ChannelListener): void; + /** + * Remove a message handler previously registered to this channel with `channel.subscribe(onMessage)`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * function onMessage(message, name) { + * // Received data + * } + * + * channel.subscribe(onMessage); + * + * channel.unsubscribe(onMessage); + * ``` + * @since v15.1.0, v14.17.0 + * @deprecated Since v18.7.0,v16.17.0 - Use {@link unsubscribe(name, onMessage)} + * @param onMessage The previous subscribed handler to remove + * @return `true` if the handler was found, `false` otherwise. + */ + unsubscribe(onMessage: ChannelListener): void; + /** + * When `channel.runStores(context, ...)` is called, the given context data + * will be applied to any store bound to the channel. If the store has already been + * bound the previous `transform` function will be replaced with the new one. + * The `transform` function may be omitted to set the given context data as the + * context directly. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * import { AsyncLocalStorage } from 'node:async_hooks'; + * + * const store = new AsyncLocalStorage(); + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.bindStore(store, (data) => { + * return { data }; + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param store The store to which to bind the context data + * @param transform Transform context data before setting the store context + */ + bindStore(store: AsyncLocalStorage, transform?: (context: ContextType) => StoreType): void; + /** + * Remove a message handler previously registered to this channel with `channel.bindStore(store)`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * import { AsyncLocalStorage } from 'node:async_hooks'; + * + * const store = new AsyncLocalStorage(); + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.bindStore(store); + * channel.unbindStore(store); + * ``` + * @since v19.9.0 + * @experimental + * @param store The store to unbind from the channel. + * @return `true` if the store was found, `false` otherwise. + */ + unbindStore(store: any): void; + /** + * Applies the given data to any AsyncLocalStorage instances bound to the channel + * for the duration of the given function, then publishes to the channel within + * the scope of that data is applied to the stores. + * + * If a transform function was given to `channel.bindStore(store)` it will be + * applied to transform the message data before it becomes the context value for + * the store. The prior storage context is accessible from within the transform + * function in cases where context linking is required. + * + * The context applied to the store should be accessible in any async code which + * continues from execution which began during the given function, however + * there are some situations in which `context loss` may occur. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * import { AsyncLocalStorage } from 'node:async_hooks'; + * + * const store = new AsyncLocalStorage(); + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.bindStore(store, (message) => { + * const parent = store.getStore(); + * return new Span(message, parent); + * }); + * channel.runStores({ some: 'message' }, () => { + * store.getStore(); // Span({ some: 'message' }) + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param context Message to send to subscribers and bind to stores + * @param fn Handler to run within the entered storage context + * @param thisArg The receiver to be used for the function call. + * @param args Optional arguments to pass to the function. + */ + runStores(): void; + } + interface TracingChannelSubscribers { + start: (message: ContextType) => void; + end: ( + message: ContextType & { + error?: unknown; + result?: unknown; + }, + ) => void; + asyncStart: ( + message: ContextType & { + error?: unknown; + result?: unknown; + }, + ) => void; + asyncEnd: ( + message: ContextType & { + error?: unknown; + result?: unknown; + }, + ) => void; + error: ( + message: ContextType & { + error: unknown; + }, + ) => void; + } + interface TracingChannelCollection { + start: Channel; + end: Channel; + asyncStart: Channel; + asyncEnd: Channel; + error: Channel; + } + /** + * The class `TracingChannel` is a collection of `TracingChannel Channels` which + * together express a single traceable action. It is used to formalize and + * simplify the process of producing events for tracing application flow. {@link tracingChannel} is used to construct a `TracingChannel`. As with `Channel` it is recommended to create and reuse a + * single `TracingChannel` at the top-level of the file rather than creating them + * dynamically. + * @since v19.9.0 + * @experimental + */ + class TracingChannel implements TracingChannelCollection { + start: Channel; + end: Channel; + asyncStart: Channel; + asyncEnd: Channel; + error: Channel; + /** + * Helper to subscribe a collection of functions to the corresponding channels. + * This is the same as calling `channel.subscribe(onMessage)` on each channel + * individually. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.subscribe({ + * start(message) { + * // Handle start message + * }, + * end(message) { + * // Handle end message + * }, + * asyncStart(message) { + * // Handle asyncStart message + * }, + * asyncEnd(message) { + * // Handle asyncEnd message + * }, + * error(message) { + * // Handle error message + * }, + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param subscribers Set of `TracingChannel Channels` subscribers + */ + subscribe(subscribers: TracingChannelSubscribers): void; + /** + * Helper to unsubscribe a collection of functions from the corresponding channels. + * This is the same as calling `channel.unsubscribe(onMessage)` on each channel + * individually. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.unsubscribe({ + * start(message) { + * // Handle start message + * }, + * end(message) { + * // Handle end message + * }, + * asyncStart(message) { + * // Handle asyncStart message + * }, + * asyncEnd(message) { + * // Handle asyncEnd message + * }, + * error(message) { + * // Handle error message + * }, + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param subscribers Set of `TracingChannel Channels` subscribers + * @return `true` if all handlers were successfully unsubscribed, and `false` otherwise. + */ + unsubscribe(subscribers: TracingChannelSubscribers): void; + /** + * Trace a synchronous function call. This will always produce a `start event` and `end event` around the execution and may produce an `error event` if the given function throws an error. + * This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all + * events should have any bound stores set to match this trace context. + * + * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions + * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.traceSync(() => { + * // Do something + * }, { + * some: 'thing', + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param fn Function to wrap a trace around + * @param context Shared object to correlate events through + * @param thisArg The receiver to be used for the function call + * @param args Optional arguments to pass to the function + * @return The return value of the given function + */ + traceSync( + fn: (this: ThisArg, ...args: Args) => any, + context?: ContextType, + thisArg?: ThisArg, + ...args: Args + ): void; + /** + * Trace a promise-returning function call. This will always produce a `start event` and `end event` around the synchronous portion of the + * function execution, and will produce an `asyncStart event` and `asyncEnd event` when a promise continuation is reached. It may also + * produce an `error event` if the given function throws an error or the + * returned promise rejects. This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all + * events should have any bound stores set to match this trace context. + * + * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions + * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.tracePromise(async () => { + * // Do something + * }, { + * some: 'thing', + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param fn Promise-returning function to wrap a trace around + * @param context Shared object to correlate trace events through + * @param thisArg The receiver to be used for the function call + * @param args Optional arguments to pass to the function + * @return Chained from promise returned by the given function + */ + tracePromise( + fn: (this: ThisArg, ...args: Args) => Promise, + context?: ContextType, + thisArg?: ThisArg, + ...args: Args + ): void; + /** + * Trace a callback-receiving function call. This will always produce a `start event` and `end event` around the synchronous portion of the + * function execution, and will produce a `asyncStart event` and `asyncEnd event` around the callback execution. It may also produce an `error event` if the given function throws an error or + * the returned + * promise rejects. This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all + * events should have any bound stores set to match this trace context. + * + * The `position` will be -1 by default to indicate the final argument should + * be used as the callback. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.traceCallback((arg1, callback) => { + * // Do something + * callback(null, 'result'); + * }, 1, { + * some: 'thing', + * }, thisArg, arg1, callback); + * ``` + * + * The callback will also be run with `channel.runStores(context, ...)` which + * enables context loss recovery in some cases. + * + * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions + * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * import { AsyncLocalStorage } from 'node:async_hooks'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * const myStore = new AsyncLocalStorage(); + * + * // The start channel sets the initial store data to something + * // and stores that store data value on the trace context object + * channels.start.bindStore(myStore, (data) => { + * const span = new Span(data); + * data.span = span; + * return span; + * }); + * + * // Then asyncStart can restore from that data it stored previously + * channels.asyncStart.bindStore(myStore, (data) => { + * return data.span; + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param fn callback using function to wrap a trace around + * @param position Zero-indexed argument position of expected callback + * @param context Shared object to correlate trace events through + * @param thisArg The receiver to be used for the function call + * @param args Optional arguments to pass to the function + * @return The return value of the given function + */ + traceCallback any>( + fn: Fn, + position?: number, + context?: ContextType, + thisArg?: any, + ...args: Parameters + ): void; + } +} +declare module 'node:diagnostics_channel' { + export * from 'diagnostics_channel'; +} diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts new file mode 100644 index 000000000000..0c698633ee98 --- /dev/null +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -0,0 +1,650 @@ +/* eslint-disable max-lines */ +import type { ChannelListener } from 'node:diagnostics_channel'; +import { subscribe, unsubscribe } from 'node:diagnostics_channel'; +import type * as http from 'node:http'; +import type * as https from 'node:https'; +import type { EventEmitter } from 'node:stream'; +import { context, propagation } from '@opentelemetry/api'; +import { isTracingSuppressed } from '@opentelemetry/core'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import type { AggregationCounts, Client, SanitizedRequestData, Scope } from '@sentry/core'; +import { + addBreadcrumb, + addNonEnumerableProperty, + generateSpanId, + getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + getCurrentScope, + getIsolationScope, + getSanitizedUrlString, + getTraceData, + httpRequestToRequestData, + isError, + logger, + LRUMap, + parseUrl, + SDK_VERSION, + stripUrlQueryAndFragment, + withIsolationScope, +} from '@sentry/core'; +import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; +import { DEBUG_BUILD } from '../../debug-build'; +import { mergeBaggageHeaders } from '../../utils/baggage'; +import { getRequestUrl } from '../../utils/getRequestUrl'; + +const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; + +type Http = typeof http; +type Https = typeof https; + +export type SentryHttpInstrumentationOptions = InstrumentationConfig & { + /** + * Whether breadcrumbs should be recorded for requests. + * + * @default `true` + */ + breadcrumbs?: boolean; + + /** + * Whether to extract the trace ID from the `sentry-trace` header for incoming requests. + * + * @default `false` + */ + extractIncomingTraceFromHeader?: boolean; + + /** + * Whether to propagate Sentry trace headers in outgoing requests. + * + * @default `false` + */ + propagateTraceInOutgoingRequests?: boolean; + + /** + * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. + * For the scope of this instrumentation, this callback only controls breadcrumb creation. + * The same option can be passed to the top-level httpIntegration where it controls both, breadcrumb and + * span creation. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. + * @param request Contains the {@type RequestOptions} object used to make the outgoing request. + */ + ignoreOutgoingRequests?: (url: string, request: http.RequestOptions) => boolean; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request. + * @param request Contains the {@type RequestOptions} object used to make the incoming request. + */ + ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. + * Read more about Release Health: https://docs.sentry.io/product/releases/health/ + * + * Defaults to `true`. + */ + trackIncomingRequestsAsSessions?: boolean; + + /** + * Number of milliseconds until sessions tracked with `trackIncomingRequestsAsSessions` will be flushed as a session aggregate. + * + * Defaults to `60000` (60s). + */ + sessionFlushingDelayMS?: number; +}; + +// We only want to capture request bodies up to 1mb. +const MAX_BODY_BYTE_LENGTH = 1024 * 1024; + +/** + * This custom HTTP instrumentation is used to isolate incoming requests and annotate them with additional information. + * It does not emit any spans. + * + * The reason this is isolated from the OpenTelemetry instrumentation is that users may overwrite this, + * which would lead to Sentry not working as expected. + * + * Important note: Contrary to other OTEL instrumentation, this one cannot be unwrapped. + * It only does minimal things though and does not emit any spans. + * + * This is heavily inspired & adapted from: + * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts + */ +export class SentryHttpInstrumentation extends InstrumentationBase { + private _propagationDecisionMap: LRUMap; + private _ignoreOutgoingRequestsMap: WeakMap; + + public constructor(config: SentryHttpInstrumentationOptions = {}) { + super(INSTRUMENTATION_NAME, SDK_VERSION, config); + + this._propagationDecisionMap = new LRUMap(100); + this._ignoreOutgoingRequestsMap = new WeakMap(); + } + + /** @inheritdoc */ + public init(): [InstrumentationNodeModuleDefinition, InstrumentationNodeModuleDefinition] { + // We register handlers when either http or https is instrumented + // but we only want to register them once, whichever is loaded first + let hasRegisteredHandlers = false; + + const onHttpServerRequestStart = ((_data: unknown) => { + const data = _data as { server: http.Server }; + this._patchServerEmitOnce(data.server); + }) satisfies ChannelListener; + + const onHttpClientResponseFinish = ((_data: unknown) => { + const data = _data as { request: http.ClientRequest; response: http.IncomingMessage }; + this._onOutgoingRequestFinish(data.request, data.response); + }) satisfies ChannelListener; + + const onHttpClientRequestError = ((_data: unknown) => { + const data = _data as { request: http.ClientRequest }; + this._onOutgoingRequestFinish(data.request, undefined); + }) satisfies ChannelListener; + + const onHttpClientRequestCreated = ((_data: unknown) => { + const data = _data as { request: http.ClientRequest }; + this._onOutgoingRequestCreated(data.request); + }) satisfies ChannelListener; + + const wrap = (moduleExports: T): T => { + if (hasRegisteredHandlers) { + return moduleExports; + } + + hasRegisteredHandlers = true; + + subscribe('http.server.request.start', onHttpServerRequestStart); + subscribe('http.client.response.finish', onHttpClientResponseFinish); + + // When an error happens, we still want to have a breadcrumb + // In this case, `http.client.response.finish` is not triggered + subscribe('http.client.request.error', onHttpClientRequestError); + + // NOTE: This channel only exist since Node 22.12.0+ and 23.2.0+ + // Before that, outgoing requests are not patched + // and trace headers are not propagated, sadly. + if (this.getConfig().propagateTraceInOutgoingRequests) { + subscribe('http.client.request.created', onHttpClientRequestCreated); + } + + return moduleExports; + }; + + const unwrap = (): void => { + unsubscribe('http.server.request.start', onHttpServerRequestStart); + unsubscribe('http.client.response.finish', onHttpClientResponseFinish); + unsubscribe('http.client.request.error', onHttpClientRequestError); + unsubscribe('http.client.request.created', onHttpClientRequestCreated); + }; + + /** + * You may be wondering why we register these diagnostics-channel listeners + * in such a convoluted way (as InstrumentationNodeModuleDefinition...)˝, + * instead of simply subscribing to the events once in here. + * The reason for this is timing semantics: These functions are called once the http or https module is loaded. + * If we'd subscribe before that, there seem to be conflicts with the OTEL native instrumentation in some scenarios, + * especially the "import-on-top" pattern of setting up ESM applications. + */ + return [ + new InstrumentationNodeModuleDefinition('http', ['*'], wrap, unwrap), + new InstrumentationNodeModuleDefinition('https', ['*'], wrap, unwrap), + ]; + } + + /** + * This is triggered when an outgoing request finishes. + * It has access to the final request and response objects. + */ + private _onOutgoingRequestFinish(request: http.ClientRequest, response?: http.IncomingMessage): void { + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Handling finished outgoing request'); + + const _breadcrumbs = this.getConfig().breadcrumbs; + const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; + + // Note: We cannot rely on the map being set by `_onOutgoingRequestCreated`, because that is not run in Node <22 + const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); + this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); + + if (breadCrumbsEnabled && !shouldIgnore) { + addRequestBreadcrumb(request, response); + } + } + + /** + * This is triggered when an outgoing request is created. + * It has access to the request object, and can mutate it before the request is sent. + */ + private _onOutgoingRequestCreated(request: http.ClientRequest): void { + const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); + this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); + + if (shouldIgnore) { + return; + } + + // Add trace propagation headers + const url = getRequestUrl(request); + + // Manually add the trace headers, if it applies + // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span + // Which we do not have in this case + const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets; + const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap) + ? getTraceData() + : undefined; + + if (!addedHeaders) { + return; + } + + const { 'sentry-trace': sentryTrace, baggage } = addedHeaders; + + // We do not want to overwrite existing header here, if it was already set + if (sentryTrace && !request.getHeader('sentry-trace')) { + request.setHeader('sentry-trace', sentryTrace); + logger.log(INSTRUMENTATION_NAME, 'Added sentry-trace header to outgoing request'); + } + + if (baggage) { + // For baggage, we make sure to merge this into a possibly existing header + const newBaggage = mergeBaggageHeaders(request.getHeader('baggage'), baggage); + if (newBaggage) { + try { + request.setHeader('baggage', newBaggage); + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Added baggage header to outgoing request'); + } catch (error) { + DEBUG_BUILD && + logger.error( + INSTRUMENTATION_NAME, + 'Failed to add baggage header to outgoing request:', + isError(error) ? error.message : 'Unknown error', + ); + } + } + } + } + + /** + * Patch a server.emit function to handle process isolation for incoming requests. + * This will only patch the emit function if it was not already patched. + */ + private _patchServerEmitOnce(server: http.Server): void { + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalEmit = server.emit; + + // This means it was already patched, do nothing + if ((originalEmit as { __sentry_patched__?: boolean }).__sentry_patched__) { + return; + } + + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Patching server.emit'); + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + const { ignoreIncomingRequestBody, maxIncomingRequestBodySize = 'medium' } = instrumentation.getConfig(); + + const newEmit = new Proxy(originalEmit, { + apply(target, thisArg, args: [event: string, ...args: unknown[]]) { + // Only traces request events + if (args[0] !== 'request') { + return target.apply(thisArg, args); + } + + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Handling incoming request'); + + const isolationScope = getIsolationScope().clone(); + const request = args[1] as http.IncomingMessage; + const response = args[2] as http.OutgoingMessage; + + const normalizedRequest = httpRequestToRequestData(request); + + // request.ip is non-standard but some frameworks set this + const ipAddress = (request as { ip?: string }).ip || request.socket?.remoteAddress; + + const url = request.url || '/'; + if (!ignoreIncomingRequestBody?.(url, request) && maxIncomingRequestBodySize !== 'none') { + patchRequestToCaptureBody(request, isolationScope, maxIncomingRequestBodySize); + } + + // Update the isolation scope, isolate this request + isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress }); + + // 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 = (request.method || 'GET').toUpperCase(); + const httpTarget = stripUrlQueryAndFragment(url); + + const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; + + isolationScope.setTransactionName(bestEffortTransactionName); + + if (instrumentation.getConfig().trackIncomingRequestsAsSessions !== false) { + recordRequestSession({ + requestIsolationScope: isolationScope, + response, + sessionFlushingDelayMS: instrumentation.getConfig().sessionFlushingDelayMS ?? 60_000, + }); + } + + return withIsolationScope(isolationScope, () => { + // Set a new propagationSpanId for this request + // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope + // This way we can save an "unnecessary" `withScope()` invocation + getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); + + // If we don't want to extract the trace from the header, we can skip this + if (!instrumentation.getConfig().extractIncomingTraceFromHeader) { + return target.apply(thisArg, args); + } + + const ctx = propagation.extract(context.active(), normalizedRequest.headers); + return context.with(ctx, () => { + return target.apply(thisArg, args); + }); + }); + }, + }); + + addNonEnumerableProperty(newEmit, '__sentry_patched__', true); + + server.emit = newEmit; + } + + /** + * Check if the given outgoing request should be ignored. + */ + private _shouldIgnoreOutgoingRequest(request: http.ClientRequest): boolean { + if (isTracingSuppressed(context.active())) { + return true; + } + + const ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests; + + if (!ignoreOutgoingRequests) { + return false; + } + + const options = getRequestOptions(request); + const url = getRequestUrl(request); + return ignoreOutgoingRequests(url, options); + } +} + +/** Add a breadcrumb for outgoing requests. */ +function addRequestBreadcrumb(request: http.ClientRequest, response: http.IncomingMessage | undefined): void { + const data = getBreadcrumbData(request); + + const statusCode = response?.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + + addBreadcrumb( + { + category: 'http', + data: { + status_code: statusCode, + ...data, + }, + type: 'http', + level, + }, + { + event: 'response', + request, + response, + }, + ); +} + +function getBreadcrumbData(request: http.ClientRequest): Partial { + try { + // `request.host` does not contain the port, but the host header does + const host = request.getHeader('host') || request.host; + const url = new URL(request.path, `${request.protocol}//${host}`); + const parsedUrl = parseUrl(url.toString()); + + const data: Partial = { + url: getSanitizedUrlString(parsedUrl), + 'http.method': request.method || 'GET', + }; + + if (parsedUrl.search) { + data['http.query'] = parsedUrl.search; + } + if (parsedUrl.hash) { + data['http.fragment'] = parsedUrl.hash; + } + + return data; + } catch { + return {}; + } +} + +/** + * This method patches the request object to capture the body. + * Instead of actually consuming the streamed body ourselves, which has potential side effects, + * we monkey patch `req.on('data')` to intercept the body chunks. + * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways. + */ +function patchRequestToCaptureBody( + req: http.IncomingMessage, + isolationScope: Scope, + maxIncomingRequestBodySize: 'small' | 'medium' | 'always', +): void { + let bodyByteLength = 0; + const chunks: Buffer[] = []; + + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Patching request.on'); + + /** + * We need to keep track of the original callbacks, in order to be able to remove listeners again. + * Since `off` depends on having the exact same function reference passed in, we need to be able to map + * original listeners to our wrapped ones. + */ + const callbackMap = new WeakMap(); + + const maxBodySize = + maxIncomingRequestBodySize === 'small' + ? 1_000 + : maxIncomingRequestBodySize === 'medium' + ? 10_000 + : MAX_BODY_BYTE_LENGTH; + + try { + // eslint-disable-next-line @typescript-eslint/unbound-method + req.on = new Proxy(req.on, { + apply: (target, thisArg, args: Parameters) => { + const [event, listener, ...restArgs] = args; + + if (event === 'data') { + DEBUG_BUILD && + logger.log(INSTRUMENTATION_NAME, `Handling request.on("data") with maximum body size of ${maxBodySize}b`); + + const callback = new Proxy(listener, { + apply: (target, thisArg, args: Parameters) => { + try { + const chunk = args[0] as Buffer | string; + const bufferifiedChunk = Buffer.from(chunk); + + if (bodyByteLength < maxBodySize) { + chunks.push(bufferifiedChunk); + bodyByteLength += bufferifiedChunk.byteLength; + } else if (DEBUG_BUILD) { + logger.log( + INSTRUMENTATION_NAME, + `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, + ); + } + } catch (err) { + DEBUG_BUILD && logger.error(INSTRUMENTATION_NAME, 'Encountered error while storing body chunk.'); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + callbackMap.set(listener, callback); + + return Reflect.apply(target, thisArg, [event, callback, ...restArgs]); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + // Ensure we also remove callbacks correctly + // eslint-disable-next-line @typescript-eslint/unbound-method + req.off = new Proxy(req.off, { + apply: (target, thisArg, args: Parameters) => { + const [, listener] = args; + + const callback = callbackMap.get(listener); + if (callback) { + callbackMap.delete(listener); + + const modifiedArgs = args.slice(); + modifiedArgs[1] = callback; + return Reflect.apply(target, thisArg, modifiedArgs); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + req.on('end', () => { + try { + const body = Buffer.concat(chunks).toString('utf-8'); + if (body) { + // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long + const bodyByteLength = Buffer.byteLength(body, 'utf-8'); + const truncatedBody = + bodyByteLength > maxBodySize + ? `${Buffer.from(body) + .subarray(0, maxBodySize - 3) + .toString('utf-8')}...` + : body; + + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); + } + } catch (error) { + if (DEBUG_BUILD) { + logger.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); + } + } + }); + } catch (error) { + if (DEBUG_BUILD) { + logger.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error); + } + } +} + +function getRequestOptions(request: http.ClientRequest): http.RequestOptions { + return { + method: request.method, + protocol: request.protocol, + host: request.host, + hostname: request.host, + path: request.path, + headers: request.getHeaders(), + }; +} + +/** + * Starts a session and tracks it in the context of a given isolation scope. + * When the passed response is finished, the session is put into a task and is + * aggregated with other sessions that may happen in a certain time window + * (sessionFlushingDelayMs). + * + * The sessions are always aggregated by the client that is on the current scope + * at the time of ending the response (if there is one). + */ +// Exported for unit tests +export function recordRequestSession({ + requestIsolationScope, + response, + sessionFlushingDelayMS, +}: { + requestIsolationScope: Scope; + response: EventEmitter; + sessionFlushingDelayMS?: number; +}): void { + requestIsolationScope.setSDKProcessingMetadata({ + requestSession: { status: 'ok' }, + }); + response.once('close', () => { + // We need to grab the client off the current scope instead of the isolation scope because the isolation scope doesn't hold any client out of the box. + const client = getClient(); + const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession; + + if (client && requestSession) { + DEBUG_BUILD && logger.debug(`Recorded request session with status: ${requestSession.status}`); + + const roundedDate = new Date(); + roundedDate.setSeconds(0, 0); + const dateBucketKey = roundedDate.toISOString(); + + const existingClientAggregate = clientToRequestSessionAggregatesMap.get(client); + const bucket = existingClientAggregate?.[dateBucketKey] || { exited: 0, crashed: 0, errored: 0 }; + bucket[({ ok: 'exited', crashed: 'crashed', errored: 'errored' } as const)[requestSession.status]]++; + + if (existingClientAggregate) { + existingClientAggregate[dateBucketKey] = bucket; + } else { + DEBUG_BUILD && logger.debug('Opened new request session aggregate.'); + const newClientAggregate = { [dateBucketKey]: bucket }; + clientToRequestSessionAggregatesMap.set(client, newClientAggregate); + + const flushPendingClientAggregates = (): void => { + clearTimeout(timeout); + unregisterClientFlushHook(); + clientToRequestSessionAggregatesMap.delete(client); + + const aggregatePayload: AggregationCounts[] = Object.entries(newClientAggregate).map( + ([timestamp, value]) => ({ + started: timestamp, + exited: value.exited, + errored: value.errored, + crashed: value.crashed, + }), + ); + client.sendSession({ aggregates: aggregatePayload }); + }; + + const unregisterClientFlushHook = client.on('flush', () => { + DEBUG_BUILD && logger.debug('Sending request session aggregate due to client flush'); + flushPendingClientAggregates(); + }); + const timeout = setTimeout(() => { + DEBUG_BUILD && logger.debug('Sending request session aggregate due to flushing schedule'); + flushPendingClientAggregates(); + }, sessionFlushingDelayMS).unref(); + } + } + }); +} + +const clientToRequestSessionAggregatesMap = new Map< + Client, + { [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } } +>(); diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts new file mode 100644 index 000000000000..8bd69c22a8e7 --- /dev/null +++ b/packages/node-core/src/integrations/http/index.ts @@ -0,0 +1,141 @@ +import type { IncomingMessage, RequestOptions } from 'node:http'; +import { defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '../../otel/instrument'; +import type { SentryHttpInstrumentationOptions } from './SentryHttpInstrumentation'; +import { SentryHttpInstrumentation } from './SentryHttpInstrumentation'; + +const INTEGRATION_NAME = 'Http'; + +interface HttpOptions { + /** + * Whether breadcrumbs should be recorded for outgoing requests. + * Defaults to true + */ + breadcrumbs?: boolean; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. + * Read more about Release Health: https://docs.sentry.io/product/releases/health/ + * + * Defaults to `true`. + */ + trackIncomingRequestsAsSessions?: boolean; + + /** + * Number of milliseconds until sessions tracked with `trackIncomingRequestsAsSessions` will be flushed as a session aggregate. + * + * Defaults to `60000` (60s). + */ + sessionFlushingDelayMS?: number; + + /** + * Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + * + * The `url` param contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. + * For example: `'https://someService.com/users/details?id=123'` + * + * The `request` param contains the original {@type RequestOptions} object used to make the outgoing request. + * You can use it to filter on additional properties like method, headers, etc. + */ + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + + /** + * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`. + * Spans will be non recording if tracing is disabled. + * + * The `urlPath` param consists of the URL path and query string (if any) of the incoming request. + * For example: `'/users/details?id=123'` + * + * The `request` param contains the original {@type IncomingMessage} object of the incoming request. + * You can use it to filter on additional properties like method, headers, etc. + */ + ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + + /** + * Do not capture spans for incoming HTTP requests with the given status codes. + * By default, spans with 404 status code are ignored. + * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. + * + * @default `[404]` + */ + dropSpansForIncomingRequestStatusCodes?: (number | [number, number])[]; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request. + * @param request Contains the {@type RequestOptions} object used to make the incoming request. + */ + ignoreIncomingRequestBody?: (url: string, request: RequestOptions) => boolean; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * If true, do not generate spans for incoming requests at all. + * This is used by Remix to avoid generating spans for incoming requests, as it generates its own spans. + */ + disableIncomingRequestSpans?: boolean; +} + +const instrumentSentryHttp = generateInstrumentOnce( + `${INTEGRATION_NAME}.sentry`, + options => { + return new SentryHttpInstrumentation(options); + }, +); + +/** + * The http integration instruments Node's internal http and https modules. + * It creates breadcrumbs for outgoing HTTP requests which will be attached to the currently active span. + */ +export const httpIntegration = defineIntegration((options: HttpOptions = {}) => { + const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [404]; + + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentSentryHttp({ + ...options, + extractIncomingTraceFromHeader: true, + propagateTraceInOutgoingRequests: true, + }); + }, + processEvent(event) { + // Drop transaction if it has a status code that should be ignored + if (event.type === 'transaction') { + const statusCode = event.contexts?.trace?.data?.['http.response.status_code']; + if ( + typeof statusCode === 'number' && + dropSpansForIncomingRequestStatusCodes.some(code => { + if (typeof code === 'number') { + return code === statusCode; + } + + const [min, max] = code; + return statusCode >= min && statusCode <= max; + }) + ) { + return null; + } + } + + return event; + }, + }; +}); diff --git a/packages/node-core/src/integrations/local-variables/common.ts b/packages/node-core/src/integrations/local-variables/common.ts new file mode 100644 index 000000000000..58ccea70d6de --- /dev/null +++ b/packages/node-core/src/integrations/local-variables/common.ts @@ -0,0 +1,115 @@ +import type { Debugger } from 'node:inspector'; + +export type Variables = Record; + +export type RateLimitIncrement = () => void; + +/** + * The key used to store the local variables on the error object. + */ +export const LOCAL_VARIABLES_KEY = '__SENTRY_ERROR_LOCAL_VARIABLES__'; + +/** + * Creates a rate limiter that will call the disable callback when the rate limit is reached and the enable callback + * when a timeout has occurred. + * @param maxPerSecond Maximum number of calls per second + * @param enable Callback to enable capture + * @param disable Callback to disable capture + * @returns A function to call to increment the rate limiter count + */ +export function createRateLimiter( + maxPerSecond: number, + enable: () => void, + disable: (seconds: number) => void, +): RateLimitIncrement { + let count = 0; + let retrySeconds = 5; + let disabledTimeout = 0; + + setInterval(() => { + if (disabledTimeout === 0) { + if (count > maxPerSecond) { + retrySeconds *= 2; + disable(retrySeconds); + + // Cap at one day + if (retrySeconds > 86400) { + retrySeconds = 86400; + } + disabledTimeout = retrySeconds; + } + } else { + disabledTimeout -= 1; + + if (disabledTimeout === 0) { + enable(); + } + } + + count = 0; + }, 1_000).unref(); + + return () => { + count += 1; + }; +} + +// Add types for the exception event data +export type PausedExceptionEvent = Debugger.PausedEventDataType & { + data: { + // This contains error.stack + description: string; + objectId?: string; + }; +}; + +/** Could this be an anonymous function? */ +export function isAnonymous(name: string | undefined): boolean { + return name !== undefined && (name.length === 0 || name === '?' || name === ''); +} + +/** Do the function names appear to match? */ +export function functionNamesMatch(a: string | undefined, b: string | undefined): boolean { + return a === b || (isAnonymous(a) && isAnonymous(b)); +} + +export interface FrameVariables { + function: string; + vars?: Variables; +} + +export interface LocalVariablesIntegrationOptions { + /** + * Capture local variables for both caught and uncaught exceptions + * + * - When false, only uncaught exceptions will have local variables + * - When true, both caught and uncaught exceptions will have local variables. + * + * Defaults to `true`. + * + * Capturing local variables for all exceptions can be expensive since the debugger pauses for every throw to collect + * local variables. + * + * To reduce the likelihood of this feature impacting app performance or throughput, this feature is rate-limited. + * Once the rate limit is reached, local variables will only be captured for uncaught exceptions until a timeout has + * been reached. + */ + captureAllExceptions?: boolean; + /** + * Maximum number of exceptions to capture local variables for per second before rate limiting is triggered. + */ + maxExceptionsPerSecond?: number; +} + +export interface LocalVariablesWorkerArgs extends LocalVariablesIntegrationOptions { + /** + * Whether to enable debug logging. + */ + debug: boolean; + /** + * Base path used to calculate module name. + * + * Defaults to `dirname(process.argv[1])` and falls back to `process.cwd()` + */ + basePath?: string; +} diff --git a/packages/node-core/src/integrations/local-variables/index.ts b/packages/node-core/src/integrations/local-variables/index.ts new file mode 100644 index 000000000000..13a580383fcf --- /dev/null +++ b/packages/node-core/src/integrations/local-variables/index.ts @@ -0,0 +1,9 @@ +import type { Integration } from '@sentry/core'; +import { NODE_VERSION } from '../../nodeVersion'; +import type { LocalVariablesIntegrationOptions } from './common'; +import { localVariablesAsyncIntegration } from './local-variables-async'; +import { localVariablesSyncIntegration } from './local-variables-sync'; + +export const localVariablesIntegration = (options: LocalVariablesIntegrationOptions = {}): Integration => { + return NODE_VERSION.major < 19 ? localVariablesSyncIntegration(options) : localVariablesAsyncIntegration(options); +}; diff --git a/packages/node-core/src/integrations/local-variables/inspector.d.ts b/packages/node-core/src/integrations/local-variables/inspector.d.ts new file mode 100644 index 000000000000..5cfd496f7626 --- /dev/null +++ b/packages/node-core/src/integrations/local-variables/inspector.d.ts @@ -0,0 +1,39 @@ +/** + * @types/node doesn't have a `node:inspector/promises` module, maybe because it's still experimental? + */ +declare module 'node:inspector/promises' { + /** + * Async Debugger session + */ + class Session { + public constructor(); + + public connect(): void; + public connectToMainThread(): void; + + public post(method: 'Debugger.pause' | 'Debugger.resume' | 'Debugger.enable' | 'Debugger.disable'): Promise; + public post( + method: 'Debugger.setPauseOnExceptions', + params: Debugger.SetPauseOnExceptionsParameterType, + ): Promise; + public post( + method: 'Runtime.getProperties', + params: Runtime.GetPropertiesParameterType, + ): Promise; + public post( + method: 'Runtime.callFunctionOn', + params: Runtime.CallFunctionOnParameterType, + ): Promise; + public post( + method: 'Runtime.releaseObject', + params: Runtime.ReleaseObjectParameterType, + ): Promise; + + public on( + event: 'Debugger.paused', + listener: (message: InspectorNotification) => void, + ): Session; + + public on(event: 'Debugger.resumed', listener: () => void): Session; + } +} diff --git a/packages/node-core/src/integrations/local-variables/local-variables-async.ts b/packages/node-core/src/integrations/local-variables/local-variables-async.ts new file mode 100644 index 000000000000..6465760b5435 --- /dev/null +++ b/packages/node-core/src/integrations/local-variables/local-variables-async.ts @@ -0,0 +1,140 @@ +import { Worker } from 'node:worker_threads'; +import type { Event, EventHint, Exception, IntegrationFn } from '@sentry/core'; +import { defineIntegration, logger } from '@sentry/core'; +import type { NodeClient } from '../../sdk/client'; +import { isDebuggerEnabled } from '../../utils/debug'; +import type { FrameVariables, LocalVariablesIntegrationOptions, LocalVariablesWorkerArgs } from './common'; +import { functionNamesMatch, LOCAL_VARIABLES_KEY } from './common'; + +// This string is a placeholder that gets overwritten with the worker code. +export const base64WorkerScript = '###LocalVariablesWorkerScript###'; + +function log(...args: unknown[]): void { + logger.log('[LocalVariables]', ...args); +} + +/** + * Adds local variables to exception frames + */ +export const localVariablesAsyncIntegration = defineIntegration((( + integrationOptions: LocalVariablesIntegrationOptions = {}, +) => { + function addLocalVariablesToException(exception: Exception, localVariables: FrameVariables[]): void { + // Filter out frames where the function name is `new Promise` since these are in the error.stack frames + // but do not appear in the debugger call frames + const frames = (exception.stacktrace?.frames || []).filter(frame => frame.function !== 'new Promise'); + + for (let i = 0; i < frames.length; i++) { + // Sentry frames are in reverse order + const frameIndex = frames.length - i - 1; + + const frameLocalVariables = localVariables[i]; + const frame = frames[frameIndex]; + + if (!frame || !frameLocalVariables) { + // Drop out if we run out of frames to match up + break; + } + + if ( + // We need to have vars to add + frameLocalVariables.vars === undefined || + // We're not interested in frames that are not in_app because the vars are not relevant + frame.in_app === false || + // The function names need to match + !functionNamesMatch(frame.function, frameLocalVariables.function) + ) { + continue; + } + + frame.vars = frameLocalVariables.vars; + } + } + + function addLocalVariablesToEvent(event: Event, hint: EventHint): Event { + if ( + hint.originalException && + typeof hint.originalException === 'object' && + LOCAL_VARIABLES_KEY in hint.originalException && + Array.isArray(hint.originalException[LOCAL_VARIABLES_KEY]) + ) { + for (const exception of event.exception?.values || []) { + addLocalVariablesToException(exception, hint.originalException[LOCAL_VARIABLES_KEY]); + } + + hint.originalException[LOCAL_VARIABLES_KEY] = undefined; + } + + return event; + } + + async function startInspector(): Promise { + // We load inspector dynamically because on some platforms Node is built without inspector support + const inspector = await import('node:inspector'); + if (!inspector.url()) { + inspector.open(0); + } + } + + function startWorker(options: LocalVariablesWorkerArgs): void { + const worker = new Worker(new URL(`data:application/javascript;base64,${base64WorkerScript}`), { + workerData: options, + // We don't want any Node args to be passed to the worker + execArgv: [], + env: { ...process.env, NODE_OPTIONS: undefined }, + }); + + process.on('exit', () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + worker.terminate(); + }); + + worker.once('error', (err: Error) => { + log('Worker error', err); + }); + + worker.once('exit', (code: number) => { + log('Worker exit', code); + }); + + // Ensure this thread can't block app exit + worker.unref(); + } + + return { + name: 'LocalVariablesAsync', + async setup(client: NodeClient) { + const clientOptions = client.getOptions(); + + if (!clientOptions.includeLocalVariables) { + return; + } + + if (await isDebuggerEnabled()) { + logger.warn('Local variables capture has been disabled because the debugger was already enabled'); + return; + } + + const options: LocalVariablesWorkerArgs = { + ...integrationOptions, + debug: logger.isEnabled(), + }; + + startInspector().then( + () => { + try { + startWorker(options); + } catch (e) { + logger.error('Failed to start worker', e); + } + }, + e => { + logger.error('Failed to start inspector', e); + }, + ); + }, + processEvent(event: Event, hint: EventHint): Event { + return addLocalVariablesToEvent(event, hint); + }, + }; +}) satisfies IntegrationFn); diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts new file mode 100644 index 000000000000..495a0712eb80 --- /dev/null +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -0,0 +1,426 @@ +import type { Debugger, InspectorNotification, Runtime, Session } from 'node:inspector'; +import type { Event, Exception, IntegrationFn, StackFrame, StackParser } from '@sentry/core'; +import { defineIntegration, getClient, logger, LRUMap } from '@sentry/core'; +import { NODE_MAJOR } from '../../nodeVersion'; +import type { NodeClient } from '../../sdk/client'; +import { isDebuggerEnabled } from '../../utils/debug'; +import type { + FrameVariables, + LocalVariablesIntegrationOptions, + PausedExceptionEvent, + RateLimitIncrement, + Variables, +} from './common'; +import { createRateLimiter, functionNamesMatch } from './common'; + +/** Creates a unique hash from stack frames */ +export function hashFrames(frames: StackFrame[] | undefined): string | undefined { + if (frames === undefined) { + return; + } + + // Only hash the 10 most recent frames (ie. the last 10) + return frames.slice(-10).reduce((acc, frame) => `${acc},${frame.function},${frame.lineno},${frame.colno}`, ''); +} + +/** + * We use the stack parser to create a unique hash from the exception stack trace + * This is used to lookup vars when the exception passes through the event processor + */ +export function hashFromStack(stackParser: StackParser, stack: string | undefined): string | undefined { + if (stack === undefined) { + return undefined; + } + + return hashFrames(stackParser(stack, 1)); +} + +type OnPauseEvent = InspectorNotification; +export interface DebugSession { + /** Configures and connects to the debug session */ + configureAndConnect(onPause: (message: OnPauseEvent, complete: () => void) => void, captureAll: boolean): void; + /** Updates which kind of exceptions to capture */ + setPauseOnExceptions(captureAll: boolean): void; + /** Gets local variables for an objectId */ + getLocalVariables(objectId: string, callback: (vars: Variables) => void): void; +} + +type Next = (result: T) => void; +type Add = (fn: Next) => void; +type CallbackWrapper = { add: Add; next: Next }; + +/** Creates a container for callbacks to be called sequentially */ +export function createCallbackList(complete: Next): CallbackWrapper { + // A collection of callbacks to be executed last to first + let callbacks: Next[] = []; + + let completedCalled = false; + function checkedComplete(result: T): void { + callbacks = []; + if (completedCalled) { + return; + } + completedCalled = true; + complete(result); + } + + // complete should be called last + callbacks.push(checkedComplete); + + function add(fn: Next): void { + callbacks.push(fn); + } + + function next(result: T): void { + const popped = callbacks.pop() || checkedComplete; + + try { + popped(result); + } catch (_) { + // If there is an error, we still want to call the complete callback + checkedComplete(result); + } + } + + return { add, next }; +} + +/** + * Promise API is available as `Experimental` and in Node 19 only. + * + * Callback-based API is `Stable` since v14 and `Experimental` since v8. + * Because of that, we are creating our own `AsyncSession` class. + * + * https://nodejs.org/docs/latest-v19.x/api/inspector.html#promises-api + * https://nodejs.org/docs/latest-v14.x/api/inspector.html + */ +class AsyncSession implements DebugSession { + /** Throws if inspector API is not available */ + private constructor(private readonly _session: Session) { + // + } + + public static async create(orDefault?: DebugSession | undefined): Promise { + if (orDefault) { + return orDefault; + } + + const inspector = await import('node:inspector'); + return new AsyncSession(new inspector.Session()); + } + + /** @inheritdoc */ + public configureAndConnect(onPause: (event: OnPauseEvent, complete: () => void) => void, captureAll: boolean): void { + this._session.connect(); + + this._session.on('Debugger.paused', event => { + onPause(event, () => { + // After the pause work is complete, resume execution or the exception context memory is leaked + this._session.post('Debugger.resume'); + }); + }); + + this._session.post('Debugger.enable'); + this._session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' }); + } + + public setPauseOnExceptions(captureAll: boolean): void { + this._session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' }); + } + + /** @inheritdoc */ + public getLocalVariables(objectId: string, complete: (vars: Variables) => void): void { + this._getProperties(objectId, props => { + const { add, next } = createCallbackList(complete); + + for (const prop of props) { + if (prop.value?.objectId && prop.value.className === 'Array') { + const id = prop.value.objectId; + add(vars => this._unrollArray(id, prop.name, vars, next)); + } else if (prop.value?.objectId && prop.value.className === 'Object') { + const id = prop.value.objectId; + add(vars => this._unrollObject(id, prop.name, vars, next)); + } else if (prop.value) { + add(vars => this._unrollOther(prop, vars, next)); + } + } + + next({}); + }); + } + + /** + * Gets all the PropertyDescriptors of an object + */ + private _getProperties(objectId: string, next: (result: Runtime.PropertyDescriptor[]) => void): void { + this._session.post( + 'Runtime.getProperties', + { + objectId, + ownProperties: true, + }, + (err, params) => { + if (err) { + next([]); + } else { + next(params.result); + } + }, + ); + } + + /** + * Unrolls an array property + */ + private _unrollArray(objectId: string, name: string, vars: Variables, next: (vars: Variables) => void): void { + this._getProperties(objectId, props => { + vars[name] = props + .filter(v => v.name !== 'length' && !isNaN(parseInt(v.name, 10))) + .sort((a, b) => parseInt(a.name, 10) - parseInt(b.name, 10)) + .map(v => v.value?.value); + + next(vars); + }); + } + + /** + * Unrolls an object property + */ + private _unrollObject(objectId: string, name: string, vars: Variables, next: (obj: Variables) => void): void { + this._getProperties(objectId, props => { + vars[name] = props + .map<[string, unknown]>(v => [v.name, v.value?.value]) + .reduce((obj, [key, val]) => { + obj[key] = val; + return obj; + }, {} as Variables); + + next(vars); + }); + } + + /** + * Unrolls other properties + */ + private _unrollOther(prop: Runtime.PropertyDescriptor, vars: Variables, next: (vars: Variables) => void): void { + if (prop.value) { + if ('value' in prop.value) { + if (prop.value.value === undefined || prop.value.value === null) { + vars[prop.name] = `<${prop.value.value}>`; + } else { + vars[prop.name] = prop.value.value; + } + } else if ('description' in prop.value && prop.value.type !== 'function') { + vars[prop.name] = `<${prop.value.description}>`; + } else if (prop.value.type === 'undefined') { + vars[prop.name] = ''; + } + } + + next(vars); + } +} + +const INTEGRATION_NAME = 'LocalVariables'; + +/** + * Adds local variables to exception frames + */ +const _localVariablesSyncIntegration = (( + options: LocalVariablesIntegrationOptions = {}, + sessionOverride?: DebugSession, +) => { + const cachedFrames: LRUMap = new LRUMap(20); + let rateLimiter: RateLimitIncrement | undefined; + let shouldProcessEvent = false; + + function addLocalVariablesToException(exception: Exception): void { + const hash = hashFrames(exception.stacktrace?.frames); + + if (hash === undefined) { + return; + } + + // Check if we have local variables for an exception that matches the hash + // remove is identical to get but also removes the entry from the cache + const cachedFrame = cachedFrames.remove(hash); + + if (cachedFrame === undefined) { + return; + } + + // Filter out frames where the function name is `new Promise` since these are in the error.stack frames + // but do not appear in the debugger call frames + const frames = (exception.stacktrace?.frames || []).filter(frame => frame.function !== 'new Promise'); + + for (let i = 0; i < frames.length; i++) { + // Sentry frames are in reverse order + const frameIndex = frames.length - i - 1; + + const cachedFrameVariable = cachedFrame[i]; + const frameVariable = frames[frameIndex]; + + // Drop out if we run out of frames to match up + if (!frameVariable || !cachedFrameVariable) { + break; + } + + if ( + // We need to have vars to add + cachedFrameVariable.vars === undefined || + // We're not interested in frames that are not in_app because the vars are not relevant + frameVariable.in_app === false || + // The function names need to match + !functionNamesMatch(frameVariable.function, cachedFrameVariable.function) + ) { + continue; + } + + frameVariable.vars = cachedFrameVariable.vars; + } + } + + function addLocalVariablesToEvent(event: Event): Event { + for (const exception of event.exception?.values || []) { + addLocalVariablesToException(exception); + } + + return event; + } + + return { + name: INTEGRATION_NAME, + async setupOnce() { + const client = getClient(); + const clientOptions = client?.getOptions(); + + if (!clientOptions?.includeLocalVariables) { + return; + } + + // Only setup this integration if the Node version is >= v18 + // https://github.com/getsentry/sentry-javascript/issues/7697 + const unsupportedNodeVersion = NODE_MAJOR < 18; + + if (unsupportedNodeVersion) { + logger.log('The `LocalVariables` integration is only supported on Node >= v18.'); + return; + } + + if (await isDebuggerEnabled()) { + logger.warn('Local variables capture has been disabled because the debugger was already enabled'); + return; + } + + AsyncSession.create(sessionOverride).then( + session => { + function handlePaused( + stackParser: StackParser, + { params: { reason, data, callFrames } }: InspectorNotification, + complete: () => void, + ): void { + if (reason !== 'exception' && reason !== 'promiseRejection') { + complete(); + return; + } + + rateLimiter?.(); + + // data.description contains the original error.stack + const exceptionHash = hashFromStack(stackParser, data.description); + + if (exceptionHash == undefined) { + complete(); + return; + } + + const { add, next } = createCallbackList(frames => { + cachedFrames.set(exceptionHash, frames); + complete(); + }); + + // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack + // For this reason we only attempt to get local variables for the first 5 frames + for (let i = 0; i < Math.min(callFrames.length, 5); i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { scopeChain, functionName, this: obj } = callFrames[i]!; + + const localScope = scopeChain.find(scope => scope.type === 'local'); + + // obj.className is undefined in ESM modules + const fn = + obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + + if (localScope?.object.objectId === undefined) { + add(frames => { + frames[i] = { function: fn }; + next(frames); + }); + } else { + const id = localScope.object.objectId; + add(frames => + session.getLocalVariables(id, vars => { + frames[i] = { function: fn, vars }; + next(frames); + }), + ); + } + } + + next([]); + } + + const captureAll = options.captureAllExceptions !== false; + + session.configureAndConnect( + (ev, complete) => + handlePaused(clientOptions.stackParser, ev as InspectorNotification, complete), + captureAll, + ); + + if (captureAll) { + const max = options.maxExceptionsPerSecond || 50; + + rateLimiter = createRateLimiter( + max, + () => { + logger.log('Local variables rate-limit lifted.'); + session.setPauseOnExceptions(true); + }, + seconds => { + logger.log( + `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, + ); + session.setPauseOnExceptions(false); + }, + ); + } + + shouldProcessEvent = true; + }, + error => { + logger.log('The `LocalVariables` integration failed to start.', error); + }, + ); + }, + processEvent(event: Event): Event { + if (shouldProcessEvent) { + return addLocalVariablesToEvent(event); + } + + return event; + }, + // These are entirely for testing + _getCachedFramesCount(): number { + return cachedFrames.size; + }, + _getFirstCachedFrame(): FrameVariables[] | undefined { + return cachedFrames.values()[0]; + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds local variables to exception frames. + */ +export const localVariablesSyncIntegration = defineIntegration(_localVariablesSyncIntegration); diff --git a/packages/node-core/src/integrations/local-variables/worker.ts b/packages/node-core/src/integrations/local-variables/worker.ts new file mode 100644 index 000000000000..304c7d527ef7 --- /dev/null +++ b/packages/node-core/src/integrations/local-variables/worker.ts @@ -0,0 +1,189 @@ +import type { Debugger, InspectorNotification, Runtime } from 'node:inspector'; +import { Session } from 'node:inspector/promises'; +import { workerData } from 'node:worker_threads'; +import { consoleSandbox } from '@sentry/core'; +import type { LocalVariablesWorkerArgs, PausedExceptionEvent, RateLimitIncrement, Variables } from './common'; +import { createRateLimiter, LOCAL_VARIABLES_KEY } from './common'; + +const options: LocalVariablesWorkerArgs = workerData; + +function log(...args: unknown[]): void { + if (options.debug) { + // eslint-disable-next-line no-console + consoleSandbox(() => console.log('[LocalVariables Worker]', ...args)); + } +} + +async function unrollArray(session: Session, objectId: string, name: string, vars: Variables): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + + vars[name] = properties.result + .filter(v => v.name !== 'length' && !isNaN(parseInt(v.name, 10))) + .sort((a, b) => parseInt(a.name, 10) - parseInt(b.name, 10)) + .map(v => v.value?.value); +} + +async function unrollObject(session: Session, objectId: string, name: string, vars: Variables): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + + vars[name] = properties.result + .map<[string, unknown]>(v => [v.name, v.value?.value]) + .reduce((obj, [key, val]) => { + obj[key] = val; + return obj; + }, {} as Variables); +} + +function unrollOther(prop: Runtime.PropertyDescriptor, vars: Variables): void { + if (!prop.value) { + return; + } + + if ('value' in prop.value) { + if (prop.value.value === undefined || prop.value.value === null) { + vars[prop.name] = `<${prop.value.value}>`; + } else { + vars[prop.name] = prop.value.value; + } + } else if ('description' in prop.value && prop.value.type !== 'function') { + vars[prop.name] = `<${prop.value.description}>`; + } else if (prop.value.type === 'undefined') { + vars[prop.name] = ''; + } +} + +async function getLocalVariables(session: Session, objectId: string): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + const variables = {}; + + for (const prop of properties.result) { + if (prop.value?.objectId && prop.value.className === 'Array') { + const id = prop.value.objectId; + await unrollArray(session, id, prop.name, variables); + } else if (prop.value?.objectId && prop.value.className === 'Object') { + const id = prop.value.objectId; + await unrollObject(session, id, prop.name, variables); + } else if (prop.value) { + unrollOther(prop, variables); + } + } + + return variables; +} + +let rateLimiter: RateLimitIncrement | undefined; + +async function handlePaused( + session: Session, + { reason, data: { objectId }, callFrames }: PausedExceptionEvent, +): Promise { + if (reason !== 'exception' && reason !== 'promiseRejection') { + return; + } + + rateLimiter?.(); + + if (objectId == undefined) { + return; + } + + const frames = []; + + for (let i = 0; i < callFrames.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { scopeChain, functionName, this: obj } = callFrames[i]!; + + const localScope = scopeChain.find(scope => scope.type === 'local'); + + // obj.className is undefined in ESM modules + const fn = obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + + if (localScope?.object.objectId === undefined) { + frames[i] = { function: fn }; + } else { + const vars = await getLocalVariables(session, localScope.object.objectId); + frames[i] = { function: fn, vars }; + } + } + + // We write the local variables to a property on the error object. These can be read by the integration as the error + // event pass through the SDK event pipeline + await session.post('Runtime.callFunctionOn', { + functionDeclaration: `function() { this.${LOCAL_VARIABLES_KEY} = this.${LOCAL_VARIABLES_KEY} || ${JSON.stringify( + frames, + )}; }`, + silent: true, + objectId, + }); + + await session.post('Runtime.releaseObject', { objectId }); +} + +async function startDebugger(): Promise { + const session = new Session(); + session.connectToMainThread(); + + log('Connected to main thread'); + + let isPaused = false; + + session.on('Debugger.resumed', () => { + isPaused = false; + }); + + session.on('Debugger.paused', (event: InspectorNotification) => { + isPaused = true; + + handlePaused(session, event.params as PausedExceptionEvent).then( + async () => { + // After the pause work is complete, resume execution! + if (isPaused) { + await session.post('Debugger.resume'); + } + }, + async _ => { + if (isPaused) { + await session.post('Debugger.resume'); + } + }, + ); + }); + + await session.post('Debugger.enable'); + + const captureAll = options.captureAllExceptions !== false; + await session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' }); + + if (captureAll) { + const max = options.maxExceptionsPerSecond || 50; + + rateLimiter = createRateLimiter( + max, + async () => { + log('Rate-limit lifted.'); + await session.post('Debugger.setPauseOnExceptions', { state: 'all' }); + }, + async seconds => { + log(`Rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`); + await session.post('Debugger.setPauseOnExceptions', { state: 'uncaught' }); + }, + ); + } +} + +startDebugger().catch(e => { + log('Failed to start debugger', e); +}); + +setInterval(() => { + // Stop the worker from exiting +}, 10_000); diff --git a/packages/node-core/src/integrations/modules.ts b/packages/node-core/src/integrations/modules.ts new file mode 100644 index 000000000000..e15aa9dd245b --- /dev/null +++ b/packages/node-core/src/integrations/modules.ts @@ -0,0 +1,111 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, logger } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { isCjs } from '../utils/commonjs'; + +let moduleCache: { [key: string]: string }; + +const INTEGRATION_NAME = 'Modules'; + +const _modulesIntegration = (() => { + // This integration only works in CJS contexts + if (!isCjs()) { + DEBUG_BUILD && + logger.warn( + 'modulesIntegration only works in CommonJS (CJS) environments. Remove this integration if you are using ESM.', + ); + return { + name: INTEGRATION_NAME, + }; + } + + return { + name: INTEGRATION_NAME, + processEvent(event) { + event.modules = { + ...event.modules, + ..._getModules(), + }; + + return event; + }, + }; +}) satisfies IntegrationFn; + +/** + * Add node modules / packages to the event. + * + * Only works in CommonJS (CJS) environments. + */ +export const modulesIntegration = defineIntegration(_modulesIntegration); + +/** Extract information about paths */ +function getPaths(): string[] { + try { + return require.cache ? Object.keys(require.cache as Record) : []; + } catch (e) { + return []; + } +} + +/** Extract information about package.json modules */ +function collectModules(): { + [name: string]: string; +} { + const mainPaths = require.main?.paths || []; + const paths = getPaths(); + const infos: { + [name: string]: string; + } = {}; + const seen: { + [path: string]: boolean; + } = {}; + + paths.forEach(path => { + let dir = path; + + /** Traverse directories upward in the search of package.json file */ + const updir = (): void | (() => void) => { + const orig = dir; + dir = dirname(orig); + + if (!dir || orig === dir || seen[orig]) { + return undefined; + } + if (mainPaths.indexOf(dir) < 0) { + return updir(); + } + + const pkgfile = join(orig, 'package.json'); + seen[orig] = true; + + if (!existsSync(pkgfile)) { + return updir(); + } + + try { + const info = JSON.parse(readFileSync(pkgfile, 'utf8')) as { + name: string; + version: string; + }; + infos[info.name] = info.version; + } catch (_oO) { + // no-empty + } + }; + + updir(); + }); + + return infos; +} + +/** Fetches the list of modules and the versions loaded by the entry file for your node.js app. */ +function _getModules(): { [key: string]: string } { + if (!moduleCache) { + moduleCache = collectModules(); + } + return moduleCache; +} diff --git a/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts b/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts new file mode 100644 index 000000000000..e4dfa9ba9037 --- /dev/null +++ b/packages/node-core/src/integrations/node-fetch/SentryNodeFetchInstrumentation.ts @@ -0,0 +1,324 @@ +import { context } from '@opentelemetry/api'; +import { isTracingSuppressed } from '@opentelemetry/core'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { InstrumentationBase } from '@opentelemetry/instrumentation'; +import type { SanitizedRequestData } from '@sentry/core'; +import { + addBreadcrumb, + getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + getSanitizedUrlString, + getTraceData, + LRUMap, + parseUrl, +} from '@sentry/core'; +import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; +import * as diagch from 'diagnostics_channel'; +import { NODE_MAJOR, NODE_MINOR } from '../../nodeVersion'; +import { mergeBaggageHeaders } from '../../utils/baggage'; +import type { UndiciRequest, UndiciResponse } from './types'; + +const SENTRY_TRACE_HEADER = 'sentry-trace'; +const SENTRY_BAGGAGE_HEADER = 'baggage'; + +// For baggage, we make sure to merge this into a possibly existing header +const BAGGAGE_HEADER_REGEX = /baggage: (.*)\r\n/; + +const VERSION = '9.22.0'; + +export type SentryNodeFetchInstrumentationOptions = InstrumentationConfig & { + /** + * Whether breadcrumbs should be recorded for requests. + * + * @default `true` + */ + breadcrumbs?: boolean; + + /** + * Do not capture breadcrumbs or inject headers for outgoing fetch requests to URLs where the given callback returns `true`. + * The same option can be passed to the top-level httpIntegration where it controls both, breadcrumb and + * span creation. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. + */ + ignoreOutgoingRequests?: (url: string) => boolean; +}; + +interface ListenerRecord { + name: string; + unsubscribe: () => void; +} + +/** + * This custom node-fetch instrumentation is used to instrument outgoing fetch requests. + * It does not emit any spans. + * + * The reason this is isolated from the OpenTelemetry instrumentation is that users may overwrite this, + * which would lead to Sentry not working as expected. + * + * This is heavily inspired & adapted from: + * https://github.com/open-telemetry/opentelemetry-js-contrib/blob/28e209a9da36bc4e1f8c2b0db7360170ed46cb80/plugins/node/instrumentation-undici/src/undici.ts + */ +export class SentryNodeFetchInstrumentation extends InstrumentationBase { + // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for + // unsubscribing. + private _channelSubs: Array; + private _propagationDecisionMap: LRUMap; + private _ignoreOutgoingRequestsMap: WeakMap; + + public constructor(config: SentryNodeFetchInstrumentationOptions = {}) { + super('@sentry/instrumentation-node-fetch', VERSION, config); + this._channelSubs = []; + this._propagationDecisionMap = new LRUMap(100); + this._ignoreOutgoingRequestsMap = new WeakMap(); + } + + /** No need to instrument files/modules. */ + public init(): void { + return undefined; + } + + /** Disable the instrumentation. */ + public disable(): void { + super.disable(); + this._channelSubs.forEach(sub => sub.unsubscribe()); + this._channelSubs = []; + } + + /** Enable the instrumentation. */ + public enable(): void { + // "enabled" handling is currently a bit messy with InstrumentationBase. + // If constructed with `{enabled: false}`, this `.enable()` is still called, + // and `this.getConfig().enabled !== this.isEnabled()`, creating confusion. + // + // For now, this class will setup for instrumenting if `.enable()` is + // called, but use `this.getConfig().enabled` to determine if + // instrumentation should be generated. This covers the more likely common + // case of config being given a construction time, rather than later via + // `instance.enable()`, `.disable()`, or `.setConfig()` calls. + super.enable(); + + // This method is called by the super-class constructor before ours is + // called. So we need to ensure the property is initalized. + this._channelSubs = this._channelSubs || []; + + // Avoid to duplicate subscriptions + if (this._channelSubs.length > 0) { + return; + } + + this._subscribeToChannel('undici:request:create', this._onRequestCreated.bind(this)); + this._subscribeToChannel('undici:request:headers', this._onResponseHeaders.bind(this)); + } + + /** + * This method is called when a request is created. + * You can still mutate the request here before it is sent. + */ + private _onRequestCreated({ request }: { request: UndiciRequest }): void { + const config = this.getConfig(); + const enabled = config.enabled !== false; + + if (!enabled) { + return; + } + + const shouldIgnore = this._shouldIgnoreOutgoingRequest(request); + // We store this decisision for later so we do not need to re-evaluate it + // Additionally, the active context is not correct in _onResponseHeaders, so we need to make sure it is evaluated here + this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); + + if (shouldIgnore) { + return; + } + + const url = getAbsoluteUrl(request.origin, request.path); + + // Manually add the trace headers, if it applies + // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span + // Which we do not have in this case + // The propagator _may_ overwrite this, but this should be fine as it is the same data + const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets; + const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, this._propagationDecisionMap) + ? getTraceData() + : undefined; + + if (!addedHeaders) { + return; + } + const { 'sentry-trace': sentryTrace, baggage } = addedHeaders; + + // We do not want to overwrite existing headers here + // If the core UndiciInstrumentation is registered, it will already have set the headers + // We do not want to add any then + if (Array.isArray(request.headers)) { + const requestHeaders = request.headers; + + // We do not want to overwrite existing header here, if it was already set + if (sentryTrace && !requestHeaders.includes(SENTRY_TRACE_HEADER)) { + requestHeaders.push(SENTRY_TRACE_HEADER, sentryTrace); + } + + // For baggage, we make sure to merge this into a possibly existing header + const existingBaggagePos = requestHeaders.findIndex(header => header === SENTRY_BAGGAGE_HEADER); + if (baggage && existingBaggagePos === -1) { + requestHeaders.push(SENTRY_BAGGAGE_HEADER, baggage); + } else if (baggage) { + const existingBaggage = requestHeaders[existingBaggagePos + 1]; + const merged = mergeBaggageHeaders(existingBaggage, baggage); + if (merged) { + requestHeaders[existingBaggagePos + 1] = merged; + } + } + } else { + const requestHeaders = request.headers; + // We do not want to overwrite existing header here, if it was already set + if (sentryTrace && !requestHeaders.includes(`${SENTRY_TRACE_HEADER}:`)) { + request.headers += `${SENTRY_TRACE_HEADER}: ${sentryTrace}\r\n`; + } + + const existingBaggage = request.headers.match(BAGGAGE_HEADER_REGEX)?.[1]; + if (baggage && !existingBaggage) { + request.headers += `${SENTRY_BAGGAGE_HEADER}: ${baggage}\r\n`; + } else if (baggage) { + const merged = mergeBaggageHeaders(existingBaggage, baggage); + if (merged) { + request.headers = request.headers.replace(BAGGAGE_HEADER_REGEX, `baggage: ${merged}\r\n`); + } + } + } + } + + /** + * This method is called when a response is received. + */ + private _onResponseHeaders({ request, response }: { request: UndiciRequest; response: UndiciResponse }): void { + const config = this.getConfig(); + const enabled = config.enabled !== false; + + if (!enabled) { + return; + } + + const _breadcrumbs = config.breadcrumbs; + const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; + + const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request); + + if (breadCrumbsEnabled && !shouldIgnore) { + addRequestBreadcrumb(request, response); + } + } + + /** Subscribe to a diagnostics channel. */ + private _subscribeToChannel( + diagnosticChannel: string, + onMessage: (message: unknown, name: string | symbol) => void, + ): void { + // `diagnostics_channel` had a ref counting bug until v18.19.0. + // https://github.com/nodejs/node/pull/47520 + const useNewSubscribe = NODE_MAJOR > 18 || (NODE_MAJOR === 18 && NODE_MINOR >= 19); + + let unsubscribe: () => void; + if (useNewSubscribe) { + diagch.subscribe?.(diagnosticChannel, onMessage); + unsubscribe = () => diagch.unsubscribe?.(diagnosticChannel, onMessage); + } else { + const channel = diagch.channel(diagnosticChannel); + channel.subscribe(onMessage); + unsubscribe = () => channel.unsubscribe(onMessage); + } + + this._channelSubs.push({ + name: diagnosticChannel, + unsubscribe, + }); + } + + /** + * Check if the given outgoing request should be ignored. + */ + private _shouldIgnoreOutgoingRequest(request: UndiciRequest): boolean { + if (isTracingSuppressed(context.active())) { + return true; + } + + // Add trace propagation headers + const url = getAbsoluteUrl(request.origin, request.path); + const ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests; + + if (typeof ignoreOutgoingRequests !== 'function' || !url) { + return false; + } + + return ignoreOutgoingRequests(url); + } +} + +/** Add a breadcrumb for outgoing requests. */ +function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void { + const data = getBreadcrumbData(request); + + const statusCode = response.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + + addBreadcrumb( + { + category: 'http', + data: { + status_code: statusCode, + ...data, + }, + type: 'http', + level, + }, + { + event: 'response', + request, + response, + }, + ); +} + +function getBreadcrumbData(request: UndiciRequest): Partial { + try { + const url = getAbsoluteUrl(request.origin, request.path); + const parsedUrl = parseUrl(url); + + const data: Partial = { + url: getSanitizedUrlString(parsedUrl), + 'http.method': request.method || 'GET', + }; + + if (parsedUrl.search) { + data['http.query'] = parsedUrl.search; + } + if (parsedUrl.hash) { + data['http.fragment'] = parsedUrl.hash; + } + + return data; + } catch { + return {}; + } +} + +function getAbsoluteUrl(origin: string, path: string = '/'): string { + try { + const url = new URL(path, origin); + return url.toString(); + } catch { + // fallback: Construct it on our own + const url = `${origin}`; + + if (url.endsWith('/') && path.startsWith('/')) { + return `${url}${path.slice(1)}`; + } + + if (!url.endsWith('/') && !path.startsWith('/')) { + return `${url}/${path.slice(1)}`; + } + + return `${url}${path}`; + } +} diff --git a/packages/node-core/src/integrations/node-fetch/index.ts b/packages/node-core/src/integrations/node-fetch/index.ts new file mode 100644 index 000000000000..4ca41bddfda8 --- /dev/null +++ b/packages/node-core/src/integrations/node-fetch/index.ts @@ -0,0 +1,39 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '../../otel/instrument'; +import { SentryNodeFetchInstrumentation } from './SentryNodeFetchInstrumentation'; + +const INTEGRATION_NAME = 'NodeFetch'; + +interface NodeFetchOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * Defaults to true + */ + breadcrumbs?: boolean; + + /** + * Do not capture spans or breadcrumbs for outgoing fetch requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + */ + ignoreOutgoingRequests?: (url: string) => boolean; +} + +const instrumentSentryNodeFetch = generateInstrumentOnce( + `${INTEGRATION_NAME}.sentry`, + SentryNodeFetchInstrumentation, + (options: NodeFetchOptions) => { + return options; + }, +); + +const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { + return { + name: 'NodeFetch', + setupOnce() { + instrumentSentryNodeFetch(options); + }, + }; +}) satisfies IntegrationFn; + +export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchIntegration); diff --git a/packages/node-core/src/integrations/node-fetch/types.ts b/packages/node-core/src/integrations/node-fetch/types.ts new file mode 100644 index 000000000000..0139dadde413 --- /dev/null +++ b/packages/node-core/src/integrations/node-fetch/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Vendored from https://github.com/open-telemetry/opentelemetry-js-contrib/blob/28e209a9da36bc4e1f8c2b0db7360170ed46cb80/plugins/node/instrumentation-undici/src/types.ts + */ + +export interface UndiciRequest { + origin: string; + method: string; + path: string; + /** + * Serialized string of headers in the form `name: value\r\n` for v5 + * Array of strings v6 + */ + headers: string | string[]; + /** + * Helper method to add headers (from v6) + */ + addHeader: (name: string, value: string) => void; + throwOnError: boolean; + completed: boolean; + aborted: boolean; + idempotent: boolean; + contentLength: number | null; + contentType: string | null; + body: unknown; +} + +export interface UndiciResponse { + headers: Buffer[]; + statusCode: number; + statusText: string; +} diff --git a/packages/node-core/src/integrations/onuncaughtexception.ts b/packages/node-core/src/integrations/onuncaughtexception.ts new file mode 100644 index 000000000000..0634159338a6 --- /dev/null +++ b/packages/node-core/src/integrations/onuncaughtexception.ts @@ -0,0 +1,160 @@ +import { captureException, defineIntegration, getClient, logger } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import type { NodeClient } from '../sdk/client'; +import { logAndExitProcess } from '../utils/errorhandling'; + +type OnFatalErrorHandler = (firstError: Error, secondError?: Error) => void; + +type TaggedListener = NodeJS.UncaughtExceptionListener & { + tag?: string; +}; + +interface OnUncaughtExceptionOptions { + /** + * Controls if the SDK should register a handler to exit the process on uncaught errors: + * - `true`: The SDK will exit the process on all uncaught errors. + * - `false`: The SDK will only exit the process when there are no other `uncaughtException` handlers attached. + * + * Default: `false` + */ + exitEvenIfOtherHandlersAreRegistered: boolean; + + /** + * This is called when an uncaught error would cause the process to exit. + * + * @param firstError Uncaught error causing the process to exit + * @param secondError Will be set if the handler was called multiple times. This can happen either because + * `onFatalError` itself threw, or because an independent error happened somewhere else while `onFatalError` + * was running. + */ + onFatalError?(this: void, firstError: Error, secondError?: Error): void; +} + +const INTEGRATION_NAME = 'OnUncaughtException'; + +/** + * Add a global exception handler. + */ +export const onUncaughtExceptionIntegration = defineIntegration((options: Partial = {}) => { + const optionsWithDefaults = { + exitEvenIfOtherHandlersAreRegistered: false, + ...options, + }; + + return { + name: INTEGRATION_NAME, + setup(client: NodeClient) { + global.process.on('uncaughtException', makeErrorHandler(client, optionsWithDefaults)); + }, + }; +}); + +type ErrorHandler = { _errorHandler: boolean } & ((error: Error) => void); + +/** Exported only for tests */ +export function makeErrorHandler(client: NodeClient, options: OnUncaughtExceptionOptions): ErrorHandler { + const timeout = 2000; + let caughtFirstError: boolean = false; + let caughtSecondError: boolean = false; + let calledFatalError: boolean = false; + let firstError: Error; + + const clientOptions = client.getOptions(); + + return Object.assign( + (error: Error): void => { + let onFatalError: OnFatalErrorHandler = logAndExitProcess; + + if (options.onFatalError) { + onFatalError = options.onFatalError; + } else if (clientOptions.onFatalError) { + onFatalError = clientOptions.onFatalError as OnFatalErrorHandler; + } + + // Attaching a listener to `uncaughtException` will prevent the node process from exiting. We generally do not + // want to alter this behaviour so we check for other listeners that users may have attached themselves and adjust + // exit behaviour of the SDK accordingly: + // - If other listeners are attached, do not exit. + // - If the only listener attached is ours, exit. + const userProvidedListenersCount = (global.process.listeners('uncaughtException') as TaggedListener[]).filter( + listener => { + // There are 3 listeners we ignore: + return ( + // as soon as we're using domains this listener is attached by node itself + listener.name !== 'domainUncaughtExceptionClear' && + // the handler we register for tracing + listener.tag !== 'sentry_tracingErrorCallback' && + // the handler we register in this integration + (listener as ErrorHandler)._errorHandler !== true + ); + }, + ).length; + + const processWouldExit = userProvidedListenersCount === 0; + const shouldApplyFatalHandlingLogic = options.exitEvenIfOtherHandlersAreRegistered || processWouldExit; + + if (!caughtFirstError) { + // this is the first uncaught error and the ultimate reason for shutting down + // we want to do absolutely everything possible to ensure it gets captured + // also we want to make sure we don't go recursion crazy if more errors happen after this one + firstError = error; + caughtFirstError = true; + + if (getClient() === client) { + captureException(error, { + originalException: error, + captureContext: { + level: 'fatal', + }, + mechanism: { + handled: false, + type: 'onuncaughtexception', + }, + }); + } + + if (!calledFatalError && shouldApplyFatalHandlingLogic) { + calledFatalError = true; + onFatalError(error); + } + } else { + if (shouldApplyFatalHandlingLogic) { + if (calledFatalError) { + // we hit an error *after* calling onFatalError - pretty boned at this point, just shut it down + DEBUG_BUILD && + logger.warn( + 'uncaught exception after calling fatal error shutdown callback - this is bad! forcing shutdown', + ); + logAndExitProcess(error); + } else if (!caughtSecondError) { + // two cases for how we can hit this branch: + // - capturing of first error blew up and we just caught the exception from that + // - quit trying to capture, proceed with shutdown + // - a second independent error happened while waiting for first error to capture + // - want to avoid causing premature shutdown before first error capture finishes + // it's hard to immediately tell case 1 from case 2 without doing some fancy/questionable domain stuff + // so let's instead just delay a bit before we proceed with our action here + // in case 1, we just wait a bit unnecessarily but ultimately do the same thing + // in case 2, the delay hopefully made us wait long enough for the capture to finish + // two potential nonideal outcomes: + // nonideal case 1: capturing fails fast, we sit around for a few seconds unnecessarily before proceeding correctly by calling onFatalError + // nonideal case 2: case 2 happens, 1st error is captured but slowly, timeout completes before capture and we treat second error as the sendErr of (nonexistent) failure from trying to capture first error + // note that after hitting this branch, we might catch more errors where (caughtSecondError && !calledFatalError) + // we ignore them - they don't matter to us, we're just waiting for the second error timeout to finish + caughtSecondError = true; + setTimeout(() => { + if (!calledFatalError) { + // it was probably case 1, let's treat err as the sendErr and call onFatalError + calledFatalError = true; + onFatalError(firstError, error); + } else { + // it was probably case 2, our first error finished capturing while we waited, cool, do nothing + } + }, timeout); // capturing could take at least sendTimeout to fail, plus an arbitrary second for how long it takes to collect surrounding source etc + } + } + } + }, + { _errorHandler: true }, + ); +} diff --git a/packages/node-core/src/integrations/onunhandledrejection.ts b/packages/node-core/src/integrations/onunhandledrejection.ts new file mode 100644 index 000000000000..8b41da189a0f --- /dev/null +++ b/packages/node-core/src/integrations/onunhandledrejection.ts @@ -0,0 +1,94 @@ +import type { Client, IntegrationFn, SeverityLevel } from '@sentry/core'; +import { captureException, consoleSandbox, defineIntegration, getClient } from '@sentry/core'; +import { logAndExitProcess } from '../utils/errorhandling'; + +type UnhandledRejectionMode = 'none' | 'warn' | 'strict'; + +interface OnUnhandledRejectionOptions { + /** + * Option deciding what to do after capturing unhandledRejection, + * that mimicks behavior of node's --unhandled-rejection flag. + */ + mode: UnhandledRejectionMode; +} + +const INTEGRATION_NAME = 'OnUnhandledRejection'; + +const _onUnhandledRejectionIntegration = ((options: Partial = {}) => { + const opts = { + mode: 'warn', + ...options, + } satisfies OnUnhandledRejectionOptions; + + return { + name: INTEGRATION_NAME, + setup(client) { + global.process.on('unhandledRejection', makeUnhandledPromiseHandler(client, opts)); + }, + }; +}) satisfies IntegrationFn; + +/** + * Add a global promise rejection handler. + */ +export const onUnhandledRejectionIntegration = defineIntegration(_onUnhandledRejectionIntegration); + +/** + * Send an exception with reason + * @param reason string + * @param promise promise + * + * Exported only for tests. + */ +export function makeUnhandledPromiseHandler( + client: Client, + options: OnUnhandledRejectionOptions, +): (reason: unknown, promise: unknown) => void { + return function sendUnhandledPromise(reason: unknown, promise: unknown): void { + if (getClient() !== client) { + return; + } + + const level: SeverityLevel = options.mode === 'strict' ? 'fatal' : 'error'; + + captureException(reason, { + originalException: promise, + captureContext: { + extra: { unhandledPromiseRejection: true }, + level, + }, + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }); + + handleRejection(reason, options.mode); + }; +} + +/** + * Handler for `mode` option + */ +function handleRejection(reason: unknown, mode: UnhandledRejectionMode): void { + // https://github.com/nodejs/node/blob/7cf6f9e964aa00772965391c23acda6d71972a9a/lib/internal/process/promises.js#L234-L240 + const rejectionWarning = + 'This error originated either by ' + + 'throwing inside of an async function without a catch block, ' + + 'or by rejecting a promise which was not handled with .catch().' + + ' The promise rejected with the reason:'; + + /* eslint-disable no-console */ + if (mode === 'warn') { + consoleSandbox(() => { + console.warn(rejectionWarning); + console.error(reason && typeof reason === 'object' && 'stack' in reason ? reason.stack : reason); + }); + } else if (mode === 'strict') { + consoleSandbox(() => { + console.warn(rejectionWarning); + }); + logAndExitProcess(reason); + } + /* eslint-enable no-console */ +} diff --git a/packages/node-core/src/integrations/processSession.ts b/packages/node-core/src/integrations/processSession.ts new file mode 100644 index 000000000000..16cb9509b6e2 --- /dev/null +++ b/packages/node-core/src/integrations/processSession.ts @@ -0,0 +1,31 @@ +import { defineIntegration, endSession, getIsolationScope, startSession } from '@sentry/core'; + +const INTEGRATION_NAME = 'ProcessSession'; + +/** + * Records a Session for the current process to track release health. + */ +export const processSessionIntegration = defineIntegration(() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + startSession(); + + // Emitted in the case of healthy sessions, error of `mechanism.handled: true` and unhandledrejections because + // The 'beforeExit' event is not emitted for conditions causing explicit termination, + // such as calling process.exit() or uncaught exceptions. + // Ref: https://nodejs.org/api/process.html#process_event_beforeexit + process.on('beforeExit', () => { + const session = getIsolationScope().getSession(); + + // Only call endSession, if the Session exists on Scope and SessionStatus is not a + // Terminal Status i.e. Exited or Crashed because + // "When a session is moved away from ok it must not be updated anymore." + // Ref: https://develop.sentry.dev/sdk/sessions/ + if (session?.status !== 'ok') { + endSession(); + } + }); + }, + }; +}); diff --git a/packages/node-core/src/integrations/spotlight.ts b/packages/node-core/src/integrations/spotlight.ts new file mode 100644 index 000000000000..49a169076798 --- /dev/null +++ b/packages/node-core/src/integrations/spotlight.ts @@ -0,0 +1,118 @@ +import * as http from 'node:http'; +import type { Client, Envelope, IntegrationFn } from '@sentry/core'; +import { defineIntegration, logger, serializeEnvelope } from '@sentry/core'; + +type SpotlightConnectionOptions = { + /** + * Set this if the Spotlight Sidecar is not running on localhost:8969 + * By default, the Url is set to http://localhost:8969/stream + */ + sidecarUrl?: string; +}; + +export const INTEGRATION_NAME = 'Spotlight'; + +const _spotlightIntegration = ((options: Partial = {}) => { + const _options = { + sidecarUrl: options.sidecarUrl || 'http://localhost:8969/stream', + }; + + return { + name: INTEGRATION_NAME, + setup(client) { + if (typeof process === 'object' && process.env && process.env.NODE_ENV !== 'development') { + logger.warn("[Spotlight] It seems you're not in dev mode. Do you really want to have Spotlight enabled?"); + } + connectToSpotlight(client, _options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Use this integration to send errors and transactions to Spotlight. + * + * Learn more about spotlight at https://spotlightjs.com + * + * Important: This integration only works with Node 18 or newer. + */ +export const spotlightIntegration = defineIntegration(_spotlightIntegration); + +function connectToSpotlight(client: Client, options: Required): void { + const spotlightUrl = parseSidecarUrl(options.sidecarUrl); + if (!spotlightUrl) { + return; + } + + let failedRequests = 0; + + client.on('beforeEnvelope', (envelope: Envelope) => { + if (failedRequests > 3) { + logger.warn('[Spotlight] Disabled Sentry -> Spotlight integration due to too many failed requests'); + return; + } + + const serializedEnvelope = serializeEnvelope(envelope); + + const request = getNativeHttpRequest(); + const req = request( + { + method: 'POST', + path: spotlightUrl.pathname, + hostname: spotlightUrl.hostname, + port: spotlightUrl.port, + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + }, + res => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400) { + // Reset failed requests counter on success + failedRequests = 0; + } + res.on('data', () => { + // Drain socket + }); + + res.on('end', () => { + // Drain socket + }); + res.setEncoding('utf8'); + }, + ); + + req.on('error', () => { + failedRequests++; + logger.warn('[Spotlight] Failed to send envelope to Spotlight Sidecar'); + }); + req.write(serializedEnvelope); + req.end(); + }); +} + +function parseSidecarUrl(url: string): URL | undefined { + try { + return new URL(`${url}`); + } catch { + logger.warn(`[Spotlight] Invalid sidecar URL: ${url}`); + return undefined; + } +} + +type HttpRequestImpl = typeof http.request; +type WrappedHttpRequest = HttpRequestImpl & { __sentry_original__: HttpRequestImpl }; + +/** + * We want to get an unpatched http request implementation to avoid capturing our own calls. + */ +export function getNativeHttpRequest(): HttpRequestImpl { + const { request } = http; + if (isWrapped(request)) { + return request.__sentry_original__; + } + + return request; +} + +function isWrapped(impl: HttpRequestImpl): impl is WrappedHttpRequest { + return '__sentry_original__' in impl; +} diff --git a/packages/node-core/src/integrations/winston.ts b/packages/node-core/src/integrations/winston.ts new file mode 100644 index 000000000000..a485a6c56431 --- /dev/null +++ b/packages/node-core/src/integrations/winston.ts @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { LogSeverityLevel } from '@sentry/core'; +import { captureLog } from '../logs/capture'; + +const DEFAULT_CAPTURED_LEVELS: Array = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; + +// See: https://github.com/winstonjs/triple-beam +const LEVEL_SYMBOL = Symbol.for('level'); +const MESSAGE_SYMBOL = Symbol.for('message'); +const SPLAT_SYMBOL = Symbol.for('splat'); + +/** + * Options for the Sentry Winston transport. + */ +interface WinstonTransportOptions { + /** + * Use this option to filter which levels should be captured. By default, all levels are captured. + * + * @example + * ```ts + * const SentryWinstonTransport = Sentry.createSentryWinstonTransport(Transport, { + * // Only capture error and warn logs + * levels: ['error', 'warn'], + * }); + * ``` + */ + levels?: Array; +} + +/** + * Creates a new Sentry Winston transport that fowards logs to Sentry. Requires `_experiments.enableLogs` to be enabled. + * + * Supports Winston 3.x.x. + * + * @param TransportClass - The Winston transport class to extend. + * @returns The extended transport class. + * + * @experimental This method will experience breaking changes. This is not yet part of + * the stable Sentry SDK API and can be changed or removed without warning. + * + * @example + * ```ts + * const winston = require('winston'); + * const Transport = require('winston-transport'); + * + * const SentryWinstonTransport = Sentry.createSentryWinstonTransport(Transport); + * + * const logger = winston.createLogger({ + * transports: [new SentryWinstonTransport()], + * }); + * ``` + */ +export function createSentryWinstonTransport( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TransportClass: new (options?: any) => TransportStreamInstance, + sentryWinstonOptions?: WinstonTransportOptions, +): typeof TransportClass { + // @ts-ignore - We know this is safe because SentryWinstonTransport extends TransportClass + class SentryWinstonTransport extends TransportClass { + private _levels: Set; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public constructor(options?: any) { + super(options); + this._levels = new Set(sentryWinstonOptions?.levels ?? DEFAULT_CAPTURED_LEVELS); + } + + /** + * Forwards a winston log to the Sentry SDK. + */ + public log(info: unknown, callback: () => void): void { + try { + setImmediate(() => { + // @ts-ignore - We know this is safe because SentryWinstonTransport extends TransportClass + this.emit('logged', info); + }); + + if (!isObject(info)) { + return; + } + + const levelFromSymbol = info[LEVEL_SYMBOL]; + + // See: https://github.com/winstonjs/winston?tab=readme-ov-file#streams-objectmode-and-info-objects + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { level, message, timestamp, ...attributes } = info; + // Remove all symbols from the remaining attributes + attributes[LEVEL_SYMBOL] = undefined; + attributes[MESSAGE_SYMBOL] = undefined; + attributes[SPLAT_SYMBOL] = undefined; + + const logSeverityLevel = WINSTON_LEVEL_TO_LOG_SEVERITY_LEVEL_MAP[levelFromSymbol as string] ?? 'info'; + if (this._levels.has(logSeverityLevel)) { + captureLog(logSeverityLevel, message as string, { + ...attributes, + 'sentry.origin': 'auto.logging.winston', + }); + } + } catch { + // do nothing + } + + if (callback) { + callback(); + } + } + } + + return SentryWinstonTransport as typeof TransportClass; +} + +function isObject(anything: unknown): anything is Record { + return typeof anything === 'object' && anything != null; +} + +// npm +// { +// error: 0, +// warn: 1, +// info: 2, +// http: 3, +// verbose: 4, +// debug: 5, +// silly: 6 +// } +// +// syslog +// { +// emerg: 0, +// alert: 1, +// crit: 2, +// error: 3, +// warning: 4, +// notice: 5, +// info: 6, +// debug: 7, +// } +const WINSTON_LEVEL_TO_LOG_SEVERITY_LEVEL_MAP: Record = { + // npm + silly: 'trace', + // npm and syslog + debug: 'debug', + // npm + verbose: 'debug', + // npm + http: 'debug', + // npm and syslog + info: 'info', + // syslog + notice: 'info', + // npm + warn: 'warn', + // syslog + warning: 'warn', + // npm and syslog + error: 'error', + // syslog + emerg: 'fatal', + // syslog + alert: 'fatal', + // syslog + crit: 'fatal', +}; diff --git a/packages/node-core/src/logs/capture.ts b/packages/node-core/src/logs/capture.ts new file mode 100644 index 000000000000..17f94399f9bf --- /dev/null +++ b/packages/node-core/src/logs/capture.ts @@ -0,0 +1,29 @@ +import { format } from 'node:util'; +import type { Log, LogSeverityLevel, ParameterizedString } from '@sentry/core'; +import { _INTERNAL_captureLog } from '@sentry/core'; + +export type CaptureLogArgs = + | [message: ParameterizedString, attributes?: Log['attributes']] + | [messageTemplate: string, messageParams: Array, attributes?: Log['attributes']]; + +/** + * Capture a log with the given level. + * + * @param level - The level of the log. + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + */ +export function captureLog(level: LogSeverityLevel, ...args: CaptureLogArgs): void { + const [messageOrMessageTemplate, paramsOrAttributes, maybeAttributes] = args; + if (Array.isArray(paramsOrAttributes)) { + const attributes = { ...maybeAttributes }; + attributes['sentry.message.template'] = messageOrMessageTemplate; + paramsOrAttributes.forEach((param, index) => { + attributes[`sentry.message.parameter.${index}`] = param; + }); + const message = format(messageOrMessageTemplate, ...paramsOrAttributes); + _INTERNAL_captureLog({ level, message, attributes }); + } else { + _INTERNAL_captureLog({ level, message: messageOrMessageTemplate, attributes: paramsOrAttributes }); + } +} diff --git a/packages/node-core/src/logs/exports.ts b/packages/node-core/src/logs/exports.ts new file mode 100644 index 000000000000..c18b69f6770a --- /dev/null +++ b/packages/node-core/src/logs/exports.ts @@ -0,0 +1,168 @@ +import { type CaptureLogArgs, captureLog } from './capture'; + +/** + * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.trace('Starting database connection', { + * database: 'users', + * connectionId: 'conn_123' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.trace('Database connection %s established for %s', + * ['successful', 'users'], + * { connectionId: 'conn_123' } + * ); + * ``` + */ +export function trace(...args: CaptureLogArgs): void { + captureLog('trace', ...args); +} + +/** + * @summary Capture a log with the `debug` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.debug('Cache miss for user profile', { + * userId: 'user_123', + * cacheKey: 'profile:user_123' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.debug('Cache %s for %s: %s', + * ['miss', 'user profile', 'key not found'], + * { userId: 'user_123' } + * ); + * ``` + */ +export function debug(...args: CaptureLogArgs): void { + captureLog('debug', ...args); +} + +/** + * @summary Capture a log with the `info` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.info('User profile updated', { + * userId: 'user_123', + * updatedFields: ['email', 'preferences'] + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.info('User %s updated their %s', + * ['John Doe', 'profile settings'], + * { userId: 'user_123' } + * ); + * ``` + */ +export function info(...args: CaptureLogArgs): void { + captureLog('info', ...args); +} + +/** + * @summary Capture a log with the `warn` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.warn('Rate limit approaching', { + * endpoint: '/api/users', + * currentRate: '95/100', + * resetTime: '2024-03-20T10:00:00Z' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.warn('Rate limit %s for %s: %s', + * ['approaching', '/api/users', '95/100 requests'], + * { resetTime: '2024-03-20T10:00:00Z' } + * ); + * ``` + */ +export function warn(...args: CaptureLogArgs): void { + captureLog('warn', ...args); +} + +/** + * @summary Capture a log with the `error` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.error('Failed to process payment', { + * orderId: 'order_123', + * errorCode: 'PAYMENT_FAILED', + * amount: 99.99 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.error('Payment processing failed for order %s: %s', + * ['order_123', 'insufficient funds'], + * { amount: 99.99 } + * ); + * ``` + */ +export function error(...args: CaptureLogArgs): void { + captureLog('error', ...args); +} + +/** + * @summary Capture a log with the `fatal` level. Requires `_experiments.enableLogs` to be enabled. + * + * You can either pass a message and attributes or a message template, params and attributes. + * + * @example + * + * ``` + * Sentry.logger.fatal('Database connection pool exhausted', { + * database: 'users', + * activeConnections: 100, + * maxConnections: 100 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.fatal('Database %s: %s connections active', + * ['connection pool exhausted', '100/100'], + * { database: 'users' } + * ); + * ``` + */ +export function fatal(...args: CaptureLogArgs): void { + captureLog('fatal', ...args); +} + +export { fmt } from '@sentry/core'; diff --git a/packages/node-core/src/nodeVersion.ts b/packages/node-core/src/nodeVersion.ts new file mode 100644 index 000000000000..86c761543de7 --- /dev/null +++ b/packages/node-core/src/nodeVersion.ts @@ -0,0 +1,5 @@ +import { parseSemver } from '@sentry/core'; + +export const NODE_VERSION = parseSemver(process.versions.node) as { major: number; minor: number; patch: number }; +export const NODE_MAJOR = NODE_VERSION.major; +export const NODE_MINOR = NODE_VERSION.minor; diff --git a/packages/node-core/src/otel/contextManager.ts b/packages/node-core/src/otel/contextManager.ts new file mode 100644 index 000000000000..252508eb7c88 --- /dev/null +++ b/packages/node-core/src/otel/contextManager.ts @@ -0,0 +1,11 @@ +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { wrapContextManagerClass } from '@sentry/opentelemetry'; + +/** + * This is a custom ContextManager for OpenTelemetry, which extends the default AsyncLocalStorageContextManager. + * It ensures that we create a new hub per context, so that the OTEL Context & the Sentry Scopes are always in sync. + * + * Note that we currently only support AsyncHooks with this, + * but since this should work for Node 14+ anyhow that should be good enough. + */ +export const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); diff --git a/packages/node-core/src/otel/instrument.ts b/packages/node-core/src/otel/instrument.ts new file mode 100644 index 000000000000..c5e94991140a --- /dev/null +++ b/packages/node-core/src/otel/instrument.ts @@ -0,0 +1,150 @@ +import { type Instrumentation, registerInstrumentations } from '@opentelemetry/instrumentation'; + +/** Exported only for tests. */ +export const INSTRUMENTED: Record = {}; + +export function generateInstrumentOnce< + Options, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + InstrumentationClass extends new (...args: any[]) => Instrumentation, +>( + name: string, + instrumentationClass: InstrumentationClass, + optionsCallback: (options: Options) => ConstructorParameters[0], +): ((options: Options) => InstanceType) & { id: string }; +export function generateInstrumentOnce< + Options = unknown, + InstrumentationInstance extends Instrumentation = Instrumentation, +>( + name: string, + creator: (options?: Options) => InstrumentationInstance, +): ((options?: Options) => InstrumentationInstance) & { id: string }; +/** + * Instrument an OpenTelemetry instrumentation once. + * This will skip running instrumentation again if it was already instrumented. + */ +export function generateInstrumentOnce( + name: string, + creatorOrClass: (new (...args: any[]) => Instrumentation) | ((options?: Options) => Instrumentation), + optionsCallback?: (options: Options) => unknown, +): ((options: Options) => Instrumentation) & { id: string } { + if (optionsCallback) { + return _generateInstrumentOnceWithOptions( + name, + creatorOrClass as new (...args: unknown[]) => Instrumentation, + optionsCallback, + ); + } + + return _generateInstrumentOnce(name, creatorOrClass as (options?: Options) => Instrumentation); +} + +// The plain version without handling of options +// Should not be used with custom options that are mutated in the creator! +function _generateInstrumentOnce( + name: string, + creator: (options?: Options) => InstrumentationInstance, +): ((options?: Options) => InstrumentationInstance) & { id: string } { + return Object.assign( + (options?: Options) => { + const instrumented = INSTRUMENTED[name] as InstrumentationInstance | undefined; + if (instrumented) { + // If options are provided, ensure we update them + if (options) { + instrumented.setConfig(options); + } + return instrumented; + } + + const instrumentation = creator(options); + INSTRUMENTED[name] = instrumentation; + + registerInstrumentations({ + instrumentations: [instrumentation], + }); + + return instrumentation; + }, + { id: name }, + ); +} + +// This version handles options properly +function _generateInstrumentOnceWithOptions< + Options, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + InstrumentationClass extends new (...args: any[]) => Instrumentation, +>( + name: string, + instrumentationClass: InstrumentationClass, + optionsCallback: (options: Options) => ConstructorParameters[0], +): ((options: Options) => InstanceType) & { id: string } { + return Object.assign( + (_options: Options) => { + const options = optionsCallback(_options); + + const instrumented = INSTRUMENTED[name] as InstanceType | undefined; + if (instrumented) { + // Ensure we update options + instrumented.setConfig(options); + return instrumented; + } + + const instrumentation = new instrumentationClass(options) as InstanceType; + INSTRUMENTED[name] = instrumentation; + + registerInstrumentations({ + instrumentations: [instrumentation], + }); + + return instrumentation; + }, + { id: name }, + ); +} + +/** + * Ensure a given callback is called when the instrumentation is actually wrapping something. + * This can be used to ensure some logic is only called when the instrumentation is actually active. + * + * This function returns a function that can be invoked with a callback. + * This callback will either be invoked immediately + * (e.g. if the instrumentation was already wrapped, or if _wrap could not be patched), + * or once the instrumentation is actually wrapping something. + * + * Make sure to call this function right after adding the instrumentation, otherwise it may be too late! + * The returned callback can be used any time, and also multiple times. + */ +export function instrumentWhenWrapped(instrumentation: T): (callback: () => void) => void { + let isWrapped = false; + let callbacks: (() => void)[] = []; + + if (!hasWrap(instrumentation)) { + isWrapped = true; + } else { + const originalWrap = instrumentation['_wrap']; + + instrumentation['_wrap'] = (...args: Parameters) => { + isWrapped = true; + callbacks.forEach(callback => callback()); + callbacks = []; + return originalWrap(...args); + }; + } + + const registerCallback = (callback: () => void): void => { + if (isWrapped) { + callback(); + } else { + callbacks.push(callback); + } + }; + + return registerCallback; +} + +function hasWrap( + instrumentation: T, +): instrumentation is T & { _wrap: (...args: unknown[]) => unknown } { + return typeof (instrumentation as T & { _wrap?: (...args: unknown[]) => unknown })['_wrap'] === 'function'; +} diff --git a/packages/node-core/src/proxy/base.ts b/packages/node-core/src/proxy/base.ts new file mode 100644 index 000000000000..d758abf2b224 --- /dev/null +++ b/packages/node-core/src/proxy/base.ts @@ -0,0 +1,150 @@ +/** + * This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7 + * With the following LICENSE: + * + * (The MIT License) + * + * Copyright (c) 2013 Nathan Rajlich * + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions:* + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software.* + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable @typescript-eslint/member-ordering */ +/* eslint-disable jsdoc/require-jsdoc */ +import * as http from 'node:http'; +import type * as net from 'node:net'; +import type { Duplex } from 'node:stream'; +import type * as tls from 'node:tls'; + +export * from './helpers'; + +interface HttpConnectOpts extends net.TcpNetConnectOpts { + secureEndpoint: false; + protocol?: string; +} + +interface HttpsConnectOpts extends tls.ConnectionOptions { + secureEndpoint: true; + protocol?: string; + port: number; +} + +export type AgentConnectOpts = HttpConnectOpts | HttpsConnectOpts; + +const INTERNAL = Symbol('AgentBaseInternalState'); + +interface InternalState { + defaultPort?: number; + protocol?: string; + currentSocket?: Duplex; +} + +export abstract class Agent extends http.Agent { + private [INTERNAL]: InternalState; + + // Set by `http.Agent` - missing from `@types/node` + options!: Partial; + keepAlive!: boolean; + + constructor(opts?: http.AgentOptions) { + super(opts); + this[INTERNAL] = {}; + } + + abstract connect( + req: http.ClientRequest, + options: AgentConnectOpts, + ): Promise | Duplex | http.Agent; + + /** + * Determine whether this is an `http` or `https` request. + */ + isSecureEndpoint(options?: AgentConnectOpts): boolean { + if (options) { + // First check the `secureEndpoint` property explicitly, since this + // means that a parent `Agent` is "passing through" to this instance. + if (typeof (options as Partial).secureEndpoint === 'boolean') { + return options.secureEndpoint; + } + + // If no explicit `secure` endpoint, check if `protocol` property is + // set. This will usually be the case since using a full string URL + // or `URL` instance should be the most common usage. + if (typeof options.protocol === 'string') { + return options.protocol === 'https:'; + } + } + + // Finally, if no `protocol` property was set, then fall back to + // checking the stack trace of the current call stack, and try to + // detect the "https" module. + const { stack } = new Error(); + if (typeof stack !== 'string') return false; + return stack.split('\n').some(l => l.indexOf('(https.js:') !== -1 || l.indexOf('node:https:') !== -1); + } + + createSocket(req: http.ClientRequest, options: AgentConnectOpts, cb: (err: Error | null, s?: Duplex) => void): void { + const connectOpts = { + ...options, + secureEndpoint: this.isSecureEndpoint(options), + }; + Promise.resolve() + .then(() => this.connect(req, connectOpts)) + .then(socket => { + if (socket instanceof http.Agent) { + // @ts-expect-error `addRequest()` isn't defined in `@types/node` + return socket.addRequest(req, connectOpts); + } + this[INTERNAL].currentSocket = socket; + // @ts-expect-error `createSocket()` isn't defined in `@types/node` + super.createSocket(req, options, cb); + }, cb); + } + + createConnection(): Duplex { + const socket = this[INTERNAL].currentSocket; + this[INTERNAL].currentSocket = undefined; + if (!socket) { + throw new Error('No socket was returned in the `connect()` function'); + } + return socket; + } + + get defaultPort(): number { + return this[INTERNAL].defaultPort ?? (this.protocol === 'https:' ? 443 : 80); + } + + set defaultPort(v: number) { + if (this[INTERNAL]) { + this[INTERNAL].defaultPort = v; + } + } + + get protocol(): string { + return this[INTERNAL].protocol ?? (this.isSecureEndpoint() ? 'https:' : 'http:'); + } + + set protocol(v: string) { + if (this[INTERNAL]) { + this[INTERNAL].protocol = v; + } + } +} diff --git a/packages/node-core/src/proxy/helpers.ts b/packages/node-core/src/proxy/helpers.ts new file mode 100644 index 000000000000..4a131c47b4ac --- /dev/null +++ b/packages/node-core/src/proxy/helpers.ts @@ -0,0 +1,69 @@ +/** + * This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7 + * With the following LICENSE: + * + * (The MIT License) + * + * Copyright (c) 2013 Nathan Rajlich * + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions:* + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software.* + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable jsdoc/require-jsdoc */ +import * as http from 'node:http'; +import * as https from 'node:https'; +import type { Readable } from 'node:stream'; + +export type ThenableRequest = http.ClientRequest & { + then: Promise['then']; +}; + +export async function toBuffer(stream: Readable): Promise { + let length = 0; + const chunks: Buffer[] = []; + for await (const chunk of stream) { + length += (chunk as Buffer).length; + chunks.push(chunk); + } + return Buffer.concat(chunks, length); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function json(stream: Readable): Promise { + const buf = await toBuffer(stream); + const str = buf.toString('utf8'); + try { + return JSON.parse(str); + } catch (_err: unknown) { + const err = _err as Error; + err.message += ` (input: ${str})`; + throw err; + } +} + +export function req(url: string | URL, opts: https.RequestOptions = {}): ThenableRequest { + const href = typeof url === 'string' ? url : url.href; + const req = (href.startsWith('https:') ? https : http).request(url, opts) as ThenableRequest; + const promise = new Promise((resolve, reject) => { + req.once('response', resolve).once('error', reject).end() as unknown as ThenableRequest; + }); + req.then = promise.then.bind(promise); + return req; +} diff --git a/packages/node-core/src/proxy/index.ts b/packages/node-core/src/proxy/index.ts new file mode 100644 index 000000000000..788b302eeab3 --- /dev/null +++ b/packages/node-core/src/proxy/index.ts @@ -0,0 +1,221 @@ +/** + * This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7 + * With the following LICENSE: + * + * (The MIT License) + * + * Copyright (c) 2013 Nathan Rajlich * + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions:* + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software.* + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type * as http from 'node:http'; +import type { OutgoingHttpHeaders } from 'node:http'; +import * as net from 'node:net'; +import * as tls from 'node:tls'; +import { logger } from '@sentry/core'; +import type { AgentConnectOpts } from './base'; +import { Agent } from './base'; +import { parseProxyResponse } from './parse-proxy-response'; + +function debug(...args: unknown[]): void { + logger.log('[https-proxy-agent]', ...args); +} + +type Protocol = T extends `${infer Protocol}:${infer _}` ? Protocol : never; + +type ConnectOptsMap = { + http: Omit; + https: Omit; +}; + +type ConnectOpts = { + [P in keyof ConnectOptsMap]: Protocol extends P ? ConnectOptsMap[P] : never; +}[keyof ConnectOptsMap]; + +export type HttpsProxyAgentOptions = ConnectOpts & + http.AgentOptions & { + headers?: OutgoingHttpHeaders | (() => OutgoingHttpHeaders); + }; + +/** + * The `HttpsProxyAgent` implements an HTTP Agent subclass that connects to + * the specified "HTTP(s) proxy server" in order to proxy HTTPS requests. + * + * Outgoing HTTP requests are first tunneled through the proxy server using the + * `CONNECT` HTTP request method to establish a connection to the proxy server, + * and then the proxy server connects to the destination target and issues the + * HTTP request from the proxy server. + * + * `https:` requests have their socket connection upgraded to TLS once + * the connection to the proxy server has been established. + */ +export class HttpsProxyAgent extends Agent { + static protocols = ['http', 'https'] as const; + + readonly proxy: URL; + proxyHeaders: OutgoingHttpHeaders | (() => OutgoingHttpHeaders); + connectOpts: net.TcpNetConnectOpts & tls.ConnectionOptions; + + constructor(proxy: Uri | URL, opts?: HttpsProxyAgentOptions) { + super(opts); + this.options = {}; + this.proxy = typeof proxy === 'string' ? new URL(proxy) : proxy; + this.proxyHeaders = opts?.headers ?? {}; + debug('Creating new HttpsProxyAgent instance: %o', this.proxy.href); + + // Trim off the brackets from IPv6 addresses + const host = (this.proxy.hostname || this.proxy.host).replace(/^\[|\]$/g, ''); + const port = this.proxy.port ? parseInt(this.proxy.port, 10) : this.proxy.protocol === 'https:' ? 443 : 80; + this.connectOpts = { + // Attempt to negotiate http/1.1 for proxy servers that support http/2 + ALPNProtocols: ['http/1.1'], + ...(opts ? omit(opts, 'headers') : null), + host, + port, + }; + } + + /** + * Called when the node-core HTTP client library is creating a + * new HTTP request. + */ + async connect(req: http.ClientRequest, opts: AgentConnectOpts): Promise { + const { proxy } = this; + + if (!opts.host) { + throw new TypeError('No "host" provided'); + } + + // Create a socket connection to the proxy server. + let socket: net.Socket; + if (proxy.protocol === 'https:') { + debug('Creating `tls.Socket`: %o', this.connectOpts); + const servername = this.connectOpts.servername || this.connectOpts.host; + socket = tls.connect({ + ...this.connectOpts, + servername: servername && net.isIP(servername) ? undefined : servername, + }); + } else { + debug('Creating `net.Socket`: %o', this.connectOpts); + socket = net.connect(this.connectOpts); + } + + const headers: OutgoingHttpHeaders = + typeof this.proxyHeaders === 'function' ? this.proxyHeaders() : { ...this.proxyHeaders }; + const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host; + let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r\n`; + + // Inject the `Proxy-Authorization` header if necessary. + if (proxy.username || proxy.password) { + const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`; + headers['Proxy-Authorization'] = `Basic ${Buffer.from(auth).toString('base64')}`; + } + + headers.Host = `${host}:${opts.port}`; + + if (!headers['Proxy-Connection']) { + headers['Proxy-Connection'] = this.keepAlive ? 'Keep-Alive' : 'close'; + } + for (const name of Object.keys(headers)) { + payload += `${name}: ${headers[name]}\r\n`; + } + + const proxyResponsePromise = parseProxyResponse(socket); + + socket.write(`${payload}\r\n`); + + const { connect, buffered } = await proxyResponsePromise; + req.emit('proxyConnect', connect); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Not EventEmitter in Node types + this.emit('proxyConnect', connect, req); + + if (connect.statusCode === 200) { + req.once('socket', resume); + + if (opts.secureEndpoint) { + // The proxy is connecting to a TLS server, so upgrade + // this socket connection to a TLS connection. + debug('Upgrading socket connection to TLS'); + const servername = opts.servername || opts.host; + return tls.connect({ + ...omit(opts, 'host', 'path', 'port'), + socket, + servername: net.isIP(servername) ? undefined : servername, + }); + } + + return socket; + } + + // Some other status code that's not 200... need to re-play the HTTP + // header "data" events onto the socket once the HTTP machinery is + // attached so that the node core `http` can parse and handle the + // error status code. + + // Close the original socket, and a new "fake" socket is returned + // instead, so that the proxy doesn't get the HTTP request + // written to it (which may contain `Authorization` headers or other + // sensitive data). + // + // See: https://hackerone.com/reports/541502 + socket.destroy(); + + const fakeSocket = new net.Socket({ writable: false }); + fakeSocket.readable = true; + + // Need to wait for the "socket" event to re-play the "data" events. + req.once('socket', (s: net.Socket) => { + debug('Replaying proxy buffer for failed request'); + // Replay the "buffered" Buffer onto the fake `socket`, since at + // this point the HTTP module machinery has been hooked up for + // the user. + s.push(buffered); + s.push(null); + }); + + return fakeSocket; + } +} + +function resume(socket: net.Socket | tls.TLSSocket): void { + socket.resume(); +} + +function omit( + obj: T, + ...keys: K +): { + [K2 in Exclude]: T[K2]; +} { + const ret = {} as { + [K in keyof typeof obj]: (typeof obj)[K]; + }; + let key: keyof typeof obj; + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key]; + } + } + return ret; +} diff --git a/packages/node-core/src/proxy/parse-proxy-response.ts b/packages/node-core/src/proxy/parse-proxy-response.ts new file mode 100644 index 000000000000..afad0d2435f4 --- /dev/null +++ b/packages/node-core/src/proxy/parse-proxy-response.ts @@ -0,0 +1,137 @@ +/** + * This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7 + * With the following LICENSE: + * + * (The MIT License) + * + * Copyright (c) 2013 Nathan Rajlich * + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions:* + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software.* + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable jsdoc/require-jsdoc */ +import type { IncomingHttpHeaders } from 'node:http'; +import type { Readable } from 'node:stream'; +import { logger } from '@sentry/core'; + +function debug(...args: unknown[]): void { + logger.log('[https-proxy-agent:parse-proxy-response]', ...args); +} + +export interface ConnectResponse { + statusCode: number; + statusText: string; + headers: IncomingHttpHeaders; +} + +export function parseProxyResponse(socket: Readable): Promise<{ connect: ConnectResponse; buffered: Buffer }> { + return new Promise((resolve, reject) => { + // we need to buffer any HTTP traffic that happens with the proxy before we get + // the CONNECT response, so that if the response is anything other than an "200" + // response code, then we can re-play the "data" events on the socket once the + // HTTP parser is hooked up... + let buffersLength = 0; + const buffers: Buffer[] = []; + + function read() { + const b = socket.read(); + if (b) ondata(b); + else socket.once('readable', read); + } + + function cleanup() { + socket.removeListener('end', onend); + socket.removeListener('error', onerror); + socket.removeListener('readable', read); + } + + function onend() { + cleanup(); + debug('onend'); + reject(new Error('Proxy connection ended before receiving CONNECT response')); + } + + function onerror(err: Error) { + cleanup(); + debug('onerror %o', err); + reject(err); + } + + function ondata(b: Buffer) { + buffers.push(b); + buffersLength += b.length; + + const buffered = Buffer.concat(buffers, buffersLength); + const endOfHeaders = buffered.indexOf('\r\n\r\n'); + + if (endOfHeaders === -1) { + // keep buffering + debug('have not received end of HTTP headers yet...'); + read(); + return; + } + + const headerParts = buffered.subarray(0, endOfHeaders).toString('ascii').split('\r\n'); + const firstLine = headerParts.shift(); + if (!firstLine) { + socket.destroy(); + return reject(new Error('No header received from proxy CONNECT response')); + } + const firstLineParts = firstLine.split(' '); + const statusCode = +(firstLineParts[1] || 0); + const statusText = firstLineParts.slice(2).join(' '); + const headers: IncomingHttpHeaders = {}; + for (const header of headerParts) { + if (!header) continue; + const firstColon = header.indexOf(':'); + if (firstColon === -1) { + socket.destroy(); + return reject(new Error(`Invalid header from proxy CONNECT response: "${header}"`)); + } + const key = header.slice(0, firstColon).toLowerCase(); + const value = header.slice(firstColon + 1).trimStart(); + const current = headers[key]; + if (typeof current === 'string') { + headers[key] = [current, value]; + } else if (Array.isArray(current)) { + current.push(value); + } else { + headers[key] = value; + } + } + debug('got proxy server response: %o %o', firstLine, headers); + cleanup(); + resolve({ + connect: { + statusCode, + statusText, + headers, + }, + buffered, + }); + } + + socket.on('error', onerror); + socket.on('end', onend); + + read(); + }); +} diff --git a/packages/node-core/src/sdk/api.ts b/packages/node-core/src/sdk/api.ts new file mode 100644 index 000000000000..304d311e519e --- /dev/null +++ b/packages/node-core/src/sdk/api.ts @@ -0,0 +1,114 @@ +// PUBLIC APIS + +import type { StackParser } from '@sentry/core'; +import { createStackParser, GLOBAL_OBJ, nodeStackLineParser } from '@sentry/core'; +import { createGetModuleFromFilename } from '../utils/module'; + +/** + * Returns a release dynamically from environment variables. + */ +// eslint-disable-next-line complexity +export function getSentryRelease(fallback?: string): string | undefined { + // Always read first as Sentry takes this as precedence + if (process.env.SENTRY_RELEASE) { + return process.env.SENTRY_RELEASE; + } + + // This supports the variable that sentry-webpack-plugin injects + if (GLOBAL_OBJ.SENTRY_RELEASE?.id) { + return GLOBAL_OBJ.SENTRY_RELEASE.id; + } + + // This list is in approximate alpha order, separated into 3 categories: + // 1. Git providers + // 2. CI providers with specific environment variables (has the provider name in the variable name) + // 3. CI providers with generic environment variables (checked for last to prevent possible false positives) + + const possibleReleaseNameOfGitProvider = + // GitHub Actions - https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables + process.env['GITHUB_SHA'] || + // GitLab CI - https://docs.gitlab.com/ee/ci/variables/predefined_variables.html + process.env['CI_MERGE_REQUEST_SOURCE_BRANCH_SHA'] || + process.env['CI_BUILD_REF'] || + process.env['CI_COMMIT_SHA'] || + // Bitbucket - https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/ + process.env['BITBUCKET_COMMIT']; + + const possibleReleaseNameOfCiProvidersWithSpecificEnvVar = + // AppVeyor - https://www.appveyor.com/docs/environment-variables/ + process.env['APPVEYOR_PULL_REQUEST_HEAD_COMMIT'] || + process.env['APPVEYOR_REPO_COMMIT'] || + // AWS CodeBuild - https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html + process.env['CODEBUILD_RESOLVED_SOURCE_VERSION'] || + // AWS Amplify - https://docs.aws.amazon.com/amplify/latest/userguide/environment-variables.html + process.env['AWS_COMMIT_ID'] || + // Azure Pipelines - https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml + process.env['BUILD_SOURCEVERSION'] || + // Bitrise - https://devcenter.bitrise.io/builds/available-environment-variables/ + process.env['GIT_CLONE_COMMIT_HASH'] || + // Buddy CI - https://buddy.works/docs/pipelines/environment-variables#default-environment-variables + process.env['BUDDY_EXECUTION_REVISION'] || + // Builtkite - https://buildkite.com/docs/pipelines/environment-variables + process.env['BUILDKITE_COMMIT'] || + // CircleCI - https://circleci.com/docs/variables/ + process.env['CIRCLE_SHA1'] || + // Cirrus CI - https://cirrus-ci.org/guide/writing-tasks/#environment-variables + process.env['CIRRUS_CHANGE_IN_REPO'] || + // Codefresh - https://codefresh.io/docs/docs/codefresh-yaml/variables/ + process.env['CF_REVISION'] || + // Codemagic - https://docs.codemagic.io/yaml-basic-configuration/environment-variables/ + process.env['CM_COMMIT'] || + // Cloudflare Pages - https://developers.cloudflare.com/pages/platform/build-configuration/#environment-variables + process.env['CF_PAGES_COMMIT_SHA'] || + // Drone - https://docs.drone.io/pipeline/environment/reference/ + process.env['DRONE_COMMIT_SHA'] || + // Flightcontrol - https://www.flightcontrol.dev/docs/guides/flightcontrol/environment-variables#built-in-environment-variables + process.env['FC_GIT_COMMIT_SHA'] || + // Heroku #1 https://devcenter.heroku.com/articles/heroku-ci + process.env['HEROKU_TEST_RUN_COMMIT_VERSION'] || + // Heroku #2 https://docs.sentry.io/product/integrations/deployment/heroku/#configure-releases + process.env['HEROKU_SLUG_COMMIT'] || + // Railway - https://docs.railway.app/reference/variables#git-variables + process.env['RAILWAY_GIT_COMMIT_SHA'] || + // Render - https://render.com/docs/environment-variables + process.env['RENDER_GIT_COMMIT'] || + // Semaphore CI - https://docs.semaphoreci.com/ci-cd-environment/environment-variables + process.env['SEMAPHORE_GIT_SHA'] || + // TravisCI - https://docs.travis-ci.com/user/environment-variables/#default-environment-variables + process.env['TRAVIS_PULL_REQUEST_SHA'] || + // Vercel - https://vercel.com/docs/v2/build-step#system-environment-variables + process.env['VERCEL_GIT_COMMIT_SHA'] || + process.env['VERCEL_GITHUB_COMMIT_SHA'] || + process.env['VERCEL_GITLAB_COMMIT_SHA'] || + process.env['VERCEL_BITBUCKET_COMMIT_SHA'] || + // Zeit (now known as Vercel) + process.env['ZEIT_GITHUB_COMMIT_SHA'] || + process.env['ZEIT_GITLAB_COMMIT_SHA'] || + process.env['ZEIT_BITBUCKET_COMMIT_SHA']; + + const possibleReleaseNameOfCiProvidersWithGenericEnvVar = + // CloudBees CodeShip - https://docs.cloudbees.com/docs/cloudbees-codeship/latest/pro-builds-and-configuration/environment-variables + process.env['CI_COMMIT_ID'] || + // Coolify - https://coolify.io/docs/knowledge-base/environment-variables + process.env['SOURCE_COMMIT'] || + // Heroku #3 https://devcenter.heroku.com/changelog-items/630 + process.env['SOURCE_VERSION'] || + // Jenkins - https://plugins.jenkins.io/git/#environment-variables + process.env['GIT_COMMIT'] || + // Netlify - https://docs.netlify.com/configure-builds/environment-variables/#build-metadata + process.env['COMMIT_REF'] || + // TeamCity - https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html + process.env['BUILD_VCS_NUMBER'] || + // Woodpecker CI - https://woodpecker-ci.org/docs/usage/environment + process.env['CI_COMMIT_SHA']; + + return ( + possibleReleaseNameOfGitProvider || + possibleReleaseNameOfCiProvidersWithSpecificEnvVar || + possibleReleaseNameOfCiProvidersWithGenericEnvVar || + fallback + ); +} + +/** Node.js stack parser */ +export const defaultStackParser: StackParser = createStackParser(nodeStackLineParser(createGetModuleFromFilename())); diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts new file mode 100644 index 000000000000..4e87b47030be --- /dev/null +++ b/packages/node-core/src/sdk/client.ts @@ -0,0 +1,152 @@ +import * as os from 'node:os'; +import type { Tracer } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core'; +import { _INTERNAL_flushLogsBuffer, applySdkMetadata, logger, SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; +import { getTraceContextForScope } from '@sentry/opentelemetry'; +import { isMainThread, threadId } from 'worker_threads'; +import { DEBUG_BUILD } from '../debug-build'; +import type { NodeClientOptions } from '../types'; + +const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitrarily + +/** A client for using Sentry with Node & OpenTelemetry. */ +export class NodeClient extends ServerRuntimeClient { + public traceProvider: BasicTracerProvider | undefined; + private _tracer: Tracer | undefined; + private _clientReportInterval: NodeJS.Timeout | undefined; + private _clientReportOnExitFlushListener: (() => void) | undefined; + private _logOnExitFlushListener: (() => void) | undefined; + + public constructor(options: NodeClientOptions) { + const serverName = options.serverName || global.process.env.SENTRY_NAME || os.hostname(); + const clientOptions: ServerRuntimeClientOptions = { + ...options, + platform: 'node', + runtime: { name: 'node', version: global.process.version }, + serverName, + }; + + if (options.openTelemetryInstrumentations) { + registerInstrumentations({ + instrumentations: options.openTelemetryInstrumentations, + }); + } + + applySdkMetadata(clientOptions, 'node'); + + logger.log( + `Initializing Sentry: process: ${process.pid}, thread: ${isMainThread ? 'main' : `worker-${threadId}`}.`, + ); + + super(clientOptions); + + if (this.getOptions()._experiments?.enableLogs) { + this._logOnExitFlushListener = () => { + _INTERNAL_flushLogsBuffer(this); + }; + + if (serverName) { + this.on('beforeCaptureLog', log => { + log.attributes = { + ...log.attributes, + 'server.address': serverName, + }; + }); + } + + process.on('beforeExit', this._logOnExitFlushListener); + } + } + + /** Get the OTEL tracer. */ + public get tracer(): Tracer { + if (this._tracer) { + return this._tracer; + } + + const name = '@sentry/node'; + const version = SDK_VERSION; + const tracer = trace.getTracer(name, version); + this._tracer = tracer; + + return tracer; + } + + // Eslint ignore explanation: This is already documented in super. + // eslint-disable-next-line jsdoc/require-jsdoc + public async flush(timeout?: number): Promise { + const provider = this.traceProvider; + await provider?.forceFlush(); + + if (this.getOptions().sendClientReports) { + this._flushOutcomes(); + } + + return super.flush(timeout); + } + + // Eslint ignore explanation: This is already documented in super. + // eslint-disable-next-line jsdoc/require-jsdoc + public close(timeout?: number | undefined): PromiseLike { + if (this._clientReportInterval) { + clearInterval(this._clientReportInterval); + } + + if (this._clientReportOnExitFlushListener) { + process.off('beforeExit', this._clientReportOnExitFlushListener); + } + + if (this._logOnExitFlushListener) { + process.off('beforeExit', this._logOnExitFlushListener); + } + + return super.close(timeout); + } + + /** + * Will start tracking client reports for this client. + * + * NOTICE: This method will create an interval that is periodically called and attach a `process.on('beforeExit')` + * hook. To clean up these resources, call `.close()` when you no longer intend to use the client. Not doing so will + * result in a memory leak. + */ + // The reason client reports need to be manually activated with this method instead of just enabling them in a + // constructor, is that if users periodically and unboundedly create new clients, we will create more and more + // intervals and beforeExit listeners, thus leaking memory. In these situations, users are required to call + // `client.close()` in order to dispose of the acquired resources. + // We assume that calling this method in Sentry.init() is a sensible default, because calling Sentry.init() over and + // over again would also result in memory leaks. + // Note: We have experimented with using `FinalizationRegisty` to clear the interval when the client is garbage + // collected, but it did not work, because the cleanup function never got called. + public startClientReportTracking(): void { + const clientOptions = this.getOptions(); + if (clientOptions.sendClientReports) { + this._clientReportOnExitFlushListener = () => { + this._flushOutcomes(); + }; + + this._clientReportInterval = setInterval(() => { + DEBUG_BUILD && logger.log('Flushing client reports based on interval.'); + this._flushOutcomes(); + }, clientOptions.clientReportFlushInterval ?? DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS) + // Unref is critical for not preventing the process from exiting because the interval is active. + .unref(); + + process.on('beforeExit', this._clientReportOnExitFlushListener); + } + } + + /** Custom implementation for OTEL, so we can handle scope-span linking. */ + protected _getTraceInfoFromScope( + scope: Scope | undefined, + ): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { + if (!scope) { + return [undefined, undefined]; + } + + return getTraceContextForScope(this, scope); + } +} diff --git a/packages/node-core/src/sdk/esmLoader.ts b/packages/node-core/src/sdk/esmLoader.ts new file mode 100644 index 000000000000..487e2ce0c613 --- /dev/null +++ b/packages/node-core/src/sdk/esmLoader.ts @@ -0,0 +1,31 @@ +import { consoleSandbox, GLOBAL_OBJ, logger } from '@sentry/core'; +import { createAddHookMessageChannel } from 'import-in-the-middle'; +import moduleModule from 'module'; + +/** Initialize the ESM loader. */ +export function maybeInitializeEsmLoader(): void { + const [nodeMajor = 0, nodeMinor = 0] = process.versions.node.split('.').map(Number); + + // Register hook was added in v20.6.0 and v18.19.0 + if (nodeMajor >= 21 || (nodeMajor === 20 && nodeMinor >= 6) || (nodeMajor === 18 && nodeMinor >= 19)) { + if (!GLOBAL_OBJ._sentryEsmLoaderHookRegistered) { + try { + const { addHookMessagePort } = createAddHookMessageChannel(); + // @ts-expect-error register is available in these versions + moduleModule.register('import-in-the-middle/hook.mjs', import.meta.url, { + data: { addHookMessagePort, include: [] }, + transferList: [addHookMessagePort], + }); + } 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 v${process.versions.node} 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 upgrade your Node.js version.`, + ); + }); + } +} diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts new file mode 100644 index 000000000000..da1ded2b3c2b --- /dev/null +++ b/packages/node-core/src/sdk/index.ts @@ -0,0 +1,269 @@ +import { diag, DiagLogLevel } from '@opentelemetry/api'; +import type { Integration, Options } from '@sentry/core'; +import { + consoleIntegration, + consoleSandbox, + functionToStringIntegration, + getCurrentScope, + getIntegrationsToSetup, + hasSpansEnabled, + inboundFiltersIntegration, + linkedErrorsIntegration, + logger, + propagationContextFromHeaders, + requestDataIntegration, + stackParserFromStackParserOptions, +} from '@sentry/core'; +import { + enhanceDscWithOpenTelemetryRootSpanName, + openTelemetrySetupCheck, + setOpenTelemetryContextAsyncContextStrategy, + setupEventContextTrace, +} from '@sentry/opentelemetry'; +import { DEBUG_BUILD } from '../debug-build'; +import { childProcessIntegration } from '../integrations/childProcess'; +import { nodeContextIntegration } from '../integrations/context'; +import { contextLinesIntegration } from '../integrations/contextlines'; +import { httpIntegration } from '../integrations/http'; +import { localVariablesIntegration } from '../integrations/local-variables'; +import { modulesIntegration } from '../integrations/modules'; +import { nativeNodeFetchIntegration } from '../integrations/node-fetch'; +import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexception'; +import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection'; +import { processSessionIntegration } from '../integrations/processSession'; +import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight'; +import { makeNodeTransport } from '../transports'; +import type { NodeClientOptions, NodeOptions } from '../types'; +import { isCjs } from '../utils/commonjs'; +import { envToBool } from '../utils/envToBool'; +import { defaultStackParser, getSentryRelease } from './api'; +import { NodeClient } from './client'; +import { maybeInitializeEsmLoader } from './esmLoader'; + +function getCjsOnlyIntegrations(): Integration[] { + return isCjs() ? [modulesIntegration()] : []; +} + +/** + * Get default integrations + */ +export function getDefaultIntegrations(): Integration[] { + return [ + // Common + // TODO(v10): Replace with `eventFiltersIntegration` once we remove the deprecated `inboundFiltersIntegration` + // eslint-disable-next-line deprecation/deprecation + inboundFiltersIntegration(), + functionToStringIntegration(), + linkedErrorsIntegration(), + requestDataIntegration(), + // Native Wrappers + consoleIntegration(), + httpIntegration(), + nativeNodeFetchIntegration(), + // Global Handlers + onUncaughtExceptionIntegration(), + onUnhandledRejectionIntegration(), + // Event Info + contextLinesIntegration(), + localVariablesIntegration(), + nodeContextIntegration(), + childProcessIntegration(), + processSessionIntegration(), + ...getCjsOnlyIntegrations(), + ]; +} + +/** + * Initialize Sentry for Node. + */ +export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { + return _init(options, getDefaultIntegrations); +} + +/** + * Initialize Sentry for Node, without any integrations added by default. + */ +export function initWithoutDefaultIntegrations(options: NodeOptions | undefined = {}): NodeClient { + return _init(options, () => []); +} + +/** + * Initialize Sentry for Node, without performance instrumentation. + */ +function _init( + _options: NodeOptions | undefined = {}, + getDefaultIntegrationsImpl: (options: Options) => Integration[], +): NodeClient { + const options = getClientOptions(_options, getDefaultIntegrationsImpl); + + if (options.debug === true) { + if (DEBUG_BUILD) { + logger.enable(); + } else { + // use `console.warn` rather than `logger.warn` since by non-debug bundles have all `logger.x` statements stripped + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[Sentry] Cannot initialize SDK with `debug` option using a non-debug bundle.'); + }); + } + } + + if (!isCjs() && options.registerEsmLoaderHooks !== false) { + maybeInitializeEsmLoader(); + } + + setOpenTelemetryContextAsyncContextStrategy(); + + const scope = getCurrentScope(); + scope.update(options.initialScope); + + if (options.spotlight && !options.integrations.some(({ name }) => name === SPOTLIGHT_INTEGRATION_NAME)) { + options.integrations.push( + spotlightIntegration({ + sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined, + }), + ); + } + + const client = new NodeClient(options); + // The client is on the current scope, from where it generally is inherited + getCurrentScope().setClient(client); + + client.init(); + + logger.log(`Running in ${isCjs() ? 'CommonJS' : 'ESM'} mode.`); + + client.startClientReportTracking(); + + updateScopeFromEnvVariables(); + + setupOpenTelemetryLogger(); + + enhanceDscWithOpenTelemetryRootSpanName(client); + setupEventContextTrace(client); + + return client; +} + +/** + * Validate that your OpenTelemetry setup is correct. + */ +export function validateOpenTelemetrySetup(): void { + if (!DEBUG_BUILD) { + return; + } + + const setup = openTelemetrySetupCheck(); + + const required: ReturnType = ['SentryContextManager', 'SentryPropagator']; + + if (hasSpansEnabled()) { + required.push('SentrySpanProcessor'); + } + + for (const k of required) { + if (!setup.includes(k)) { + logger.error( + `You have to set up the ${k}. Without this, the OpenTelemetry & Sentry integration will not work properly.`, + ); + } + } + + if (!setup.includes('SentrySampler')) { + logger.warn( + 'You have to set up the SentrySampler. Without this, the OpenTelemetry & Sentry integration may still work, but sample rates set for the Sentry SDK will not be respected. If you use a custom sampler, make sure to use `wrapSamplingDecision`.', + ); + } +} + +function getClientOptions( + options: NodeOptions, + getDefaultIntegrationsImpl: (options: Options) => Integration[], +): NodeClientOptions { + const release = getRelease(options.release); + const spotlight = + options.spotlight ?? envToBool(process.env.SENTRY_SPOTLIGHT, { strict: true }) ?? process.env.SENTRY_SPOTLIGHT; + const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate); + + const mergedOptions = { + ...options, + dsn: options.dsn ?? process.env.SENTRY_DSN, + environment: options.environment ?? process.env.SENTRY_ENVIRONMENT, + sendClientReports: options.sendClientReports ?? true, + transport: options.transport ?? makeNodeTransport, + stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), + release, + tracesSampleRate, + spotlight, + debug: envToBool(options.debug ?? process.env.SENTRY_DEBUG), + }; + + const integrations = options.integrations; + const defaultIntegrations = options.defaultIntegrations ?? getDefaultIntegrationsImpl(mergedOptions); + + return { + ...mergedOptions, + integrations: getIntegrationsToSetup({ + defaultIntegrations, + integrations, + }), + }; +} + +function getRelease(release: NodeOptions['release']): string | undefined { + if (release !== undefined) { + return release; + } + + const detectedRelease = getSentryRelease(); + if (detectedRelease !== undefined) { + return detectedRelease; + } + + return undefined; +} + +function getTracesSampleRate(tracesSampleRate: NodeOptions['tracesSampleRate']): number | undefined { + if (tracesSampleRate !== undefined) { + return tracesSampleRate; + } + + const sampleRateFromEnv = process.env.SENTRY_TRACES_SAMPLE_RATE; + if (!sampleRateFromEnv) { + return undefined; + } + + const parsed = parseFloat(sampleRateFromEnv); + return isFinite(parsed) ? parsed : undefined; +} + +/** + * Update scope and propagation context based on environmental variables. + * + * See https://github.com/getsentry/rfcs/blob/main/text/0071-continue-trace-over-process-boundaries.md + * for more details. + */ +function updateScopeFromEnvVariables(): void { + if (envToBool(process.env.SENTRY_USE_ENVIRONMENT) !== false) { + const sentryTraceEnv = process.env.SENTRY_TRACE; + const baggageEnv = process.env.SENTRY_BAGGAGE; + const propagationContext = propagationContextFromHeaders(sentryTraceEnv, baggageEnv); + getCurrentScope().setPropagationContext(propagationContext); + } +} + +/** + * 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-core/src/sdk/scope.ts b/packages/node-core/src/sdk/scope.ts new file mode 100644 index 000000000000..cc376fbf086c --- /dev/null +++ b/packages/node-core/src/sdk/scope.ts @@ -0,0 +1,14 @@ +import { context } from '@opentelemetry/api'; +import type { Scope } from '@sentry/core'; +import { getScopesFromContext } from '@sentry/opentelemetry'; + +/** + * Update the active isolation scope. + * Should be used with caution! + */ +export function setIsolationScope(isolationScope: Scope): void { + const scopes = getScopesFromContext(context.active()); + if (scopes) { + scopes.isolationScope = isolationScope; + } +} diff --git a/packages/node-core/src/transports/http-module.ts b/packages/node-core/src/transports/http-module.ts new file mode 100644 index 000000000000..65bf99349b10 --- /dev/null +++ b/packages/node-core/src/transports/http-module.ts @@ -0,0 +1,29 @@ +import type { ClientRequest, IncomingHttpHeaders, RequestOptions as HTTPRequestOptions } from 'node:http'; +import type { RequestOptions as HTTPSRequestOptions } from 'node:https'; + +export type HTTPModuleRequestOptions = HTTPRequestOptions | HTTPSRequestOptions | string | URL; + +/** + * Cut version of http.IncomingMessage. + * Some transports work in a special Javascript environment where http.IncomingMessage is not available. + */ +export interface HTTPModuleRequestIncomingMessage { + headers: IncomingHttpHeaders; + statusCode?: number; + on(event: 'data' | 'end', listener: (chunk: Buffer) => void): void; + off(event: 'data' | 'end', listener: (chunk: Buffer) => void): void; + setEncoding(encoding: string): void; +} + +/** + * Internal used interface for typescript. + * @hidden + */ +export interface HTTPModule { + /** + * Request wrapper + * @param options These are {@see TransportOptions} + * @param callback Callback when request is finished + */ + request(options: HTTPModuleRequestOptions, callback?: (res: HTTPModuleRequestIncomingMessage) => void): ClientRequest; +} diff --git a/packages/node-core/src/transports/http.ts b/packages/node-core/src/transports/http.ts new file mode 100644 index 000000000000..ab0754eb4487 --- /dev/null +++ b/packages/node-core/src/transports/http.ts @@ -0,0 +1,174 @@ +import * as http from 'node:http'; +import * as https from 'node:https'; +import { Readable } from 'node:stream'; +import { createGzip } from 'node:zlib'; +import type { + BaseTransportOptions, + Transport, + TransportMakeRequestResponse, + TransportRequest, + TransportRequestExecutor, +} from '@sentry/core'; +import { consoleSandbox, createTransport, suppressTracing } from '@sentry/core'; +import { HttpsProxyAgent } from '../proxy'; +import type { HTTPModule } from './http-module'; + +export interface NodeTransportOptions extends BaseTransportOptions { + /** Define custom headers */ + headers?: Record; + /** Set a proxy that should be used for outbound requests. */ + proxy?: string; + /** HTTPS proxy CA certificates */ + caCerts?: string | Buffer | Array; + /** Custom HTTP module. Defaults to the native 'http' and 'https' modules. */ + httpModule?: HTTPModule; + /** Allow overriding connection keepAlive, defaults to false */ + keepAlive?: boolean; +} + +// Estimated maximum size for reasonable standalone event +const GZIP_THRESHOLD = 1024 * 32; + +/** + * Gets a stream from a Uint8Array or string + * Readable.from is ideal but was added in node.js v12.3.0 and v10.17.0 + */ +function streamFromBody(body: Uint8Array | string): Readable { + return new Readable({ + read() { + this.push(body); + this.push(null); + }, + }); +} + +/** + * Creates a Transport that uses native the native 'http' and 'https' modules to send events to Sentry. + */ +export function makeNodeTransport(options: NodeTransportOptions): Transport { + let urlSegments: URL; + + try { + urlSegments = new URL(options.url); + } catch (e) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/node]: Invalid dsn or tunnel option, will not send any events. The tunnel option must be a full URL when used.', + ); + }); + return createTransport(options, () => Promise.resolve({})); + } + + const isHttps = urlSegments.protocol === 'https:'; + + // Proxy prioritization: http => `options.proxy` | `process.env.http_proxy` + // Proxy prioritization: https => `options.proxy` | `process.env.https_proxy` | `process.env.http_proxy` + const proxy = applyNoProxyOption( + urlSegments, + options.proxy || (isHttps ? process.env.https_proxy : undefined) || process.env.http_proxy, + ); + + const nativeHttpModule = isHttps ? https : http; + const keepAlive = options.keepAlive === undefined ? false : options.keepAlive; + + // TODO(v10): Evaluate if we can set keepAlive to true. This would involve testing for memory leaks in older node + // versions(>= 8) as they had memory leaks when using it: #2555 + const agent = proxy + ? (new HttpsProxyAgent(proxy) as http.Agent) + : new nativeHttpModule.Agent({ keepAlive, maxSockets: 30, timeout: 2000 }); + + const requestExecutor = createRequestExecutor(options, options.httpModule ?? nativeHttpModule, agent); + return createTransport(options, requestExecutor); +} + +/** + * Honors the `no_proxy` env variable with the highest priority to allow for hosts exclusion. + * + * @param transportUrl The URL the transport intends to send events to. + * @param proxy The client configured proxy. + * @returns A proxy the transport should use. + */ +function applyNoProxyOption(transportUrlSegments: URL, proxy: string | undefined): string | undefined { + const { no_proxy } = process.env; + + const urlIsExemptFromProxy = no_proxy + ?.split(',') + .some( + exemption => transportUrlSegments.host.endsWith(exemption) || transportUrlSegments.hostname.endsWith(exemption), + ); + + if (urlIsExemptFromProxy) { + return undefined; + } else { + return proxy; + } +} + +/** + * Creates a RequestExecutor to be used with `createTransport`. + */ +function createRequestExecutor( + options: NodeTransportOptions, + httpModule: HTTPModule, + agent: http.Agent, +): TransportRequestExecutor { + const { hostname, pathname, port, protocol, search } = new URL(options.url); + return function makeRequest(request: TransportRequest): Promise { + return new Promise((resolve, reject) => { + // This ensures we do not generate any spans in OpenTelemetry for the transport + suppressTracing(() => { + let body = streamFromBody(request.body); + + const headers: Record = { ...options.headers }; + + if (request.body.length > GZIP_THRESHOLD) { + headers['content-encoding'] = 'gzip'; + body = body.pipe(createGzip()); + } + + const req = httpModule.request( + { + method: 'POST', + agent, + headers, + hostname, + path: `${pathname}${search}`, + port, + protocol, + ca: options.caCerts, + }, + res => { + res.on('data', () => { + // Drain socket + }); + + res.on('end', () => { + // Drain socket + }); + + res.setEncoding('utf8'); + + // "Key-value pairs of header names and values. Header names are lower-cased." + // https://nodejs.org/api/http.html#http_message_headers + const retryAfterHeader = res.headers['retry-after'] ?? null; + const rateLimitsHeader = res.headers['x-sentry-rate-limits'] ?? null; + + resolve({ + statusCode: res.statusCode, + headers: { + 'retry-after': retryAfterHeader, + 'x-sentry-rate-limits': Array.isArray(rateLimitsHeader) + ? rateLimitsHeader[0] || null + : rateLimitsHeader, + }, + }); + }, + ); + + req.on('error', reject); + body.pipe(req); + }); + }); + }; +} diff --git a/packages/node-core/src/transports/index.ts b/packages/node-core/src/transports/index.ts new file mode 100644 index 000000000000..ba59ba8878a4 --- /dev/null +++ b/packages/node-core/src/transports/index.ts @@ -0,0 +1,3 @@ +export type { NodeTransportOptions } from './http'; + +export { makeNodeTransport } from './http'; diff --git a/packages/node-core/src/types.ts b/packages/node-core/src/types.ts new file mode 100644 index 000000000000..7b22ade22330 --- /dev/null +++ b/packages/node-core/src/types.ts @@ -0,0 +1,168 @@ +import type { Span as WriteableSpan } from '@opentelemetry/api'; +import type { Instrumentation } from '@opentelemetry/instrumentation'; +import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { ClientOptions, Options, SamplingContext, Scope, Span, TracePropagationTargets } from '@sentry/core'; +import type { NodeTransportOptions } from './transports'; + +export interface BaseNodeOptions { + /** + * List of strings/regex controlling to which outgoing requests + * the SDK will attach tracing headers. + * + * By default the SDK will attach those headers to all outgoing + * requests. If this option is provided, the SDK will match the + * request URL of outgoing requests against the items in this + * array, and only attach tracing headers if a match was found. + * + * @example + * ```js + * Sentry.init({ + * tracePropagationTargets: ['api.site.com'], + * }); + * ``` + */ + tracePropagationTargets?: TracePropagationTargets; + + /** + * Sets profiling sample rate when @sentry/profiling-node is installed + * + * @deprecated + */ + profilesSampleRate?: number; + + /** + * Function to compute profiling sample rate dynamically and filter unwanted profiles. + * + * Profiling is enabled if either this or `profilesSampleRate` is defined. If both are defined, `profilesSampleRate` is + * ignored. + * + * Will automatically be passed a context object of default and optional custom data. + * + * @returns A sample rate between 0 and 1 (0 drops the profile, 1 guarantees it will be sent). Returning `true` is + * equivalent to returning 1 and returning `false` is equivalent to returning 0. + * + * @deprecated + */ + profilesSampler?: (samplingContext: SamplingContext) => number | boolean; + + /** + * Sets profiling session sample rate - only evaluated once per SDK initialization. + * @default 0 + */ + profileSessionSampleRate?: number; + + /** + * Set the lifecycle of the profiler. + * + * - `manual`: The profiler will be manually started and stopped. + * - `trace`: The profiler will be automatically started when when a span is sampled and stopped when there are no more sampled spans. + * + * @default 'manual' + */ + profileLifecycle?: 'manual' | 'trace'; + + /** Sets an optional server name (device name) */ + serverName?: string; + + /** + * Include local variables with stack traces. + * + * Requires the `LocalVariables` integration. + */ + includeLocalVariables?: boolean; + + /** + * If you use Spotlight by Sentry during development, use + * this option to forward captured Sentry events to Spotlight. + * + * Either set it to true, or provide a specific Spotlight Sidecar URL. + * + * More details: https://spotlightjs.com/ + * + * IMPORTANT: Only set this option to `true` while developing, not in production! + */ + spotlight?: boolean | string; + + /** + * Provide an array of OpenTelemetry Instrumentations that should be registered. + * + * Use this option if you want to register OpenTelemetry instrumentation that the Sentry SDK does not yet have support for. + */ + openTelemetryInstrumentations?: Instrumentation[]; + + /** + * Provide an array of additional OpenTelemetry SpanProcessors that should be registered. + */ + openTelemetrySpanProcessors?: SpanProcessor[]; + + /** + * The max. duration in seconds that the SDK will wait for parent spans to be finished before discarding a span. + * The SDK will automatically clean up spans that have no finished parent after this duration. + * This is necessary to prevent memory leaks in case of parent spans that are never finished or otherwise dropped/missing. + * However, if you have very long-running spans in your application, a shorter duration might cause spans to be discarded too early. + * In this case, you can increase this duration to a value that fits your expected data. + * + * Defaults to 300 seconds (5 minutes). + */ + maxSpanWaitDuration?: number; + + /** + * Whether to register ESM loader hooks to automatically instrument libraries. + * This is necessary to auto instrument libraries that are loaded via ESM imports, but it can cause issues + * with certain libraries. If you run into problems running your app with this enabled, + * please raise an issue in https://github.com/getsentry/sentry-javascript. + * + * Defaults to `true`. + */ + registerEsmLoaderHooks?: boolean; + + /** + * Configures in which interval client reports will be flushed. Defaults to `60_000` (milliseconds). + */ + clientReportFlushInterval?: number; + + /** + * By default, the SDK will try to identify problems with your instrumentation setup and warn you about it. + * If you want to disable these warnings, set this to `true`. + */ + disableInstrumentationWarnings?: boolean; + + /** + * Controls how many milliseconds to wait before shutting down. The default is 2 seconds. Setting this too low can cause + * problems for sending events from command line applications. Setting it too + * high can cause the application to block for users with network connectivity + * problems. + */ + shutdownTimeout?: number; + + /** Callback that is executed when a fatal global error occurs. */ + onFatalError?(this: void, error: Error): void; +} + +/** + * Configuration options for the Sentry Node SDK + * @see @sentry/core Options for more information. + */ +export interface NodeOptions extends Options, BaseNodeOptions {} + +/** + * Configuration options for the Sentry Node SDK Client class + * @see NodeClient for more information. + */ +export interface NodeClientOptions extends ClientOptions, BaseNodeOptions {} + +export interface CurrentScopes { + scope: Scope; + isolationScope: Scope; +} + +/** + * The base `Span` type is basically a `WriteableSpan`. + * There are places where we basically want to allow passing _any_ span, + * so in these cases we type this as `AbstractSpan` which could be either a regular `Span` or a `ReadableSpan`. + * You'll have to make sur to check relevant fields before accessing them. + * + * Note that technically, the `Span` exported from `@opentelemetry/sdk-trace-base` matches this, + * but we cannot be 100% sure that we are actually getting such a span, so this type is more defensive. + */ +export type AbstractSpan = WriteableSpan | ReadableSpan | Span; diff --git a/packages/node-core/src/utils/addOriginToSpan.ts b/packages/node-core/src/utils/addOriginToSpan.ts new file mode 100644 index 000000000000..2a23710fa7cf --- /dev/null +++ b/packages/node-core/src/utils/addOriginToSpan.ts @@ -0,0 +1,8 @@ +import type { Span } from '@opentelemetry/api'; +import type { SpanOrigin } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; + +/** Adds an origin to an OTEL Span. */ +export function addOriginToSpan(span: Span, origin: SpanOrigin): void { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, origin); +} diff --git a/packages/node-core/src/utils/baggage.ts b/packages/node-core/src/utils/baggage.ts new file mode 100644 index 000000000000..be8e62b9497b --- /dev/null +++ b/packages/node-core/src/utils/baggage.ts @@ -0,0 +1,31 @@ +import { objectToBaggageHeader, parseBaggageHeader } from '@sentry/core'; + +/** + * Merge two baggage headers into one, where the existing one takes precedence. + * The order of the existing baggage will be preserved, and new entries will be added to the end. + */ +export function mergeBaggageHeaders( + existing: Existing, + baggage: string, +): string | undefined | Existing { + if (!existing) { + return baggage; + } + + const existingBaggageEntries = parseBaggageHeader(existing); + const newBaggageEntries = parseBaggageHeader(baggage); + + if (!newBaggageEntries) { + return existing; + } + + // Existing entries take precedence, ensuring order remains stable for minimal changes + const mergedBaggageEntries = { ...existingBaggageEntries }; + Object.entries(newBaggageEntries).forEach(([key, value]) => { + if (!mergedBaggageEntries[key]) { + mergedBaggageEntries[key] = value; + } + }); + + return objectToBaggageHeader(mergedBaggageEntries); +} diff --git a/packages/node-core/src/utils/commonjs.ts b/packages/node-core/src/utils/commonjs.ts new file mode 100644 index 000000000000..23a9b97f9fc1 --- /dev/null +++ b/packages/node-core/src/utils/commonjs.ts @@ -0,0 +1,8 @@ +/** Detect CommonJS. */ +export function isCjs(): boolean { + try { + return typeof module !== 'undefined' && typeof module.exports !== 'undefined'; + } catch { + return false; + } +} diff --git a/packages/node-core/src/utils/createMissingInstrumentationContext.ts b/packages/node-core/src/utils/createMissingInstrumentationContext.ts new file mode 100644 index 000000000000..1930bcf782eb --- /dev/null +++ b/packages/node-core/src/utils/createMissingInstrumentationContext.ts @@ -0,0 +1,7 @@ +import type { MissingInstrumentationContext } from '@sentry/core'; +import { isCjs } from './commonjs'; + +export const createMissingInstrumentationContext = (pkg: string): MissingInstrumentationContext => ({ + package: pkg, + 'javascript.is_cjs': isCjs(), +}); diff --git a/packages/node-core/src/utils/debug.ts b/packages/node-core/src/utils/debug.ts new file mode 100644 index 000000000000..71df5e761230 --- /dev/null +++ b/packages/node-core/src/utils/debug.ts @@ -0,0 +1,18 @@ +let cachedDebuggerEnabled: boolean | undefined; + +/** + * Was the debugger enabled when this function was first called? + */ +export async function isDebuggerEnabled(): Promise { + if (cachedDebuggerEnabled === undefined) { + try { + // Node can be built without inspector support + const inspector = await import('node:inspector'); + cachedDebuggerEnabled = !!inspector.url(); + } catch (_) { + cachedDebuggerEnabled = false; + } + } + + return cachedDebuggerEnabled; +} diff --git a/packages/node-core/src/utils/ensureIsWrapped.ts b/packages/node-core/src/utils/ensureIsWrapped.ts new file mode 100644 index 000000000000..70253d9debb7 --- /dev/null +++ b/packages/node-core/src/utils/ensureIsWrapped.ts @@ -0,0 +1,37 @@ +import { isWrapped } from '@opentelemetry/instrumentation'; +import { consoleSandbox, getClient, getGlobalScope, hasSpansEnabled, isEnabled } from '@sentry/core'; +import type { NodeClient } from '../sdk/client'; +import { isCjs } from './commonjs'; +import { createMissingInstrumentationContext } from './createMissingInstrumentationContext'; + +/** + * Checks and warns if a framework isn't wrapped by opentelemetry. + */ +export function ensureIsWrapped( + maybeWrappedFunction: unknown, + name: 'express' | 'connect' | 'fastify' | 'hapi' | 'koa', +): void { + const clientOptions = getClient()?.getOptions(); + if ( + !clientOptions?.disableInstrumentationWarnings && + !isWrapped(maybeWrappedFunction) && + isEnabled() && + hasSpansEnabled(clientOptions) + ) { + consoleSandbox(() => { + if (isCjs()) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] ${name} is not instrumented. This is likely because you required/imported ${name} before calling \`Sentry.init()\`.`, + ); + } else { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] ${name} is not instrumented. Please make sure to initialize Sentry in a separate file that you \`--import\` when running node, see: https://docs.sentry.io/platforms/javascript/guides/${name}/install/esm/.`, + ); + } + }); + + getGlobalScope().setContext('missing_instrumentation', createMissingInstrumentationContext(name)); + } +} diff --git a/packages/node-core/src/utils/entry-point.ts b/packages/node-core/src/utils/entry-point.ts new file mode 100644 index 000000000000..e73130f1efff --- /dev/null +++ b/packages/node-core/src/utils/entry-point.ts @@ -0,0 +1,66 @@ +import { resolve } from 'node:path'; +import { defaultStackParser } from '../sdk/api'; + +export interface ProcessInterface { + execArgv: string[]; + argv: string[]; + cwd: () => string; +} + +export interface ProcessArgs { + appPath: string; + importPaths: string[]; + requirePaths: string[]; +} + +/** + * Parses the process arguments to determine the app path, import paths, and require paths. + */ +export function parseProcessPaths(proc: ProcessInterface): ProcessArgs { + const { execArgv, argv, cwd: getCwd } = proc; + const cwd = getCwd(); + const appPath = resolve(cwd, argv[1] || ''); + + const joinedArgs = execArgv.join(' '); + const importPaths = Array.from(joinedArgs.matchAll(/--import[ =](\S+)/g)).map(e => resolve(cwd, e[1] || '')); + const requirePaths = Array.from(joinedArgs.matchAll(/--require[ =](\S+)/g)).map(e => resolve(cwd, e[1] || '')); + + return { appPath, importPaths, requirePaths }; +} + +/** + * Gets the current entry point type. + * + * `app` means this function was most likely called via the app entry point. + * `import` means this function was most likely called from an --import cli arg. + * `require` means this function was most likely called from a --require cli arg. + * `unknown` means we couldn't determine for sure. + */ +export function getEntryPointType(proc: ProcessInterface = process): 'import' | 'require' | 'app' | 'unknown' { + const filenames = defaultStackParser(new Error().stack || '') + .map(f => f.filename) + .filter(Boolean) as string[]; + + const { appPath, importPaths, requirePaths } = parseProcessPaths(proc); + + const output = []; + + if (appPath && filenames.includes(appPath)) { + output.push('app'); + } + + if (importPaths.some(p => filenames.includes(p))) { + output.push('import'); + } + + if (requirePaths.some(p => filenames.includes(p))) { + output.push('require'); + } + + // We only only return anything other than 'unknown' if we only got one match. + if (output.length === 1) { + return output[0] as 'import' | 'require' | 'app'; + } + + return 'unknown'; +} diff --git a/packages/node-core/src/utils/envToBool.ts b/packages/node-core/src/utils/envToBool.ts new file mode 100644 index 000000000000..f78d05bb380c --- /dev/null +++ b/packages/node-core/src/utils/envToBool.ts @@ -0,0 +1,38 @@ +export const FALSY_ENV_VALUES = new Set(['false', 'f', 'n', 'no', 'off', '0']); +export const TRUTHY_ENV_VALUES = new Set(['true', 't', 'y', 'yes', 'on', '1']); + +export type StrictBoolCast = { + strict: true; +}; + +export type LooseBoolCast = { + strict?: false; +}; + +export type BoolCastOptions = StrictBoolCast | LooseBoolCast; + +export function envToBool(value: unknown, options?: LooseBoolCast): boolean; +export function envToBool(value: unknown, options: StrictBoolCast): boolean | null; +export function envToBool(value: unknown, options?: BoolCastOptions): boolean | null; +/** + * A helper function which casts an ENV variable value to `true` or `false` using the constants defined above. + * In strict mode, it may return `null` if the value doesn't match any of the predefined values. + * + * @param value The value of the env variable + * @param options -- Only has `strict` key for now, which requires a strict match for `true` in TRUTHY_ENV_VALUES + * @returns true/false if the lowercase value matches the predefined values above. If not, null in strict mode, + * and Boolean(value) in loose mode. + */ +export function envToBool(value: unknown, options?: BoolCastOptions): boolean | null { + const normalized = String(value).toLowerCase(); + + if (FALSY_ENV_VALUES.has(normalized)) { + return false; + } + + if (TRUTHY_ENV_VALUES.has(normalized)) { + return true; + } + + return options?.strict ? null : Boolean(value); +} diff --git a/packages/node-core/src/utils/errorhandling.ts b/packages/node-core/src/utils/errorhandling.ts new file mode 100644 index 000000000000..bac86d09ccb5 --- /dev/null +++ b/packages/node-core/src/utils/errorhandling.ts @@ -0,0 +1,38 @@ +import { consoleSandbox, getClient, logger } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import type { NodeClient } from '../sdk/client'; + +const DEFAULT_SHUTDOWN_TIMEOUT = 2000; + +/** + * @hidden + */ +export function logAndExitProcess(error: unknown): void { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.error(error); + }); + + const client = getClient(); + + if (client === undefined) { + DEBUG_BUILD && logger.warn('No NodeClient was defined, we are exiting the process now.'); + global.process.exit(1); + return; + } + + const options = client.getOptions(); + const timeout = + options?.shutdownTimeout && options.shutdownTimeout > 0 ? options.shutdownTimeout : DEFAULT_SHUTDOWN_TIMEOUT; + client.close(timeout).then( + (result: boolean) => { + if (!result) { + DEBUG_BUILD && logger.warn('We reached the timeout for emptying the request buffer, still exiting now!'); + } + global.process.exit(1); + }, + error => { + DEBUG_BUILD && logger.error(error); + }, + ); +} diff --git a/packages/node-core/src/utils/getRequestUrl.ts b/packages/node-core/src/utils/getRequestUrl.ts new file mode 100644 index 000000000000..5005224f59e0 --- /dev/null +++ b/packages/node-core/src/utils/getRequestUrl.ts @@ -0,0 +1,15 @@ +import type { RequestOptions } from 'node:http'; + +/** Build a full URL from request options. */ +export function getRequestUrl(requestOptions: RequestOptions): string { + const protocol = requestOptions.protocol || ''; + const hostname = requestOptions.hostname || requestOptions.host || ''; + // Don't log standard :80 (http) and :443 (https) ports to reduce the noise + // Also don't add port if the hostname already includes a port + const port = + !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) + ? '' + : `:${requestOptions.port}`; + const path = requestOptions.path ? requestOptions.path : '/'; + return `${protocol}//${hostname}${port}${path}`; +} diff --git a/packages/node-core/src/utils/module.ts b/packages/node-core/src/utils/module.ts new file mode 100644 index 000000000000..c7ea0ffee30a --- /dev/null +++ b/packages/node-core/src/utils/module.ts @@ -0,0 +1,55 @@ +import { posix, sep } from 'node:path'; +import { dirname } from '@sentry/core'; + +/** normalizes Windows paths */ +function normalizeWindowsPath(path: string): string { + return path + .replace(/^[A-Z]:/, '') // remove Windows-style prefix + .replace(/\\/g, '/'); // replace all `\` instances with `/` +} + +/** Creates a function that gets the module name from a filename */ +export function createGetModuleFromFilename( + basePath: string = process.argv[1] ? dirname(process.argv[1]) : process.cwd(), + isWindows: boolean = sep === '\\', +): (filename: string | undefined) => string | undefined { + const normalizedBase = isWindows ? normalizeWindowsPath(basePath) : basePath; + + return (filename: string | undefined) => { + if (!filename) { + return; + } + + const normalizedFilename = isWindows ? normalizeWindowsPath(filename) : filename; + + // eslint-disable-next-line prefer-const + let { dir, base: file, ext } = posix.parse(normalizedFilename); + + if (ext === '.js' || ext === '.mjs' || ext === '.cjs') { + file = file.slice(0, ext.length * -1); + } + + // The file name might be URI-encoded which we want to decode to + // the original file name. + const decodedFile = decodeURIComponent(file); + + if (!dir) { + // No dirname whatsoever + dir = '.'; + } + + const n = dir.lastIndexOf('/node_modules'); + if (n > -1) { + return `${dir.slice(n + 14).replace(/\//g, '.')}:${decodedFile}`; + } + + // Let's see if it's a part of the main module + // To be a part of main module, it has to share the same base + if (dir.startsWith(normalizedBase)) { + const moduleName = dir.slice(normalizedBase.length + 1).replace(/\//g, '.'); + return moduleName ? `${moduleName}:${decodedFile}` : decodedFile; + } + + return decodedFile; + }; +} diff --git a/packages/node-core/src/utils/prepareEvent.ts b/packages/node-core/src/utils/prepareEvent.ts new file mode 100644 index 000000000000..854fea91f45e --- /dev/null +++ b/packages/node-core/src/utils/prepareEvent.ts @@ -0,0 +1,57 @@ +import type { CaptureContext, EventHint, Scope as ScopeInterface, ScopeContext } from '@sentry/core'; +import { Scope } from '@sentry/core'; + +/** + * This type makes sure that we get either a CaptureContext, OR an EventHint. + * It does not allow mixing them, which could lead to unexpected outcomes, e.g. this is disallowed: + * { user: { id: '123' }, mechanism: { handled: false } } + */ +export type ExclusiveEventHintOrCaptureContext = + | (CaptureContext & Partial<{ [key in keyof EventHint]: never }>) + | (EventHint & Partial<{ [key in keyof ScopeContext]: never }>); + +/** + * Parse either an `EventHint` directly, or convert a `CaptureContext` to an `EventHint`. + * This is used to allow to update method signatures that used to accept a `CaptureContext` but should now accept an `EventHint`. + */ +export function parseEventHintOrCaptureContext( + hint: ExclusiveEventHintOrCaptureContext | undefined, +): EventHint | undefined { + if (!hint) { + return undefined; + } + + // If you pass a Scope or `() => Scope` as CaptureContext, we just return this as captureContext + if (hintIsScopeOrFunction(hint)) { + return { captureContext: hint }; + } + + if (hintIsScopeContext(hint)) { + return { + captureContext: hint, + }; + } + + return hint; +} + +function hintIsScopeOrFunction( + hint: CaptureContext | EventHint, +): hint is ScopeInterface | ((scope: ScopeInterface) => ScopeInterface) { + return hint instanceof Scope || typeof hint === 'function'; +} + +type ScopeContextProperty = keyof ScopeContext; +const captureContextKeys: readonly ScopeContextProperty[] = [ + 'user', + 'level', + 'extra', + 'contexts', + 'tags', + 'fingerprint', + 'propagationContext', +] as const; + +function hintIsScopeContext(hint: Partial | EventHint): hint is Partial { + return Object.keys(hint).some(key => captureContextKeys.includes(key as ScopeContextProperty)); +} diff --git a/packages/node-core/src/utils/redisCache.ts b/packages/node-core/src/utils/redisCache.ts new file mode 100644 index 000000000000..20cca873c55a --- /dev/null +++ b/packages/node-core/src/utils/redisCache.ts @@ -0,0 +1,115 @@ +import type { CommandArgs as IORedisCommandArgs } from '@opentelemetry/instrumentation-ioredis'; + +const SINGLE_ARG_COMMANDS = ['get', 'set', 'setex']; + +export const GET_COMMANDS = ['get', 'mget']; +export const SET_COMMANDS = ['set', 'setex']; +// todo: del, expire + +/** Checks if a given command is in the list of redis commands. + * Useful because commands can come in lowercase or uppercase (depending on the library). */ +export function isInCommands(redisCommands: string[], command: string): boolean { + return redisCommands.includes(command.toLowerCase()); +} + +/** Determine cache operation based on redis statement */ +export function getCacheOperation( + command: string, +): 'cache.get' | 'cache.put' | 'cache.remove' | 'cache.flush' | undefined { + if (isInCommands(GET_COMMANDS, command)) { + return 'cache.get'; + } else if (isInCommands(SET_COMMANDS, command)) { + return 'cache.put'; + } else { + return undefined; + } +} + +function keyHasPrefix(key: string, prefixes: string[]): boolean { + return prefixes.some(prefix => key.startsWith(prefix)); +} + +/** Safely converts a redis key to a string (comma-separated if there are multiple keys) */ +export function getCacheKeySafely(redisCommand: string, cmdArgs: IORedisCommandArgs): string[] | undefined { + try { + if (cmdArgs.length === 0) { + return undefined; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const processArg = (arg: string | Buffer | number | any[]): string[] => { + if (typeof arg === 'string' || typeof arg === 'number' || Buffer.isBuffer(arg)) { + return [arg.toString()]; + } else if (Array.isArray(arg)) { + return flatten(arg.map(arg => processArg(arg))); + } else { + return ['']; + } + }; + + const firstArg = cmdArgs[0]; + if (isInCommands(SINGLE_ARG_COMMANDS, redisCommand) && firstArg != null) { + return processArg(firstArg); + } + + return flatten(cmdArgs.map(arg => processArg(arg))); + } catch (e) { + return undefined; + } +} + +/** Determines whether a redis operation should be considered as "cache operation" by checking if a key is prefixed. + * We only support certain commands (such as 'set', 'get', 'mget'). */ +export function shouldConsiderForCache(redisCommand: string, keys: string[], prefixes: string[]): boolean { + if (!getCacheOperation(redisCommand)) { + return false; + } + + for (const key of keys) { + if (keyHasPrefix(key, prefixes)) { + return true; + } + } + return false; +} + +/** Calculates size based on the cache response value */ +export function calculateCacheItemSize(response: unknown): number | undefined { + const getSize = (value: unknown): number | undefined => { + try { + if (Buffer.isBuffer(value)) return value.byteLength; + else if (typeof value === 'string') return value.length; + else if (typeof value === 'number') return value.toString().length; + else if (value === null || value === undefined) return 0; + return JSON.stringify(value).length; + } catch (e) { + return undefined; + } + }; + + return Array.isArray(response) + ? response.reduce((acc: number | undefined, curr) => { + const size = getSize(curr); + return typeof size === 'number' ? (acc !== undefined ? acc + size : size) : acc; + }, 0) + : getSize(response); +} + +type NestedArray = Array | T>; + +function flatten(input: NestedArray): T[] { + const result: T[] = []; + + const flattenHelper = (input: NestedArray): void => { + input.forEach((el: T | NestedArray) => { + if (Array.isArray(el)) { + flattenHelper(el as NestedArray); + } else { + result.push(el as T); + } + }); + }; + + flattenHelper(input); + return result; +} diff --git a/packages/node-core/test/cron.test.ts b/packages/node-core/test/cron.test.ts new file mode 100644 index 000000000000..efa146b90f20 --- /dev/null +++ b/packages/node-core/test/cron.test.ts @@ -0,0 +1,224 @@ +import * as SentryCore from '@sentry/core'; +import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cron } from '../src'; +import type { CronJob, CronJobParams } from '../src/cron/cron'; +import type { NodeCron, NodeCronOptions } from '../src/cron/node-cron'; + +describe('cron check-ins', () => { + let withMonitorSpy: MockInstance; + + beforeEach(() => { + withMonitorSpy = vi.spyOn(SentryCore, 'withMonitor'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('cron', () => { + class CronJobMock { + constructor( + cronTime: CronJobParams['cronTime'], + onTick: CronJobParams['onTick'], + _onComplete?: CronJobParams['onComplete'], + _start?: CronJobParams['start'], + _timeZone?: CronJobParams['timeZone'], + _context?: CronJobParams['context'], + _runOnInit?: CronJobParams['runOnInit'], + _utcOffset?: CronJobParams['utcOffset'], + _unrefTimeout?: CronJobParams['unrefTimeout'], + ) { + expect(cronTime).toBe('* * * Jan,Sep Sun'); + expect(onTick).toBeInstanceOf(Function); + setImmediate(() => onTick(undefined, undefined)); + } + + static from(params: CronJobParams): CronJob { + return new CronJobMock( + params.cronTime, + params.onTick, + params.onComplete, + params.start, + params.timeZone, + params.context, + params.runOnInit, + params.utcOffset, + params.unrefTimeout, + ); + } + } + + test('new CronJob()', () => + new Promise(done => { + expect.assertions(4); + + const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job'); + + new CronJobWithCheckIn( + '* * * Jan,Sep Sun', + () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + timezone: 'America/Los_Angeles', + }); + done(); + }, + undefined, + true, + 'America/Los_Angeles', + ); + })); + + test('CronJob.from()', () => + new Promise(done => { + expect.assertions(4); + + const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job'); + + CronJobWithCheckIn.from({ + cronTime: '* * * Jan,Sep Sun', + onTick: () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + }); + done(); + }, + }); + })); + + test('throws with multiple jobs same name', () => { + const CronJobWithCheckIn = cron.instrumentCron(CronJobMock, 'my-cron-job'); + + CronJobWithCheckIn.from({ + cronTime: '* * * Jan,Sep Sun', + onTick: () => { + // + }, + }); + + expect(() => { + CronJobWithCheckIn.from({ + cronTime: '* * * Jan,Sep Sun', + onTick: () => { + // + }, + }); + }).toThrowError("A job named 'my-cron-job' has already been scheduled"); + }); + }); + + describe('node-cron', () => { + test('calls withMonitor', () => + new Promise(done => { + expect.assertions(5); + + const nodeCron: NodeCron = { + schedule: (expression: string, callback: () => void, options?: NodeCronOptions): unknown => { + expect(expression).toBe('* * * Jan,Sep Sun'); + expect(callback).toBeInstanceOf(Function); + expect(options?.name).toBe('my-cron-job'); + return callback(); + }, + }; + + const cronWithCheckIn = cron.instrumentNodeCron(nodeCron); + + cronWithCheckIn.schedule( + '* * * Jan,Sep Sun', + () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + }); + done(); + }, + { name: 'my-cron-job' }, + ); + })); + + test('throws without supplied name', () => { + const nodeCron: NodeCron = { + schedule: (): unknown => { + return undefined; + }, + }; + + const cronWithCheckIn = cron.instrumentNodeCron(nodeCron); + + expect(() => { + // @ts-expect-error Initially missing name + cronWithCheckIn.schedule('* * * * *', () => { + // + }); + }).toThrowError('Missing "name" for scheduled job. A name is required for Sentry check-in monitoring.'); + }); + }); + + describe('node-schedule', () => { + test('calls withMonitor', () => + new Promise(done => { + expect.assertions(5); + + class NodeScheduleMock { + scheduleJob( + nameOrExpression: string | Date | object, + expressionOrCallback: string | Date | object | (() => void), + callback: () => void, + ): unknown { + expect(nameOrExpression).toBe('my-cron-job'); + expect(expressionOrCallback).toBe('* * * Jan,Sep Sun'); + expect(callback).toBeInstanceOf(Function); + return callback(); + } + } + + const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock()); + + scheduleWithCheckIn.scheduleJob('my-cron-job', '* * * Jan,Sep Sun', () => { + expect(withMonitorSpy).toHaveBeenCalledTimes(1); + expect(withMonitorSpy).toHaveBeenLastCalledWith('my-cron-job', expect.anything(), { + schedule: { type: 'crontab', value: '* * * 1,9 0' }, + }); + done(); + }); + })); + + test('throws without crontab string', () => { + class NodeScheduleMock { + scheduleJob(_: string, __: string | Date, ___: () => void): unknown { + return undefined; + } + } + + const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock()); + + expect(() => { + scheduleWithCheckIn.scheduleJob('my-cron-job', new Date(), () => { + // + }); + }).toThrowError( + "Automatic instrumentation of 'node-schedule' requires the first parameter of 'scheduleJob' to be a job name string and the second parameter to be a crontab string", + ); + }); + + test('throws without job name', () => { + class NodeScheduleMock { + scheduleJob(_: string, __: () => void): unknown { + return undefined; + } + } + + const scheduleWithCheckIn = cron.instrumentNodeSchedule(new NodeScheduleMock()); + + expect(() => { + scheduleWithCheckIn.scheduleJob('* * * * *', () => { + // + }); + }).toThrowError( + "Automatic instrumentation of 'node-schedule' requires the first parameter of 'scheduleJob' to be a job name string and the second parameter to be a crontab string", + ); + }); + }); +}); diff --git a/packages/node-core/test/helpers/conditional.ts b/packages/node-core/test/helpers/conditional.ts new file mode 100644 index 000000000000..ceea11315db4 --- /dev/null +++ b/packages/node-core/test/helpers/conditional.ts @@ -0,0 +1,19 @@ +import { parseSemver } from '@sentry/core'; +import { it, test } from 'vitest'; + +const NODE_VERSION = parseSemver(process.versions.node).major; + +/** + * Returns`describe` or `describe.skip` depending on allowed major versions of Node. + * + * @param {{ min?: number; max?: number }} allowedVersion + */ +export const conditionalTest = (allowedVersion: { min?: number; max?: number }) => { + if (!NODE_VERSION) { + return it.skip; + } + + return NODE_VERSION < (allowedVersion.min || -Infinity) || NODE_VERSION > (allowedVersion.max || Infinity) + ? test.skip + : test; +}; diff --git a/packages/node-core/test/helpers/error.ts b/packages/node-core/test/helpers/error.ts new file mode 100644 index 000000000000..03d4150c3f11 --- /dev/null +++ b/packages/node-core/test/helpers/error.ts @@ -0,0 +1,4 @@ +/* this method is exported from an external file to be able to test contextlines when adding an external file */ +export function getError(): Error { + return new Error('mock error'); +} diff --git a/packages/node-core/test/helpers/getDefaultNodeClientOptions.ts b/packages/node-core/test/helpers/getDefaultNodeClientOptions.ts new file mode 100644 index 000000000000..8cff09d3c0ee --- /dev/null +++ b/packages/node-core/test/helpers/getDefaultNodeClientOptions.ts @@ -0,0 +1,13 @@ +import { createTransport, resolvedSyncPromise } from '@sentry/core'; +import type { NodeClientOptions } from '../../src/types'; + +export function getDefaultNodeClientOptions(options: Partial = {}): NodeClientOptions { + return { + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), + stackParser: () => [], + ...options, + }; +} diff --git a/packages/node-core/test/helpers/mockSdkInit.ts b/packages/node-core/test/helpers/mockSdkInit.ts new file mode 100644 index 000000000000..373c39bacd74 --- /dev/null +++ b/packages/node-core/test/helpers/mockSdkInit.ts @@ -0,0 +1,73 @@ +import { context, propagation, ProxyTracerProvider, trace } from '@opentelemetry/api'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { SentryPropagator, SentrySampler, SentrySpanProcessor } from '@sentry/opentelemetry'; +import type { NodeClient } from '../../src'; +import { SentryContextManager } from '../../src/otel/contextManager'; +import { init, validateOpenTelemetrySetup } from '../../src/sdk'; +import type { NodeClientOptions } from '../../src/types'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +export function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); +} + +export function mockSdkInit(options?: Partial) { + resetGlobals(); + const client = init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + // We are disabling client reports because we would be acquiring resources with every init call and that would leak + // memory every time we call init in the tests + sendClientReports: false, + ...options, + }); + + const provider = new BasicTracerProvider({ + sampler: client ? new SentrySampler(client) : undefined, + spanProcessors: [new SentrySpanProcessor()], + }); + + provider.register({ + propagator: new SentryPropagator(), + contextManager: new SentryContextManager(), + }); + + validateOpenTelemetrySetup(); + + return provider; +} + +export function cleanupOtel(_provider?: BasicTracerProvider): void { + const provider = getProvider(_provider); + + if (!provider) { + return; + } + + void provider.forceFlush(); + void provider.shutdown(); + + // Disable all globally registered APIs + trace.disable(); + context.disable(); + propagation.disable(); +} + +export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined { + let provider = _provider || getClient()?.traceProvider || trace.getTracerProvider(); + + if (provider instanceof ProxyTracerProvider) { + provider = provider.getDelegate(); + } + + if (!(provider instanceof BasicTracerProvider)) { + return undefined; + } + + return provider; +} diff --git a/packages/node-core/test/integration/breadcrumbs.test.ts b/packages/node-core/test/integration/breadcrumbs.test.ts new file mode 100644 index 000000000000..dc21809d1f75 --- /dev/null +++ b/packages/node-core/test/integration/breadcrumbs.test.ts @@ -0,0 +1,361 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { addBreadcrumb, captureException, withIsolationScope, withScope } from '@sentry/core'; +import { startSpan } from '@sentry/opentelemetry'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getClient } from '../../src/'; +import type { NodeClient } from '../../src/sdk/client'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +describe('Integration | breadcrumbs', () => { + const beforeSendTransaction = vi.fn(() => null); + + let provider: BasicTracerProvider | undefined; + + afterEach(() => { + cleanupOtel(provider); + }); + + describe('without tracing', () => { + it('correctly adds & retrieves breadcrumbs', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + provider = mockSdkInit({ beforeSend, beforeBreadcrumb }); + + const client = getClient() as NodeClient; + + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + + const error = new Error('test'); + captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(3); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { data: { nested: 'yes' }, message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('handles parallel scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + provider = mockSdkInit({ beforeSend, beforeBreadcrumb }); + + const client = getClient(); + + const error = new Error('test'); + + addBreadcrumb({ timestamp: 123456, message: 'test0' }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test2' }); + captureException(error); + }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test3' }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(4); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test0', timestamp: 123456 }, + { message: 'test2', timestamp: 123456 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + it('correctly adds & retrieves breadcrumbs', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + provider = mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as NodeClient; + + const error = new Error('test'); + + startSpan({ name: 'test' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); + }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); + + captureException(error); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(3); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { data: { nested: 'yes' }, message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('correctly adds & retrieves breadcrumbs for the current isolation span only', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + provider = mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as NodeClient; + + const error = new Error('test'); + + withIsolationScope(() => { + startSpan({ name: 'test1' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); + + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); + }); + }); + }); + + withIsolationScope(() => { + startSpan({ name: 'test2' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); + + captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(4); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test2-a', timestamp: 123456 }, + { message: 'test2-b', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('ignores scopes inside of root span', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + provider = mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as NodeClient; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + withScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); + + captureException(error); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(2); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('handles deep nesting of scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + provider = mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as NodeClient; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + withScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test3' }); + + startSpan({ name: 'inner3' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test4' }); + + captureException(error); + + startSpan({ name: 'inner4' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test5' }); + }); + + addBreadcrumb({ timestamp: 123457, message: 'test6' }); + }); + }); + }); + + addBreadcrumb({ timestamp: 123456, message: 'test99' }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123457 }, + { message: 'test4', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('correctly adds & retrieves breadcrumbs in async spans', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as NodeClient; + + const error = new Error('test'); + + const promise1 = withIsolationScope(async () => { + await startSpan({ name: 'test' }, async () => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + + await startSpan({ name: 'inner1' }, async () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); + + await startSpan({ name: 'inner2' }, async () => { + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + captureException(error); + }); + }); + + const promise2 = withIsolationScope(async () => { + await startSpan({ name: 'test-b' }, async () => { + addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); + + await startSpan({ name: 'inner1b' }, async () => { + addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); + + await startSpan({ name: 'inner2b' }, async () => { + addBreadcrumb({ timestamp: 123455, message: 'test3-b' }); + }); + }); + }); + + await Promise.all([promise1, promise2]); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(6); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); +}); diff --git a/packages/node-core/test/integration/scope.test.ts b/packages/node-core/test/integration/scope.test.ts new file mode 100644 index 000000000000..32b8a16e122e --- /dev/null +++ b/packages/node-core/test/integration/scope.test.ts @@ -0,0 +1,695 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { getCapturedScopesOnSpan, getCurrentScope } from '@sentry/core'; +import { getClient } from '@sentry/opentelemetry'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearGlobalScope } from '../../../core/test/lib/clear-global-scope'; +import * as Sentry from '../../src/'; +import type { NodeClient } from '../../src/sdk/client'; +import { cleanupOtel, mockSdkInit, resetGlobals } from '../helpers/mockSdkInit'; + +describe('Integration | Scope', () => { + let provider: BasicTracerProvider | undefined; + + afterEach(() => { + cleanupOtel(provider); + }); + + describe.each([ + ['with tracing', true], + ['without tracing', false], + ])('%s', (_name, tracingEnabled) => { + it('correctly syncs OTEL context & Sentry hub/scope', async () => { + const beforeSend = vi.fn(() => null); + const beforeSendTransaction = vi.fn(() => null); + + provider = mockSdkInit({ tracesSampleRate: tracingEnabled ? 1 : 0, beforeSend, beforeSendTransaction }); + + const client = getClient() as NodeClient; + + const rootScope = getCurrentScope(); + + const error = new Error('test error'); + let spanId: string | undefined; + let traceId: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + Sentry.withScope(scope1 => { + scope1.setTag('tag2', 'val2'); + + Sentry.withScope(scope2b => { + scope2b.setTag('tag3-b', 'val3-b'); + }); + + Sentry.withScope(scope2 => { + scope2.setTag('tag3', 'val3'); + + Sentry.startSpan({ name: 'outer' }, span => { + expect(getCapturedScopesOnSpan(span).scope).toBe(tracingEnabled ? scope2 : undefined); + + spanId = span.spanContext().spanId; + traceId = span.spanContext().traceId; + + Sentry.setTag('tag4', 'val4'); + + Sentry.captureException(error); + }); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + + if (spanId) { + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: spanId, + trace_id: traceId, + }, + }), + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + } + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + tag4: 'val4', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + + if (tracingEnabled) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + // Note: Scope for transaction is taken at `start` time, not `finish` time + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + }, + span_id: spanId, + status: 'ok', + trace_id: traceId, + origin: 'manual', + }, + }), + spans: [], + start_timestamp: expect.any(Number), + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + tag4: 'val4', + }, + timestamp: expect.any(Number), + transaction_info: { source: 'custom' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + } + }); + + it('isolates parallel root scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeSendTransaction = vi.fn(() => null); + + provider = mockSdkInit({ tracesSampleRate: tracingEnabled ? 1 : 0, beforeSend, beforeSendTransaction }); + + const client = getClient() as NodeClient; + const rootScope = getCurrentScope(); + + const error1 = new Error('test error 1'); + const error2 = new Error('test error 2'); + let spanId1: string | undefined; + let spanId2: string | undefined; + let traceId1: string | undefined; + let traceId2: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + Sentry.withScope(scope1 => { + scope1.setTag('tag2', 'val2a'); + + Sentry.withScope(scope2 => { + scope2.setTag('tag3', 'val3a'); + + Sentry.startSpan({ name: 'outer' }, span => { + spanId1 = span.spanContext().spanId; + traceId1 = span.spanContext().traceId; + + Sentry.setTag('tag4', 'val4a'); + + Sentry.captureException(error1); + }); + }); + }); + + Sentry.withScope(scope1 => { + scope1.setTag('tag2', 'val2b'); + + Sentry.withScope(scope2 => { + scope2.setTag('tag3', 'val3b'); + + Sentry.startSpan({ name: 'outer' }, span => { + spanId2 = span.spanContext().spanId; + traceId2 = span.spanContext().traceId; + + Sentry.setTag('tag4', 'val4b'); + + Sentry.captureException(error2); + }); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(2); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: spanId1 + ? { + span_id: spanId1, + trace_id: traceId1, + } + : expect.any(Object), + }), + tags: { + tag1: 'val1', + tag2: 'val2a', + tag3: 'val3a', + tag4: 'val4a', + }, + }), + { + event_id: expect.any(String), + originalException: error1, + syntheticException: expect.any(Error), + }, + ); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: spanId2 + ? { + span_id: spanId2, + trace_id: traceId2, + } + : expect.any(Object), + }), + tags: { + tag1: 'val1', + tag2: 'val2b', + tag3: 'val3b', + tag4: 'val4b', + }, + }), + { + event_id: expect.any(String), + originalException: error2, + syntheticException: expect.any(Error), + }, + ); + + if (tracingEnabled) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + } + }); + }); + + describe('global scope', () => { + beforeEach(() => { + clearGlobalScope(); + }); + + it('works before calling init', () => { + const globalScope = Sentry.getGlobalScope(); + expect(globalScope).toBeDefined(); + // No client attached + expect(globalScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getGlobalScope()).toBe(globalScope); + + globalScope.setTag('tag1', 'val1'); + globalScope.setTag('tag2', 'val2'); + + expect(globalScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the global scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + expect(globalScope.getClient()).toBeUndefined(); + expect(Sentry.getGlobalScope()).toBe(globalScope); + expect(globalScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = vi.fn(); + provider = mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const globalScope = Sentry.getGlobalScope(); + globalScope.setTag('tag1', 'val1'); + globalScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('isolation scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const isolationScope = Sentry.getIsolationScope(); + expect(isolationScope).toBeDefined(); + // No client attached + expect(isolationScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getIsolationScope()).toBe(isolationScope); + + isolationScope.setTag('tag1', 'val1'); + isolationScope.setTag('tag2', 'val2'); + + expect(isolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the isolation scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + // client is only attached to global scope by default + expect(isolationScope.getClient()).toBeUndefined(); + expect(Sentry.getIsolationScope()).toBe(isolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = vi.fn(); + provider = mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const isolationScope = Sentry.getIsolationScope(); + isolationScope.setTag('tag1', 'val1'); + isolationScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('withIsolationScope works', async () => { + const beforeSend = vi.fn(); + provider = mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialIsolationScope = Sentry.getIsolationScope(); + initialIsolationScope.setTag('tag1', 'val1'); + initialIsolationScope.setTag('tag2', 'val2'); + + const initialCurrentScope = Sentry.getCurrentScope(); + + const error = new Error('test error'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag4', 'val4'); + }); + + Sentry.withIsolationScope(newIsolationScope => { + expect(Sentry.getCurrentScope()).not.toBe(initialCurrentScope); + expect(Sentry.getIsolationScope()).toBe(newIsolationScope); + expect(newIsolationScope).not.toBe(initialIsolationScope); + + // Data is forked off original isolation scope + expect(newIsolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + newIsolationScope.setTag('tag3', 'val3'); + + Sentry.captureException(error); + }); + + expect(initialIsolationScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('can be deeply nested', async () => { + const beforeSend = vi.fn(); + provider = mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialIsolationScope = Sentry.getIsolationScope(); + initialIsolationScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag2', 'val2'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag3', 'val3'); + + Sentry.withIsolationScope(newIsolationScope => { + newIsolationScope.setTag('tag4', 'val4'); + }); + + Sentry.captureException(error); + }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('current scope', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('works before calling init', () => { + const currentScope = Sentry.getCurrentScope(); + expect(currentScope).toBeDefined(); + // No client attached + expect(currentScope.getClient()).toBeUndefined(); + // Repeatedly returns the same instance + expect(Sentry.getCurrentScope()).toBe(currentScope); + + currentScope.setTag('tag1', 'val1'); + currentScope.setTag('tag2', 'val2'); + + expect(currentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + // Now when we call init, the current scope remains intact + Sentry.init({ dsn: 'https://username@domain/123', defaultIntegrations: false }); + + // client is attached to current scope + expect(currentScope.getClient()).toBeDefined(); + + expect(Sentry.getCurrentScope()).toBe(currentScope); + expect(currentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + }); + + it('is applied to events', async () => { + const beforeSend = vi.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const currentScope = Sentry.getCurrentScope(); + currentScope.setTag('tag1', 'val1'); + currentScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + Sentry.captureException(error); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('withScope works', async () => { + const beforeSend = vi.fn(); + provider = mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const isolationScope = Sentry.getIsolationScope(); + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + initialCurrentScope.setTag('tag2', 'val2'); + + const error = new Error('test error'); + + Sentry.withScope(newCurrentScope => { + newCurrentScope.setTag('tag4', 'val4'); + }); + + Sentry.withScope(newCurrentScope => { + expect(Sentry.getCurrentScope()).toBe(newCurrentScope); + expect(Sentry.getIsolationScope()).toBe(isolationScope); + expect(newCurrentScope).not.toBe(initialCurrentScope); + + // Data is forked off original isolation scope + expect(newCurrentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + newCurrentScope.setTag('tag3', 'val3'); + + Sentry.captureException(error); + }); + + expect(initialCurrentScope.getScopeData().tags).toEqual({ tag1: 'val1', tag2: 'val2' }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('can be deeply nested', async () => { + const beforeSend = vi.fn(); + mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag2', 'val2'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag3', 'val3'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag4', 'val4'); + expect(Sentry.getCurrentScope()).toBe(currentScope); + }); + + Sentry.captureException(error); + }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('automatically forks with OTEL context', async () => { + const beforeSend = vi.fn(); + provider = mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + const initialCurrentScope = Sentry.getCurrentScope(); + initialCurrentScope.setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.startSpan({ name: 'outer' }, () => { + Sentry.getCurrentScope().setTag('tag2', 'val2'); + + Sentry.startSpan({ name: 'inner 1' }, () => { + Sentry.getCurrentScope().setTag('tag3', 'val3'); + + Sentry.startSpan({ name: 'inner 2' }, () => { + Sentry.getCurrentScope().setTag('tag4', 'val4'); + }); + + Sentry.captureException(error); + }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + describe('scope merging', () => { + beforeEach(() => { + resetGlobals(); + }); + + it('merges data from global, isolation and current scope', async () => { + const beforeSend = vi.fn(); + provider = mockSdkInit({ beforeSend }); + const client = Sentry.getClient(); + + Sentry.getGlobalScope().setTag('tag1', 'val1'); + + const error = new Error('test error'); + + Sentry.withIsolationScope(isolationScope => { + Sentry.getCurrentScope().setTag('tag2', 'val2a'); + isolationScope.setTag('tag2', 'val2b'); + isolationScope.setTag('tag3', 'val3'); + + Sentry.withScope(currentScope => { + currentScope.setTag('tag4', 'val4'); + + Sentry.captureException(error); + }); + }); + + await client?.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2a', + tag3: 'val3', + tag4: 'val4', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); +}); diff --git a/packages/node-core/test/integration/transactions.test.ts b/packages/node-core/test/integration/transactions.test.ts new file mode 100644 index 000000000000..87e4315cc28b --- /dev/null +++ b/packages/node-core/test/integration/transactions.test.ts @@ -0,0 +1,551 @@ +import { context, trace, TraceFlags } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { TransactionEvent } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as Sentry from '../../src'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +describe('Integration | Transactions', () => { + let provider: BasicTracerProvider | undefined; + + afterEach(() => { + vi.restoreAllMocks(); + cleanupOtel(provider); + }); + + it('correctly creates transaction & spans', async () => { + const transactions: TransactionEvent[] = []; + const beforeSendTransaction = vi.fn(event => { + transactions.push(event); + return null; + }); + + provider = mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction, + release: '8.0.0', + }); + + const client = Sentry.getClient()!; + + Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + Sentry.setTag('outer.tag', 'test value'); + + Sentry.startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + }, + }, + span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + Sentry.setTag('test.tag', 'test value'); + + Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + }); + }, + ); + + await client.flush(); + + expect(transactions).toHaveLength(1); + const transaction = transactions[0]!; + + expect(transaction.breadcrumbs).toEqual([ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2', timestamp: 123456 }, + { message: 'test breadcrumb 3', timestamp: 123456 }, + ]); + + expect(transaction.contexts?.otel).toEqual({ + resource: { + 'service.name': expect.any(String), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + }, + }); + + expect(transaction.contexts?.trace).toEqual({ + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + 'sentry.sample_rate': 1, + 'test.outer': 'test value', + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.test', + }); + + expect(transaction.sdkProcessingMetadata?.sampleRate).toEqual(1); + expect(transaction.sdkProcessingMetadata?.dynamicSamplingContext).toEqual({ + environment: 'production', + public_key: expect.any(String), + sample_rate: '1', + sampled: 'true', + release: '8.0.0', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + transaction: 'test name', + sample_rand: expect.any(String), + }); + + expect(transaction.environment).toEqual('production'); + expect(transaction.event_id).toEqual(expect.any(String)); + expect(transaction.start_timestamp).toEqual(expect.any(Number)); + expect(transaction.timestamp).toEqual(expect.any(Number)); + expect(transaction.transaction).toEqual('test name'); + + expect(transaction.tags).toEqual({ + 'outer.tag': 'test value', + 'test.tag': 'test value', + }); + expect(transaction.transaction_info).toEqual({ source: 'task' }); + expect(transaction.type).toEqual('transaction'); + + expect(transaction.spans).toHaveLength(2); + const spans = transaction.spans || []; + + // note: Currently, spans do not have any context/span added to them + // This is the same behavior as for the "regular" SDKs + expect(spans).toEqual([ + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 1', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + { + data: { + 'test.inner': 'test value', + 'sentry.origin': 'manual', + }, + description: 'inner span 2', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]); + }); + + it('correctly creates concurrent transaction & spans', async () => { + const beforeSendTransaction = vi.fn(() => null); + + provider = mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const client = Sentry.getClient()!; + + Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + + Sentry.withIsolationScope(() => { + Sentry.startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + }, + }, + span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + Sentry.setTag('test.tag', 'test value'); + + Sentry.startSpan({ name: 'inner span 2' }, innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + }); + }, + ); + }); + + Sentry.withIsolationScope(() => { + Sentry.startSpan({ op: 'test op b', name: 'test name b' }, span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value b', + }); + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' }); + subSpan.end(); + + Sentry.setTag('test.tag', 'test value b'); + + Sentry.startSpan({ name: 'inner span 2b' }, innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value b', + }); + }); + }); + }); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2', timestamp: 123456 }, + { message: 'test breadcrumb 3', timestamp: 123456 }, + ], + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + 'test.outer': 'test value', + 'sentry.sample_rate': 1, + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.test', + }, + }), + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + tags: { + 'test.tag': 'test value', + }, + timestamp: expect.any(Number), + transaction: 'test name', + transaction_info: { source: 'task' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2b', timestamp: 123456 }, + { message: 'test breadcrumb 3b', timestamp: 123456 }, + ], + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op b', + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + 'test.outer': 'test value b', + 'sentry.sample_rate': 1, + }, + op: 'test op b', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }, + }), + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + tags: { + 'test.tag': 'test value b', + }, + timestamp: expect.any(Number), + transaction: 'test name b', + transaction_info: { source: 'custom' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + }); + + it('correctly creates concurrent transaction & spans when using native OTEL tracer', async () => { + const beforeSendTransaction = vi.fn(() => null); + + provider = mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const client = Sentry.getClient(); + + Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + + Sentry.withIsolationScope(() => { + client?.tracer.startActiveSpan('test name', span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + Sentry.setTag('test.tag', 'test value'); + + client.tracer.startActiveSpan('inner span 2', innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + + innerSpan.end(); + }); + + span.end(); + }); + }); + + Sentry.withIsolationScope(() => { + client?.tracer.startActiveSpan('test name b', span => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value b', + }); + + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' }); + subSpan.end(); + + Sentry.setTag('test.tag', 'test value b'); + + client.tracer.startActiveSpan('inner span 2b', innerSpan => { + Sentry.addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value b', + }); + + innerSpan.end(); + }); + + span.end(); + }); + }); + + await client?.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2', timestamp: 123456 }, + { message: 'test breadcrumb 3', timestamp: 123456 }, + ], + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + 'test.outer': 'test value', + 'sentry.sample_rate': 1, + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }, + }), + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + tags: { + 'test.tag': 'test value', + }, + timestamp: expect.any(Number), + transaction: 'test name', + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2b', timestamp: 123456 }, + { message: 'test breadcrumb 3b', timestamp: 123456 }, + ], + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + 'test.outer': 'test value b', + 'sentry.sample_rate': 1, + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }, + }), + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + tags: { + 'test.tag': 'test value b', + }, + timestamp: expect.any(Number), + transaction: 'test name b', + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + }); + + it('correctly creates transaction & spans with a trace header data', async () => { + const beforeSendTransaction = vi.fn(() => null); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const spanContext = { + traceId, + spanId: parentSpanId, + sampled: true, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + }; + + provider = mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const client = Sentry.getClient()!; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(context.active(), spanContext), () => { + Sentry.startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + }, + }, + () => { + const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + Sentry.startSpan({ name: 'inner span 2' }, () => {}); + }, + ); + }); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(beforeSendTransaction).toHaveBeenLastCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: parentSpanId, + status: 'ok', + trace_id: traceId, + origin: 'auto.test', + }, + }), + // spans are circular (they have a reference to the transaction), which leads to jest choking on this + // instead we compare them in detail below + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'test name', + transaction_info: { source: 'task' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + + // Checking the spans here, as they are circular to the transaction... + const runArgs = beforeSendTransaction.mock.calls[0] as unknown as [TransactionEvent, unknown]; + const spans = runArgs[0].spans || []; + + // note: Currently, spans do not have any context/span added to them + // This is the same behavior as for the "regular" SDKs + expect(spans).toEqual([ + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 1', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: traceId, + }, + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 2', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: traceId, + }, + ]); + }); +}); diff --git a/packages/node-core/test/integrations/context.test.ts b/packages/node-core/test/integrations/context.test.ts new file mode 100644 index 000000000000..a22bfcf6ec34 --- /dev/null +++ b/packages/node-core/test/integrations/context.test.ts @@ -0,0 +1,57 @@ +import * as os from 'node:os'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { getAppContext, getDeviceContext } from '../../src/integrations/context'; +import { conditionalTest } from '../helpers/conditional'; + +vi.mock('node:os', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = (await vi.importActual('node:os')) as typeof import('node:os'); + return { + ...original, + uptime: original.uptime, + }; +}); + +describe('Context', () => { + describe('getAppContext', () => { + afterAll(() => { + vi.clearAllMocks(); + }); + + conditionalTest({ max: 18 })('it does not return free_memory on older node versions', () => { + const appContext = getAppContext(); + expect(appContext.free_memory).toBeUndefined(); + }); + + conditionalTest({ min: 22 })( + 'returns free_memory if process.availableMemory is defined and returns a valid value', + () => { + const appContext = getAppContext(); + expect(appContext.free_memory).toEqual(expect.any(Number)); + }, + ); + + conditionalTest({ min: 22 })('returns no free_memory if process.availableMemory ', () => { + vi.spyOn(process as any, 'availableMemory').mockReturnValue(undefined as unknown as number); + const appContext = getAppContext(); + expect(appContext.free_memory).toBeUndefined(); + }); + }); + + describe('getDeviceContext', () => { + afterAll(() => { + vi.clearAllMocks(); + }); + + it('returns boot time if os.uptime is defined and returns a valid uptime', () => { + const deviceCtx = getDeviceContext({}); + expect(deviceCtx.boot_time).toEqual(expect.any(String)); + }); + + it('returns no boot time if os.uptime() returns undefined', () => { + vi.spyOn(os, 'uptime').mockReturnValue(undefined as unknown as number); + const deviceCtx = getDeviceContext({}); + expect(deviceCtx.boot_time).toBeUndefined(); + }); + }); +}); diff --git a/packages/node-core/test/integrations/contextlines.test.ts b/packages/node-core/test/integrations/contextlines.test.ts new file mode 100644 index 000000000000..2965acded28f --- /dev/null +++ b/packages/node-core/test/integrations/contextlines.test.ts @@ -0,0 +1,235 @@ +import * as fs from 'node:fs'; +import type { StackFrame } from '@sentry/core'; +import { parseStackFrames } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + _contextLinesIntegration, + MAX_CONTEXTLINES_COLNO, + MAX_CONTEXTLINES_LINENO, + resetFileContentCache, +} from '../../src/integrations/contextlines'; +import { defaultStackParser } from '../../src/sdk/api'; +import { getError } from '../helpers/error'; + +vi.mock('node:fs', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = (await vi.importActual('node:fs')) as typeof import('node:fs'); + return { + ...original, + createReadStream: original.createReadStream, + }; +}); + +describe('ContextLines', () => { + let contextLines: ReturnType; + + async function addContext(frames: StackFrame[]): Promise { + await contextLines.processEvent({ exception: { values: [{ stacktrace: { frames } }] } }); + } + + beforeEach(() => { + contextLines = _contextLinesIntegration(); + resetFileContentCache(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('limits', () => { + test(`colno above ${MAX_CONTEXTLINES_COLNO}`, async () => { + expect.assertions(1); + const frames: StackFrame[] = [ + { + colno: MAX_CONTEXTLINES_COLNO + 1, + filename: 'file:///var/task/index.js', + lineno: 1, + function: 'fxn1', + }, + ]; + + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + await addContext(frames); + expect(readStreamSpy).not.toHaveBeenCalled(); + }); + + test(`lineno above ${MAX_CONTEXTLINES_LINENO}`, async () => { + expect.assertions(1); + const frames: StackFrame[] = [ + { + colno: 1, + filename: 'file:///var/task/index.js', + lineno: MAX_CONTEXTLINES_LINENO + 1, + function: 'fxn1', + }, + ]; + + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + await addContext(frames); + expect(readStreamSpy).not.toHaveBeenCalled(); + }); + }); + + describe('lru file cache', () => { + test('parseStack when file does not exist', async () => { + expect.assertions(4); + const frames: StackFrame[] = [ + { + colno: 1, + filename: 'file:///var/task/nonexistent.js', + lineno: 1, + function: 'fxn1', + }, + ]; + + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + await addContext(frames); + + expect(frames[0]!.pre_context).toBeUndefined(); + expect(frames[0]!.post_context).toBeUndefined(); + expect(frames[0]!.context_line).toBeUndefined(); + expect(readStreamSpy).toHaveBeenCalledTimes(1); + }); + test('parseStack with same file', async () => { + expect.assertions(1); + + const frames = parseStackFrames(defaultStackParser, new Error('test')); + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + + await addContext(frames); + const numCalls = readStreamSpy.mock.calls.length; + await addContext(frames); + + // Calls to `readFile` shouldn't increase if there isn't a new error to + // parse whose stacktrace contains a file we haven't yet seen + expect(readStreamSpy).toHaveBeenCalledTimes(numCalls); + }); + + test('parseStack with ESM module names', async () => { + expect.assertions(1); + + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + const framesWithFilePath: StackFrame[] = [ + { + colno: 1, + filename: 'file:///var/task/index.js', + lineno: 1, + function: 'fxn1', + }, + ]; + + await addContext(framesWithFilePath); + expect(readStreamSpy).toHaveBeenCalledTimes(1); + }); + + test('parseStack with adding different file', async () => { + expect.assertions(1); + const frames = parseStackFrames(defaultStackParser, new Error('test')); + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + + await addContext(frames); + + const numCalls = readStreamSpy.mock.calls.length; + const parsedFrames = parseStackFrames(defaultStackParser, getError()); + await addContext(parsedFrames); + + const newErrorCalls = readStreamSpy.mock.calls.length; + expect(newErrorCalls).toBeGreaterThan(numCalls); + }); + + test('parseStack with overlapping errors', async () => { + function inner() { + return new Error('inner'); + } + function outer() { + return inner(); + } + + const overlappingContextWithFirstError = parseStackFrames(defaultStackParser, outer()); + + await addContext(overlappingContextWithFirstError); + + const innerFrame = overlappingContextWithFirstError[overlappingContextWithFirstError.length - 1]!; + const outerFrame = overlappingContextWithFirstError[overlappingContextWithFirstError.length - 2]!; + + expect(innerFrame.context_line).toBe(" return new Error('inner');"); + expect(innerFrame.pre_context).toHaveLength(7); + expect(innerFrame.post_context).toHaveLength(7); + + expect(outerFrame.context_line).toBe(' return inner();'); + expect(outerFrame.pre_context).toHaveLength(7); + expect(outerFrame.post_context).toHaveLength(7); + }); + + test('parseStack with error on first line errors', async () => { + const overlappingContextWithFirstError = parseStackFrames(defaultStackParser, getError()); + + await addContext(overlappingContextWithFirstError); + + const errorFrame = overlappingContextWithFirstError.find(f => f.filename?.endsWith('error.ts')); + + if (!errorFrame) { + throw new Error('Could not find error frame'); + } + + expect(errorFrame.context_line).toBe(" return new Error('mock error');"); + expect(errorFrame.pre_context).toHaveLength(2); + expect(errorFrame.post_context).toHaveLength(1); + }); + + test('parseStack with duplicate files', async () => { + expect.assertions(1); + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + const framesWithDuplicateFiles: StackFrame[] = [ + { + colno: 1, + filename: '/var/task/index.js', + lineno: 1, + function: 'fxn1', + }, + { + colno: 2, + filename: '/var/task/index.js', + lineno: 2, + function: 'fxn2', + }, + { + colno: 3, + filename: '/var/task/index.js', + lineno: 3, + function: 'fxn3', + }, + ]; + + await addContext(framesWithDuplicateFiles); + expect(readStreamSpy).toHaveBeenCalledTimes(1); + }); + + test('stack errors without lineno', async () => { + expect.assertions(1); + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + const framesWithDuplicateFiles: StackFrame[] = [ + { + colno: 1, + filename: '/var/task/index.js', + lineno: undefined, + function: 'fxn1', + }, + ]; + + await addContext(framesWithDuplicateFiles); + expect(readStreamSpy).not.toHaveBeenCalled(); + }); + + test('parseStack with no context', async () => { + expect.assertions(1); + contextLines = _contextLinesIntegration({ frameContextLines: 0 }); + const readStreamSpy = vi.spyOn(fs, 'createReadStream'); + + const frames = parseStackFrames(defaultStackParser, new Error('test')); + + await addContext(frames); + expect(readStreamSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/node-core/test/integrations/localvariables.test.ts b/packages/node-core/test/integrations/localvariables.test.ts new file mode 100644 index 000000000000..72096fc4fa14 --- /dev/null +++ b/packages/node-core/test/integrations/localvariables.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createRateLimiter } from '../../src/integrations/local-variables/common'; +import { createCallbackList } from '../../src/integrations/local-variables/local-variables-sync'; +import { NODE_MAJOR } from '../../src/nodeVersion'; + +vi.useFakeTimers(); + +const describeIf = (condition: boolean) => (condition ? describe : describe.skip); + +describeIf(NODE_MAJOR >= 18)('LocalVariables', () => { + describe('createCallbackList', () => { + it('Should call callbacks in reverse order', () => + new Promise(done => { + const log: number[] = []; + + const { add, next } = createCallbackList(n => { + expect(log).toEqual([5, 4, 3, 2, 1]); + expect(n).toBe(15); + done(); + }); + + add(n => { + log.push(1); + next(n + 1); + }); + + add(n => { + log.push(2); + next(n + 1); + }); + + add(n => { + log.push(3); + next(n + 1); + }); + + add(n => { + log.push(4); + next(n + 1); + }); + + add(n => { + log.push(5); + next(n + 11); + }); + + next(0); + })); + + it('only calls complete once even if multiple next', () => + new Promise(done => { + const { add, next } = createCallbackList(n => { + expect(n).toBe(1); + done(); + }); + + add(n => { + next(n + 1); + // We dont actually do this in our code... + next(n + 1); + }); + + next(0); + })); + + it('calls completed if added closure throws', () => + new Promise(done => { + const { add, next } = createCallbackList(n => { + expect(n).toBe(10); + done(); + }); + + add(n => { + throw new Error('test'); + next(n + 1); + }); + + next(10); + })); + }); + + describe('rateLimiter', () => { + it('calls disable if exceeded', () => + new Promise(done => { + const increment = createRateLimiter( + 5, + () => {}, + () => { + done(); + }, + ); + + for (let i = 0; i < 7; i++) { + increment(); + vi.advanceTimersByTime(100); + } + + vi.advanceTimersByTime(1_000); + })); + + it('does not call disable if not exceeded', () => { + const increment = createRateLimiter( + 5, + () => { + throw new Error('Should not be called'); + }, + () => { + throw new Error('Should not be called'); + }, + ); + + for (let i = 0; i < 4; i++) { + increment(); + vi.advanceTimersByTime(200); + } + + vi.advanceTimersByTime(600); + + for (let i = 0; i < 4; i++) { + increment(); + vi.advanceTimersByTime(200); + } + }); + + it('re-enables after timeout', () => + new Promise(done => { + let called = false; + + const increment = createRateLimiter( + 5, + () => { + expect(called).toEqual(true); + done(); + }, + () => { + expect(called).toEqual(false); + called = true; + }, + ); + + for (let i = 0; i < 10; i++) { + increment(); + vi.advanceTimersByTime(100); + } + + vi.advanceTimersByTime(10_000); + })); + }); +}); diff --git a/packages/node-core/test/integrations/request-session-tracking.test.ts b/packages/node-core/test/integrations/request-session-tracking.test.ts new file mode 100644 index 000000000000..02446eee875d --- /dev/null +++ b/packages/node-core/test/integrations/request-session-tracking.test.ts @@ -0,0 +1,153 @@ +import type { Client } from '@sentry/core'; +import { createTransport, Scope, ServerRuntimeClient, withScope } from '@sentry/core'; +import { EventEmitter } from 'stream'; +import { describe, expect, it, vi } from 'vitest'; +import { recordRequestSession } from '../../src/integrations/http/SentryHttpInstrumentation'; + +vi.useFakeTimers(); + +describe('recordRequestSession()', () => { + it('should send an "exited" session for an ok ended request', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC')); + + simulateRequest(client, 'ok'); + + vi.runAllTimers(); + + expect(sendSessionSpy).toBeCalledWith({ + aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }], + }); + }); + + it('should send an "crashed" session when the session on the requestProcessingMetadata was overridden with crashed', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC')); + + simulateRequest(client, 'crashed'); + + vi.runAllTimers(); + + expect(sendSessionSpy).toBeCalledWith({ + aggregates: [{ crashed: 1, errored: 0, exited: 0, started: expect.stringMatching(/....-..-..T..:..:00.000Z/) }], + }); + }); + + it('should send an "errored" session when the session on the requestProcessingMetadata was overridden with errored', () => { + const client = createTestClient(); + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC')); + + simulateRequest(client, 'errored'); + + vi.runAllTimers(); + + expect(sendSessionSpy).toBeCalledWith({ + aggregates: [{ crashed: 0, errored: 1, exited: 0, started: expect.stringMatching(/....-..-..T..:..:00.000Z/) }], + }); + }); + + it('should aggregate request sessions within a time frame', async () => { + const client = createTestClient(); + + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + vi.setSystemTime(new Date('March 19, 1999 06:00:00 UTC')); + + simulateRequest(client, 'ok'); + simulateRequest(client, 'ok'); + simulateRequest(client, 'crashed'); + simulateRequest(client, 'errored'); + + // "Wait" 1+ second to get into new bucket + vi.setSystemTime(new Date('March 19, 1999 06:01:01 UTC')); + + simulateRequest(client, 'ok'); + simulateRequest(client, 'errored'); + + vi.runAllTimers(); + + expect(sendSessionSpy).toBeCalledWith({ + aggregates: [ + { + crashed: 1, + errored: 1, + exited: 2, + started: '1999-03-19T06:00:00.000Z', + }, + { crashed: 0, errored: 1, exited: 1, started: '1999-03-19T06:01:00.000Z' }, + ], + }); + }); + + it('should flush pending sessions when the client emits a "flush" hook', async () => { + const client = createTestClient(); + + const sendSessionSpy = vi.spyOn(client, 'sendSession'); + + vi.setSystemTime(new Date('March 19, 1999 06:00:00 UTC')); + + simulateRequest(client, 'ok'); + + // "Wait" 1+ second to get into new bucket + vi.setSystemTime(new Date('March 19, 1999 06:01:01 UTC')); + + simulateRequest(client, 'ok'); + + client.emit('flush'); + + expect(sendSessionSpy).toBeCalledWith({ + aggregates: [ + { + crashed: 0, + errored: 0, + exited: 1, + started: '1999-03-19T06:00:00.000Z', + }, + { + crashed: 0, + errored: 0, + exited: 1, + started: '1999-03-19T06:01:00.000Z', + }, + ], + }); + }); +}); + +function simulateRequest(client: Client, status: 'ok' | 'errored' | 'crashed') { + const requestIsolationScope = new Scope(); + const response = new EventEmitter(); + + recordRequestSession({ + requestIsolationScope, + response, + }); + + requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession!.status = status; + + withScope(scope => { + scope.setClient(client); + // "end" request + response.emit('close'); + }); +} + +function createTestClient() { + return new ServerRuntimeClient({ + integrations: [], + transport: () => + createTransport( + { + recordDroppedEvent: () => undefined, + }, + () => Promise.resolve({}), + ), + stackParser: () => [], + }); +} diff --git a/packages/node-core/test/integrations/spotlight.test.ts b/packages/node-core/test/integrations/spotlight.test.ts new file mode 100644 index 000000000000..2bd10080fd31 --- /dev/null +++ b/packages/node-core/test/integrations/spotlight.test.ts @@ -0,0 +1,190 @@ +import * as http from 'node:http'; +import type { Envelope, EventEnvelope } from '@sentry/core'; +import { createEnvelope, logger } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { spotlightIntegration } from '../../src/integrations/spotlight'; +import { NodeClient } from '../../src/sdk/client'; +import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; + +vi.mock('node:http', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = (await vi.importActual('node:http')) as typeof import('node:http'); + return { + ...original, + request: original.request, + }; +}); + +describe('Spotlight', () => { + const loggerSpy = vi.spyOn(logger, 'warn'); + + afterEach(() => { + loggerSpy.mockClear(); + vi.clearAllMocks(); + }); + + const options = getDefaultNodeClientOptions(); + const client = new NodeClient(options); + + it('has a name', () => { + const integration = spotlightIntegration(); + expect(integration.name).toEqual('Spotlight'); + }); + + it('registers a callback on the `beforeEnvelope` hook', () => { + const clientWithSpy = { + ...client, + on: vi.fn(), + }; + const integration = spotlightIntegration(); + // @ts-expect-error - this is fine in tests + integration.setup(clientWithSpy); + expect(clientWithSpy.on).toHaveBeenCalledWith('beforeEnvelope', expect.any(Function)); + }); + + it('sends an envelope POST request to the sidecar url', () => { + const httpSpy = vi.spyOn(http, 'request').mockImplementationOnce(() => { + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } as any; + }); + + let callback: (envelope: Envelope) => void = () => {}; + const clientWithSpy = { + ...client, + on: vi.fn().mockImplementationOnce((_, cb) => (callback = cb)), + }; + + const integration = spotlightIntegration(); + // @ts-expect-error - this is fine in tests + integration.setup(clientWithSpy); + + const envelope = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + ]); + + callback(envelope); + + expect(httpSpy).toHaveBeenCalledWith( + { + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + hostname: 'localhost', + method: 'POST', + path: '/stream', + port: '8969', + }, + expect.any(Function), + ); + }); + + it('sends an envelope POST request to a custom sidecar url', () => { + const httpSpy = vi.spyOn(http, 'request').mockImplementationOnce(() => { + return { + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } as any; + }); + + let callback: (envelope: Envelope) => void = () => {}; + const clientWithSpy = { + ...client, + on: vi.fn().mockImplementationOnce((_, cb) => (callback = cb)), + }; + + const integration = spotlightIntegration({ sidecarUrl: 'http://mylocalhost:8888/abcd' }); + // @ts-expect-error - this is fine in tests + integration.setup(clientWithSpy); + + const envelope = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + ]); + + callback(envelope); + + expect(httpSpy).toHaveBeenCalledWith( + { + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + hostname: 'mylocalhost', + method: 'POST', + path: '/abcd', + port: '8888', + }, + expect.any(Function), + ); + }); + + describe('no-ops if', () => { + it('an invalid URL is passed', () => { + const integration = spotlightIntegration({ sidecarUrl: 'invalid-url' }); + integration.setup!(client); + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid sidecar URL: invalid-url')); + }); + }); + + it('warns if the NODE_ENV variable doesn\'t equal "development"', () => { + const oldEnvValue = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const integration = spotlightIntegration({ sidecarUrl: 'http://localhost:8969' }); + integration.setup!(client); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), + ); + + process.env.NODE_ENV = oldEnvValue; + }); + + it('doesn\'t warn if the NODE_ENV variable equals "development"', () => { + const oldEnvValue = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + const integration = spotlightIntegration({ sidecarUrl: 'http://localhost:8969' }); + integration.setup!(client); + + expect(loggerSpy).not.toHaveBeenCalledWith( + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), + ); + + process.env.NODE_ENV = oldEnvValue; + }); + + it('handles `process` not being available', () => { + const originalProcess = process; + + // @ts-expect-error - TS complains but we explicitly wanna test this + delete global.process; + + const integration = spotlightIntegration({ sidecarUrl: 'http://localhost:8969' }); + integration.setup!(client); + + expect(loggerSpy).not.toHaveBeenCalledWith( + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), + ); + + global.process = originalProcess; + }); + + it('handles `process.env` not being available', () => { + const originalEnv = process.env; + + // @ts-expect-error - TS complains but we explicitly wanna test this + delete process.env; + + const integration = spotlightIntegration({ sidecarUrl: 'http://localhost:8969' }); + integration.setup!(client); + + expect(loggerSpy).not.toHaveBeenCalledWith( + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), + ); + + process.env = originalEnv; + }); +}); diff --git a/packages/node-core/test/logs/exports.test.ts b/packages/node-core/test/logs/exports.test.ts new file mode 100644 index 000000000000..9e1cc4900e29 --- /dev/null +++ b/packages/node-core/test/logs/exports.test.ts @@ -0,0 +1,145 @@ +import * as sentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as nodeLogger from '../../src/logs/exports'; + +// Mock the core functions +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + _INTERNAL_captureLog: vi.fn(), + }; +}); + +describe('Node Logger', () => { + // Use the mocked function + const mockCaptureLog = vi.mocked(sentryCore._INTERNAL_captureLog); + + beforeEach(() => { + // Reset mocks + mockCaptureLog.mockClear(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Basic logging methods', () => { + it('should export all log methods', () => { + expect(nodeLogger.trace).toBeTypeOf('function'); + expect(nodeLogger.debug).toBeTypeOf('function'); + expect(nodeLogger.info).toBeTypeOf('function'); + expect(nodeLogger.warn).toBeTypeOf('function'); + expect(nodeLogger.error).toBeTypeOf('function'); + expect(nodeLogger.fatal).toBeTypeOf('function'); + }); + + it('should call _INTERNAL_captureLog with trace level', () => { + nodeLogger.trace('Test trace message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'trace', + message: 'Test trace message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with debug level', () => { + nodeLogger.debug('Test debug message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'debug', + message: 'Test debug message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with info level', () => { + nodeLogger.info('Test info message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Test info message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with warn level', () => { + nodeLogger.warn('Test warn message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'warn', + message: 'Test warn message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with error level', () => { + nodeLogger.error('Test error message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'error', + message: 'Test error message', + attributes: { key: 'value' }, + }); + }); + + it('should call _INTERNAL_captureLog with fatal level', () => { + nodeLogger.fatal('Test fatal message', { key: 'value' }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'fatal', + message: 'Test fatal message', + attributes: { key: 'value' }, + }); + }); + }); + + describe('Template string logging', () => { + it('should handle template strings with parameters', () => { + nodeLogger.info('Hello %s, your balance is %d', ['John', 100], { userId: 123 }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: 'Hello John, your balance is 100', + attributes: { + userId: 123, + 'sentry.message.template': 'Hello %s, your balance is %d', + 'sentry.message.parameter.0': 'John', + 'sentry.message.parameter.1': 100, + }, + }); + }); + + it('should handle template strings without additional attributes', () => { + nodeLogger.debug('User %s logged in from %s', ['Alice', 'mobile']); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'debug', + message: 'User Alice logged in from mobile', + attributes: { + 'sentry.message.template': 'User %s logged in from %s', + 'sentry.message.parameter.0': 'Alice', + 'sentry.message.parameter.1': 'mobile', + }, + }); + }); + + it('should handle parameterized strings with parameters', () => { + nodeLogger.info(nodeLogger.fmt`Hello ${'John'}, your balance is ${100}`, { userId: 123 }); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'info', + message: expect.objectContaining({ + __sentry_template_string__: 'Hello %s, your balance is %s', + __sentry_template_values__: ['John', 100], + }), + attributes: { + userId: 123, + }, + }); + }); + + it('should handle parameterized strings without additional attributes', () => { + nodeLogger.debug(nodeLogger.fmt`User ${'Alice'} logged in from ${'mobile'}`); + expect(mockCaptureLog).toHaveBeenCalledWith({ + level: 'debug', + message: expect.objectContaining({ + __sentry_template_string__: 'User %s logged in from %s', + __sentry_template_values__: ['Alice', 'mobile'], + }), + }); + }); + }); +}); diff --git a/packages/node-core/test/sdk/api.test.ts b/packages/node-core/test/sdk/api.test.ts new file mode 100644 index 000000000000..9b6e010a6d58 --- /dev/null +++ b/packages/node-core/test/sdk/api.test.ts @@ -0,0 +1,107 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { Event } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getActiveSpan, getClient, startInactiveSpan, startSpan, withActiveSpan } from '../../src'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +let provider: BasicTracerProvider | undefined; + +afterEach(() => { + vi.restoreAllMocks(); + cleanupOtel(provider); +}); + +describe('withActiveSpan()', () => { + it('should set the active span within the callback', () => { + provider = mockSdkInit(); + + const inactiveSpan = startInactiveSpan({ name: 'inactive-span' }); + + expect(getActiveSpan()).not.toBe(inactiveSpan); + + withActiveSpan(inactiveSpan, () => { + expect(getActiveSpan()).toBe(inactiveSpan); + }); + }); + + it('should create child spans when calling startSpan within the callback', async () => { + const beforeSendTransaction = vi.fn(() => null); + provider = mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + const client = getClient(); + + const inactiveSpan = startInactiveSpan({ name: 'inactive-span' }); + + withActiveSpan(inactiveSpan, () => { + startSpan({ name: 'child-span' }, () => {}); + }); + + startSpan({ name: 'floating-span' }, () => {}); + + inactiveSpan.end(); + + await client?.flush(); + + // The child span should be a child of the inactive span + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + transaction: 'inactive-span', + spans: expect.arrayContaining([expect.any(Object)]), + }), + expect.anything(), + ); + + // The floating span should be a separate transaction + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + transaction: 'floating-span', + }), + expect.anything(), + ); + }); + + it('when `null` is passed, no span should be active within the callback', () => { + expect.assertions(1); + startSpan({ name: 'parent-span' }, () => { + withActiveSpan(null, () => { + expect(getActiveSpan()).toBeUndefined(); + }); + }); + }); + + it('when `null` is passed, should start a new trace for new spans', async () => { + const transactions: Event[] = []; + const beforeSendTransaction = vi.fn((event: Event) => { + transactions.push(event); + return null; + }); + provider = mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + const client = getClient(); + + startSpan({ name: 'parent-span' }, () => { + withActiveSpan(null, () => { + startSpan({ name: 'child-span' }, () => {}); + }); + }); + + await client?.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + + // The child span should be a child of the inactive span + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + transaction: 'parent-span', + spans: expect.not.arrayContaining([expect.objectContaining({ description: 'child-span' })]), + }), + expect.anything(), + ); + + // The floating span should be a separate transaction + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + transaction: 'child-span', + }), + expect.anything(), + ); + }); +}); diff --git a/packages/node-core/test/sdk/client.test.ts b/packages/node-core/test/sdk/client.test.ts new file mode 100644 index 000000000000..5511f339e8e6 --- /dev/null +++ b/packages/node-core/test/sdk/client.test.ts @@ -0,0 +1,312 @@ +import { ProxyTracer } from '@opentelemetry/api'; +import * as opentelemetryInstrumentationPackage from '@opentelemetry/instrumentation'; +import type { Event, EventHint, Log } from '@sentry/core'; +import { getCurrentScope, getGlobalScope, getIsolationScope, Scope, SDK_VERSION } from '@sentry/core'; +import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; +import * as os from 'os'; +import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; +import { NodeClient } from '../../src'; +import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; +import { cleanupOtel } from '../helpers/mockSdkInit'; + +describe('NodeClient', () => { + beforeEach(() => { + getIsolationScope().clear(); + getGlobalScope().clear(); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + setOpenTelemetryContextAsyncContextStrategy(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + cleanupOtel(); + }); + + it('sets correct metadata', () => { + const options = getDefaultNodeClientOptions(); + const client = new NodeClient(options); + + expect(client.getOptions()).toEqual({ + dsn: expect.any(String), + integrations: [], + transport: options.transport, + stackParser: options.stackParser, + _metadata: { + sdk: { + name: 'sentry.javascript.node', + packages: [ + { + name: 'npm:@sentry/node', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }, + }, + platform: 'node', + runtime: { name: 'node', version: expect.any(String) }, + serverName: expect.any(String), + tracesSampleRate: 1, + }); + }); + + it('exposes a tracer', () => { + const client = new NodeClient(getDefaultNodeClientOptions()); + + const tracer = client.tracer; + expect(tracer).toBeDefined(); + expect(tracer).toBeInstanceOf(ProxyTracer); + + // Ensure we always get the same tracer instance + const tracer2 = client.tracer; + + expect(tracer2).toBe(tracer); + }); + + describe('_prepareEvent', () => { + const currentScope = new Scope(); + const isolationScope = new Scope(); + + test('adds platform to event', () => { + const options = getDefaultNodeClientOptions({}); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.platform).toEqual('node'); + }); + + test('adds runtime context to event', () => { + const options = getDefaultNodeClientOptions({}); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.contexts?.runtime).toEqual({ + name: 'node', + version: process.version, + }); + }); + + test('adds server name to event when value passed in options', () => { + const options = getDefaultNodeClientOptions({ serverName: 'foo' }); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.server_name).toEqual('foo'); + }); + + test('adds server name to event when value given in env', () => { + const options = getDefaultNodeClientOptions({}); + process.env.SENTRY_NAME = 'foo'; + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.server_name).toEqual('foo'); + + delete process.env.SENTRY_NAME; + }); + + test('adds hostname as event server name when no value given', () => { + const options = getDefaultNodeClientOptions({}); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.server_name).toEqual(os.hostname()); + }); + + test("doesn't clobber existing runtime data", () => { + const options = getDefaultNodeClientOptions({ serverName: 'bar' }); + const client = new NodeClient(options); + + const event: Event = { contexts: { runtime: { name: 'foo', version: '1.2.3' } } }; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.contexts?.runtime).toEqual({ name: 'foo', version: '1.2.3' }); + expect(event.contexts?.runtime).not.toEqual({ name: 'node', version: process.version }); + }); + + test("doesn't clobber existing server name", () => { + const options = getDefaultNodeClientOptions({ serverName: 'bar' }); + const client = new NodeClient(options); + + const event: Event = { server_name: 'foo' }; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint, currentScope, isolationScope); + + expect(event.server_name).toEqual('foo'); + expect(event.server_name).not.toEqual('bar'); + }); + }); + + describe('captureCheckIn', () => { + it('sends a checkIn envelope', () => { + const options = getDefaultNodeClientOptions({ + serverName: 'bar', + release: '1.0.0', + environment: 'dev', + }); + const client = new NodeClient(options); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + const id = client.captureCheckIn( + { monitorSlug: 'foo', status: 'in_progress' }, + { + schedule: { + type: 'crontab', + value: '0 * * * *', + }, + checkinMargin: 2, + maxRuntime: 12333, + timezone: 'Canada/Eastern', + }, + ); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect(sendEnvelopeSpy).toHaveBeenCalledWith([ + expect.any(Object), + [ + [ + expect.any(Object), + { + check_in_id: id, + monitor_slug: 'foo', + status: 'in_progress', + release: '1.0.0', + environment: 'dev', + monitor_config: { + schedule: { + type: 'crontab', + value: '0 * * * *', + }, + checkin_margin: 2, + max_runtime: 12333, + timezone: 'Canada/Eastern', + }, + }, + ], + ], + ]); + + client.captureCheckIn({ monitorSlug: 'foo', status: 'ok', duration: 1222, checkInId: id }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + expect(sendEnvelopeSpy).toHaveBeenCalledWith([ + expect.any(Object), + [ + [ + expect.any(Object), + { + check_in_id: id, + monitor_slug: 'foo', + duration: 1222, + status: 'ok', + release: '1.0.0', + environment: 'dev', + }, + ], + ], + ]); + }); + + it('sends a checkIn envelope for heartbeat checkIns', () => { + const options = getDefaultNodeClientOptions({ + serverName: 'server', + release: '1.0.0', + environment: 'dev', + }); + const client = new NodeClient(options); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + const id = client.captureCheckIn({ monitorSlug: 'heartbeat-monitor', status: 'ok' }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect(sendEnvelopeSpy).toHaveBeenCalledWith([ + expect.any(Object), + [ + [ + expect.any(Object), + { + check_in_id: id, + monitor_slug: 'heartbeat-monitor', + status: 'ok', + release: '1.0.0', + environment: 'dev', + }, + ], + ], + ]); + }); + + it('does not send a checkIn envelope if disabled', () => { + const options = getDefaultNodeClientOptions({ serverName: 'bar', enabled: false }); + const client = new NodeClient(options); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + client.captureCheckIn({ monitorSlug: 'foo', status: 'in_progress' }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(0); + }); + }); + + it('registers instrumentations provided with `openTelemetryInstrumentations`', () => { + const registerInstrumentationsSpy = vi + .spyOn(opentelemetryInstrumentationPackage, 'registerInstrumentations') + .mockImplementationOnce(() => () => undefined); + const instrumentationsArray = ['foobar'] as unknown as opentelemetryInstrumentationPackage.Instrumentation[]; + + new NodeClient(getDefaultNodeClientOptions({ openTelemetryInstrumentations: instrumentationsArray })); + + expect(registerInstrumentationsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + instrumentations: instrumentationsArray, + }), + ); + }); + + describe('log capture', () => { + it('adds server name to log attributes', () => { + const options = getDefaultNodeClientOptions({ _experiments: { enableLogs: true } }); + const client = new NodeClient(options); + + const log: Log = { level: 'info', message: 'test message', attributes: {} }; + client.emit('beforeCaptureLog', log); + + expect(log.attributes).toEqual({ + 'server.address': expect.any(String), + }); + }); + + it('preserves existing log attributes', () => { + const serverName = 'test-server'; + const options = getDefaultNodeClientOptions({ serverName, _experiments: { enableLogs: true } }); + const client = new NodeClient(options); + + const log: Log = { level: 'info', message: 'test message', attributes: { 'existing.attr': 'value' } }; + client.emit('beforeCaptureLog', log); + + expect(log.attributes).toEqual({ + 'existing.attr': 'value', + 'server.address': serverName, + }); + }); + }); +}); diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts new file mode 100644 index 000000000000..24a8eb64291c --- /dev/null +++ b/packages/node-core/test/sdk/init.test.ts @@ -0,0 +1,269 @@ +import type { Integration } from '@sentry/core'; +import { logger } from '@sentry/core'; +import * as SentryOpentelemetry from '@sentry/opentelemetry'; +import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getClient } from '../../src/'; +import { init, validateOpenTelemetrySetup } from '../../src/sdk'; +import { NodeClient } from '../../src/sdk/client'; +import { cleanupOtel } from '../helpers/mockSdkInit'; + +// eslint-disable-next-line no-var +declare var global: any; + +const PUBLIC_DSN = 'https://username@domain/123'; + +class MockIntegration implements Integration { + public name: string; + public setupOnce: Mock = vi.fn(); + public constructor(name: string) { + this.name = name; + } +} + +describe('init()', () => { + beforeEach(() => { + global.__SENTRY__ = {}; + }); + + afterEach(() => { + cleanupOtel(); + + vi.clearAllMocks(); + }); + + describe('integrations', () => { + it("doesn't install default integrations if told not to", () => { + init({ dsn: PUBLIC_DSN, defaultIntegrations: false }); + + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: [], + }), + ); + }); + + it('installs merged default integrations, with overrides provided through options', () => { + const mockDefaultIntegrations = [ + new MockIntegration('Some mock integration 2.1'), + new MockIntegration('Some mock integration 2.2'), + ]; + + const mockIntegrations = [ + new MockIntegration('Some mock integration 2.1'), + new MockIntegration('Some mock integration 2.3'), + ]; + + init({ dsn: PUBLIC_DSN, integrations: mockIntegrations, defaultIntegrations: mockDefaultIntegrations }); + + expect(mockDefaultIntegrations[0]?.setupOnce as Mock).toHaveBeenCalledTimes(0); + expect(mockDefaultIntegrations[1]?.setupOnce as Mock).toHaveBeenCalledTimes(1); + expect(mockIntegrations[0]?.setupOnce as Mock).toHaveBeenCalledTimes(1); + expect(mockIntegrations[1]?.setupOnce as Mock).toHaveBeenCalledTimes(1); + }); + + it('installs integrations returned from a callback function', () => { + const mockDefaultIntegrations = [ + new MockIntegration('Some mock integration 3.1'), + new MockIntegration('Some mock integration 3.2'), + ]; + + const newIntegration = new MockIntegration('Some mock integration 3.3'); + + init({ + dsn: PUBLIC_DSN, + defaultIntegrations: mockDefaultIntegrations, + integrations: integrations => { + const newIntegrations = [...integrations]; + newIntegrations[1] = newIntegration; + return newIntegrations; + }, + }); + + expect(mockDefaultIntegrations[0]?.setupOnce as Mock).toHaveBeenCalledTimes(1); + expect(mockDefaultIntegrations[1]?.setupOnce as Mock).toHaveBeenCalledTimes(0); + expect(newIntegration.setupOnce as Mock).toHaveBeenCalledTimes(1); + }); + }); + + it('returns initialized client', () => { + const client = init({ dsn: PUBLIC_DSN }); + + expect(client).toBeInstanceOf(NodeClient); + }); + + describe('environment variable options', () => { + const originalProcessEnv = { ...process.env }; + + afterEach(() => { + process.env = originalProcessEnv; + global.__SENTRY__ = {}; + cleanupOtel(); + vi.clearAllMocks(); + }); + + it('sets debug from `SENTRY_DEBUG` env variable', () => { + process.env.SENTRY_DEBUG = '1'; + + const client = init({ dsn: PUBLIC_DSN }); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + debug: true, + }), + ); + }); + + it('prefers `debug` option over `SENTRY_DEBUG` env variable', () => { + process.env.SENTRY_DEBUG = '1'; + + const client = init({ dsn: PUBLIC_DSN, debug: false }); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + debug: false, + }), + ); + }); + + it('sets tracesSampleRate from `SENTRY_TRACES_SAMPLE_RATE` env variable', () => { + process.env.SENTRY_TRACES_SAMPLE_RATE = '0.5'; + + const client = init({ dsn: PUBLIC_DSN }); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + tracesSampleRate: 0.5, + }), + ); + }); + + it('prefers `tracesSampleRate` option over `SENTRY_TRACES_SAMPLE_RATE` env variable', () => { + process.env.SENTRY_TRACES_SAMPLE_RATE = '0.5'; + + const client = init({ dsn: PUBLIC_DSN, tracesSampleRate: 0.1 }); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + tracesSampleRate: 0.1, + }), + ); + }); + + it('sets release from `SENTRY_RELEASE` env variable', () => { + process.env.SENTRY_RELEASE = '1.0.0'; + + const client = init({ dsn: PUBLIC_DSN }); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + release: '1.0.0', + }), + ); + }); + + it('prefers `release` option over `SENTRY_RELEASE` env variable', () => { + process.env.SENTRY_RELEASE = '1.0.0'; + + const client = init({ dsn: PUBLIC_DSN, release: '2.0.0' }); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + release: '2.0.0', + }), + ); + }); + + it('sets environment from `SENTRY_ENVIRONMENT` env variable', () => { + process.env.SENTRY_ENVIRONMENT = 'production'; + + const client = init({ dsn: PUBLIC_DSN }); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + environment: 'production', + }), + ); + }); + + it('prefers `environment` option over `SENTRY_ENVIRONMENT` env variable', () => { + process.env.SENTRY_ENVIRONMENT = 'production'; + + const client = init({ dsn: PUBLIC_DSN, environment: 'staging' }); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + environment: 'staging', + }), + ); + }); + }); +}); + +describe('validateOpenTelemetrySetup', () => { + afterEach(() => { + global.__SENTRY__ = {}; + cleanupOtel(); + vi.clearAllMocks(); + }); + + it('works with correct setup', () => { + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + vi.spyOn(SentryOpentelemetry, 'openTelemetrySetupCheck').mockImplementation(() => { + return ['SentryContextManager', 'SentryPropagator', 'SentrySampler']; + }); + + validateOpenTelemetrySetup(); + + expect(errorSpy).toHaveBeenCalledTimes(0); + expect(warnSpy).toHaveBeenCalledTimes(0); + }); + + it('works with missing setup, without tracing', () => { + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + vi.spyOn(SentryOpentelemetry, 'openTelemetrySetupCheck').mockImplementation(() => { + return []; + }); + + validateOpenTelemetrySetup(); + + // Without tracing, this is expected only twice + expect(errorSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(1); + + expect(errorSpy).toBeCalledWith(expect.stringContaining('You have to set up the SentryContextManager.')); + expect(errorSpy).toBeCalledWith(expect.stringContaining('You have to set up the SentryPropagator.')); + expect(warnSpy).toBeCalledWith(expect.stringContaining('You have to set up the SentrySampler.')); + }); + + it('works with missing setup, with tracing', () => { + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + vi.spyOn(SentryOpentelemetry, 'openTelemetrySetupCheck').mockImplementation(() => { + return []; + }); + + init({ dsn: PUBLIC_DSN, tracesSampleRate: 1 }); + + validateOpenTelemetrySetup(); + + expect(errorSpy).toHaveBeenCalledTimes(3); + expect(warnSpy).toHaveBeenCalledTimes(1); + + expect(errorSpy).toBeCalledWith(expect.stringContaining('You have to set up the SentryContextManager.')); + expect(errorSpy).toBeCalledWith(expect.stringContaining('You have to set up the SentryPropagator.')); + expect(errorSpy).toBeCalledWith(expect.stringContaining('You have to set up the SentrySpanProcessor.')); + expect(warnSpy).toBeCalledWith(expect.stringContaining('You have to set up the SentrySampler.')); + }); + + // Regression test for https://github.com/getsentry/sentry-javascript/issues/15558 + it('accepts an undefined transport', () => { + init({ dsn: PUBLIC_DSN, transport: undefined }); + }); +}); diff --git a/packages/node-core/test/transports/http.test.ts b/packages/node-core/test/transports/http.test.ts new file mode 100644 index 000000000000..0f01fdc5639a --- /dev/null +++ b/packages/node-core/test/transports/http.test.ts @@ -0,0 +1,428 @@ +import type { EventEnvelope, EventItem } from '@sentry/core'; +import { + addItemToEnvelope, + createAttachmentEnvelopeItem, + createEnvelope, + createTransport, + serializeEnvelope, +} from '@sentry/core'; +import * as http from 'http'; +import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; +import { createGunzip } from 'zlib'; +import * as httpProxyAgent from '../../src/proxy'; +import { makeNodeTransport } from '../../src/transports'; + +vi.mock('@sentry/core', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actualCore = (await vi.importActual('@sentry/core')) as typeof import('@sentry/core'); + return { + ...actualCore, + createTransport: vi.fn().mockImplementation(actualCore.createTransport), + }; +}); + +vi.mock('node:http', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const original = (await vi.importActual('node:http')) as typeof import('node:http'); + return { + ...original, + request: original.request, + }; +}); + +const SUCCESS = 200; +const RATE_LIMIT = 429; +const INVALID = 400; +const FAILED = 500; + +interface TestServerOptions { + statusCode: number; + responseHeaders?: Record; +} + +let testServer: http.Server | undefined; + +function setupTestServer( + options: TestServerOptions, + requestInspector?: (req: http.IncomingMessage, body: string, raw: Uint8Array) => void, +) { + testServer = http.createServer((req, res) => { + const chunks: Buffer[] = []; + + const stream = req.headers['content-encoding'] === 'gzip' ? req.pipe(createGunzip({})) : req; + + stream.on('data', data => { + chunks.push(data); + }); + + stream.on('end', () => { + requestInspector?.(req, chunks.join(), Buffer.concat(chunks)); + }); + + res.writeHead(options.statusCode, options.responseHeaders); + res.end(); + + // also terminate socket because keepalive hangs connection a bit + // eslint-disable-next-line deprecation/deprecation + res.connection?.end(); + }); + + testServer.listen(18101); + + return new Promise(resolve => { + testServer?.on('listening', resolve); + }); +} + +const TEST_SERVER_URL = 'http://localhost:18101'; + +const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); + +const ATTACHMENT_ITEM = createAttachmentEnvelopeItem({ filename: 'empty-file.bin', data: new Uint8Array(50_000) }); +const EVENT_ATTACHMENT_ENVELOPE = addItemToEnvelope(EVENT_ENVELOPE, ATTACHMENT_ITEM); +const SERIALIZED_EVENT_ATTACHMENT_ENVELOPE = serializeEnvelope(EVENT_ATTACHMENT_ENVELOPE) as Uint8Array; + +const defaultOptions = { + url: TEST_SERVER_URL, + recordDroppedEvent: () => undefined, +}; + +// empty function to keep test output clean +const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + +afterEach( + () => + new Promise(done => { + vi.clearAllMocks(); + + if (testServer?.listening) { + testServer.close(() => done()); + } else { + done(); + } + }), +); + +describe('makeNewHttpTransport()', () => { + describe('.send()', () => { + it('should correctly send envelope to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, (req, body) => { + expect(req.method).toBe('POST'); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }); + + const transport = makeNodeTransport(defaultOptions); + await transport.send(EVENT_ENVELOPE); + }); + + it('allows overriding keepAlive', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + connection: 'keep-alive', + }), + ); + }); + + const transport = makeNodeTransport({ keepAlive: true, ...defaultOptions }); + await transport.send(EVENT_ENVELOPE); + }); + + it('should correctly send user-provided headers to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + 'x-some-custom-header-1': 'value1', + 'x-some-custom-header-2': 'value2', + }), + ); + }); + + const transport = makeNodeTransport({ + ...defaultOptions, + headers: { + 'X-Some-Custom-Header-1': 'value1', + 'X-Some-Custom-Header-2': 'value2', + }, + }); + + await transport.send(EVENT_ENVELOPE); + }); + + it.each([RATE_LIMIT, INVALID, FAILED])( + 'should resolve on bad server response (status %i)', + async serverStatusCode => { + await setupTestServer({ statusCode: serverStatusCode }); + + const transport = makeNodeTransport(defaultOptions); + + await expect(transport.send(EVENT_ENVELOPE)).resolves.toEqual( + expect.objectContaining({ statusCode: serverStatusCode }), + ); + }, + ); + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport(defaultOptions); + await expect(transport.send(EVENT_ENVELOPE)).resolves.toEqual({ + statusCode: SUCCESS, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }); + }); + }); + + describe('compression', () => { + it('small envelopes should not be compressed', async () => { + await setupTestServer( + { + statusCode: SUCCESS, + responseHeaders: {}, + }, + (req, body) => { + expect(req.headers['content-encoding']).toBeUndefined(); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }, + ); + + const transport = makeNodeTransport(defaultOptions); + await transport.send(EVENT_ENVELOPE); + }); + + it('large envelopes should be compressed', async () => { + await setupTestServer( + { + statusCode: SUCCESS, + responseHeaders: {}, + }, + (req, _, raw) => { + expect(req.headers['content-encoding']).toEqual('gzip'); + expect(raw.buffer).toStrictEqual(SERIALIZED_EVENT_ATTACHMENT_ENVELOPE.buffer); + }, + ); + + const transport = makeNodeTransport(defaultOptions); + await transport.send(EVENT_ATTACHMENT_ENVELOPE); + }); + }); + + describe('proxy', () => { + const proxyAgentSpy = vi + .spyOn(httpProxyAgent, 'HttpsProxyAgent') + // @ts-expect-error using http agent as https proxy agent + .mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); + + it('can be configured through option', () => { + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://example.com', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('http://example.com'); + }); + + it('can be configured through env variables option', () => { + process.env.http_proxy = 'http://example.com'; + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('http://example.com'); + delete process.env.http_proxy; + }); + + it('client options have priority over env variables', () => { + process.env.http_proxy = 'http://foo.com'; + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://bar.com', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('http://bar.com'); + delete process.env.http_proxy; + }); + + it('no_proxy allows for skipping specific hosts', () => { + process.env.no_proxy = 'sentry.io'; + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://example.com', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + }); + + it('no_proxy works with a port', () => { + process.env.http_proxy = 'http://example.com:8080'; + process.env.no_proxy = 'sentry.io:8989'; + + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + + it('no_proxy works with multiple comma-separated hosts', () => { + process.env.http_proxy = 'http://example.com:8080'; + process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; + + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + }); + + describe('should register TransportRequestExecutor that returns the correct object from server response', () => { + it('rate limit', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: {}, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as Mock).mock.calls[0]?.[1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: RATE_LIMIT, + }), + ); + }); + + it('OK', async () => { + await setupTestServer({ + statusCode: SUCCESS, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as Mock).mock.calls[0]?.[1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: SUCCESS, + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + }), + ); + }); + + it('OK with rate-limit headers', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as Mock).mock.calls[0]?.[1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: SUCCESS, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }), + ); + }); + + it('NOK with rate-limit headers', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as Mock).mock.calls[0]?.[1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: RATE_LIMIT, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }), + ); + }); + }); + + it('should create a noop transport if an invalid url is passed', async () => { + const requestSpy = vi.spyOn(http, 'request'); + const transport = makeNodeTransport({ ...defaultOptions, url: 'foo' }); + await transport.send(EVENT_ENVELOPE); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('should warn if an invalid url is passed', async () => { + const transport = makeNodeTransport({ ...defaultOptions, url: 'invalid url' }); + await transport.send(EVENT_ENVELOPE); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/node]: Invalid dsn or tunnel option, will not send any events. The tunnel option must be a full URL when used.', + ); + }); +}); diff --git a/packages/node-core/test/transports/https.test.ts b/packages/node-core/test/transports/https.test.ts new file mode 100644 index 000000000000..bb4147a20133 --- /dev/null +++ b/packages/node-core/test/transports/https.test.ts @@ -0,0 +1,389 @@ +import type { EventEnvelope, EventItem } from '@sentry/core'; +import { createEnvelope, createTransport, serializeEnvelope } from '@sentry/core'; +import * as http from 'http'; +import * as https from 'https'; +import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; +import * as httpProxyAgent from '../../src/proxy'; +import { makeNodeTransport } from '../../src/transports'; +import type { HTTPModule, HTTPModuleRequestIncomingMessage } from '../../src/transports/http-module'; +import testServerCerts from './test-server-certs'; + +vi.mock('@sentry/core', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actualCore = (await vi.importActual('@sentry/core')) as typeof import('@sentry/core'); + return { + ...actualCore, + createTransport: vi.fn().mockImplementation(actualCore.createTransport), + }; +}); + +const SUCCESS = 200; +const RATE_LIMIT = 429; +const INVALID = 400; +const FAILED = 500; + +interface TestServerOptions { + statusCode: number; + responseHeaders?: Record; +} + +let testServer: http.Server | undefined; + +function setupTestServer( + options: TestServerOptions, + requestInspector?: (req: http.IncomingMessage, body: string) => void, +) { + testServer = https.createServer(testServerCerts, (req, res) => { + let body = ''; + + req.on('data', data => { + body += data; + }); + + req.on('end', () => { + requestInspector?.(req, body); + }); + + res.writeHead(options.statusCode, options.responseHeaders); + res.end(); + + // also terminate socket because keepalive hangs connection a bit + // eslint-disable-next-line deprecation/deprecation + res.connection?.end(); + }); + + testServer.listen(8100); + + return new Promise(resolve => { + testServer?.on('listening', resolve); + }); +} + +const TEST_SERVER_URL = 'https://localhost:8100'; + +const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE); + +const unsafeHttpsModule: HTTPModule = { + request: vi + .fn() + .mockImplementation((options: https.RequestOptions, callback?: (res: HTTPModuleRequestIncomingMessage) => void) => { + return https.request({ ...options, rejectUnauthorized: false }, callback); + }), +}; + +const defaultOptions = { + httpModule: unsafeHttpsModule, + url: TEST_SERVER_URL, + recordDroppedEvent: () => undefined, // noop +}; + +afterEach( + () => + new Promise(done => { + vi.clearAllMocks(); + + if (testServer?.listening) { + testServer.close(() => done()); + } else { + done(); + } + }), +); + +describe('makeNewHttpsTransport()', () => { + describe('.send()', () => { + it('should correctly send envelope to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, (req, body) => { + expect(req.method).toBe('POST'); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }); + + const transport = makeNodeTransport(defaultOptions); + await transport.send(EVENT_ENVELOPE); + }); + + it('should correctly send user-provided headers to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + 'x-some-custom-header-1': 'value1', + 'x-some-custom-header-2': 'value2', + }), + ); + }); + + const transport = makeNodeTransport({ + ...defaultOptions, + headers: { + 'X-Some-Custom-Header-1': 'value1', + 'X-Some-Custom-Header-2': 'value2', + }, + }); + + await transport.send(EVENT_ENVELOPE); + }); + + it.each([RATE_LIMIT, INVALID, FAILED])( + 'should resolve on bad server response (status %i)', + async serverStatusCode => { + await setupTestServer({ statusCode: serverStatusCode }); + + const transport = makeNodeTransport(defaultOptions); + expect(() => { + expect(transport.send(EVENT_ENVELOPE)); + }).not.toThrow(); + }, + ); + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport(defaultOptions); + await expect(transport.send(EVENT_ENVELOPE)).resolves.toEqual({ + statusCode: SUCCESS, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }); + }); + + it('should use `caCerts` option', async () => { + await setupTestServer({ statusCode: SUCCESS }); + + const transport = makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: TEST_SERVER_URL, + caCerts: 'some cert', + }); + + await transport.send(EVENT_ENVELOPE); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(unsafeHttpsModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + ca: 'some cert', + }), + expect.anything(), + ); + }); + }); + + describe('proxy', () => { + const proxyAgentSpy = vi + .spyOn(httpProxyAgent, 'HttpsProxyAgent') + // @ts-expect-error using http agent as https proxy agent + .mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); + + it('can be configured through option', () => { + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://example.com', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('https://example.com'); + }); + + it('can be configured through env variables option (http)', () => { + process.env.http_proxy = 'https://example.com'; + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('https://example.com'); + delete process.env.http_proxy; + }); + + it('can be configured through env variables option (https)', () => { + process.env.https_proxy = 'https://example.com'; + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('https://example.com'); + delete process.env.https_proxy; + }); + + it('client options have priority over env variables', () => { + process.env.https_proxy = 'https://foo.com'; + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://bar.com', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('https://bar.com'); + delete process.env.https_proxy; + }); + + it('no_proxy allows for skipping specific hosts', () => { + process.env.no_proxy = 'sentry.io'; + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://example.com', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + }); + + it('no_proxy works with a port', () => { + process.env.http_proxy = 'https://example.com:8080'; + process.env.no_proxy = 'sentry.io:8989'; + + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + + it('no_proxy works with multiple comma-separated hosts', () => { + process.env.http_proxy = 'https://example.com:8080'; + process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; + + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: {}, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as Mock).mock.calls[0]?.[1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: RATE_LIMIT, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as Mock).mock.calls[0]?.[1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: SUCCESS, + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as Mock).mock.calls[0]?.[1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: SUCCESS, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as Mock).mock.calls[0]?.[1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: RATE_LIMIT, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }), + ); + }); +}); diff --git a/packages/node-core/test/transports/test-server-certs.ts b/packages/node-core/test/transports/test-server-certs.ts new file mode 100644 index 000000000000..a5ce436c4234 --- /dev/null +++ b/packages/node-core/test/transports/test-server-certs.ts @@ -0,0 +1,48 @@ +export default { + key: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAuMunjXC2tu2d4x8vKuPQbHwPjYG6pVvAUs7wzpDnMEGo3o2A +bZpL7vUAkQWZ86M84rX9b65cVvT35uqM9uxnJKQhSdGARxEcrz9yxjc9RaIO9xM4 +6WdFd6pcVHW9MF6njnc19jyIoSGXRADJjreNZHyMobAHyL2ZbFiptknUWFW3YT4t +q9bQD5yfhZ94fRt1IbdBAn5Bmz6x61BYudWU2KA3G1akPUmzj0OwZwaIrnGbfLUH +M5F50dNUYfCdmxtE8YRBPyWwcg+KOWa/P8C84p1UQ+/0GHNqUTa4wXBgKeUXNjth +AhV/4JgDDdec+/W0Z1UdEqxZvKfAYnjveFpxEwIDAQABAoIBADLsjEPB59gJKxVH +pqvfE7SRi4enVFP1MM6hEGMcM1ls/qg1vkp11q8G/Rz5ui8VsNWY6To5hmDAKQCN +akMxaksCn9nDzeHHqWvxxCMzXcMuoYkc1vYa613KqJ7twzDtJKdx2oD8tXoR06l9 +vg2CL4idefOkmsCK3xioZjxBpC6jF6ybvlY241MGhaAGRHmP6ik1uFJ+6Y8smh6R +AQKO0u0oQPy6bka9F6DTP6BMUeZ+OA/oOrrb5FxTHu8AHcyCSk2wHnCkB9EF/Ou2 +xSWrnu0O0/0Px6OO9oEsNSq2/fKNV9iuEU8LeAoDVm4ysyMrPce2c4ZsB4U244bj +yQpQZ6ECgYEA9KwA7Lmyf+eeZHxEM4MNSqyeXBtSKu4Zyk0RRY1j69ConjHKet3Q +ylVedXQ0/FJAHHKEm4zFGZtnaaxrzCIcQSKJBCoaA+cN44MM3D1nKmHjgPy8R/yE +BNgIVwJB1MmVSGa+NYnQgUomcCIEr/guNMIxV7p2iybqoxaEHKLfGFUCgYEAwVn1 +8LARsZihLUdxxbAc9+v/pBeMTrkTw1eN1ki9VWYoRam2MLozehEzabt677cU4h7+ +bjdKCKo1x2liY9zmbIiVHssv9Jf3E9XhcajsXB42m1+kjUYVPh8o9lDXcatV9EKt +DZK8wfRY9boyDKB2zRyo6bvIEK3qWbas31W3a8cCgYA6w0TFliPkzEAiaiYHKSZ8 +FNFD1dv6K41OJQxM5BRngom81MCImdWXgsFY/DvtjeOP8YEfysNbzxMbMioBsP+Q +NTcrJOFypn+TcNoZ2zV33GLDi++8ak1azHfUTdp5vKB57xMn0J2fL6vjqoftq3GN +gkZPh50I9qPL35CDQCrMsQKBgC6tFfc1uf/Cld5FagzMOCINodguKxvyB/hXUZFS +XAqar8wpbScUPEsSjfPPY50s+GiiDM/0nvW6iWMLaMos0J+Q1VbqvDfy2525O0Ri +ADU4wfv+Oc41BfnKMexMlcYGE6j006v8KX81Cqi/e0ebETLw4UITp/eG1JU1yUPd +AHuPAoGBAL25v4/onoH0FBLdEwb2BAENxc+0g4In1T+83jfHbfD0gOF3XTbgH4FF +MduIG8qBoZC5whiZ3qH7YJK7sydaM1bDwiesqIik+gEUE65T7S2ZF84y5GC5JjTf +z6v6i+DMCIJXDY5/gjzOED6UllV2Jrn2pDoV++zVyR6KAwXpCmK6 +-----END RSA PRIVATE KEY-----`, + cert: `-----BEGIN CERTIFICATE----- +MIIDETCCAfkCFCMI53aBdS2kWTrw39Kkv93ErG3iMA0GCSqGSIb3DQEBCwUAMEUx +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwMzI4MDgzODQwWhcNNDkwODEyMDgz +ODQwWjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE +CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAuMunjXC2tu2d4x8vKuPQbHwPjYG6pVvAUs7wzpDnMEGo3o2A +bZpL7vUAkQWZ86M84rX9b65cVvT35uqM9uxnJKQhSdGARxEcrz9yxjc9RaIO9xM4 +6WdFd6pcVHW9MF6njnc19jyIoSGXRADJjreNZHyMobAHyL2ZbFiptknUWFW3YT4t +q9bQD5yfhZ94fRt1IbdBAn5Bmz6x61BYudWU2KA3G1akPUmzj0OwZwaIrnGbfLUH +M5F50dNUYfCdmxtE8YRBPyWwcg+KOWa/P8C84p1UQ+/0GHNqUTa4wXBgKeUXNjth +AhV/4JgDDdec+/W0Z1UdEqxZvKfAYnjveFpxEwIDAQABMA0GCSqGSIb3DQEBCwUA +A4IBAQBh4BKiByhyvAc5uHj5bkSqspY2xZWW8xiEGaCaQWDMlyjP9mVVWFHfE3XL +lzsJdZVnHDZUliuA5L+qTEpLJ5GmgDWqnKp3HdhtkL16mPbPyJLPY0X+m7wvoZRt +RwLfFCx1E13m0ktYWWgmSCnBl+rI7pyagDhZ2feyxsMrecCazyG/llFBuyWSOnIi +OHxjdHV7be5c8uOOp1iNB9j++LW1pRVrSCWOKRLcsUBal73FW+UvhM5+1If/F9pF +GNQrMhVRA8aHD0JAu3tpjYRKRuOpAbbqtiAUSbDPsJBQy/K9no2K83G7+AV+aGai +HXfQqFFJS6xGKU79azH51wLVEGXq +-----END CERTIFICATE-----`, +}; diff --git a/packages/node-core/test/tsconfig.json b/packages/node-core/test/tsconfig.json new file mode 100644 index 000000000000..38ca0b13bcdd --- /dev/null +++ b/packages/node-core/test/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.test.json" +} diff --git a/packages/node-core/test/utils/ensureIsWrapped.test.ts b/packages/node-core/test/utils/ensureIsWrapped.test.ts new file mode 100644 index 000000000000..2ce1b888df9b --- /dev/null +++ b/packages/node-core/test/utils/ensureIsWrapped.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ensureIsWrapped } from '../../src/utils/ensureIsWrapped'; +import { cleanupOtel, mockSdkInit, resetGlobals } from '../helpers/mockSdkInit'; + +const unwrappedFunction = () => {}; + +// We simulate a wrapped function +const wrappedfunction = Object.assign(() => {}, { + __wrapped: true, + __original: () => {}, + __unwrap: () => {}, +}); + +let provider; + +describe('ensureIsWrapped', () => { + afterEach(() => { + vi.restoreAllMocks(); + cleanupOtel(provider); + resetGlobals(); + }); + + it('warns when the method is unwrapped', () => { + const spyWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + provider = mockSdkInit({ tracesSampleRate: 1 }); + + ensureIsWrapped(unwrappedFunction, 'express'); + + expect(spyWarn).toHaveBeenCalledTimes(1); + expect(spyWarn).toHaveBeenCalledWith( + '[Sentry] express is not instrumented. This is likely because you required/imported express before calling `Sentry.init()`.', + ); + }); + + it('does not warn when the method is wrapped', () => { + const spyWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + provider = mockSdkInit({ tracesSampleRate: 1 }); + + ensureIsWrapped(wrappedfunction, 'express'); + + expect(spyWarn).toHaveBeenCalledTimes(0); + }); + + it('does not warn without a client', () => { + const spyWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resetGlobals(); + + ensureIsWrapped(wrappedfunction, 'express'); + + expect(spyWarn).toHaveBeenCalledTimes(0); + }); + + it('does not warn without tracing', () => { + const spyWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + provider = mockSdkInit({}); + + ensureIsWrapped(unwrappedFunction, 'express'); + + expect(spyWarn).toHaveBeenCalledTimes(0); + }); + + it('does not warn if disableInstrumentationWarnings=true', () => { + const spyWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + provider = mockSdkInit({ tracesSampleRate: 1, disableInstrumentationWarnings: true }); + + ensureIsWrapped(unwrappedFunction, 'express'); + + expect(spyWarn).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/node-core/test/utils/entry-point.test.ts b/packages/node-core/test/utils/entry-point.test.ts new file mode 100644 index 000000000000..bd65ca5506c0 --- /dev/null +++ b/packages/node-core/test/utils/entry-point.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import type { ProcessArgs, ProcessInterface } from '../../src/utils/entry-point'; +import { getEntryPointType, parseProcessPaths } from '../../src/utils/entry-point'; + +const PROCESS_ARG_TESTS: [ProcessInterface, ProcessArgs][] = [ + [ + { cwd: () => '/user/tim/docs', argv: ['/bin/node', 'app.js'], execArgv: ['--import', './something.js'] }, + { appPath: '/user/tim/docs/app.js', importPaths: ['/user/tim/docs/something.js'], requirePaths: [] }, + ], + [ + { + cwd: () => '/user/tim/docs', + argv: ['/bin/node', 'app.js'], + execArgv: ['--import', './something.js', '--import=./else.js'], + }, + { + appPath: '/user/tim/docs/app.js', + importPaths: ['/user/tim/docs/something.js', '/user/tim/docs/else.js'], + requirePaths: [], + }, + ], + [ + { + cwd: () => '/user/tim/docs', + argv: ['/bin/node', 'app.js'], + execArgv: ['--require', './something.js', '--import=else.js'], + }, + { + appPath: '/user/tim/docs/app.js', + importPaths: ['/user/tim/docs/else.js'], + requirePaths: ['/user/tim/docs/something.js'], + }, + ], + [ + { + cwd: () => '/user/tim/docs', + argv: ['/bin/node', 'app.js'], + execArgv: ['--require=here/something.js'], + }, + { + appPath: '/user/tim/docs/app.js', + importPaths: [], + requirePaths: ['/user/tim/docs/here/something.js'], + }, + ], +]; + +describe('getEntryPointType', () => { + it.each(PROCESS_ARG_TESTS)('parseProcessArgs', (input, output) => { + const result = parseProcessPaths(input); + expect(result).toStrictEqual(output); + }); + + it('app absolute', () => { + const ctx = getEntryPointType({ + cwd: () => __dirname, + argv: ['/bin/node', __filename], + execArgv: [], + }); + + expect(ctx).toEqual('app'); + }); + + it('app relative', () => { + const ctx = getEntryPointType({ + cwd: () => __dirname, + argv: ['/bin/node', 'entry-point.test.ts'], + execArgv: [], + }); + + expect(ctx).toEqual('app'); + }); + + it('import absolute', () => { + const ctx = getEntryPointType({ + cwd: () => __dirname, + argv: ['/bin/node', 'app.ts'], + execArgv: ['--import', __filename], + }); + + expect(ctx).toEqual('import'); + }); + + it('import relative', () => { + const ctx = getEntryPointType({ + cwd: () => __dirname, + argv: ['/bin/node', 'app.ts'], + execArgv: ['--import', './entry-point.test.ts'], + }); + + expect(ctx).toEqual('import'); + }); + + it('require relative', () => { + const ctx = getEntryPointType({ + cwd: () => __dirname, + argv: ['/bin/node', 'app.ts'], + execArgv: ['--require', './entry-point.test.ts'], + }); + + expect(ctx).toEqual('require'); + }); +}); diff --git a/packages/node-core/test/utils/envToBool.test.ts b/packages/node-core/test/utils/envToBool.test.ts new file mode 100644 index 000000000000..aa3c73fe1e8f --- /dev/null +++ b/packages/node-core/test/utils/envToBool.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { envToBool } from '../../src/utils/envToBool'; + +describe('envToBool', () => { + it.each([ + ['', true, null], + ['', false, false], + ['t', true, true], + ['T', true, true], + ['t', false, true], + ['T', false, true], + ['y', true, true], + ['Y', true, true], + ['y', false, true], + ['Y', false, true], + ['1', true, true], + ['1', false, true], + ['true', true, true], + ['true', false, true], + ['tRuE', true, true], + ['tRuE', false, true], + ['Yes', true, true], + ['Yes', false, true], + ['yes', true, true], + ['yes', false, true], + ['yEs', true, true], + ['yEs', false, true], + ['On', true, true], + ['On', false, true], + ['on', true, true], + ['on', false, true], + ['oN', true, true], + ['oN', false, true], + ['f', true, false], + ['f', false, false], + ['n', true, false], + ['N', true, false], + ['n', false, false], + ['N', false, false], + ['0', true, false], + ['0', false, false], + ['false', true, false], + ['false', false, false], + ['false', true, false], + ['false', false, false], + ['FaLsE', true, false], + ['FaLsE', false, false], + ['No', true, false], + ['No', false, false], + ['no', true, false], + ['no', false, false], + ['nO', true, false], + ['nO', false, false], + ['Off', true, false], + ['Off', false, false], + ['off', true, false], + ['off', false, false], + ['oFf', true, false], + ['oFf', false, false], + ['xxx', true, null], + ['xxx', false, true], + [undefined, false, false], + [undefined, true, null], + ])('%s becomes (strict: %s): %s', (value, strict, expected) => { + expect(envToBool(value, { strict })).toBe(expected); + }); +}); diff --git a/packages/node-core/test/utils/getRequestUrl.test.ts b/packages/node-core/test/utils/getRequestUrl.test.ts new file mode 100644 index 000000000000..a96514380481 --- /dev/null +++ b/packages/node-core/test/utils/getRequestUrl.test.ts @@ -0,0 +1,20 @@ +import type { RequestOptions } from 'http'; +import { describe, expect, it } from 'vitest'; +import { getRequestUrl } from '../../src/utils/getRequestUrl'; + +describe('getRequestUrl', () => { + it.each([ + [{ protocol: 'http:', hostname: 'localhost', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', host: 'localhost:80', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'http:', host: 'localhost:3000', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443 }, 'https://localhost/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443, path: '/my-path' }, 'https://localhost/my-path'], + [ + { protocol: 'https:', hostname: 'www.example.com', port: 443, path: '/my-path' }, + 'https://www.example.com/my-path', + ], + ])('works with %s', (input: RequestOptions, expected: string | undefined) => { + expect(getRequestUrl(input)).toBe(expected); + }); +}); diff --git a/packages/node-core/test/utils/instrument.test.ts b/packages/node-core/test/utils/instrument.test.ts new file mode 100644 index 000000000000..0b9e1b6c727a --- /dev/null +++ b/packages/node-core/test/utils/instrument.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test, vi } from 'vitest'; +import { instrumentWhenWrapped } from '../../src/otel/instrument'; + +describe('instrumentWhenWrapped', () => { + test('calls callback immediately when instrumentation has no _wrap method', () => { + const callback = vi.fn(); + const instrumentation = {} as any; + + const registerCallback = instrumentWhenWrapped(instrumentation); + registerCallback(callback); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('calls callback when _wrap is called', () => { + const callback = vi.fn(); + const originalWrap = vi.fn(); + const instrumentation = { + _wrap: originalWrap, + } as any; + + const registerCallback = instrumentWhenWrapped(instrumentation); + registerCallback(callback); + + // Callback should not be called yet + expect(callback).not.toHaveBeenCalled(); + + // Call _wrap + instrumentation._wrap(); + + // Callback should be called once + expect(callback).toHaveBeenCalledTimes(1); + expect(originalWrap).toHaveBeenCalled(); + }); + + test('calls multiple callbacks when _wrap is called', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const originalWrap = vi.fn(); + const instrumentation = { + _wrap: originalWrap, + } as any; + + const registerCallback = instrumentWhenWrapped(instrumentation); + registerCallback(callback1); + registerCallback(callback2); + + // Callbacks should not be called yet + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + + // Call _wrap + instrumentation._wrap(); + + // Both callbacks should be called once + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(originalWrap).toHaveBeenCalled(); + }); + + test('calls callback immediately if already wrapped', () => { + const callback = vi.fn(); + const originalWrap = vi.fn(); + const instrumentation = { + _wrap: originalWrap, + } as any; + + const registerCallback = instrumentWhenWrapped(instrumentation); + + // Call _wrap first + instrumentation._wrap(); + + registerCallback(callback); + + // Callback should be called immediately + expect(callback).toHaveBeenCalledTimes(1); + expect(originalWrap).toHaveBeenCalled(); + }); + + test('passes through arguments to original _wrap', () => { + const callback = vi.fn(); + const originalWrap = vi.fn(); + const instrumentation = { + _wrap: originalWrap, + } as any; + + const registerCallback = instrumentWhenWrapped(instrumentation); + registerCallback(callback); + + // Call _wrap with arguments + const args = ['arg1', 'arg2']; + instrumentation._wrap(...args); + + expect(originalWrap).toHaveBeenCalledWith(...args); + }); +}); diff --git a/packages/node-core/test/utils/module.test.ts b/packages/node-core/test/utils/module.test.ts new file mode 100644 index 000000000000..73404c37673e --- /dev/null +++ b/packages/node-core/test/utils/module.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { createGetModuleFromFilename } from '../../src'; + +describe('createGetModuleFromFilename', () => { + it.each([ + ['/path/to/file.js', 'file'], + ['/path/to/file.mjs', 'file'], + ['/path/to/file.cjs', 'file'], + ['file.js', 'file'], + ])('returns the module name from a filename %s', (filename, expected) => { + const getModule = createGetModuleFromFilename(); + expect(getModule(filename)).toBe(expected); + }); + + it('applies the given base path', () => { + const getModule = createGetModuleFromFilename('/path/to/base'); + expect(getModule('/path/to/base/file.js')).toBe('file'); + }); + + it('decodes URI-encoded file names', () => { + const getModule = createGetModuleFromFilename(); + expect(getModule('/path%20with%space/file%20with%20spaces(1).js')).toBe('file with spaces(1)'); + }); + + it('returns undefined if no filename is provided', () => { + const getModule = createGetModuleFromFilename(); + expect(getModule(undefined)).toBeUndefined(); + }); + + it.each([ + ['/path/to/base/node_modules/@sentry/test/file.js', '@sentry.test:file'], + ['/path/to/base/node_modules/somePkg/file.js', 'somePkg:file'], + ])('handles node_modules file paths %s', (filename, expected) => { + const getModule = createGetModuleFromFilename(); + expect(getModule(filename)).toBe(expected); + }); + + it('handles windows paths with passed basePath and node_modules', () => { + const getModule = createGetModuleFromFilename('C:\\path\\to\\base', true); + expect(getModule('C:\\path\\to\\base\\node_modules\\somePkg\\file.js')).toBe('somePkg:file'); + }); + + it('handles windows paths with default basePath', () => { + const getModule = createGetModuleFromFilename(undefined, true); + expect(getModule('C:\\path\\to\\base\\somePkg\\file.js')).toBe('file'); + }); +}); diff --git a/packages/node-core/tsconfig.json b/packages/node-core/tsconfig.json new file mode 100644 index 000000000000..b9683a850600 --- /dev/null +++ b/packages/node-core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + "lib": ["es2018", "es2020.string"], + "module": "Node16" + } +} diff --git a/packages/node-core/tsconfig.test.json b/packages/node-core/tsconfig.test.json new file mode 100644 index 000000000000..3f2ffb86f0f7 --- /dev/null +++ b/packages/node-core/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "./src/integrations/diagnostic_channel.d.ts", "vite.config.ts"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node"] + + // other package-specific, test-specific options + } +} diff --git a/packages/node-core/tsconfig.types.json b/packages/node-core/tsconfig.types.json new file mode 100644 index 000000000000..65455f66bd75 --- /dev/null +++ b/packages/node-core/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/node-core/vite.config.ts b/packages/node-core/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/node-core/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; diff --git a/yarn.lock b/yarn.lock index f8ee2a1e749b..2204766ff3aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5743,7 +5743,7 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6" integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA== -"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.34.0": +"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.34.0": version "1.34.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz#8b6a46681b38a4d5947214033ac48128328c1738" integrity sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA== @@ -5878,6 +5878,13 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73" integrity sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw== +"@prisma/instrumentation@6.7.0": + version "6.7.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.7.0.tgz#5fd97be1f89e9d9268148424a812deaea491f80a" + integrity sha512-3NuxWlbzYNevgPZbV0ktA2z6r0bfh0g22ONTxcK09a6+6MdIPjHsYx1Hnyu4yOq+j7LmupO5J69hhuOnuvj8oQ== + dependencies: + "@opentelemetry/instrumentation" "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" + "@prisma/instrumentation@6.8.2": version "6.8.2" resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.8.2.tgz#77a87a37f67ab35eaaf8ff629f889e9e11a465ac" @@ -6751,6 +6758,58 @@ "@sentry/cli-win32-i686" "2.45.0" "@sentry/cli-win32-x64" "2.45.0" +"@sentry/core@9.22.0": + version "9.22.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-9.22.0.tgz#b35113bcba24a6ae437c548c2cbac941302a60ef" + integrity sha512-ixvtKmPF42Y6ckGUbFlB54OWI75H2gO5UYHojO6eXFpS7xO3ZGgV/QH6wb40mWK+0w5XZ0233FuU9VpsuE6mKA== + +"@sentry/node@9.22.0": + version "9.22.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-9.22.0.tgz#a3829d8c962609f626f5c470d2903dff627ee125" + integrity sha512-89r2p6n0xeT2eiqIB0WXgz/rJzUgiOgZex7NvYwzEGeP0GoteDIf0Kbth/gCOy1md/ngiW+0X+S2Ed/uRw4XEQ== + dependencies: + "@opentelemetry/api" "^1.9.0" + "@opentelemetry/context-async-hooks" "^1.30.1" + "@opentelemetry/core" "^1.30.1" + "@opentelemetry/instrumentation" "^0.57.2" + "@opentelemetry/instrumentation-amqplib" "^0.46.1" + "@opentelemetry/instrumentation-connect" "0.43.1" + "@opentelemetry/instrumentation-dataloader" "0.16.1" + "@opentelemetry/instrumentation-express" "0.47.1" + "@opentelemetry/instrumentation-fs" "0.19.1" + "@opentelemetry/instrumentation-generic-pool" "0.43.1" + "@opentelemetry/instrumentation-graphql" "0.47.1" + "@opentelemetry/instrumentation-hapi" "0.45.2" + "@opentelemetry/instrumentation-http" "0.57.2" + "@opentelemetry/instrumentation-ioredis" "0.47.1" + "@opentelemetry/instrumentation-kafkajs" "0.7.1" + "@opentelemetry/instrumentation-knex" "0.44.1" + "@opentelemetry/instrumentation-koa" "0.47.1" + "@opentelemetry/instrumentation-lru-memoizer" "0.44.1" + "@opentelemetry/instrumentation-mongodb" "0.52.0" + "@opentelemetry/instrumentation-mongoose" "0.46.1" + "@opentelemetry/instrumentation-mysql" "0.45.1" + "@opentelemetry/instrumentation-mysql2" "0.45.2" + "@opentelemetry/instrumentation-pg" "0.51.1" + "@opentelemetry/instrumentation-redis-4" "0.46.1" + "@opentelemetry/instrumentation-tedious" "0.18.1" + "@opentelemetry/instrumentation-undici" "0.10.1" + "@opentelemetry/resources" "^1.30.1" + "@opentelemetry/sdk-trace-base" "^1.30.1" + "@opentelemetry/semantic-conventions" "^1.30.0" + "@prisma/instrumentation" "6.7.0" + "@sentry/core" "9.22.0" + "@sentry/opentelemetry" "9.22.0" + import-in-the-middle "^1.13.1" + minimatch "^9.0.0" + +"@sentry/opentelemetry@9.22.0": + version "9.22.0" + resolved "https://registry.yarnpkg.com/@sentry/opentelemetry/-/opentelemetry-9.22.0.tgz#28d55596ed69b400af326c341f528e40c2d956b1" + integrity sha512-m6JI2LUCm4FT34OQgh7or2Y9chKn8BrqawNqu7BEqbsGADE5VPwtdu7DwPOD7pC6KN9lGHVf0bqS7197e8Kz/A== + dependencies: + "@sentry/core" "9.22.0" + "@sentry/rollup-plugin@3.4.0": version "3.4.0" resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-3.4.0.tgz#326618d6fe91a030ee4ab335e1bab35f201090b0"