diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.gitignore new file mode 100644 index 000000000000..686a0277246c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.gitignore @@ -0,0 +1,2 @@ +dist +.vscode diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.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-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json new file mode 100644 index 000000000000..d445419ece51 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json @@ -0,0 +1,36 @@ +{ + "name": "node-core-express-otel-v1-custom-sampler", + "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": { + "@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", + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/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-custom-sampler/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/app.ts new file mode 100644 index 000000000000..e5da185262ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/app.ts @@ -0,0 +1,51 @@ +import './instrument'; + +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const PORT = 3030; +const app = express(); + +const wait = (duration: number) => { + return new Promise(res => { + setTimeout(() => res(), duration); + }); +}; + +app.get('/task', async (_req, res) => { + await Sentry.startSpan({ name: 'Long task', op: 'custom.op' }, async () => { + await wait(200); + }); + res.send('ok'); +}); + +app.get('/unsampled/task', async (_req, res) => { + await wait(200); + res.send('ok'); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // Explicitly capture the error with Sentry + Sentry.captureException(err); + + // 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('App listening on ', PORT); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/custom-sampler.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/custom-sampler.ts new file mode 100644 index 000000000000..cbaaac57c8ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/custom-sampler.ts @@ -0,0 +1,31 @@ +import { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'; +import { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-node'; +import { wrapSamplingDecision } from '@sentry/opentelemetry'; + +export class CustomSampler implements Sampler { + public shouldSample( + context: Context, + _traceId: string, + _spanName: string, + _spanKind: SpanKind, + attributes: Attributes, + _links: Link[], + ): SamplingResult { + const route = attributes['http.route']; + const target = attributes['http.target']; + const decision = + (typeof route === 'string' && route.includes('/unsampled')) || + (typeof target === 'string' && target.includes('/unsampled')) + ? 0 + : 1; + return wrapSamplingDecision({ + decision, + context, + spanAttributes: attributes, + }); + } + + public toString(): string { + return CustomSampler.name; + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/instrument.ts new file mode 100644 index 000000000000..b01601ad8910 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/instrument.ts @@ -0,0 +1,27 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import * as Sentry from '@sentry/node-core'; +import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry'; +import { CustomSampler } from './custom-sampler'; + +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: new CustomSampler(), + spanProcessors: [new SentrySpanProcessor()], +}); + +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/start-event-proxy.mjs new file mode 100644 index 000000000000..fd7ba2bfcbc2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v1-custom-sampler', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/errors.test.ts new file mode 100644 index 000000000000..a5f45ddc4b52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/errors.test.ts @@ -0,0 +1,30 @@ +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-custom-sampler', 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', + }); + + // For node-core without Express integration, transaction name is the actual URL + 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}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts new file mode 100644 index 000000000000..60e2424552cd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a sampled API route transaction', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-core-express-otel-v1-custom-sampler', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /task'; + }); + + await fetch(`${baseURL}/task`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.source': 'url', + 'sentry.op': 'http.server', + 'sentry.origin': 'manual', + url: 'http://localhost:3030/task', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/task', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/task', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': 3030, + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + }, + origin: 'manual', + op: 'http.server', + status: 'ok', + }); + + expect(transactionEvent.spans?.length).toBe(1); + + expect(transactionEvent.spans).toContainEqual({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.origin': 'manual', + 'sentry.op': 'custom.op', + }, + description: 'Long task', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'custom.op', + origin: 'manual', + }); +}); + +test('Does not send an unsampled API route transaction', async ({ baseURL }) => { + const unsampledTransactionEventPromise = waitForTransaction( + 'node-core-express-otel-v1-custom-sampler', + transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /unsampled/task' + ); + }, + ); + + await fetch(`${baseURL}/unsampled/task`); + + const promiseShouldNotResolve = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resolve(); // Test passes because promise did not resolve within timeout + }, 1000); + + unsampledTransactionEventPromise.then( + () => { + clearTimeout(timeout); + reject(new Error('Promise should not have resolved')); + }, + () => { + clearTimeout(timeout); + reject(new Error('Promise should not have been rejected')); + }, + ); + }); + + expect(promiseShouldNotResolve()).resolves.not.toThrow(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.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-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json new file mode 100644 index 000000000000..868334df93fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json @@ -0,0 +1,38 @@ +{ + "name": "node-core-express-otel-v1-sdk-node", + "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": { + "@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-node": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.30.0", + "@opentelemetry/sdk-node": "^0.57.2", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.2", + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/playwright.config.mjs @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + { + webServer: [ + { + command: `node ./start-event-proxy.mjs`, + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: `node ./start-otel-proxy.mjs`, + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: 3030, + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/app.ts new file mode 100644 index 000000000000..69f55b25e6ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/app.ts @@ -0,0 +1,55 @@ +import './instrument'; + +// Other imports below +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +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({}); + }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // Explicitly capture the error with Sentry because @sentry/node-core doesn't have + // a way to capture errors from express like @sentry/node does. + res.sentry = Sentry.captureException(err); + + // 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-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/instrument.ts new file mode 100644 index 000000000000..276b4f55ac73 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/instrument.ts @@ -0,0 +1,35 @@ +const opentelemetry = require('@opentelemetry/sdk-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); +const Sentry = require('@sentry/node-core'); +const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); +const { SentrySpanProcessor, SentryPropagator, SentrySampler } = require('@sentry/opentelemetry'); + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: true, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +if (sentryClient) { + const sdk = new opentelemetry.NodeSDK({ + sampler: new SentrySampler(sentryClient), + textMapPropagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), + spanProcessors: [ + new SentrySpanProcessor(), + new opentelemetry.node.BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], + instrumentations: [new HttpInstrumentation()], + }); + + sdk.start(); + + Sentry.validateOpenTelemetrySetup(); +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-event-proxy.mjs new file mode 100644 index 000000000000..815dabeb77f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v1-sdk-node', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-otel-proxy.mjs new file mode 100644 index 000000000000..ecbbbabea624 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-core-express-otel-v1-sdk-node-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/errors.test.ts new file mode 100644 index 000000000000..7377bd3d91ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/errors.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v1-sdk-node', 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('Errors do not leak between requests', async ({ baseURL }) => { + // Set up promises to capture errors for both requests + const firstErrorPromise = waitForError('node-core-express-otel-v1-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 111'; + }); + + const secondErrorPromise = waitForError('node-core-express-otel-v1-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 222'; + }); + + // Make first error request + await fetch(`${baseURL}/test-exception/111`); + + // Make second error request + await fetch(`${baseURL}/test-exception/222`); + + // Wait for both error events to be captured + const [firstError, secondError] = await Promise.all([firstErrorPromise, secondErrorPromise]); + + // Verify first error has correct data and doesn't contain data from second error + expect(firstError.exception?.values?.[0]?.value).toBe('This is an exception with id 111'); + expect(firstError.transaction).toEqual('GET /test-exception/111'); + expect(firstError.request?.url).toBe('http://localhost:3030/test-exception/111'); + + // Verify second error has correct data and doesn't contain data from first error + expect(secondError.exception?.values?.[0]?.value).toBe('This is an exception with id 222'); + expect(secondError.transaction).toEqual('GET /test-exception/222'); + expect(secondError.request?.url).toBe('http://localhost:3030/test-exception/222'); + + // Verify errors have different trace contexts (no leakage) + expect(firstError.contexts?.trace?.trace_id).not.toEqual(secondError.contexts?.trace?.trace_id); + expect(firstError.contexts?.trace?.span_id).not.toEqual(secondError.contexts?.trace?.span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts new file mode 100644 index 000000000000..6141261d8954 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-core-express-otel-v1-sdk-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + // Ensure we also send data to the OTLP endpoint + const otelPromise = waitForPlainRequest('node-core-express-otel-v1-sdk-node-otel', data => { + const json = JSON.parse(data) as any; + + return json.resourceSpans.length > 0; + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + const otelData = await otelPromise; + + // For now we do not test the actual shape of this, but only existence + expect(otelData).toBeDefined(); + + 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).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-sdk-node', 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-sdk-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.gitignore new file mode 100644 index 000000000000..686a0277246c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.gitignore @@ -0,0 +1,2 @@ +dist +.vscode diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.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-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json new file mode 100644 index 000000000000..3b6c974f44b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json @@ -0,0 +1,36 @@ +{ + "name": "node-core-express-otel-v2-custom-sampler", + "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": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-http": "^0.203.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/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-custom-sampler/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/app.ts new file mode 100644 index 000000000000..e5da185262ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/app.ts @@ -0,0 +1,51 @@ +import './instrument'; + +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const PORT = 3030; +const app = express(); + +const wait = (duration: number) => { + return new Promise(res => { + setTimeout(() => res(), duration); + }); +}; + +app.get('/task', async (_req, res) => { + await Sentry.startSpan({ name: 'Long task', op: 'custom.op' }, async () => { + await wait(200); + }); + res.send('ok'); +}); + +app.get('/unsampled/task', async (_req, res) => { + await wait(200); + res.send('ok'); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // Explicitly capture the error with Sentry + Sentry.captureException(err); + + // 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('App listening on ', PORT); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/custom-sampler.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/custom-sampler.ts new file mode 100644 index 000000000000..cbaaac57c8ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/custom-sampler.ts @@ -0,0 +1,31 @@ +import { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'; +import { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-node'; +import { wrapSamplingDecision } from '@sentry/opentelemetry'; + +export class CustomSampler implements Sampler { + public shouldSample( + context: Context, + _traceId: string, + _spanName: string, + _spanKind: SpanKind, + attributes: Attributes, + _links: Link[], + ): SamplingResult { + const route = attributes['http.route']; + const target = attributes['http.target']; + const decision = + (typeof route === 'string' && route.includes('/unsampled')) || + (typeof target === 'string' && target.includes('/unsampled')) + ? 0 + : 1; + return wrapSamplingDecision({ + decision, + context, + spanAttributes: attributes, + }); + } + + public toString(): string { + return CustomSampler.name; + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/instrument.ts new file mode 100644 index 000000000000..b01601ad8910 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/instrument.ts @@ -0,0 +1,27 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import * as Sentry from '@sentry/node-core'; +import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry'; +import { CustomSampler } from './custom-sampler'; + +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: new CustomSampler(), + spanProcessors: [new SentrySpanProcessor()], +}); + +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/start-event-proxy.mjs new file mode 100644 index 000000000000..1c678218dde5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v2-custom-sampler', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/errors.test.ts new file mode 100644 index 000000000000..f2de2878ed55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/errors.test.ts @@ -0,0 +1,30 @@ +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-custom-sampler', 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', + }); + + // For node-core without Express integration, transaction name is the actual URL + 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}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts new file mode 100644 index 000000000000..134f9f22b429 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a sampled API route transaction', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-core-express-otel-v2-custom-sampler', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /task'; + }); + + await fetch(`${baseURL}/task`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.source': 'url', + 'sentry.op': 'http.server', + 'sentry.origin': 'manual', + url: 'http://localhost:3030/task', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/task', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/task', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': 3030, + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + }, + origin: 'manual', + op: 'http.server', + status: 'ok', + }); + + expect(transactionEvent.spans?.length).toBe(1); + + expect(transactionEvent.spans).toContainEqual({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.origin': 'manual', + 'sentry.op': 'custom.op', + }, + description: 'Long task', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'custom.op', + origin: 'manual', + }); +}); + +test('Does not send an unsampled API route transaction', async ({ baseURL }) => { + const unsampledTransactionEventPromise = waitForTransaction( + 'node-core-express-otel-v2-custom-sampler', + transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /unsampled/task' + ); + }, + ); + + await fetch(`${baseURL}/unsampled/task`); + + const promiseShouldNotResolve = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resolve(); // Test passes because promise did not resolve within timeout + }, 1000); + + unsampledTransactionEventPromise.then( + () => { + clearTimeout(timeout); + reject(new Error('Promise should not have resolved')); + }, + () => { + clearTimeout(timeout); + reject(new Error('Promise should not have been rejected')); + }, + ); + }); + + expect(promiseShouldNotResolve()).resolves.not.toThrow(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.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-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json new file mode 100644 index 000000000000..9da336fdf8d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json @@ -0,0 +1,38 @@ +{ + "name": "node-core-express-otel-v2-sdk-node", + "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": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-http": "^0.203.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sdk-node": "^0.203.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/playwright.config.mjs @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + { + webServer: [ + { + command: `node ./start-event-proxy.mjs`, + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: `node ./start-otel-proxy.mjs`, + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: 3030, + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/app.ts new file mode 100644 index 000000000000..69f55b25e6ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/app.ts @@ -0,0 +1,55 @@ +import './instrument'; + +// Other imports below +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +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({}); + }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // Explicitly capture the error with Sentry because @sentry/node-core doesn't have + // a way to capture errors from express like @sentry/node does. + res.sentry = Sentry.captureException(err); + + // 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-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/instrument.ts new file mode 100644 index 000000000000..276b4f55ac73 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/instrument.ts @@ -0,0 +1,35 @@ +const opentelemetry = require('@opentelemetry/sdk-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); +const Sentry = require('@sentry/node-core'); +const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); +const { SentrySpanProcessor, SentryPropagator, SentrySampler } = require('@sentry/opentelemetry'); + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: true, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +if (sentryClient) { + const sdk = new opentelemetry.NodeSDK({ + sampler: new SentrySampler(sentryClient), + textMapPropagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), + spanProcessors: [ + new SentrySpanProcessor(), + new opentelemetry.node.BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], + instrumentations: [new HttpInstrumentation()], + }); + + sdk.start(); + + Sentry.validateOpenTelemetrySetup(); +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-event-proxy.mjs new file mode 100644 index 000000000000..5c5352234039 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v2-sdk-node', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-otel-proxy.mjs new file mode 100644 index 000000000000..8875601e95bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-core-express-otel-v2-sdk-node-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/errors.test.ts new file mode 100644 index 000000000000..ec43573dc910 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/errors.test.ts @@ -0,0 +1,63 @@ +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-sdk-node', 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('Errors do not leak between requests', async ({ baseURL }) => { + // Set up promises to capture errors for both requests + const firstErrorPromise = waitForError('node-core-express-otel-v2-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 111'; + }); + + const secondErrorPromise = waitForError('node-core-express-otel-v2-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 222'; + }); + + // Make first error request + await fetch(`${baseURL}/test-exception/111`); + + // Make second error request + await fetch(`${baseURL}/test-exception/222`); + + // Wait for both error events to be captured + const [firstError, secondError] = await Promise.all([firstErrorPromise, secondErrorPromise]); + + // Verify first error has correct data and doesn't contain data from second error + expect(firstError.exception?.values?.[0]?.value).toBe('This is an exception with id 111'); + expect(firstError.transaction).toEqual('GET /test-exception/111'); + expect(firstError.request?.url).toBe('http://localhost:3030/test-exception/111'); + + // Verify second error has correct data and doesn't contain data from first error + expect(secondError.exception?.values?.[0]?.value).toBe('This is an exception with id 222'); + expect(secondError.transaction).toEqual('GET /test-exception/222'); + expect(secondError.request?.url).toBe('http://localhost:3030/test-exception/222'); + + // Verify errors have different trace contexts (no leakage) + expect(firstError.contexts?.trace?.trace_id).not.toEqual(secondError.contexts?.trace?.trace_id); + expect(firstError.contexts?.trace?.span_id).not.toEqual(secondError.contexts?.trace?.span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts new file mode 100644 index 000000000000..08c8f80cd9f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-core-express-otel-v2-sdk-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + // Ensure we also send data to the OTLP endpoint + const otelPromise = waitForPlainRequest('node-core-express-otel-v2-sdk-node-otel', data => { + const json = JSON.parse(data) as any; + + return json.resourceSpans.length > 0; + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + const otelData = await otelPromise; + + // For now we do not test the actual shape of this, but only existence + expect(otelData).toBeDefined(); + + 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).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-sdk-node', 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-sdk-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}