-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(nitro): Instrument HTTP Server #19225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: awad/create-nitro-sdk-metadata
Are you sure you want to change the base?
Changes from all commits
8318537
658ddcf
77272c2
12af50c
d03a640
90d55a9
c97d244
0b015c9
b2c8644
7316cdb
2ccb551
4ce39e6
5ddb5d8
932c623
6b8ec96
7b3d849
90a2273
3a0f66f
d59e69e
07726e5
26ea2be
8d44437
214e8f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| @sentry:registry=http://127.0.0.1:4873 | ||
| @sentry-internal:registry=http://127.0.0.1:4873 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <title>Nitro E2E Test</title> | ||
| </head> | ||
| <body> | ||
| <h1>Nitro E2E Test App</h1> | ||
| <script type="module" src="/src/main.ts"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import * as Sentry from '@sentry/nitro'; | ||
|
|
||
| Sentry.init({ | ||
| environment: 'qa', // dynamic sampling bias to keep transactions | ||
| dsn: process.env.E2E_TEST_DSN, | ||
| tunnel: `http://localhost:3031/`, // proxy server | ||
| tracesSampleRate: 1, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| { | ||
| "name": "nitro-3", | ||
| "version": "1.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "vite build", | ||
| "start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' node .output/server/index.mjs", | ||
| "clean": "npx rimraf node_modules pnpm-lock.yaml .output", | ||
| "test": "playwright test", | ||
| "test:build": "pnpm install && pnpm build", | ||
| "test:assert": "pnpm test" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing
|
||
| }, | ||
| "dependencies": { | ||
| "@sentry/browser": "latest || *", | ||
| "@sentry/nitro": "latest || *" | ||
| }, | ||
| "devDependencies": { | ||
| "@playwright/test": "~1.56.0", | ||
| "@sentry-internal/test-utils": "link:../../../test-utils", | ||
| "@sentry/core": "latest || *", | ||
| "nitro": "^3.0.260415-beta", | ||
| "rolldown": "latest", | ||
| "vite": "latest" | ||
| }, | ||
| "volta": { | ||
| "extends": "../../package.json" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { getPlaywrightConfig } from '@sentry-internal/test-utils'; | ||
|
|
||
| const config = getPlaywrightConfig({ | ||
| startCommand: `pnpm start`, | ||
| }); | ||
|
|
||
| export default config; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { defineHandler } from 'nitro/h3'; | ||
|
|
||
| export default defineHandler(() => { | ||
| return { status: 'ok' }; | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { defineHandler } from 'nitro/h3'; | ||
|
|
||
| export default defineHandler(() => { | ||
| throw new Error('This is a test error'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { getDefaultIsolationScope, setTag } from '@sentry/core'; | ||
| import { defineHandler } from 'nitro/h3'; | ||
|
|
||
| export default defineHandler(() => { | ||
| setTag('my-isolated-tag', true); | ||
| // Check if the tag leaked into the default (global) isolation scope | ||
| setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); | ||
|
|
||
| throw new Error('Isolation test error'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { defineHandler } from 'nitro/h3'; | ||
|
|
||
| export default defineHandler(event => { | ||
| const id = event.req.url; | ||
| return { id }; | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { defineHandler } from 'nitro/h3'; | ||
|
|
||
| export default defineHandler(() => { | ||
| return { status: 'ok', transaction: true }; | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; | ||
|
|
||
| export default defineHandler(event => { | ||
| setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); | ||
|
|
||
| const query = getQuery(event); | ||
| if (query['middleware-error'] === '1') { | ||
| throw new Error('Middleware error'); | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import * as Sentry from '@sentry/browser'; | ||
|
|
||
| // Let's us test trace propagation | ||
| Sentry.init({ | ||
| environment: 'qa', | ||
| dsn: 'https://public@dsn.ingest.sentry.io/1337', | ||
| tunnel: 'http://localhost:3031/', // proxy server | ||
| integrations: [Sentry.browserTracingIntegration()], | ||
| tracesSampleRate: 1.0, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { startEventProxyServer } from '@sentry-internal/test-utils'; | ||
|
|
||
| startEventProxyServer({ | ||
| port: 3031, | ||
| proxyServerName: 'nitro-3', | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForError } from '@sentry-internal/test-utils'; | ||
|
|
||
| test('Sends an error event to Sentry', async ({ request }) => { | ||
| const errorEventPromise = waitForError('nitro-3', event => { | ||
| return !event.type && !!event.exception?.values?.some(v => v.value === 'This is a test error'); | ||
| }); | ||
|
|
||
| await request.get('/api/test-error'); | ||
|
|
||
| const errorEvent = await errorEventPromise; | ||
|
|
||
| // Nitro wraps thrown errors in an HTTPError with .cause, producing a chained exception | ||
| expect(errorEvent.exception?.values).toHaveLength(2); | ||
|
|
||
| // The innermost exception (values[0]) is the original thrown error | ||
| expect(errorEvent.exception?.values?.[0]?.type).toBe('Error'); | ||
| expect(errorEvent.exception?.values?.[0]?.value).toBe('This is a test error'); | ||
| expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( | ||
| expect.objectContaining({ | ||
| handled: false, | ||
| type: 'auto.function.nitro', | ||
| }), | ||
| ); | ||
|
|
||
| // The outermost exception (values[1]) is the HTTPError wrapper | ||
| expect(errorEvent.exception?.values?.[1]?.type).toBe('HTTPError'); | ||
| expect(errorEvent.exception?.values?.[1]?.value).toBe('This is a test error'); | ||
| }); | ||
|
|
||
| test('Does not send 404 errors to Sentry', async ({ request }) => { | ||
| let errorReceived = false; | ||
|
|
||
| void waitForError('nitro-3', event => { | ||
| if (!event.type) { | ||
| errorReceived = true; | ||
| return true; | ||
| } | ||
| return false; | ||
| }); | ||
|
|
||
| await request.get('/api/non-existent-route'); | ||
|
|
||
| expect(errorReceived).toBe(false); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; | ||
|
|
||
| test('Isolation scope prevents tag leaking between requests', async ({ request }) => { | ||
| const transactionEventPromise = waitForTransaction('nitro-3', event => { | ||
| return event?.transaction === 'GET /api/test-isolation/:id'; | ||
| }); | ||
|
|
||
| const errorPromise = waitForError('nitro-3', event => { | ||
| return !event.type && !!event.exception?.values?.some(v => v.value === 'Isolation test error'); | ||
| }); | ||
|
|
||
| await request.get('/api/test-isolation/1').catch(() => { | ||
| // noop - route throws | ||
| }); | ||
|
|
||
| const transactionEvent = await transactionEventPromise; | ||
| const error = await errorPromise; | ||
|
|
||
| // Assert that isolation scope works properly | ||
| expect(error.tags?.['my-isolated-tag']).toBe(true); | ||
| expect(error.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); | ||
| expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true); | ||
| expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; | ||
|
|
||
| test('Creates middleware spans for requests', async ({ request }) => { | ||
| const transactionEventPromise = waitForTransaction('nitro-3', event => { | ||
| return event?.transaction === 'GET /api/test-transaction'; | ||
| }); | ||
|
|
||
| const response = await request.get('/api/test-transaction'); | ||
|
|
||
| expect(response.headers()['x-sentry-test-middleware']).toBe('executed'); | ||
|
|
||
| const transactionEvent = await transactionEventPromise; | ||
|
|
||
| // h3 middleware spans have origin auto.http.nitro.h3 and op middleware.nitro | ||
| const h3MiddlewareSpans = transactionEvent.spans?.filter( | ||
| span => span.origin === 'auto.http.nitro.h3' && span.op === 'middleware.nitro', | ||
| ); | ||
| expect(h3MiddlewareSpans?.length).toBeGreaterThanOrEqual(1); | ||
| }); | ||
|
|
||
| test('Captures errors thrown in middleware with error status on span', async ({ request }) => { | ||
| const errorEventPromise = waitForError('nitro-3', event => { | ||
| return !event.type && !!event.exception?.values?.some(v => v.value === 'Middleware error'); | ||
| }); | ||
|
|
||
| const transactionEventPromise = waitForTransaction('nitro-3', event => { | ||
| return event?.transaction === 'GET /api/test-transaction' && event?.contexts?.trace?.status === 'internal_error'; | ||
| }); | ||
|
|
||
| await request.get('/api/test-transaction?middleware-error=1'); | ||
|
|
||
| const errorEvent = await errorEventPromise; | ||
| expect(errorEvent.exception?.values?.some(v => v.value === 'Middleware error')).toBe(true); | ||
|
|
||
| const transactionEvent = await transactionEventPromise; | ||
|
|
||
| // The transaction span should have error status | ||
| expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForTransaction } from '@sentry-internal/test-utils'; | ||
|
|
||
| test('Propagates server trace to client pageload via Server-Timing headers', async ({ page }) => { | ||
| const clientTxnPromise = waitForTransaction('nitro-3', event => { | ||
| return event?.contexts?.trace?.op === 'pageload'; | ||
| }); | ||
|
|
||
| await page.goto('/'); | ||
|
|
||
| const clientTxn = await clientTxnPromise; | ||
|
|
||
| expect(clientTxn.contexts?.trace?.trace_id).toBeDefined(); | ||
| expect(clientTxn.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); | ||
| expect(clientTxn.contexts?.trace?.op).toBe('pageload'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForTransaction } from '@sentry-internal/test-utils'; | ||
|
|
||
| test('Sends a transaction event for a successful route', async ({ request }) => { | ||
| const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { | ||
| return transactionEvent?.transaction === 'GET /api/test-transaction'; | ||
| }); | ||
|
|
||
| await request.get('/api/test-transaction'); | ||
|
|
||
| const transactionEvent = await transactionEventPromise; | ||
|
|
||
| expect(transactionEvent).toEqual( | ||
| expect.objectContaining({ | ||
| transaction: 'GET /api/test-transaction', | ||
| type: 'transaction', | ||
| }), | ||
| ); | ||
|
|
||
| // srvx.request creates a span for the request | ||
| const srvxSpans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.srvx'); | ||
| expect(srvxSpans?.length).toBeGreaterThanOrEqual(1); | ||
|
|
||
| // h3 creates a child span for the route handler | ||
| const h3Spans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.h3'); | ||
| expect(h3Spans?.length).toBeGreaterThanOrEqual(1); | ||
| }); | ||
|
|
||
| test('Sets correct HTTP status code on transaction', async ({ request }) => { | ||
| const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { | ||
| return transactionEvent?.transaction === 'GET /api/test-transaction'; | ||
| }); | ||
|
|
||
| await request.get('/api/test-transaction'); | ||
|
|
||
| const transactionEvent = await transactionEventPromise; | ||
|
|
||
| expect(transactionEvent.contexts?.trace?.data).toEqual( | ||
| expect.objectContaining({ | ||
| 'http.response.status_code': 200, | ||
| }), | ||
| ); | ||
|
|
||
| expect(transactionEvent.contexts?.trace?.status).toBe('ok'); | ||
| }); | ||
|
|
||
| test('Uses parameterized route for transaction name', async ({ request }) => { | ||
| const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { | ||
| return transactionEvent?.transaction === 'GET /api/test-param/:id'; | ||
| }); | ||
|
|
||
| await request.get('/api/test-param/123'); | ||
|
|
||
| const transactionEvent = await transactionEventPromise; | ||
|
|
||
| expect(transactionEvent).toEqual( | ||
| expect.objectContaining({ | ||
| transaction: 'GET /api/test-param/:id', | ||
| transaction_info: expect.objectContaining({ source: 'route' }), | ||
| type: 'transaction', | ||
| }), | ||
| ); | ||
|
|
||
| expect(transactionEvent.contexts?.trace?.data).toEqual( | ||
| expect.objectContaining({ | ||
| 'http.route': '/api/test-param/:id', | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| test('Sets Server-Timing response headers for trace propagation', async ({ request }) => { | ||
| const response = await request.get('/api/test-transaction'); | ||
| const headers = response.headers(); | ||
|
|
||
| expect(headers['server-timing']).toBeDefined(); | ||
| expect(headers['server-timing']).toContain('sentry-trace;desc="'); | ||
| expect(headers['server-timing']).toContain('baggage;desc="'); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ESNext", | ||
| "module": "ESNext", | ||
| "moduleResolution": "bundler", | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "paths": { | ||
| "~/*": ["./*"] | ||
| } | ||
| }, | ||
| "include": ["src/**/*.ts", "routes/**/*.ts", "vite.config.ts"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { withSentryConfig } from '@sentry/nitro'; | ||
| import { nitro } from 'nitro/vite'; | ||
| import { defineConfig } from 'vite'; | ||
|
|
||
| export default defineConfig({ | ||
| plugins: [ | ||
| nitro( | ||
| // FIXME: Nitro plugin has a type issue | ||
| // @ts-expect-error | ||
| withSentryConfig({ | ||
| serverDir: './server', | ||
| }), | ||
| ), | ||
| ], | ||
| }); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The canary workflow matrix runs
yarn test:build-canaryfor this app, but this package.json only definestest:build(notest:build-canary). This will cause the canary CI job fornitro-3to fail. Add atest:build-canaryscript (can alias totest:buildif no special canary steps are needed) or update the workflow entry to match the existing script name.