Repository navigation
feat(core): Add the primitives to answer managed tunnel requests in the runtimes #25997
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
Changes from all commits
ae3cbec
bc33cba
6435b16
3fd8179
0fdfaf1
c92ecd2
a672b62
8180949
a63290a
cc48a54
2d0875b
46d84af
9c67136
f92c9ac
3d4645d
bf844da
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,30 @@ | ||
| import { getTunnelPath } from '@sentry/core'; | ||
| import { serveTunnelIntegration } from '@sentry/core/server'; | ||
| import { wrapRequestHandler } from '@sentry/cloudflare/request'; | ||
|
|
||
| interface Env { | ||
| SENTRY_DSN: string; | ||
| } | ||
|
|
||
| export default { | ||
| async fetch(request, env, ctx) { | ||
| return wrapRequestHandler( | ||
| { | ||
| options: { | ||
| dsn: env.SENTRY_DSN, | ||
| // The app answers the tunnel requests itself. | ||
| integrations: [serveTunnelIntegration({ intercept: false })], | ||
| }, | ||
| request, | ||
| context: ctx, | ||
| }, | ||
| () => { | ||
| if (new URL(request.url).pathname === '/tunnel-path') { | ||
| return new Response(getTunnelPath(env.SENTRY_DSN)); | ||
| } | ||
|
|
||
| throw new Error('The app failed to answer a tunnel request'); | ||
| }, | ||
| ); | ||
| }, | ||
| } satisfies ExportedHandler<Env>; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import type { Event } from '@sentry/core'; | ||
| import { expect, it } from 'vitest'; | ||
| import { createRunner } from '../../../runner'; | ||
|
|
||
| it('captures errors the app throws while it answers tunnel requests, with their own request data', async ({ | ||
| signal, | ||
| }) => { | ||
| let tunnelPath: string | undefined; | ||
| const runner = createRunner(__dirname) | ||
| .expect(envelope => { | ||
| const event = envelope[1]?.[0]?.[1] as Event; | ||
| expect(event.exception?.values?.[0]?.value).toBe('The app failed to answer a tunnel request'); | ||
| // Without `AsyncLocalStorage`, the previous request's data would still be on the shared isolation scope. | ||
| expect(event.request?.method).toBe('POST'); | ||
| expect(new URL(event.request!.url!).pathname).toBe(tunnelPath); | ||
| }) | ||
| .start(signal); | ||
|
|
||
| tunnelPath = await runner.makeRequest<string>('get', '/tunnel-path'); | ||
| await runner.makeRequest('post', tunnelPath!, { data: 'envelope', expectError: true }); | ||
|
|
||
| await runner.completed(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "name": "managed-tunnel-app-errors", | ||
| "compatibility_date": "2025-06-17", | ||
| "main": "index.ts", | ||
| // No `nodejs_compat`, so no `AsyncLocalStorage`: all requests share one isolation scope. | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
| import { loggingTransport } from '@sentry-internal/node-integration-tests'; | ||
|
|
||
| Sentry.init({ | ||
| // The test's fake ingest, so the tunnel forwards the browser's envelopes to it. | ||
| dsn: `http://public@localhost:${process.env.INGEST_PORT}/1337`, | ||
| release: '1.0', | ||
| integrations: [Sentry.serveTunnelIntegration({ intercept: false })], | ||
| transport: loggingTransport, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| 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', | ||
| tracesSampleRate: 1.0, | ||
| integrations: [Sentry.serveTunnelIntegration({ intercept: false })], | ||
| transport: loggingTransport, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
| import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; | ||
| import http from 'http'; | ||
|
|
||
| const server = http.createServer((request, response) => { | ||
| if (!Sentry.handleTunnelRequestIfMatched(request, response)) { | ||
| response.end('app'); | ||
| } | ||
| }); | ||
|
|
||
| server.listen(0, () => { | ||
| sendPortToRunner(server.address().port); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
| import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; | ||
| import http from 'http'; | ||
|
|
||
| const server = http.createServer((request, response) => { | ||
| // Middleware that tags the request's user, run here on the tunnel request the app answers itself. | ||
| if (request.method === 'POST') { | ||
| Sentry.setUser({ id: 'tunnel-user' }); | ||
| response.end('app'); | ||
| return; | ||
| } | ||
|
|
||
| Sentry.captureException(new Error('after tunnel')); | ||
| response.end('app'); | ||
| }); | ||
|
|
||
| server.listen(0, () => { | ||
| sendPortToRunner(server.address().port); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import type { HttpIncomingMessage, HttpInstrumentationOptions, HttpServer, HttpS | |
| import { DEBUG_BUILD } from '../../debug-build'; | ||
| import { debug } from '../../utils/debug-logger'; | ||
| import { safeCallback } from '../../utils/safeCallback'; | ||
| import { isServedTunnelRequest } from '../../utils/tunnel'; | ||
| import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } from '../../currentScopes'; | ||
| import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; | ||
| import { | ||
|
|
@@ -132,6 +133,15 @@ export function instrumentServer(options: HttpInstrumentationOptions, server: Ht | |
| return target.apply(thisArg, args); | ||
| } | ||
|
|
||
| // Tunnel requests record no session or span. The app may still answer them (`intercept: false`), so they keep | ||
| // their own isolation scope. | ||
| if (isServedTunnelRequest(request)) { | ||
| const tunnelIsolationScope = getIsolationScope().clone(); | ||
| tunnelIsolationScope.setClient(client); | ||
|
|
||
| return withIsolationScope(tunnelIsolationScope, () => target.apply(thisArg, args)); | ||
| } | ||
|
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. Tunnel errors omit request dataMedium Severity The Node Reviewed by Cursor Bugbot for commit bf844da. Configure here. |
||
|
|
||
| DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Handling incoming request'); | ||
| const isolationScope = getIsolationScope().clone(); | ||
| isolationScope.setClient(client); | ||
|
|
||


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.
Tunnel match ignores known client
Low Severity
This is more an "is this necessary" check than a hard violation:
isServedTunnelRequest(request)callsgetClient()internally even thoughclientis already in hand.wrapServerRequestHandlerin this same PR passes that client through.Triggered by project rule: PR Review Guidelines for Cursor Bot
Reviewed by Cursor Bugbot for commit bf844da. Configure here.