diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 369932ab3e94..c5047e7acd0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -990,6 +990,7 @@ jobs: 'node-express-esm-without-loader', 'node-express-cjs-preload', 'node-otel-sdk-node', + 'node-otel-custom-sampler', 'ember-classic', 'ember-embroider', 'nextjs-app-dir', diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/.gitignore b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/.gitignore new file mode 100644 index 000000000000..686a0277246c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/.gitignore @@ -0,0 +1,2 @@ +dist +.vscode diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/.npmrc b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-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-otel-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/package.json new file mode 100644 index 000000000000..3507981ff013 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/package.json @@ -0,0 +1,31 @@ +{ + "name": "node-otel-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/sdk-trace-node": "^1.25.1", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@sentry/types": "latest || *", + "@types/express": "4.17.17", + "@types/node": "18.15.1", + "express": "4.19.2", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-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-otel-custom-sampler/src/app.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/app.ts new file mode 100644 index 000000000000..cc7e98446706 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/app.ts @@ -0,0 +1,50 @@ +import './instrument'; + +import * as Sentry from '@sentry/node'; +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}`); +}); + +Sentry.setupExpressErrorHandler(app); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // 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-otel-custom-sampler/src/custom-sampler.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/custom-sampler.ts new file mode 100644 index 000000000000..cbaaac57c8ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-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-otel-custom-sampler/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts new file mode 100644 index 000000000000..bba794d9f027 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts @@ -0,0 +1,30 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as Sentry from '@sentry/node'; +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 || + 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + skipOpenTelemetrySetup: true, + // By defining _any_ sample rate, tracing intergations will be added by default + tracesSampleRate: 0, +}); + +const provider = new NodeTracerProvider({ + sampler: new CustomSampler(), +}); + +provider.addSpanProcessor(new SentrySpanProcessor()); + +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/start-event-proxy.mjs new file mode 100644 index 000000000000..4650bf6ecc67 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-otel-custom-sampler', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/errors.test.ts new file mode 100644 index 000000000000..18d14cd12080 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-otel-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', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts new file mode 100644 index 000000000000..cb374ec8d440 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts @@ -0,0 +1,149 @@ +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-otel-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.any(String), + trace_id: expect.any(String), + data: { + 'sentry.source': 'route', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 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', + 'http.route': '/task', + }, + origin: 'auto.http.otel.http', + op: 'http.server', + status: 'ok', + }); + + expect(transactionEvent.spans?.length).toBe(4); + + expect(transactionEvent.spans).toContainEqual({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'query', + 'express.type': 'middleware', + }, + description: 'query', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'middleware.express', + origin: 'auto.http.otel.express', + }); + + expect(transactionEvent.spans).toContainEqual({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'expressInit', + 'express.type': 'middleware', + }, + description: 'expressInit', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'middleware.express', + origin: 'auto.http.otel.express', + }); + + expect(transactionEvent.spans).toContainEqual({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/task', + 'express.name': '/task', + 'express.type': 'request_handler', + }, + description: '/task', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + }); + + expect(transactionEvent.spans).toContainEqual({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'manual', + 'sentry.op': 'custom.op', + }, + description: 'Long task', + parent_span_id: expect.any(String), + 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-otel-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-otel-custom-sampler/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tsconfig.json new file mode 100644 index 000000000000..8cb64e989ed9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}