From 3bdcafc023aa0b81b5a66c8997a0a590a34d3c75 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 27 Jun 2024 17:17:34 +0200 Subject: [PATCH 1/4] test: Add OTEL E2E test app using sdk-node --- .../node-otel-sdk-node/.gitignore | 1 + .../node-otel-sdk-node/.npmrc | 2 + .../node-otel-sdk-node/package.json | 32 +++ .../node-otel-sdk-node/playwright.config.mjs | 34 +++ .../node-otel-sdk-node/src/app.ts | 53 ++++ .../node-otel-sdk-node/src/instrument.ts | 35 +++ .../node-otel-sdk-node/start-event-proxy.mjs | 6 + .../node-otel-sdk-node/start-otel-proxy.mjs | 6 + .../node-otel-sdk-node/tests/errors.test.ts | 42 +++ .../tests/transactions.test.ts | 213 +++++++++++++++ .../node-otel-sdk-node/tests/trpc.test.ts | 129 +++++++++ .../node-otel-sdk-node/tsconfig.json | 10 + .../test-utils/src/event-proxy-server.ts | 244 +++++++++++------- dev-packages/test-utils/src/index.ts | 2 + 14 files changed, 711 insertions(+), 98 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/trpc.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.gitignore b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.npmrc b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-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-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json new file mode 100644 index 000000000000..8a1634725184 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -0,0 +1,32 @@ +{ + "name": "node-otel-sdk-trace", + "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/sdk-node": "0.52.1", + "@opentelemetry/exporter-trace-otlp-http": "0.52.1", + "@sentry/core": "latest || *", + "@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-sdk-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-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-otel-sdk-node/src/app.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts new file mode 100644 index 000000000000..26779990f6d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/app.ts @@ -0,0 +1,53 @@ +import './instrument'; + +// Other imports below +import * as Sentry from '@sentry/node'; +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}`); +}); + +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(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/src/instrument.ts new file mode 100644 index 000000000000..fb270e1252d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-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'); +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 || + 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + + skipOpenTelemetrySetup: true, +}); + +const sdk = new opentelemetry.NodeSDK({ + sampler: sentryClient ? new SentrySampler(sentryClient) : undefined, + textMapPropagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), + spanProcessors: [ + new SentrySpanProcessor(), + new opentelemetry.node.BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], +}); + +sdk.start(); + +Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs new file mode 100644 index 000000000000..8c74fa842a1b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-otel-sdk-trace', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs new file mode 100644 index 000000000000..1cf9ef3e2c27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-otel-sdk-trace-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts new file mode 100644 index 000000000000..72fe5009ef8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/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-otel-sdk-trace', 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), + }); +}); + +test('Should record caught exceptions with local variable', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-otel-sdk-trace', 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-otel-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts new file mode 100644 index 000000000000..39a7d27e9cb1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts @@ -0,0 +1,213 @@ +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-otel-sdk-trace', 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-otel-sdk-trace-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': 'route', + 'sentry.origin': 'auto.http.otel.http', + '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', + 'http.route': '/test-transaction', + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'query', + 'express.type': 'middleware', + 'otel.kind': 'INTERNAL', + }, + description: 'query', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'expressInit', + 'express.type': 'middleware', + 'otel.kind': 'INTERNAL', + }, + description: 'expressInit', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + 'otel.kind': 'INTERNAL', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + 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/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'query', + 'express.type': 'middleware', + 'otel.kind': 'INTERNAL', + }, + description: 'query', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'middleware.express', + 'http.route': '/', + 'express.name': 'expressInit', + 'express.type': 'middleware', + 'otel.kind': 'INTERNAL', + }, + description: 'expressInit', + op: 'middleware.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); + + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-exception/:id', + 'express.name': '/test-exception/:id', + 'express.type': 'request_handler', + 'otel.kind': 'INTERNAL', + }, + description: '/test-exception/:id', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/trpc.test.ts new file mode 100644 index 000000000000..0a3289206165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/trpc.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from '../src/app'; + +test('Should record span for trpc query', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/getSomething') + ); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.getSomething.query('foobar'); + + await expect(transactionEventPromise).resolves.toBeDefined(); + const transaction = await transactionEventPromise; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'rpc.server', + 'sentry.origin': 'auto.rpc.trpc', + }), + description: `trpc/getSomething`, + }), + ); + + expect(transaction.contexts?.trpc).toMatchObject({ + procedure_type: 'query', + input: 'foobar', + }); +}); + +test('Should record transaction for trpc mutation', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/createSomething') + ); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.createSomething.mutate(); + + await expect(transactionEventPromise).resolves.toBeDefined(); + const transaction = await transactionEventPromise; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'rpc.server', + 'sentry.origin': 'auto.rpc.trpc', + }), + description: `trpc/createSomething`, + }), + ); + + expect(transaction.contexts?.trpc).toMatchObject({ + procedure_type: 'mutation', + }); +}); + +test('Should record transaction and error for a crashing trpc handler', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/crashSomething') + ); + }); + + const errorEventPromise = waitForError('node-otel-sdk-trace', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('I crashed in a trpc handler')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.crashSomething.mutate()).rejects.toBeDefined(); + + await expect(transactionEventPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); +}); + +test('Should record transaction and error for a trpc handler that returns a status code', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/dontFindSomething') + ); + }); + + const errorEventPromise = waitForError('node-otel-sdk-trace', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Page not found')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.dontFindSomething.mutate()).rejects.toBeDefined(); + + await expect(transactionEventPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json new file mode 100644 index 000000000000..8cb64e989ed9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 82693bf4af89..30bedadc38bb 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -1,6 +1,5 @@ import * as fs from 'fs'; import * as http from 'http'; -import * as https from 'https'; import type { AddressInfo } from 'net'; import * as os from 'os'; import * as path from 'path'; @@ -31,11 +30,27 @@ interface SentryRequestCallbackData { sentryResponseStatusCode?: number; } +type OnRequest = ( + eventCallbackListeners: Set<(data: string) => void>, + proxyRequest: http.IncomingMessage, + proxyRequestBody: string, +) => Promise<[number, string, Record | undefined]>; + /** - * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` - * option to this server (like this `tunnel: http://localhost:${port option}/`). + * Start a generic proxy server. + * The `onRequest` callback receives the incoming request and the request body, + * and should return a promise that resolves to a tuple with: + * statusCode, responseBody, responseHeaders */ -export async function startEventProxyServer(options: EventProxyServerOptions): Promise { +export async function startProxyServer( + options: { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; + }, + onRequest?: OnRequest, +): Promise { const eventCallbackListeners: Set<(data: string) => void> = new Set(); const proxyServer = http.createServer((proxyRequest, proxyResponse) => { @@ -59,102 +74,29 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() : Buffer.concat(proxyRequestChunks).toString(); - const envelopeHeader: EnvelopeItem[0] = JSON.parse(proxyRequestBody.split('\n')[0] as string); - - const shouldForwardEventToSentry = options.forwardToSentry != null ? options.forwardToSentry : true; - - if (!envelopeHeader.dsn && shouldForwardEventToSentry) { - // eslint-disable-next-line no-console - console.log( - '[event-proxy-server] Warn: No dsn on envelope header. Maybe a client-report was received. Proxy request body:', - proxyRequestBody, - ); - - proxyResponse.writeHead(200); - proxyResponse.write('{}', 'utf-8'); - proxyResponse.end(); - return; - } - - if (!shouldForwardEventToSentry) { - const data: SentryRequestCallbackData = { - envelope: parseEnvelope(proxyRequestBody), - rawProxyRequestBody: proxyRequestBody, - rawSentryResponseBody: '', - sentryResponseStatusCode: 200, - }; - eventCallbackListeners.forEach(listener => { - listener(Buffer.from(JSON.stringify(data)).toString('base64')); - }); - - proxyResponse.writeHead(200); - proxyResponse.write('{}', 'utf-8'); - proxyResponse.end(); - return; - } - - const { origin, pathname, host } = new URL(envelopeHeader.dsn as string); - - const projectId = pathname.substring(1); - const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; - - proxyRequest.headers.host = host; - - const sentryResponseChunks: Uint8Array[] = []; - - const sentryRequest = https.request( - sentryIngestUrl, - { headers: proxyRequest.headers, method: proxyRequest.method }, - sentryResponse => { - sentryResponse.addListener('data', (chunk: Buffer) => { - proxyResponse.write(chunk, 'binary'); - sentryResponseChunks.push(chunk); - }); - - sentryResponse.addListener('end', () => { - eventCallbackListeners.forEach(listener => { - const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); - - try { - const data: SentryRequestCallbackData = { - envelope: parseEnvelope(proxyRequestBody), - rawProxyRequestBody: proxyRequestBody, - rawSentryResponseBody, - sentryResponseStatusCode: sentryResponse.statusCode, - }; - - listener(Buffer.from(JSON.stringify(data)).toString('base64')); - } catch (error) { - if (`${error}`.includes('Unexpected token') && proxyRequestBody.includes('{"type":"replay_event"}')) { - // eslint-disable-next-line no-console - console.log('[event-proxy-server] Info: Received replay event, skipping...'); - } else { - // eslint-disable-next-line no-console - console.error( - '[event-proxy-server] Error: Failed to parse Sentry request envelope', - error, - proxyRequestBody, - ); - } - } - }); - proxyResponse.end(); + const callback: OnRequest = + onRequest || + (async (eventCallbackListeners, proxyRequest, proxyRequestBody) => { + eventCallbackListeners.forEach(listener => { + listener(proxyRequestBody); }); - sentryResponse.addListener('error', err => { - // eslint-disable-next-line no-console - console.log('[event-proxy-server] Warn: Proxying to Sentry returned an error!', err); - proxyResponse.writeHead(500); - proxyResponse.write('{}', 'utf-8'); - proxyResponse.end(); - }); - - proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); - }, - ); + return [200, '{}', {}]; + }); - sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); - sentryRequest.end(); + callback(eventCallbackListeners, proxyRequest, proxyRequestBody) + .then(([statusCode, responseBody, responseHeaders]) => { + proxyResponse.writeHead(statusCode, responseHeaders); + proxyResponse.write(responseBody, 'utf-8'); + proxyResponse.end(); + }) + .catch(error => { + // eslint-disable-next-line no-console + console.log('[event-proxy-server] Warn: Proxy server returned an error', error); + proxyResponse.writeHead(500); + proxyResponse.write('{}', 'utf-8'); + proxyResponse.end(); + }); }); }); @@ -193,7 +135,113 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P await eventCallbackServerStartupPromise; await proxyServerStartupPromise; - return; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + await startProxyServer(options, async (eventCallbackListeners, proxyRequest, proxyRequestBody) => { + const envelopeHeader: EnvelopeItem[0] = JSON.parse(proxyRequestBody.split('\n')[0] as string); + + const shouldForwardEventToSentry = options.forwardToSentry != null ? options.forwardToSentry : true; + + if (!envelopeHeader.dsn && shouldForwardEventToSentry) { + // eslint-disable-next-line no-console + console.log( + '[event-proxy-server] Warn: No dsn on envelope header. Maybe a client-report was received. Proxy request body:', + proxyRequestBody, + ); + + return [200, '{}', {}]; + } + + if (!shouldForwardEventToSentry) { + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody: '', + sentryResponseStatusCode: 200, + }; + eventCallbackListeners.forEach(listener => { + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + + return [200, '{}', {}]; + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn as string); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const reqHeaders: Record = {}; + for (const [key, value] of Object.entries(proxyRequest.headers)) { + reqHeaders[key] = value as string; + } + + // Fetch does not like this + delete reqHeaders['transfer-encoding']; + + return fetch(sentryIngestUrl, { + body: proxyRequestBody, + headers: reqHeaders, + method: proxyRequest.method, + }).then(async res => { + const rawSentryResponseBody = await res.text(); + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: res.status, + }; + + eventCallbackListeners.forEach(listener => { + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + + const resHeaders: Record = {}; + for (const [key, value] of res.headers.entries()) { + resHeaders[key] = value; + } + + return [res.status, rawSentryResponseBody, resHeaders]; + }); + }); +} + +/** Wait for any plain request being made to the proxy. */ +export async function waitForPlainRequest( + proxyServerName: string, + callback: (eventData: string) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + + eventContents = eventContents.concat(chunkString); + + if (callback(eventContents)) { + response.destroy(); + return resolve(eventContents); + } + }); + }); + + request.end(); + }); } /** Wait for a request to be sent. */ diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 4425d2688800..49685a6b18c2 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -1,9 +1,11 @@ export { + startProxyServer, startEventProxyServer, waitForEnvelopeItem, waitForError, waitForRequest, waitForTransaction, + waitForPlainRequest, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; From 5d5a419940618ab9409116fa7fbd968fbc76793e Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 28 Jun 2024 09:41:09 +0200 Subject: [PATCH 2/4] actually test app on CI --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 445c57284056..85fc105d0e42 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1050,6 +1050,7 @@ jobs: 'node-express-esm-preload', 'node-express-esm-without-loader', 'node-express-cjs-preload', + 'node-otel-sdk-node', 'nextjs-app-dir', 'nextjs-14', 'nextjs-15', From 1cfefd1948e5d34a5f1d117027277d7661c7fad0 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 1 Jul 2024 09:47:49 +0200 Subject: [PATCH 3/4] remove trpc test --- .../node-otel-sdk-node/tests/trpc.test.ts | 129 ------------------ 1 file changed, 129 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/trpc.test.ts diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/trpc.test.ts deleted file mode 100644 index 0a3289206165..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/trpc.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; -import type { AppRouter } from '../src/app'; - -test('Should record span for trpc query', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { - return ( - transactionEvent.transaction === 'GET /trpc' && - !!transactionEvent.spans?.find(span => span.description === 'trpc/getSomething') - ); - }); - - const trpcClient = createTRPCProxyClient({ - links: [ - httpBatchLink({ - url: `${baseURL}/trpc`, - }), - ], - }); - - await trpcClient.getSomething.query('foobar'); - - await expect(transactionEventPromise).resolves.toBeDefined(); - const transaction = await transactionEventPromise; - - expect(transaction.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'rpc.server', - 'sentry.origin': 'auto.rpc.trpc', - }), - description: `trpc/getSomething`, - }), - ); - - expect(transaction.contexts?.trpc).toMatchObject({ - procedure_type: 'query', - input: 'foobar', - }); -}); - -test('Should record transaction for trpc mutation', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { - return ( - transactionEvent.transaction === 'POST /trpc' && - !!transactionEvent.spans?.find(span => span.description === 'trpc/createSomething') - ); - }); - - const trpcClient = createTRPCProxyClient({ - links: [ - httpBatchLink({ - url: `${baseURL}/trpc`, - }), - ], - }); - - await trpcClient.createSomething.mutate(); - - await expect(transactionEventPromise).resolves.toBeDefined(); - const transaction = await transactionEventPromise; - - expect(transaction.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'rpc.server', - 'sentry.origin': 'auto.rpc.trpc', - }), - description: `trpc/createSomething`, - }), - ); - - expect(transaction.contexts?.trpc).toMatchObject({ - procedure_type: 'mutation', - }); -}); - -test('Should record transaction and error for a crashing trpc handler', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { - return ( - transactionEvent.transaction === 'POST /trpc' && - !!transactionEvent.spans?.find(span => span.description === 'trpc/crashSomething') - ); - }); - - const errorEventPromise = waitForError('node-otel-sdk-trace', errorEvent => { - return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('I crashed in a trpc handler')); - }); - - const trpcClient = createTRPCProxyClient({ - links: [ - httpBatchLink({ - url: `${baseURL}/trpc`, - }), - ], - }); - - await expect(trpcClient.crashSomething.mutate()).rejects.toBeDefined(); - - await expect(transactionEventPromise).resolves.toBeDefined(); - await expect(errorEventPromise).resolves.toBeDefined(); -}); - -test('Should record transaction and error for a trpc handler that returns a status code', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { - return ( - transactionEvent.transaction === 'POST /trpc' && - !!transactionEvent.spans?.find(span => span.description === 'trpc/dontFindSomething') - ); - }); - - const errorEventPromise = waitForError('node-otel-sdk-trace', errorEvent => { - return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Page not found')); - }); - - const trpcClient = createTRPCProxyClient({ - links: [ - httpBatchLink({ - url: `${baseURL}/trpc`, - }), - ], - }); - - await expect(trpcClient.dontFindSomething.mutate()).rejects.toBeDefined(); - - await expect(transactionEventPromise).resolves.toBeDefined(); - await expect(errorEventPromise).resolves.toBeDefined(); -}); From c35459edfdd3c50f66664a8570bfd70680ad59c5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 1 Jul 2024 12:00:42 +0200 Subject: [PATCH 4/4] remove unused test --- .../node-otel-sdk-node/tests/errors.test.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts index 72fe5009ef8e..9cb97a051476 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts @@ -27,16 +27,3 @@ test('Sends correct error event', async ({ baseURL }) => { span_id: expect.any(String), }); }); - -test('Should record caught exceptions with local variable', async ({ baseURL }) => { - const errorEventPromise = waitForError('node-otel-sdk-trace', 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(); -});