diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 44bc53c15f56..83fb3f0be7b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,7 @@ env: # packages/utils/cjs and packages/utils/esm: Symlinks to the folders inside of `build`, needed for tests CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dev-packages/*/build ${{ github.workspace }}/packages/*/build ${{ github.workspace }}/packages/ember/*.d.ts ${{ github.workspace }}/packages/gatsby/*.d.ts diff --git a/dev-packages/node-integration-tests/.eslintrc.js b/dev-packages/node-integration-tests/.eslintrc.js index 6c8a493dccb3..df04aa267446 100644 --- a/dev-packages/node-integration-tests/.eslintrc.js +++ b/dev-packages/node-integration-tests/.eslintrc.js @@ -6,7 +6,7 @@ module.exports = { extends: ['../../.eslintrc.js'], overrides: [ { - files: ['utils/**/*.ts'], + files: ['utils/**/*.ts', 'src/**/*.ts'], parserOptions: { project: ['tsconfig.json'], sourceType: 'module', diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 9740072d6ff1..d9f292a9be4f 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -6,7 +6,14 @@ "node": ">=10" }, "private": true, + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/src/index.d.ts", "scripts": { + "build": "run-s build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", "clean": "rimraf -g **/node_modules", "prisma:init": "(cd suites/tracing/prisma-orm && ts-node ./setup.ts)", "prisma:init:new": "(cd suites/tracing-new/prisma-orm && ts-node ./setup.ts)", @@ -15,12 +22,14 @@ "type-check": "tsc", "pretest": "run-s --silent prisma:init prisma:init:new", "test": "ts-node ./utils/run-tests.ts", + "jest": "jest --config ./jest.config.js", "test:watch": "yarn test --watch" }, "dependencies": { "@prisma/client": "3.15.2", "@sentry/node": "7.93.0", "@sentry/tracing": "7.93.0", + "@sentry/types": "7.93.0", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", diff --git a/dev-packages/node-integration-tests/rollup.npm.config.mjs b/dev-packages/node-integration-tests/rollup.npm.config.mjs new file mode 100644 index 000000000000..84a06f2fb64a --- /dev/null +++ b/dev-packages/node-integration-tests/rollup.npm.config.mjs @@ -0,0 +1,3 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants(makeBaseNPMConfig()); diff --git a/dev-packages/node-integration-tests/src/index.ts b/dev-packages/node-integration-tests/src/index.ts new file mode 100644 index 000000000000..423b1ac3d93f --- /dev/null +++ b/dev-packages/node-integration-tests/src/index.ts @@ -0,0 +1,31 @@ +import type { AddressInfo } from 'net'; +import type { BaseTransportOptions, Envelope, Transport, TransportMakeRequestResponse } from '@sentry/types'; +import type { Express } from 'express'; + +/** + * Debug logging transport + */ +export function loggingTransport(_options: BaseTransportOptions): Transport { + return { + send(request: Envelope): Promise { + // eslint-disable-next-line no-console + console.log(JSON.stringify(request)); + return Promise.resolve({ statusCode: 200 }); + }, + flush(): PromiseLike { + return Promise.resolve(true); + }, + }; +} + +/** + * Starts an express server and sends the port to the runner + */ +export function startExpressServerAndSendPortToRunner(app: Express): void { + const server = app.listen(0, () => { + const address = server.address() as AddressInfo; + + // eslint-disable-next-line no-console + console.log(`{"port":${address.port}}`); + }); +} diff --git a/dev-packages/node-integration-tests/suites/anr/basic-session.js b/dev-packages/node-integration-tests/suites/anr/basic-session.js index 03c8c94fdadf..fe4190c8cc46 100644 --- a/dev-packages/node-integration-tests/suites/anr/basic-session.js +++ b/dev-packages/node-integration-tests/suites/anr/basic-session.js @@ -11,11 +11,11 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, - integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], + integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 100 })], }); function longWork() { - for (let i = 0; i < 100; i++) { + for (let i = 0; i < 20; i++) { const salt = crypto.randomBytes(128).toString('base64'); // eslint-disable-next-line no-unused-vars const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); diff --git a/dev-packages/node-integration-tests/suites/anr/basic.js b/dev-packages/node-integration-tests/suites/anr/basic.js index 5e0323e2c6c5..097dec6c925c 100644 --- a/dev-packages/node-integration-tests/suites/anr/basic.js +++ b/dev-packages/node-integration-tests/suites/anr/basic.js @@ -12,11 +12,11 @@ Sentry.init({ release: '1.0', debug: true, autoSessionTracking: false, - integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], + integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 100 })], }); function longWork() { - for (let i = 0; i < 100; i++) { + for (let i = 0; i < 20; i++) { const salt = crypto.randomBytes(128).toString('base64'); // eslint-disable-next-line no-unused-vars const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); diff --git a/dev-packages/node-integration-tests/suites/anr/basic.mjs b/dev-packages/node-integration-tests/suites/anr/basic.mjs index 17c8a2d460df..43a8d02a41ac 100644 --- a/dev-packages/node-integration-tests/suites/anr/basic.mjs +++ b/dev-packages/node-integration-tests/suites/anr/basic.mjs @@ -12,11 +12,11 @@ Sentry.init({ release: '1.0', debug: true, autoSessionTracking: false, - integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], + integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 100 })], }); function longWork() { - for (let i = 0; i < 100; i++) { + for (let i = 0; i < 20; i++) { const salt = crypto.randomBytes(128).toString('base64'); // eslint-disable-next-line no-unused-vars const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); diff --git a/dev-packages/node-integration-tests/suites/anr/forked.js b/dev-packages/node-integration-tests/suites/anr/forked.js index 5e0323e2c6c5..097dec6c925c 100644 --- a/dev-packages/node-integration-tests/suites/anr/forked.js +++ b/dev-packages/node-integration-tests/suites/anr/forked.js @@ -12,11 +12,11 @@ Sentry.init({ release: '1.0', debug: true, autoSessionTracking: false, - integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], + integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 100 })], }); function longWork() { - for (let i = 0; i < 100; i++) { + for (let i = 0; i < 20; i++) { const salt = crypto.randomBytes(128).toString('base64'); // eslint-disable-next-line no-unused-vars const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); diff --git a/dev-packages/node-integration-tests/suites/anr/legacy.js b/dev-packages/node-integration-tests/suites/anr/legacy.js index 46b6e1437b10..f91db4bec054 100644 --- a/dev-packages/node-integration-tests/suites/anr/legacy.js +++ b/dev-packages/node-integration-tests/suites/anr/legacy.js @@ -15,9 +15,9 @@ Sentry.init({ }); // eslint-disable-next-line deprecation/deprecation -Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }).then(() => { +Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 100 }).then(() => { function longWork() { - for (let i = 0; i < 100; i++) { + for (let i = 0; i < 20; i++) { const salt = crypto.randomBytes(128).toString('base64'); // eslint-disable-next-line no-unused-vars const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index 2c2e513559cf..5edbe6dd2f78 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -1,188 +1,105 @@ -import * as childProcess from 'child_process'; -import * as path from 'path'; -import type { Event } from '@sentry/node'; -import type { SerializedSession } from '@sentry/types'; import { conditionalTest } from '../../utils'; - -/** The output will contain logging so we need to find the line that parses as JSON */ -function parseJsonLines(input: string, expected: number): T { - const results = input - .split('\n') - .map(line => { - const trimmed = line.startsWith('[ANR Worker] ') ? line.slice(13) : line; - try { - return JSON.parse(trimmed) as T; - } catch { - return undefined; - } - }) - .filter(a => a) as T; - - expect(results.length).toEqual(expected); - - return results; -} +import { createRunner } from '../../utils/runner'; + +const EXPECTED_ANR_EVENT = { + // Ensure we have context + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + device: { + arch: expect.any(String), + }, + app: { + app_start_time: expect.any(String), + }, + os: { + name: expect.any(String), + }, + culture: { + timezone: expect.any(String), + }, + }, + // and an exception that is our ANR + exception: { + values: [ + { + type: 'ApplicationNotResponding', + value: 'Application Not Responding for at least 100 ms', + mechanism: { type: 'ANR' }, + stacktrace: { + frames: expect.arrayContaining([ + { + colno: expect.any(Number), + lineno: expect.any(Number), + filename: expect.any(String), + function: '?', + in_app: true, + }, + { + colno: expect.any(Number), + lineno: expect.any(Number), + filename: expect.any(String), + function: 'longWork', + in_app: true, + }, + ]), + }, + }, + ], + }, +}; conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => { - test('CJS', done => { - expect.assertions(13); - - const testScriptPath = path.resolve(__dirname, 'basic.js'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const [event] = parseJsonLines<[Event]>(stdout, 1); - - expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); - expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); - expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); - expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - - expect(event.contexts?.trace?.trace_id).toBeDefined(); - expect(event.contexts?.trace?.span_id).toBeDefined(); - - expect(event.contexts?.device?.arch).toBeDefined(); - expect(event.contexts?.app?.app_start_time).toBeDefined(); - expect(event.contexts?.os?.name).toBeDefined(); - expect(event.contexts?.culture?.timezone).toBeDefined(); - - done(); - }); - }); - + // TODO (v8): Remove this old API and this test test('Legacy API', done => { - // TODO (v8): Remove this old API and this test - expect.assertions(9); - - const testScriptPath = path.resolve(__dirname, 'legacy.js'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const [event] = parseJsonLines<[Event]>(stdout, 1); - - expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); - expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); - expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); - expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - - expect(event.contexts?.trace?.trace_id).toBeDefined(); - expect(event.contexts?.trace?.span_id).toBeDefined(); + createRunner(__dirname, 'legacy.js').expect({ event: EXPECTED_ANR_EVENT }).start(done); + }); - done(); - }); + test('CJS', done => { + createRunner(__dirname, 'basic.js').expect({ event: EXPECTED_ANR_EVENT }).start(done); }); test('ESM', done => { - expect.assertions(7); - - const testScriptPath = path.resolve(__dirname, 'basic.mjs'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const [event] = parseJsonLines<[Event]>(stdout, 1); - - expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); - expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); - expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); - expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThanOrEqual(4); - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - - done(); - }); + createRunner(__dirname, 'basic.mjs').expect({ event: EXPECTED_ANR_EVENT }).start(done); }); test('With --inspect', done => { - expect.assertions(7); - - const testScriptPath = path.resolve(__dirname, 'basic.js'); - - childProcess.exec(`node --inspect ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const [event] = parseJsonLines<[Event]>(stdout, 1); - - expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); - expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); - expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); - expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - - done(); - }); + createRunner(__dirname, 'basic.mjs').withFlags('--inspect').expect({ event: EXPECTED_ANR_EVENT }).start(done); }); test('should exit', done => { - const testScriptPath = path.resolve(__dirname, 'should-exit.js'); - let hasClosed = false; + const runner = createRunner(__dirname, 'should-exit.js').start(); setTimeout(() => { - expect(hasClosed).toBe(true); + expect(runner.childHasExited()).toBe(true); done(); }, 5_000); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, () => { - hasClosed = true; - }); }); test('should exit forced', done => { - const testScriptPath = path.resolve(__dirname, 'should-exit-forced.js'); - let hasClosed = false; + const runner = createRunner(__dirname, 'should-exit-forced.js').start(); setTimeout(() => { - expect(hasClosed).toBe(true); + expect(runner.childHasExited()).toBe(true); done(); }, 5_000); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, () => { - hasClosed = true; - }); }); test('With session', done => { - expect.assertions(9); - - const testScriptPath = path.resolve(__dirname, 'basic-session.js'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const [session, event] = parseJsonLines<[SerializedSession, Event]>(stdout, 2); - - expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); - expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); - expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); - expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - - expect(session.status).toEqual('abnormal'); - expect(session.abnormal_mechanism).toEqual('anr_foreground'); - - done(); - }); + createRunner(__dirname, 'basic-session.js') + .expect({ + session: { + status: 'abnormal', + abnormal_mechanism: 'anr_foreground', + }, + }) + .expect({ event: EXPECTED_ANR_EVENT }) + .start(done); }); test('from forked process', done => { - expect.assertions(7); - - const testScriptPath = path.resolve(__dirname, 'forker.js'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const [event] = parseJsonLines<[Event]>(stdout, 1); - - expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); - expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); - expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); - expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - - done(); - }); + createRunner(__dirname, 'forker.js').expect({ event: EXPECTED_ANR_EVENT }).start(done); }); }); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/server.ts b/dev-packages/node-integration-tests/suites/express/tracing/server.ts index 1c56a81fef98..dfd6df7526fd 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/server.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/server.ts @@ -1,3 +1,4 @@ +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; import cors from 'cors'; import express from 'express'; @@ -11,6 +12,7 @@ Sentry.init({ tracePropagationTargets: [/^(?!.*test).*$/], integrations: [new Sentry.Integrations.Http({ tracing: true }), new Sentry.Integrations.Express({ app })], tracesSampleRate: 1.0, + transport: loggingTransport, }); app.use(Sentry.Handlers.requestHandler()); @@ -36,4 +38,4 @@ app.get(['/test/arr/:id', /\/test\/arr[0-9]*\/required(path)?(\/optionalPath)?\/ app.use(Sentry.Handlers.errorHandler()); -export default app; +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index e391ca881b30..089a2ac16edf 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -1,89 +1,95 @@ -import { TestEnv, assertSentryTransaction } from '../../../utils/index'; +import { createRunner } from '../../../utils/runner'; -test('should create and send transactions for Express routes and spans for middlewares.', async () => { - const env = await TestEnv.init(__dirname, `${__dirname}/server.ts`); - const envelope = await env.getEnvelopeRequest({ url: `${env.url}/express`, envelopeType: 'transaction' }); - - expect(envelope).toHaveLength(3); - - assertSentryTransaction(envelope[2], { - contexts: { - trace: { - data: { - url: '/test/express', - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - tags: { - 'http.status_code': '200', +test('should create and send transactions for Express routes and spans for middlewares.', done => { + createRunner(__dirname, 'server.ts') + .expect({ + transaction: { + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + url: '/test/express', + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + tags: { + 'http.status_code': '200', + }, + }, }, + spans: [ + expect.objectContaining({ + description: 'corsMiddleware', + op: 'middleware.express.use', + }), + ], }, - }, - spans: [ - { - description: 'corsMiddleware', - op: 'middleware.express.use', - }, - ], - }); + }) + .start(done) + .makeRequest('get', '/test/express'); }); -test('should set a correct transaction name for routes specified in RegEx', async () => { - const env = await TestEnv.init(__dirname, `${__dirname}/server.ts`); - const envelope = await env.getEnvelopeRequest({ url: `${env.url}/regex`, envelopeType: 'transaction' }); - - expect(envelope).toHaveLength(3); - - assertSentryTransaction(envelope[2], { - transaction: 'GET /\\/test\\/regex/', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - url: '/test/regex', - 'http.response.status_code': 200, +test('should set a correct transaction name for routes specified in RegEx', done => { + createRunner(__dirname, 'server.ts') + .expect({ + transaction: { + transaction: 'GET /\\/test\\/regex/', + transaction_info: { + source: 'route', }, - op: 'http.server', - status: 'ok', - tags: { - 'http.status_code': '200', + contexts: { + trace: { + trace_id: expect.any(String), + span_id: expect.any(String), + data: { + url: '/test/regex', + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + tags: { + 'http.status_code': '200', + }, + }, }, }, - }, - }); + }) + .start(done) + .makeRequest('get', '/test/regex'); }); test.each([['array1'], ['array5']])( 'should set a correct transaction name for routes consisting of arrays of routes', - async segment => { - const env = await TestEnv.init(__dirname, `${__dirname}/server.ts`); - const envelope = await env.getEnvelopeRequest({ url: `${env.url}/${segment}`, envelopeType: 'transaction' }); - - expect(envelope).toHaveLength(3); - - assertSentryTransaction(envelope[2], { - transaction: 'GET /test/array1,/\\/test\\/array[2-9]', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - url: `/test/${segment}`, - 'http.response.status_code': 200, + ((segment: string, done: () => void) => { + createRunner(__dirname, 'server.ts') + .expect({ + transaction: { + transaction: 'GET /test/array1,/\\/test\\/array[2-9]', + transaction_info: { + source: 'route', }, - op: 'http.server', - status: 'ok', - tags: { - 'http.status_code': '200', + contexts: { + trace: { + trace_id: expect.any(String), + span_id: expect.any(String), + data: { + url: `/test/${segment}`, + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + tags: { + 'http.status_code': '200', + }, + }, }, }, - }, - }); - }, + }) + .start(done) + .makeRequest('get', `/test/${segment}`); + }) as any, ); test.each([ @@ -95,29 +101,31 @@ test.each([ ['arr55/required/lastParam'], ['arr/requiredPath/optionalPath/'], ['arr/requiredPath/optionalPath/lastParam'], -])('should handle more complex regexes in route arrays correctly', async segment => { - const env = await TestEnv.init(__dirname, `${__dirname}/server.ts`); - const envelope = await env.getEnvelopeRequest({ url: `${env.url}/${segment}`, envelopeType: 'transaction' }); - - expect(envelope).toHaveLength(3); - - assertSentryTransaction(envelope[2], { - transaction: 'GET /test/arr/:id,/\\/test\\/arr[0-9]*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - url: `/test/${segment}`, - 'http.response.status_code': 200, +])('should handle more complex regexes in route arrays correctly', ((segment: string, done: () => void) => { + createRunner(__dirname, 'server.ts') + .expect({ + transaction: { + transaction: 'GET /test/arr/:id,/\\/test\\/arr[0-9]*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?', + transaction_info: { + source: 'route', }, - op: 'http.server', - status: 'ok', - tags: { - 'http.status_code': '200', + contexts: { + trace: { + trace_id: expect.any(String), + span_id: expect.any(String), + data: { + url: `/test/${segment}`, + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + tags: { + 'http.status_code': '200', + }, + }, }, }, - }, - }); -}); + }) + .start(done) + .makeRequest('get', `/test/${segment}`); +}) as any); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js index 08a8d81383a1..7c86004da43b 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js @@ -1,13 +1,11 @@ /* eslint-disable no-unused-vars */ const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', includeLocalVariables: true, - beforeSend: event => { - // eslint-disable-next-line no-console - console.log(JSON.stringify(event)); - }, + transport: loggingTransport, }); class Some { diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs index 3fbf2ae69df7..37e5966bc575 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs @@ -1,13 +1,11 @@ /* eslint-disable no-unused-vars */ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', includeLocalVariables: true, - beforeSend: event => { - // eslint-disable-next-line no-console - console.log(JSON.stringify(event)); - }, + transport: loggingTransport, }); class Some { diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-memory-test.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-memory-test.js index 7b227d4d08de..7aa9feb9ae2a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-memory-test.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-memory-test.js @@ -1,12 +1,11 @@ /* eslint-disable no-unused-vars */ const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', includeLocalVariables: true, - beforeSend: _ => { - return null; - }, + transport: loggingTransport, // Stop the rate limiting from kicking in integrations: [new Sentry.Integrations.LocalVariables({ maxExceptionsPerSecond: 10000000 })], }); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables.js index a579a9cf5ff0..4a16ad89b5aa 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables.js @@ -1,13 +1,11 @@ /* eslint-disable no-unused-vars */ const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', includeLocalVariables: true, - beforeSend: event => { - // eslint-disable-next-line no-console - console.log(JSON.stringify(event)); - }, + transport: loggingTransport, }); process.on('uncaughtException', () => { diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/no-local-variables.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/no-local-variables.js index e9f189647e1a..f01e33a9cafa 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/no-local-variables.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/no-local-variables.js @@ -1,12 +1,10 @@ /* eslint-disable no-unused-vars */ const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - beforeSend: event => { - // eslint-disable-next-line no-console - console.log(JSON.stringify(event)); - }, + transport: loggingTransport, }); process.on('uncaughtException', () => { diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts index 1c58fd802122..75e95f09860c 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts @@ -1,113 +1,67 @@ import * as childProcess from 'child_process'; import * as path from 'path'; -import type { Event } from '@sentry/node'; - import { conditionalTest } from '../../../utils'; +import { createRunner } from '../../../utils/runner'; + +const EXPECTED_LOCAL_VARIABLES_EVENT = { + exception: { + values: [ + { + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: 'one', + vars: { + name: 'some name', + arr: [1, '2', null], + obj: { name: 'some name', num: 5 }, + ty: '', + }, + }), + expect.objectContaining({ + function: 'Some.two', + vars: { name: 'some name' }, + }), + ]), + }, + }, + ], + }, +}; conditionalTest({ min: 18 })('LocalVariables integration', () => { test('Should not include local variables by default', done => { - expect.assertions(2); - - const testScriptPath = path.resolve(__dirname, 'no-local-variables.js'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const event = JSON.parse(stdout) as Event; - - const frames = event.exception?.values?.[0].stacktrace?.frames || []; - const lastFrame = frames[frames.length - 1]; - - expect(lastFrame.vars).toBeUndefined(); - - const penultimateFrame = frames[frames.length - 2]; - - expect(penultimateFrame.vars).toBeUndefined(); - - done(); - }); + createRunner(__dirname, 'no-local-variables.js') + .ignore('session') + .expect({ + event: event => { + for (const frame of event.exception?.values?.[0].stacktrace?.frames || []) { + expect(frame.vars).toBeUndefined(); + } + }, + }) + .start(done); }); test('Should include local variables when enabled', done => { - expect.assertions(4); - - const testScriptPath = path.resolve(__dirname, 'local-variables.js'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const event = JSON.parse(stdout) as Event; - - const frames = event.exception?.values?.[0].stacktrace?.frames || []; - const lastFrame = frames[frames.length - 1]; - - expect(lastFrame.function).toBe('Some.two'); - expect(lastFrame.vars).toEqual({ name: 'some name' }); - - const penultimateFrame = frames[frames.length - 2]; - - expect(penultimateFrame.function).toBe('one'); - expect(penultimateFrame.vars).toEqual({ - name: 'some name', - arr: [1, '2', null], - obj: { name: 'some name', num: 5 }, - ty: '', - }); - - done(); - }); + createRunner(__dirname, 'local-variables.js') + .ignore('session') + .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) + .start(done); }); test('Should include local variables with ESM', done => { - expect.assertions(4); - - const testScriptPath = path.resolve(__dirname, 'local-variables-caught.mjs'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const event = JSON.parse(stdout) as Event; - - const frames = event.exception?.values?.[0].stacktrace?.frames || []; - const lastFrame = frames[frames.length - 1]; - - expect(lastFrame.function).toBe('Some.two'); - expect(lastFrame.vars).toEqual({ name: 'some name' }); - - const penultimateFrame = frames[frames.length - 2]; - - expect(penultimateFrame.function).toBe('one'); - expect(penultimateFrame.vars).toEqual({ - name: 'some name', - arr: [1, '2', null], - obj: { name: 'some name', num: 5 }, - ty: '', - }); - - done(); - }); + createRunner(__dirname, 'local-variables-caught.mjs') + .ignore('session') + .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) + .start(done); }); test('Includes local variables for caught exceptions when enabled', done => { - expect.assertions(4); - - const testScriptPath = path.resolve(__dirname, 'local-variables-caught.js'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const event = JSON.parse(stdout) as Event; - - const frames = event.exception?.values?.[0].stacktrace?.frames || []; - const lastFrame = frames[frames.length - 1]; - - expect(lastFrame.function).toBe('Some.two'); - expect(lastFrame.vars).toEqual({ name: 'some name' }); - - const penultimateFrame = frames[frames.length - 2]; - - expect(penultimateFrame.function).toBe('one'); - expect(penultimateFrame.vars).toEqual({ - name: 'some name', - arr: [1, '2', null], - obj: { name: 'some name', num: 5 }, - ty: '', - }); - - done(); - }); + createRunner(__dirname, 'local-variables-caught.js') + .ignore('session') + .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) + .start(done); }); test('Should not leak memory', done => { diff --git a/dev-packages/node-integration-tests/tsconfig.json b/dev-packages/node-integration-tests/tsconfig.json index 782d8f9c517f..92db70d5ca09 100644 --- a/dev-packages/node-integration-tests/tsconfig.json +++ b/dev-packages/node-integration-tests/tsconfig.json @@ -1,11 +1,11 @@ { "extends": "../../tsconfig.json", - "include": ["utils/**/*.ts"], + "include": ["utils/**/*.ts", "src/**/*.ts"], "compilerOptions": { // package-specific options "esModuleInterop": true, - "types": ["node"] + "types": ["node", "jest"] } } diff --git a/dev-packages/node-integration-tests/tsconfig.types.json b/dev-packages/node-integration-tests/tsconfig.types.json new file mode 100644 index 000000000000..65455f66bd75 --- /dev/null +++ b/dev-packages/node-integration-tests/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts new file mode 100644 index 000000000000..2b89c9deb46f --- /dev/null +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -0,0 +1,226 @@ +import { spawn } from 'child_process'; +import { join } from 'path'; +import type { Envelope, EnvelopeItemType, Event, SerializedSession } from '@sentry/types'; +import axios from 'axios'; + +export function assertSentryEvent(actual: Event, expected: Event): void { + expect(actual).toMatchObject({ + event_id: expect.any(String), + ...expected, + }); +} + +export function assertSentrySession(actual: SerializedSession, expected: Partial): void { + expect(actual).toMatchObject({ + sid: expect.any(String), + ...expected, + }); +} + +export function assertSentryTransaction(actual: Event, expected: Partial): void { + expect(actual).toMatchObject({ + event_id: expect.any(String), + timestamp: expect.anything(), + start_timestamp: expect.anything(), + spans: expect.any(Array), + type: 'transaction', + ...expected, + }); +} + +type Expected = + | { + event: Partial | ((event: Event) => void); + } + | { + transaction: Partial | ((event: Event) => void); + } + | { + session: Partial | ((event: SerializedSession) => void); + }; + +/** */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createRunner(...paths: string[]) { + const testPath = join(...paths); + + const expectedEnvelopes: Expected[] = []; + const flags: string[] = []; + const ignored: EnvelopeItemType[] = []; + let hasExited = false; + + if (testPath.endsWith('.ts')) { + flags.push('-r', 'ts-node/register'); + } + + return { + expect: function (expected: Expected) { + expectedEnvelopes.push(expected); + return this; + }, + withFlags: function (...args: string[]) { + flags.push(...args); + return this; + }, + ignore: function (...types: EnvelopeItemType[]) { + ignored.push(...types); + return this; + }, + start: function (done?: (e?: unknown) => void) { + const expectedEnvelopeCount = expectedEnvelopes.length; + let envelopeCount = 0; + let serverPort: number | undefined; + + const child = spawn('node', [...flags, testPath]); + + child.on('close', () => { + hasExited = true; + }); + + // Pass error to done to end the test quickly + child.on('error', e => { + done?.(e); + }); + + async function waitForServerPort(timeout = 10_000): Promise { + let remaining = timeout; + while (serverPort === undefined) { + await new Promise(resolve => setTimeout(resolve, 100)); + remaining -= 100; + if (remaining < 0) { + throw new Error('Timed out waiting for server port'); + } + } + } + + /** Called after each expect callback to check if we're complete */ + function expectCallbackCalled(): void { + envelopeCount++; + if (envelopeCount === expectedEnvelopeCount) { + child.kill(); + done?.(); + } + } + + function tryParseLine(line: string): void { + // Lines can have leading '[something] [{' which we need to remove + const cleanedLine = line.replace(/^.*?] \[{"/, '[{"'); + + // See if we have a port message + if (cleanedLine.startsWith('{"port":')) { + const { port } = JSON.parse(cleanedLine) as { port: number }; + serverPort = port; + return; + } + + // Skip any lines that don't start with envelope JSON + if (!cleanedLine.startsWith('[{')) { + return; + } + + let envelope: Envelope | undefined; + try { + envelope = JSON.parse(cleanedLine) as Envelope; + } catch (_) { + return; + } + + for (const item of envelope[1]) { + const envelopeItemType = item[0].type; + + if (ignored.includes(envelopeItemType)) { + continue; + } + + const expected = expectedEnvelopes.shift(); + + // Catch any error or failed assertions and pass them to done to end the test quickly + try { + if (!expected) { + throw new Error(`No more expected envelope items but we received a '${envelopeItemType}' item`); + } + + const expectedType = Object.keys(expected)[0]; + + if (expectedType !== envelopeItemType) { + throw new Error(`Expected envelope item type '${expectedType}' but got '${envelopeItemType}'`); + } + + if ('event' in expected) { + const event = item[1] as Event; + if (typeof expected.event === 'function') { + expected.event(event); + } else { + assertSentryEvent(event, expected.event); + } + + expectCallbackCalled(); + } + + if ('transaction' in expected) { + const event = item[1] as Event; + if (typeof expected.transaction === 'function') { + expected.transaction(event); + } else { + assertSentryTransaction(event, expected.transaction); + } + + expectCallbackCalled(); + } + + if ('session' in expected) { + const session = item[1] as SerializedSession; + if (typeof expected.session === 'function') { + expected.session(session); + } else { + assertSentrySession(session, expected.session); + } + + expectCallbackCalled(); + } + } catch (e) { + done?.(e); + } + } + } + + let buffer = Buffer.alloc(0); + child.stdout.on('data', (data: Buffer) => { + // This is horribly memory inefficient but it's only for tests + buffer = Buffer.concat([buffer, data]); + + let splitIndex = -1; + while ((splitIndex = buffer.indexOf(0xa)) >= 0) { + const line = buffer.subarray(0, splitIndex).toString(); + buffer = Buffer.from(buffer.subarray(splitIndex + 1)); + tryParseLine(line); + } + }); + + return { + childHasExited: function (): boolean { + return hasExited; + }, + makeRequest: async function ( + method: 'get' | 'post', + path: string, + headers: Record = {}, + ): Promise { + try { + await waitForServerPort(); + + const url = `http://localhost:${serverPort}${path}`; + if (method === 'get') { + return (await axios.get(url, { headers })).data; + } else { + return (await axios.post(url, { headers })).data; + } + } catch (e) { + done?.(e); + return undefined; + } + }, + }; + }, + }; +} diff --git a/packages/node/src/integrations/anr/worker.ts b/packages/node/src/integrations/anr/worker.ts index 4f9278b5b78f..2e23f823891c 100644 --- a/packages/node/src/integrations/anr/worker.ts +++ b/packages/node/src/integrations/anr/worker.ts @@ -48,9 +48,10 @@ async function sendAbnormalSession(): Promise { log('Sending abnormal session'); updateSession(session, { status: 'abnormal', abnormal_mechanism: 'anr_foreground' }); - log(JSON.stringify(session)); - const envelope = createSessionEnvelope(session, options.dsn, options.sdkMetadata); + // Log the envelope so to aid in testing + log(JSON.stringify(envelope)); + await transport.send(envelope); try { @@ -119,9 +120,10 @@ async function sendAnrEvent(frames?: StackFrame[], traceContext?: TraceContext): tags: options.staticTags, }; - log(JSON.stringify(event)); - const envelope = createEventEnvelope(event, options.dsn, options.sdkMetadata); + // Log the envelope so to aid in testing + log(JSON.stringify(envelope)); + await transport.send(envelope); await transport.flush(2000);