Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8318537
feat: added h3 channel
logaretm Dec 31, 2025
658ddcf
feat: added srvx channel and enhanced attr collection
logaretm Dec 31, 2025
77272c2
fix: ensure runtime plugins are present in the dist
logaretm Jan 27, 2026
12af50c
fix: tracing channel name
logaretm Feb 5, 2026
d03a640
ref: use only one global flag for channel installation
logaretm Feb 6, 2026
90d55a9
feat: handle http errors
logaretm Feb 9, 2026
c97d244
feat: added http status code handling
logaretm Feb 9, 2026
0b015c9
feat: force enable tracing for the user
logaretm Feb 9, 2026
b2c8644
fix: tracing config may have changed
logaretm Feb 9, 2026
7316cdb
fix: correctly enable the config
logaretm Feb 9, 2026
2ccb551
fix: configure externals correctly
logaretm Feb 9, 2026
4ce39e6
test: added e2e tests
logaretm Feb 9, 2026
5ddb5d8
test: test isolation scope
logaretm Feb 9, 2026
932c623
feat: added server timing headers
logaretm Feb 9, 2026
6b8ec96
feat: use vite mode for better test coverage
logaretm Feb 9, 2026
7b3d849
fix: update channel names and always end the spans
logaretm Feb 9, 2026
90a2273
feat: ensure trace channel spans has correct origins
logaretm Feb 9, 2026
3a0f66f
test: add middleware error test
logaretm Feb 9, 2026
d59e69e
feat: route parameterization
logaretm Feb 9, 2026
07726e5
fix: set headers before they get frozen
logaretm Feb 12, 2026
26ea2be
fix: update config name
logaretm Apr 15, 2026
8d44437
chore: pin versions properly
logaretm Apr 15, 2026
214e8f5
chore: add canary entry
logaretm Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ jobs:
- test-application: 'nestjs-microservices'
build-command: 'test:build-latest'
label: 'nestjs-microservices (latest)'
- test-application: 'nitro-3'
build-command: 'test:build-canary'
label: 'nitro-3 (canary)'

steps:
- name: Check out current commit
Expand Down
2 changes: 2 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/.npmrc
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
11 changes: 11 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/index.html
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,
});
29 changes: 29 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/package.json
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",
Copy link

Copilot AI Apr 15, 2026

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-canary for this app, but this package.json only defines test:build (no test:build-canary). This will cause the canary CI job for nitro-3 to fail. Add a test:build-canary script (can alias to test:build if no special canary steps are needed) or update the workflow entry to match the existing script name.

Suggested change
"test:build": "pnpm install && pnpm build",
"test:build": "pnpm install && pnpm build",
"test:build-canary": "pnpm install && pnpm build",

Copilot uses AI. Check for mistakes.
"test:assert": "pnpm test"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test:build-canary script in nitro-3 package.json

Medium Severity

The canary workflow references build-command: 'test:build-canary' for the nitro-3 test application, but the package.json only defines test:build and test:assert scripts — no test:build-canary. Every other test application listed in the canary workflow with test:build-canary has it defined in their package.json (e.g., nextjs-14, angular-21). The canary CI job for nitro-3 will fail.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 214e8f5. Configure here.

},
"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');
}
});
10 changes: 10 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts
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="');
});
14 changes: 14 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json
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"]
}
15 changes: 15 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/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',
}),
),
],
});
8 changes: 5 additions & 3 deletions packages/nitro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@
"access": "public"
},
"peerDependencies": {
"nitro": ">=3.0.260311-beta"
"nitro": ">=3.0.260415-beta"
},
"dependencies": {
"@sentry/core": "10.48.0",
"@sentry/node": "10.48.0"
"@sentry/node": "10.48.0",
"otel-tracing-channel": "^0.2.0"
},
"devDependencies": {
"nitro": "^3.0.260311-beta"
"nitro": "^3.0.260415-beta",
"h3": "^2.0.1-rc.13"
},
"scripts": {
"build": "run-p build:transpile build:types",
Expand Down
Loading
Loading