From 7ab871e356cc6fb8483c62e8d3405d60a6c5b83e Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 2 Apr 2024 12:48:34 +0100 Subject: [PATCH 1/5] feat(node): Add NestJS error handler. --- .../node-nestjs-app/.eslintrc.js | 25 ++ .../node-nestjs-app/.gitignore | 56 +++ .../test-applications/node-nestjs-app/.npmrc | 2 + .../node-nestjs-app/.prettierrc | 4 + .../node-nestjs-app/event-proxy-server.ts | 253 +++++++++++ .../node-nestjs-app/nest-cli.json | 8 + .../node-nestjs-app/package.json | 47 ++ .../node-nestjs-app/playwright.config.ts | 77 ++++ .../node-nestjs-app/src/app.controller.ts | 82 ++++ .../node-nestjs-app/src/app.module.ts | 17 + .../node-nestjs-app/src/app.service.ts | 97 ++++ .../node-nestjs-app/src/main.ts | 26 ++ .../node-nestjs-app/src/utils.ts | 26 ++ .../node-nestjs-app/start-event-proxy.ts | 6 + .../node-nestjs-app/tests/errors.test.ts | 76 ++++ .../node-nestjs-app/tests/propagation.test.ts | 415 ++++++++++++++++++ .../tests/transactions.test.ts | 143 ++++++ .../node-nestjs-app/tsconfig.build.json | 4 + .../node-nestjs-app/tsconfig.json | 21 + .../suites/hapi/tracing/server.js | 34 ++ .../suites/hapi/tracing/test.ts | 21 + packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 6 +- packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 8 +- .../node/src/integrations/tracing/nest.ts | 17 +- packages/remix/src/index.server.ts | 1 + 28 files changed, 1464 insertions(+), 11 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/.eslintrc.js create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/.prettierrc create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/event-proxy-server.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/nest-cli.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.module.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/src/utils.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/start-event-proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/tsconfig.build.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/tsconfig.json create mode 100644 dev-packages/node-integration-tests/suites/hapi/tracing/server.js create mode 100644 dev-packages/node-integration-tests/suites/hapi/tracing/test.ts diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/.eslintrc.js b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.eslintrc.js new file mode 100644 index 000000000000..259de13c733a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/.gitignore b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.gitignore new file mode 100644 index 000000000000..4b56acfbebf4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/.npmrc b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.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-nestjs-app/.prettierrc b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.prettierrc new file mode 100644 index 000000000000..dcb72794f530 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/event-proxy-server.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/event-proxy-server.ts new file mode 100644 index 000000000000..d14ca5cb5e72 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/event-proxy-server.ts @@ -0,0 +1,253 @@ +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'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** 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; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * 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 { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + 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(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => 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'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/nest-cli.json b/dev-packages/e2e-tests/test-applications/node-nestjs-app/nest-cli.json new file mode 100644 index 000000000000..f9aa683b1ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/package.json b/dev-packages/e2e-tests/test-applications/node-nestjs-app/package.json new file mode 100644 index 000000000000..adfad6147147 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/package.json @@ -0,0 +1,47 @@ +{ + "name": "node-nestjs-app", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "clean": "npx rimraf node_modules,pnpm-lock.yaml", + "test": "playwright test", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@sentry/node": "latest || *", + "@sentry/types": "latest || *", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@playwright/test": "^1.27.1", + "@types/express": "^4.17.17", + "@types/node": "18.15.1", + "@types/supertest": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "prettier": "^3.0.0", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^4.9.5" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/playwright.config.ts new file mode 100644 index 000000000000..bb16d8c803f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const nestjsPort = 3030; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${nestjsPort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + // For now we only test Chrome! + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: 'pnpm start', + port: nestjsPort, + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.controller.ts new file mode 100644 index 000000000000..5dda4845d392 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.controller.ts @@ -0,0 +1,82 @@ +import { Controller, Get, Headers, Param } from '@nestjs/common'; +import { AppService1, AppService2 } from './app.service'; + +@Controller() +export class AppController1 { + constructor(private readonly appService: AppService1) {} + + @Get('test-success') + testSuccess() { + return this.appService.testSuccess(); + } + + @Get('test-param/:param') + testParam(@Param() params) { + return this.appService.testParam(params.param); + } + + @Get('test-inbound-headers/:id') + testInboundHeaders(@Headers() headers, @Param('id') id: string) { + return this.appService.testInboundHeaders(headers, id); + } + + @Get('test-outgoing-http/:id') + async testOutgoingHttp(@Param('id') id: string) { + return this.appService.testOutgoingHttp(id); + } + + @Get('test-outgoing-fetch/:id') + async testOutgoingFetch(@Param('id') id: string) { + return this.appService.testOutgoingFetch(id); + } + + @Get('test-transaction') + testTransaction() { + return this.appService.testTransaction(); + } + + @Get('test-error') + async testError() { + return this.appService.testError(); + } + + @Get('test-exception') + async testException() { + return this.appService.testException(); + } + + @Get('test-outgoing-fetch-external-allowed') + async testOutgoingFetchExternalAllowed() { + return this.appService.testOutgoingFetchExternalAllowed(); + } + + @Get('test-outgoing-fetch-external-disallowed') + async testOutgoingFetchExternalDisallowed() { + return this.appService.testOutgoingFetchExternalDisallowed(); + } + + @Get('test-outgoing-http-external-allowed') + async testOutgoingHttpExternalAllowed() { + return this.appService.testOutgoingHttpExternalAllowed(); + } + + @Get('test-outgoing-http-external-disallowed') + async testOutgoingHttpExternalDisallowed() { + return this.appService.testOutgoingHttpExternalDisallowed(); + } +} + +@Controller() +export class AppController2 { + constructor(private readonly appService: AppService2) {} + + @Get('external-allowed') + externalAllowed(@Headers() headers) { + return this.appService.externalAllowed(headers); + } + + @Get('external-disallowed') + externalDisallowed(@Headers() headers) { + return this.appService.externalDisallowed(headers); + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.module.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.module.ts new file mode 100644 index 000000000000..5fda2f1e209f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { AppController1, AppController2 } from './app.controller'; +import { AppService1, AppService2 } from './app.service'; + +@Module({ + imports: [], + controllers: [AppController1], + providers: [AppService1], +}) +export class AppModule1 {} + +@Module({ + imports: [], + controllers: [AppController2], + providers: [AppService2], +}) +export class AppModule2 {} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts new file mode 100644 index 000000000000..2629ee5506da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; +import { makeHttpRequest } from './utils'; + +@Injectable() +export class AppService1 { + testSuccess() { + return { version: 'v1' }; + } + + testParam(id: string) { + return { + paramWas: id, + }; + } + + testInboundHeaders(headers: Record, id: string) { + return { + headers, + id, + }; + } + + async testOutgoingHttp(id: string) { + const data = await makeHttpRequest( + `http://localhost:3030/test-inbound-headers/${id}`, + ); + + return data; + } + + async testOutgoingFetch(id: string) { + const response = await fetch( + `http://localhost:3030/test-inbound-headers/${id}`, + ); + const data = await response.json(); + + return data; + } + + testTransaction() { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + } + + async testError() { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + return { exceptionId }; + } + + testException() { + throw new Error('This is an exception'); + } + + async testOutgoingFetchExternalAllowed() { + const fetchResponse = await fetch('http://localhost:3040/external-allowed'); + + return fetchResponse.json(); + } + + async testOutgoingFetchExternalDisallowed() { + const fetchResponse = await fetch( + 'http://localhost:3040/external-disallowed', + ); + + return fetchResponse.json(); + } + + async testOutgoingHttpExternalAllowed() { + return makeHttpRequest('http://localhost:3040/external-allowed'); + } + + async testOutgoingHttpExternalDisallowed() { + return makeHttpRequest('http://localhost:3040/external-disallowed'); + } +} + +@Injectable() +export class AppService2 { + externalAllowed(headers: Record) { + return { + headers, + route: 'external-allowed', + }; + } + + externalDisallowed(headers: Record) { + return { + headers, + route: 'external-disallowed', + }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/main.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/main.ts new file mode 100644 index 000000000000..f852b29c8e06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/main.ts @@ -0,0 +1,26 @@ +import { NestFactory } from '@nestjs/core'; +import * as Sentry from '@sentry/node'; +import { AppModule1, AppModule2 } from './app.module'; + +const app1Port = 3030; +const app2Port = 3040; + +async function bootstrap() { + Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], + }); + + const app1 = await NestFactory.create(AppModule1); + Sentry.setupNestErrorHandler(app1); + + await app1.listen(app1Port); + + const app2 = await NestFactory.create(AppModule2); + await app2.listen(app2Port); +} + +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/utils.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/utils.ts new file mode 100644 index 000000000000..8f5e45d119a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/utils.ts @@ -0,0 +1,26 @@ +import * as http from 'http'; + +export function makeHttpRequest(url) { + return new Promise((resolve) => { + const data = []; + + http + .request(url, (httpRes) => { + httpRes.on('data', (chunk) => { + data.push(chunk); + }); + httpRes.on('error', (error) => { + resolve({ error: error.message, url }); + }); + httpRes.on('end', () => { + try { + const json = JSON.parse(Buffer.concat(data).toString()); + resolve(json); + } catch { + resolve({ data: Buffer.concat(data).toString(), url }); + } + }); + }) + .end(); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/start-event-proxy.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/start-event-proxy.ts new file mode 100644 index 000000000000..07fab2dc80cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-nestjs-app', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts new file mode 100644 index 000000000000..2d51cff37fe4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; +import { waitForError } from '../event-proxy-server'; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; +const EVENT_POLLING_TIMEOUT = 90_000; + +test('Sends captured error to Sentry', async ({ baseURL }) => { + const { data } = await axios.get(`${baseURL}/test-error`); + const { exceptionId } = data; + + const url = `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionId}/`; + + console.log(`Polling for error eventId: ${exceptionId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get(url, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { timeout: EVENT_POLLING_TIMEOUT }, + ) + .toBe(200); +}); + +test('Sends exception to Sentry', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-nestjs-app', (event) => { + return ( + !event.type && + event.exception?.values?.[0]?.value === 'This is an exception' + ); + }); + + try { + axios.get(`${baseURL}/test-exception`); + } catch { + // this results in an error, but we don't care - we want to check the error event + } + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception'); + + 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-nestjs-app/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/propagation.test.ts new file mode 100644 index 000000000000..5bd02867295d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/propagation.test.ts @@ -0,0 +1,415 @@ +import crypto from 'crypto'; +import { expect, test } from '@playwright/test'; +import { SpanJSON } from '@sentry/types'; +import axios from 'axios'; +import { waitForTransaction } from '../event-proxy-server'; + +test('Propagates trace for outgoing http requests', async ({ baseURL }) => { + const id = crypto.randomUUID(); + + const inboundTransactionPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/test-inbound-headers/${id}` + ); + }, + ); + + const outboundTransactionPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/test-outgoing-http/${id}` + ); + }, + ); + + const { data } = await axios.get(`${baseURL}/test-outgoing-http/${id}`); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + const outgoingHttpSpan = outboundTransaction?.spans?.find( + (span) => span.op === 'http.client', + ) as SpanJSON | undefined; + + expect(outgoingHttpSpan).toBeDefined(); + + const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + + expect(traceId).toEqual(expect.any(String)); + + // data is passed through from the inbound request, to verify we have the correct headers set + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toEqual( + `${traceId}-${outgoingHttpSpanId}-1`, + ); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + expect(outboundTransaction.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-outgoing-http/${id}`, + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': `http://localhost:3030/test-outgoing-http/${id}`, + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': `/test-outgoing-http/${id}`, + 'http.user_agent': 'axios/1.6.7', + '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-outgoing-http/:id', + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + trace_id: traceId, + origin: 'auto.http.otel.http', + }); + + expect(inboundTransaction.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-inbound-headers/${id}`, + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': `/test-inbound-headers/${id}`, + '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-inbound-headers/:id', + }, + op: 'http.server', + parent_span_id: outgoingHttpSpanId, + span_id: expect.any(String), + status: 'ok', + trace_id: traceId, + origin: 'auto.http.otel.http', + }); +}); + +test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { + const id = crypto.randomUUID(); + + const inboundTransactionPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/test-inbound-headers/${id}` + ); + }, + ); + + const outboundTransactionPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/test-outgoing-fetch/${id}` + ); + }, + ); + + const { data } = await axios.get(`${baseURL}/test-outgoing-fetch/${id}`); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + const outgoingHttpSpan = outboundTransaction?.spans?.find( + (span) => span.op === 'http.client', + ) as SpanJSON | undefined; + + expect(outgoingHttpSpan).toBeDefined(); + + const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + + expect(traceId).toEqual(expect.any(String)); + + // data is passed through from the inbound request, to verify we have the correct headers set + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toEqual( + `${traceId}-${outgoingHttpSpanId}-1`, + ); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + expect(outboundTransaction.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-outgoing-fetch/${id}`, + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': `/test-outgoing-fetch/${id}`, + 'http.user_agent': 'axios/1.6.7', + '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-outgoing-fetch/:id', + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + trace_id: traceId, + origin: 'auto.http.otel.http', + }); + + expect(inboundTransaction.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: `http://localhost:3030/test-inbound-headers/${id}`, + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': `/test-inbound-headers/${id}`, + '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-inbound-headers/:id', + }), + op: 'http.server', + parent_span_id: outgoingHttpSpanId, + span_id: expect.any(String), + status: 'ok', + trace_id: traceId, + origin: 'auto.http.otel.http', + }); +}); + +test('Propagates trace for outgoing external http requests', async ({ + baseURL, +}) => { + const inboundTransactionPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/test-outgoing-http-external-allowed` + ); + }, + ); + + const { data } = await axios.get( + `${baseURL}/test-outgoing-http-external-allowed`, + ); + + const inboundTransaction = await inboundTransactionPromise; + + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const spanId = inboundTransaction?.spans?.find( + (span) => span.op === 'http.client', + )?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data).toEqual({ + headers: expect.objectContaining({ + 'sentry-trace': `${traceId}-${spanId}-1`, + baggage: expect.any(String), + }), + route: 'external-allowed', + }); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing http requests not covered by tracePropagationTargets', async ({ + baseURL, +}) => { + const inboundTransactionPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/test-outgoing-http-external-disallowed` + ); + }, + ); + + const { data } = await axios.get( + `${baseURL}/test-outgoing-http-external-disallowed`, + ); + + const inboundTransaction = await inboundTransactionPromise; + + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const spanId = inboundTransaction?.spans?.find( + (span) => span.op === 'http.client', + )?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data.route).toBe('external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); + +test('Propagates trace for outgoing external fetch requests', async ({ + baseURL, +}) => { + const inboundTransactionPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/test-outgoing-fetch-external-allowed` + ); + }, + ); + + const { data } = await axios.get( + `${baseURL}/test-outgoing-fetch-external-allowed`, + ); + + const inboundTransaction = await inboundTransactionPromise; + + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const spanId = inboundTransaction?.spans?.find( + (span) => span.op === 'http.client', + )?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data).toEqual({ + headers: expect.objectContaining({ + 'sentry-trace': `${traceId}-${spanId}-1`, + baggage: expect.any(String), + }), + route: 'external-allowed', + }); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ + baseURL, +}) => { + const inboundTransactionPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/test-outgoing-fetch-external-disallowed` + ); + }, + ); + + const { data } = await axios.get( + `${baseURL}/test-outgoing-fetch-external-disallowed`, + ); + + const inboundTransaction = await inboundTransactionPromise; + + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const spanId = inboundTransaction?.spans?.find( + (span) => span.op === 'http.client', + )?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data.route).toBe('external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/transactions.test.ts new file mode 100644 index 000000000000..9c9fd6061a27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/transactions.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; +import { waitForTransaction } from '../event-proxy-server'; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; +const EVENT_POLLING_TIMEOUT = 90_000; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction( + 'node-nestjs-app', + (transactionEvent) => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }, + ); + + await axios.get(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + const transactionEventId = transactionEvent.event_id; + + 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': 'axios/1.6.7', + '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({ + spans: expect.arrayContaining([ + { + data: { + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + 'http.route': '/test-transaction', + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'auto.http.otel.express', + }, + description: 'request handler - /test-transaction', + 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), + origin: 'auto.http.otel.express', + }, + { + data: { + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'manual', + }, + description: 'test-span', + 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), + origin: 'manual', + }, + { + data: { + 'otel.kind': 'INTERNAL', + 'sentry.origin': 'manual', + }, + description: 'child-span', + 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), + origin: 'manual', + }, + ]), + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tsconfig.json new file mode 100644 index 000000000000..95f5641cf7f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/dev-packages/node-integration-tests/suites/hapi/tracing/server.js b/dev-packages/node-integration-tests/suites/hapi/tracing/server.js new file mode 100644 index 000000000000..5948bb255087 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hapi/tracing/server.js @@ -0,0 +1,34 @@ +const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); +const Hapi = require('@hapi/hapi'); + +const port = 5999; + +const init = async () => { + const server = Hapi.server({ + host: 'localhost', + port, + }); + + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [new Sentry.Integrations.Hapi({ server })], + }); + + server.route({ + method: 'GET', + path: '/', + handler: async () => { + return 'Hello World!'; + }, + }); + + await server.start(); + + sendPortToRunner(port); +}; + +init(); diff --git a/dev-packages/node-integration-tests/suites/hapi/tracing/test.ts b/dev-packages/node-integration-tests/suites/hapi/tracing/test.ts new file mode 100644 index 000000000000..bdd3e78bd919 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hapi/tracing/test.ts @@ -0,0 +1,21 @@ +import type { Envelope } from '@sentry/types'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('Hapi Scope.', done => { + createRunner(__dirname, 'server.js') + .ignore('session', 'sessions', 'transaction') + .withRecordedEnvelopes(4, (envelopes: Envelope[]) => { + expect(envelopes.length).toBe(1); + }) + .start(done) + .makeConsecutiveRequests({ + method: 'get', + path: '/', + delay: 100, + count: 4, + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 1cc715c8345a..7dd2e589b86c 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -82,6 +82,7 @@ export { mysqlIntegration, mysql2Integration, nestIntegration, + setupNestErrorHandler, postgresIntegration, prismaIntegration, hapiIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 0cc875d29338..554ba0b8c8ca 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -83,6 +83,7 @@ export { mysqlIntegration, mysql2Integration, nestIntegration, + setupNestErrorHandler, postgresIntegration, prismaIntegration, hapiIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index ae09717c34b4..3653ea123498 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -104,6 +104,7 @@ export { mysqlIntegration, mysql2Integration, nestIntegration, + setupNestErrorHandler, postgresIntegration, prismaIntegration, hapiIntegration, @@ -125,9 +126,6 @@ export { export type { BunOptions } from './types'; export { BunClient } from './client'; -export { - getDefaultIntegrations, - init, -} from './sdk'; +export { getDefaultIntegrations, init } from './sdk'; export { bunServerIntegration } from './integrations/bunserver'; export { makeFetchTransport } from './transports'; diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 8bde94373cef..ee890ad338c3 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -83,6 +83,7 @@ export { mysqlIntegration, mysql2Integration, nestIntegration, + setupNestErrorHandler, postgresIntegration, prismaIntegration, hapiIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 33bbcca54213..44d15747125b 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -17,7 +17,7 @@ export { mongoIntegration } from './integrations/tracing/mongo'; export { mongooseIntegration } from './integrations/tracing/mongoose'; export { mysqlIntegration } from './integrations/tracing/mysql'; export { mysql2Integration } from './integrations/tracing/mysql2'; -export { nestIntegration } from './integrations/tracing/nest'; +export { nestIntegration, setupNestErrorHandler } from './integrations/tracing/nest'; export { postgresIntegration } from './integrations/tracing/postgres'; export { prismaIntegration } from './integrations/tracing/prisma'; export { hapiIntegration, setupHapiErrorHandler } from './integrations/tracing/hapi'; @@ -34,11 +34,7 @@ export { cron } from './cron'; export type { NodeOptions } from './types'; -export { - addRequestDataToEvent, - DEFAULT_USER_INCLUDES, - extractRequestData, -} from '@sentry/utils'; +export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from '@sentry/utils'; // These are custom variants that need to be used instead of the core one // As they have slightly different implementations diff --git a/packages/node/src/integrations/tracing/nest.ts b/packages/node/src/integrations/tracing/nest.ts index 1f2c75e3807e..fb9a211b2a1e 100644 --- a/packages/node/src/integrations/tracing/nest.ts +++ b/packages/node/src/integrations/tracing/nest.ts @@ -1,6 +1,6 @@ import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; -import { defineIntegration } from '@sentry/core'; +import { captureException, defineIntegration } from '@sentry/core'; import type { IntegrationFn } from '@sentry/types'; const _nestIntegration = (() => { @@ -20,3 +20,18 @@ const _nestIntegration = (() => { * Capture tracing data for nest. */ export const nestIntegration = defineIntegration(_nestIntegration); + +const SentryNestExceptionFilter = { + catch(exception: unknown) { + captureException(exception); + }, +}; + +/** + * Setup an error handler for Nest. + */ +export function setupNestErrorHandler(app: { + useGlobalFilters: (arg0: { catch(exception: unknown): void }) => void; +}): void { + app.useGlobalFilters(SentryNestExceptionFilter); +} diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index e31c0b29fbd8..ef63192eef58 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -88,6 +88,7 @@ export { mysqlIntegration, mysql2Integration, nestIntegration, + setupNestErrorHandler, postgresIntegration, prismaIntegration, hapiIntegration, From 7205e214886ed08243a47b6d0aba33e5ad5820bc Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 2 Apr 2024 12:59:23 +0100 Subject: [PATCH 2/5] Remove eslint and prettier configs. --- .../node-nestjs-app/.eslintrc.js | 25 ------------------- .../node-nestjs-app/.prettierrc | 4 --- 2 files changed, 29 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/.eslintrc.js delete mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-app/.prettierrc diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/.eslintrc.js b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.eslintrc.js deleted file mode 100644 index 259de13c733a..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/.eslintrc.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - tsconfigRootDir: __dirname, - sourceType: 'module', - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:prettier/recommended', - ], - root: true, - env: { - node: true, - jest: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - }, -}; diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/.prettierrc b/dev-packages/e2e-tests/test-applications/node-nestjs-app/.prettierrc deleted file mode 100644 index dcb72794f530..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "all" -} \ No newline at end of file From 14d577a8449c0680ff532eba6e4d6a624e15c08b Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 2 Apr 2024 13:01:24 +0100 Subject: [PATCH 3/5] Remove leftovers. --- .../suites/hapi/tracing/server.js | 34 ------------------- .../suites/hapi/tracing/test.ts | 21 ------------ 2 files changed, 55 deletions(-) delete mode 100644 dev-packages/node-integration-tests/suites/hapi/tracing/server.js delete mode 100644 dev-packages/node-integration-tests/suites/hapi/tracing/test.ts diff --git a/dev-packages/node-integration-tests/suites/hapi/tracing/server.js b/dev-packages/node-integration-tests/suites/hapi/tracing/server.js deleted file mode 100644 index 5948bb255087..000000000000 --- a/dev-packages/node-integration-tests/suites/hapi/tracing/server.js +++ /dev/null @@ -1,34 +0,0 @@ -const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); -const Hapi = require('@hapi/hapi'); - -const port = 5999; - -const init = async () => { - const server = Hapi.server({ - host: 'localhost', - port, - }); - - Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, - integrations: [new Sentry.Integrations.Hapi({ server })], - }); - - server.route({ - method: 'GET', - path: '/', - handler: async () => { - return 'Hello World!'; - }, - }); - - await server.start(); - - sendPortToRunner(port); -}; - -init(); diff --git a/dev-packages/node-integration-tests/suites/hapi/tracing/test.ts b/dev-packages/node-integration-tests/suites/hapi/tracing/test.ts deleted file mode 100644 index bdd3e78bd919..000000000000 --- a/dev-packages/node-integration-tests/suites/hapi/tracing/test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Envelope } from '@sentry/types'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('Hapi Scope.', done => { - createRunner(__dirname, 'server.js') - .ignore('session', 'sessions', 'transaction') - .withRecordedEnvelopes(4, (envelopes: Envelope[]) => { - expect(envelopes.length).toBe(1); - }) - .start(done) - .makeConsecutiveRequests({ - method: 'get', - path: '/', - delay: 100, - count: 4, - }); -}); From 8bb4ff8826507f09dae59fe0c853cd09ba2ba6f9 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 2 Apr 2024 14:31:05 +0100 Subject: [PATCH 4/5] Fix formatting. --- .../node-nestjs-app/src/app.service.ts | 12 +- .../node-nestjs-app/src/utils.ts | 8 +- .../node-nestjs-app/tests/errors.test.ts | 7 +- .../node-nestjs-app/tests/propagation.test.ts | 192 ++++++------------ .../tests/transactions.test.ts | 15 +- 5 files changed, 79 insertions(+), 155 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts index 2629ee5506da..387668889c24 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/app.service.ts @@ -22,17 +22,13 @@ export class AppService1 { } async testOutgoingHttp(id: string) { - const data = await makeHttpRequest( - `http://localhost:3030/test-inbound-headers/${id}`, - ); + const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); return data; } async testOutgoingFetch(id: string) { - const response = await fetch( - `http://localhost:3030/test-inbound-headers/${id}`, - ); + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); const data = await response.json(); return data; @@ -63,9 +59,7 @@ export class AppService1 { } async testOutgoingFetchExternalDisallowed() { - const fetchResponse = await fetch( - 'http://localhost:3040/external-disallowed', - ); + const fetchResponse = await fetch('http://localhost:3040/external-disallowed'); return fetchResponse.json(); } diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/utils.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/utils.ts index 8f5e45d119a7..27639ef26349 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/utils.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/src/utils.ts @@ -1,15 +1,15 @@ import * as http from 'http'; export function makeHttpRequest(url) { - return new Promise((resolve) => { + return new Promise(resolve => { const data = []; http - .request(url, (httpRes) => { - httpRes.on('data', (chunk) => { + .request(url, httpRes => { + httpRes.on('data', chunk => { data.push(chunk); }); - httpRes.on('error', (error) => { + httpRes.on('error', error => { resolve({ error: error.message, url }); }); httpRes.on('end', () => { diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts index 2d51cff37fe4..65f005a94e6f 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/errors.test.ts @@ -42,11 +42,8 @@ test('Sends captured error to Sentry', async ({ baseURL }) => { }); test('Sends exception to Sentry', async ({ baseURL }) => { - const errorEventPromise = waitForError('node-nestjs-app', (event) => { - return ( - !event.type && - event.exception?.values?.[0]?.value === 'This is an exception' - ); + const errorEventPromise = waitForError('node-nestjs-app', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception'; }); try { diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/propagation.test.ts index 5bd02867295d..05b035c9aeae 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/propagation.test.ts @@ -7,27 +7,19 @@ import { waitForTransaction } from '../event-proxy-server'; test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const id = crypto.randomUUID(); - const inboundTransactionPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/test-inbound-headers/${id}` - ); - }, - ); + const inboundTransactionPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-inbound-headers/${id}` + ); + }); - const outboundTransactionPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/test-outgoing-http/${id}` - ); - }, - ); + const outboundTransactionPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-http/${id}` + ); + }); const { data } = await axios.get(`${baseURL}/test-outgoing-http/${id}`); @@ -35,9 +27,7 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const outboundTransaction = await outboundTransactionPromise; const traceId = outboundTransaction?.contexts?.trace?.trace_id; - const outgoingHttpSpan = outboundTransaction?.spans?.find( - (span) => span.op === 'http.client', - ) as SpanJSON | undefined; + const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client') as SpanJSON | undefined; expect(outgoingHttpSpan).toBeDefined(); @@ -49,9 +39,7 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; const inboundHeaderBaggage = data.headers?.['baggage']; - expect(inboundHeaderSentryTrace).toEqual( - `${traceId}-${outgoingHttpSpanId}-1`, - ); + expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`); expect(inboundHeaderBaggage).toBeDefined(); const baggage = (inboundHeaderBaggage || '').split(','); @@ -133,27 +121,19 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const id = crypto.randomUUID(); - const inboundTransactionPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/test-inbound-headers/${id}` - ); - }, - ); + const inboundTransactionPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-inbound-headers/${id}` + ); + }); - const outboundTransactionPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/test-outgoing-fetch/${id}` - ); - }, - ); + const outboundTransactionPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-fetch/${id}` + ); + }); const { data } = await axios.get(`${baseURL}/test-outgoing-fetch/${id}`); @@ -161,9 +141,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const outboundTransaction = await outboundTransactionPromise; const traceId = outboundTransaction?.contexts?.trace?.trace_id; - const outgoingHttpSpan = outboundTransaction?.spans?.find( - (span) => span.op === 'http.client', - ) as SpanJSON | undefined; + const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client') as SpanJSON | undefined; expect(outgoingHttpSpan).toBeDefined(); @@ -175,9 +153,7 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; const inboundHeaderBaggage = data.headers?.['baggage']; - expect(inboundHeaderSentryTrace).toEqual( - `${traceId}-${outgoingHttpSpanId}-1`, - ); + expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`); expect(inboundHeaderBaggage).toBeDefined(); const baggage = (inboundHeaderBaggage || '').split(','); @@ -256,30 +232,20 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { }); }); -test('Propagates trace for outgoing external http requests', async ({ - baseURL, -}) => { - const inboundTransactionPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/test-outgoing-http-external-allowed` - ); - }, - ); +test('Propagates trace for outgoing external http requests', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-http-external-allowed` + ); + }); - const { data } = await axios.get( - `${baseURL}/test-outgoing-http-external-allowed`, - ); + const { data } = await axios.get(`${baseURL}/test-outgoing-http-external-allowed`); const inboundTransaction = await inboundTransactionPromise; const traceId = inboundTransaction?.contexts?.trace?.trace_id; - const spanId = inboundTransaction?.spans?.find( - (span) => span.op === 'http.client', - )?.span_id; + const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; expect(traceId).toEqual(expect.any(String)); expect(spanId).toEqual(expect.any(String)); @@ -302,30 +268,20 @@ test('Propagates trace for outgoing external http requests', async ({ ); }); -test('Does not propagate outgoing http requests not covered by tracePropagationTargets', async ({ - baseURL, -}) => { - const inboundTransactionPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/test-outgoing-http-external-disallowed` - ); - }, - ); +test('Does not propagate outgoing http requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-http-external-disallowed` + ); + }); - const { data } = await axios.get( - `${baseURL}/test-outgoing-http-external-disallowed`, - ); + const { data } = await axios.get(`${baseURL}/test-outgoing-http-external-disallowed`); const inboundTransaction = await inboundTransactionPromise; const traceId = inboundTransaction?.contexts?.trace?.trace_id; - const spanId = inboundTransaction?.spans?.find( - (span) => span.op === 'http.client', - )?.span_id; + const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; expect(traceId).toEqual(expect.any(String)); expect(spanId).toEqual(expect.any(String)); @@ -335,30 +291,20 @@ test('Does not propagate outgoing http requests not covered by tracePropagationT expect(data.headers?.baggage).toBeUndefined(); }); -test('Propagates trace for outgoing external fetch requests', async ({ - baseURL, -}) => { - const inboundTransactionPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/test-outgoing-fetch-external-allowed` - ); - }, - ); +test('Propagates trace for outgoing external fetch requests', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-fetch-external-allowed` + ); + }); - const { data } = await axios.get( - `${baseURL}/test-outgoing-fetch-external-allowed`, - ); + const { data } = await axios.get(`${baseURL}/test-outgoing-fetch-external-allowed`); const inboundTransaction = await inboundTransactionPromise; const traceId = inboundTransaction?.contexts?.trace?.trace_id; - const spanId = inboundTransaction?.spans?.find( - (span) => span.op === 'http.client', - )?.span_id; + const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; expect(traceId).toEqual(expect.any(String)); expect(spanId).toEqual(expect.any(String)); @@ -381,30 +327,20 @@ test('Propagates trace for outgoing external fetch requests', async ({ ); }); -test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ - baseURL, -}) => { - const inboundTransactionPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/test-outgoing-fetch-external-disallowed` - ); - }, - ); +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-fetch-external-disallowed` + ); + }); - const { data } = await axios.get( - `${baseURL}/test-outgoing-fetch-external-disallowed`, - ); + const { data } = await axios.get(`${baseURL}/test-outgoing-fetch-external-disallowed`); const inboundTransaction = await inboundTransactionPromise; const traceId = inboundTransaction?.contexts?.trace?.trace_id; - const spanId = inboundTransaction?.spans?.find( - (span) => span.op === 'http.client', - )?.span_id; + const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; expect(traceId).toEqual(expect.any(String)); expect(spanId).toEqual(expect.any(String)); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/transactions.test.ts index 9c9fd6061a27..5040055b2080 100644 --- a/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-app/tests/transactions.test.ts @@ -8,15 +8,12 @@ const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; const EVENT_POLLING_TIMEOUT = 90_000; test('Sends an API route transaction', async ({ baseURL }) => { - const pageloadTransactionEventPromise = waitForTransaction( - 'node-nestjs-app', - (transactionEvent) => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-transaction' - ); - }, - ); + const pageloadTransactionEventPromise = waitForTransaction('node-nestjs-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); await axios.get(`${baseURL}/test-transaction`); From c315d8e5fe69d729914affc02ca203f826b088eb Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 2 Apr 2024 15:56:08 +0100 Subject: [PATCH 5/5] Add NestJS e2e tests to the workflow. --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a017b3d7add1..c7979bb36eb9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1049,6 +1049,7 @@ jobs: 'node-fastify-app', # TODO(v8): Re-enable hapi tests # 'node-hapi-app', + 'node-nestjs-app', 'node-exports-test-app', 'vue-3', 'webpack-4',