Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '521 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
7 changes: 7 additions & 0 deletions dev-packages/bun-integration-tests/node-suites/excludes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const NO_HTTP_SERVER_SPANS = [
'suites/tracing/httpServerSpans-unrouted/test.ts',
'suites/tracing/ignoreSpans/**',
'suites/tracing/managed-tunnel/test.ts',
'suites/tunnel/hardening/test.ts',
'suites/tracing/meta-tags-twp-errors/test.ts',
'suites/tracing/meta-tags/test.ts',
'suites/tracing/requestData/test.ts',
Expand Down Expand Up @@ -193,7 +194,13 @@ export const NODE_SUITES_EXCLUDE = ['**/node_modules/**', ...NODE_ONLY, ...NO_AU
export const SENTRY_NODE_EXCLUDE = [...NO_HTTP_SERVER_SPANS, ...NO_FETCH_INSTRUMENTATION];

// Excluded only in the `node-suites-sentry-bun` project, which maps `@sentry/node` to `@sentry/bun`.
// Bun answers nothing on a connection whose first request awaits anything while a second one is
// queued behind it, which the pipelined cases need. `packages/bun` runs the same cases against Bun's
// servers without those.
const BUN_DROPS_PIPELINED_REQUESTS = ['suites/tunnel/hardening/test.ts'];

export const SENTRY_BUN_EXCLUDE = [
...BUN_DROPS_PIPELINED_REQUESTS,
...HTTP_SERVER_OPTIONS_IGNORED,
...NO_EXPRESS_INSTRUMENTATION,
...NO_NATIVE_NODE_FETCH_INTEGRATION,
Expand Down
5 changes: 5 additions & 0 deletions dev-packages/cloudflare-integration-tests/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ type StartResult = {
completed(): Promise<void>;
/** Every non-ignored envelope received so far, matched or not, for count assertions. */
getReceivedEnvelopes(): Envelope[];
/** The port wrangler serves the worker on, once it is ready. */
getPort(): Promise<number>;
makeRequest<T>(
method: 'get' | 'post',
path: string,
Expand Down Expand Up @@ -558,6 +560,9 @@ export function createRunner(...paths: string[]) {
getReceivedEnvelopes: function (): Envelope[] {
return receivedEnvelopes;
},
getPort: function (): Promise<number> {
return workerPortPromise;
},
makeRequest: async function <T>(
method: 'get' | 'post',
path: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core';
import {
CLIENT_ADDRESS,
SENTRY_SEGMENT_NAME_SOURCE,
SENTRY_SDK_NAME,
SENTRY_SDK_VERSION,
Expand Down Expand Up @@ -205,6 +206,15 @@ it('sends a streamed span envelope with correct spans for a manually started spa
type: 'string',
value: 'localhost',
},
// From the `CF-Connecting-IP` header that `wrangler dev` sets.
[CLIENT_ADDRESS]: {
type: 'string',
value: '127.0.0.1',
},
'http.response.header.content-type': {
type: 'array',
value: ['text/plain;charset=UTF-8'],
},
'url.full': {
type: 'string',
value: expect.stringMatching(/^http:\/\/localhost:.+$/),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@ import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudfl
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1,
// A meta-framework answers the tunnel requests, the Worker only drops their server spans.
integrations: [serveTunnelIntegration()],
}));
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ function expectServerSpanAt(path: string) {
};
}

it('drops the server span of managed tunnel requests', async ({ signal }) => {
it('answers managed tunnel requests before the app, without a server span', async ({ signal }) => {
// Envelopes are expected in order, so a span of the tunnel request would arrive before the last one.
const runner = createRunner(__dirname)
.expect(expectServerSpanAt('/tunnel-path'))
.expect(expectServerSpanAt('/after-tunnel'))
.start(signal);

const tunnelPath = await runner.makeRequest<string>('get', '/tunnel-path');
expect(await runner.makeRequest<string>('post', tunnelPath!, { data: 'envelope' })).toBe('app');
// The tunnel rejects the body as an envelope. The app would have answered with a 200.
await runner.makeRequest('post', tunnelPath!, { data: 'envelope', expectError: true });
await runner.makeRequest('get', '/after-tunnel');

await runner.completed();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import type { Event } from '@sentry/core';
import { HTTP_SERVER } from '@sentry/conventions/op';
import { expect, it } from 'vitest';
import { createRunner } from '../../../../runner';
import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils';

it('does not capture spans emitted through @opentelemetry/api inside wrapRequestHandler from the /request subpath', async ({
signal,
}) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const event = envelope[1]?.[0]?.[1] as Event;
expect(event.transaction).toBe('GET /');
expect(event.contexts?.trace?.op).toBe('http.server');
expect(event.spans).toEqual([]);
// Only the request's segment span. The OpenTelemetry span is not captured.
const spans = getSpansFromEnvelope(envelope);
expect(spans).toHaveLength(1);
expect(spans[0]?.is_segment).toBe(true);
expect(spans[0]?.name).toBe('GET /');
expect(getSpanOp(spans[0]!)).toBe(HTTP_SERVER);
})
.start(signal);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
ALLOWED_DSNS: string;
TUNNEL_PATH: string;
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
integrations: [Sentry.serveTunnelIntegration({ allowedDsns: env.ALLOWED_DSNS.split(',') })],
}),
{
async fetch(request, env) {
const { pathname } = new URL(request.url);
if (pathname === '/health') {
return new Response('ok');
}
if (pathname === '/done') {
Sentry.captureMessage('done');
return new Response('done');
}
// The app's own handler on the tunnel path, which the runtime answers before it.
if (pathname === env.TUNNEL_PATH) {
return new Response('app tunnel handler', { status: 418 });
}

return new Response('not found', { status: 404 });
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { getTunnelPath } from '@sentry/core';
import type { FakeIngests } from '@sentry-internal/test-utils';
import { EXPECTED_TUNNEL_OUTCOMES, runTunnelHardeningCases, startFakeIngests } from '@sentry-internal/test-utils';
import { afterAll, beforeAll, expect, it } from 'vitest';
import { createRunner } from '../../runner';

// `wrangler dev` stalls the next request after any worker answers before reading a large body, Sentry or not.
const WRANGLER_DEV_STALLS = ['70KB header line', 'binary garbage', 'rejected tunnel with a big unread body, then app'];

let ingests: FakeIngests;

beforeAll(async () => {
ingests = await startFakeIngests();
});

afterAll(async () => {
await ingests.close();
});

it('keeps the worker serving whatever is thrown at the tunnel, and reports nothing of its own', async ({ signal }) => {
const runner = createRunner(__dirname)
.withWranglerArgs(
'--var',
`ALLOWED_DSNS:${ingests.allDsns().join(',')}`,
'--var',
`TUNNEL_PATH:${getTunnelPath(ingests.dsn('ok'))}`,
)
// The first event is the app's own, so the tunnel captured no error along the way.
.expect(envelope => {
expect(envelope[1][0]?.[1]).toMatchObject({ message: 'done' });
})
.start(signal);
await runner.makeRequest('get', '/health');

const results = await runTunnelHardeningCases({
port: await runner.getPort(),
ingests,
getTunnelPath,
skip: WRANGLER_DEV_STALLS,
});

expect(results.filter(result => result.health !== '200')).toEqual([]);
expect(Object.fromEntries(results.map(result => [result.name, result.outcome]))).toMatchObject(
Object.fromEntries(
Object.entries(EXPECTED_TUNNEL_OUTCOMES).filter(([name]) => !WRANGLER_DEV_STALLS.includes(name)),
),
);

await runner.makeRequest('get', '/done');
await runner.completed();
}, 180_000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "worker-name",
"compatibility_date": "2025-06-17",
"main": "index.ts",
"compatibility_flags": ["nodejs_compat"],
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.

const DSN = 'https://username@domain/123';

Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => {
Deno.test('Deno.serve answers managed tunnel requests before the app, without a server span', async () => {
resetGlobals();
const sink = spanSink();

init({
dsn: DSN,
tracesSampleRate: 1,
// A meta-framework answers the tunnel requests, Deno only drops their server spans.
integrations: [serveTunnelIntegration()],
transport: sink.transport,
});
Expand All @@ -33,7 +32,9 @@ Deno.test('Deno.serve drops the server span of managed tunnel requests', async (
'segment span for /after-tunnel',
);

await (await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' })).text();
const tunnelResponse = await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' });
// Answered by the tunnel, which rejects the body as an envelope, not by the app.
assertEquals(await tunnelResponse.text(), 'Invalid envelope');
await (await fetch(`${baseUrl}/after-tunnel`)).text();

await flush();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const NODE_EXPORTS_IGNORE = [
'initWithoutDefaultIntegrations',
// Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration)
'_INTERNAL_normalizeCollectionInterval',
// Lambda helpers only needed by `@sentry/aws-serverless`
'_INTERNAL_answerLambdaTunnelEvent',
'_INTERNAL_answerStreamingLambdaTunnelEvent',
'_INTERNAL_parseLambdaHandlerString',
'_INTERNAL_redirectLambdaHandler',
'_INTERNAL_resolveLambdaHandlerFile',
// not exported by bun
'nativeNodeFetchIntegration',
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,6 @@ test('Sends client-side errors through the configured tunnel route', async ({ pa
expect(errorEvent.transaction).toBe(`${basePath}/`);
});

test('Rejects non-POST requests to the managed tunnel route instead of rendering the app', async ({ request }) => {
test.skip(tunnelRouteMode !== 'static', 'Requires a known managed tunnel path');

const response = await request.get('/monitor');

expect(response.status()).toBe(405);
expect(response.headers()['allow']).toBe('POST');
});

function pathnameMatchesTunnelRoute(pathname: string): boolean {
return typeof expectedTunnelPathMatcher === 'string'
? pathname === expectedTunnelPathMatcher
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
// A meta-framework answers the tunnel requests, Node only drops their server spans.
integrations: [Sentry.serveTunnelIntegration()],
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('managed tunnel', () => {
});

createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
test('drops the server span of tunnel requests', async () => {
test('answers tunnel requests before the app, without a server span', async () => {
const runner = createRunner()
.expect({
span: container => {
Expand All @@ -20,7 +20,10 @@ describe('managed tunnel', () => {
})
.start();

await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!);
// The tunnel rejects the empty body. The app would have answered with a 200.
await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!, {
expectError: true,
});
await runner.makeRequest('get', '/after-tunnel');

await runner.completed();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
integrations: [Sentry.serveTunnelIntegration({ allowedDsns: process.env.ALLOWED_DSNS.split(',') })],
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as Sentry from '@sentry/node';
import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
import express from 'express';

const app = express();

// Consumes the body of every request, which the tunnel must not be affected by.
app.use(express.json());

// The app's own handler on the tunnel path, which the runtime answers before it.
app.post(process.env.TUNNEL_PATH, (_req, res) => {
res.status(418).send('app tunnel handler');
});

app.get('/health', (_req, res) => {
res.send('ok');
});

app.get('/done', (_req, res) => {
Sentry.captureMessage('done');
res.send('done');
});

startExpressServerAndSendPortToRunner(app);
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { getTunnelPath } from '@sentry/core';
import type { FakeIngests } from '@sentry-internal/test-utils';
import { EXPECTED_TUNNEL_OUTCOMES, runTunnelHardeningCases, startFakeIngests } from '@sentry-internal/test-utils';
import { afterAll, beforeAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

describe('managed tunnel hardening', () => {
let ingests: FakeIngests;

beforeAll(async () => {
ingests = await startFakeIngests();
});

afterAll(async () => {
cleanupChildProcesses();
await ingests.close();
});

createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => {
test('keeps the app serving whatever is thrown at the tunnel, and reports nothing of its own', async () => {
const runner = createRunner()
.withEnv({ ALLOWED_DSNS: ingests.allDsns().join(','), TUNNEL_PATH: getTunnelPath(ingests.dsn('ok'))! })
// The first event is the app's own, so the tunnel captured no error along the way.
.expect({ event: { message: 'done' } })
.start();
await runner.makeRequest('get', '/health');

const results = await runTunnelHardeningCases({ port: runner.getPort()!, ingests, getTunnelPath });

expect(results.filter(result => result.health !== '200')).toEqual([]);
expect(Object.fromEntries(results.map(result => [result.name, result.outcome]))).toMatchObject(
EXPECTED_TUNNEL_OUTCOMES,
);

await runner.makeRequest('get', '/done');
await runner.completed();
}, 120_000);
});
});
2 changes: 2 additions & 0 deletions dev-packages/test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export {
} from './event-proxy-server';
export type { SerializedStreamedSpan } from '@sentry/core';
export { isServerSpanAt, makeClientReportEnvelope, watchForTunnelSpan } from './tunnel';
export { EXPECTED_TUNNEL_OUTCOMES, runTunnelHardeningCases, startFakeIngests } from './tunnel-hardening';
export type { FakeIngests, TunnelHardeningResult } from './tunnel-hardening';

export {
findAbsolutePathImports,
Expand Down
Loading
Loading