Skip to content

Commit 4ff9de7

Browse files
committed
feat(deno): instrument Deno.serve with async context support
1 parent 5bd3a79 commit 4ff9de7

File tree

6 files changed

+374
-1
lines changed

6 files changed

+374
-1
lines changed

packages/deno/src/async.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Need to use node: prefix for deno compatibility
2+
import { AsyncLocalStorage } from 'node:async_hooks';
3+
import type { Scope } from '@sentry/core';
4+
import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core';
5+
6+
/**
7+
* Sets the async context strategy to use AsyncLocalStorage.
8+
*
9+
* @internal Only exported to be used in higher-level Sentry packages
10+
* @hidden Only exported to be used in higher-level Sentry packages
11+
*/
12+
export function setAsyncLocalStorageAsyncContextStrategy(): void {
13+
const asyncStorage = new AsyncLocalStorage<{
14+
scope: Scope;
15+
isolationScope: Scope;
16+
}>();
17+
18+
function getScopes(): { scope: Scope; isolationScope: Scope } {
19+
const scopes = asyncStorage.getStore();
20+
21+
if (scopes) {
22+
return scopes;
23+
}
24+
25+
// fallback behavior:
26+
// if, for whatever reason, we can't find scopes on the context here, we have to fix this somehow
27+
return {
28+
scope: getDefaultCurrentScope(),
29+
isolationScope: getDefaultIsolationScope(),
30+
};
31+
}
32+
33+
function withScope<T>(callback: (scope: Scope) => T): T {
34+
const scope = getScopes().scope.clone();
35+
const isolationScope = getScopes().isolationScope;
36+
return asyncStorage.run({ scope, isolationScope }, () => {
37+
return callback(scope);
38+
});
39+
}
40+
41+
function withSetScope<T>(scope: Scope, callback: (scope: Scope) => T): T {
42+
const isolationScope = getScopes().isolationScope.clone();
43+
return asyncStorage.run({ scope, isolationScope }, () => {
44+
return callback(scope);
45+
});
46+
}
47+
48+
function withIsolationScope<T>(callback: (isolationScope: Scope) => T): T {
49+
const scope = getScopes().scope;
50+
const isolationScope = getScopes().isolationScope.clone();
51+
return asyncStorage.run({ scope, isolationScope }, () => {
52+
return callback(isolationScope);
53+
});
54+
}
55+
56+
function withSetIsolationScope<T>(isolationScope: Scope, callback: (isolationScope: Scope) => T): T {
57+
const scope = getScopes().scope;
58+
return asyncStorage.run({ scope, isolationScope }, () => {
59+
return callback(isolationScope);
60+
});
61+
}
62+
63+
// In contrast to the browser, we can rely on async context isolation here
64+
function suppressTracing<T>(callback: () => T): T {
65+
return withScope(scope => {
66+
scope.setSDKProcessingMetadata({ __SENTRY_SUPPRESS_TRACING__: true });
67+
return callback();
68+
});
69+
}
70+
71+
setAsyncContextStrategy({
72+
suppressTracing,
73+
withScope,
74+
withSetScope,
75+
withIsolationScope,
76+
withSetIsolationScope,
77+
getCurrentScope: () => getScopes().scope,
78+
getIsolationScope: () => getScopes().isolationScope,
79+
});
80+
}
81+
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { defineIntegration, getClient } from '@sentry/core';
2+
import type { IntegrationFn } from '@sentry/core';
3+
import { setAsyncLocalStorageAsyncContextStrategy } from '../async';
4+
import { DenoOptions } from '../types';
5+
import { wrapDenoRequestHandler } from '../wrap-deno-request-handler';
6+
7+
export type RequestHandlerWrapperOptions<Addr extends Deno.Addr> = {
8+
request: Request;
9+
options: DenoOptions;
10+
info: Deno.ServeHandlerInfo<Addr>;
11+
serveOptions?: Deno.ServeOptions<Addr>;
12+
};
13+
14+
const INTEGRATION_NAME = 'DenoServe';
15+
16+
export type ServeParams =
17+
// [(Request) => Response]
18+
| [Deno.ServeHandler<Deno.NetAddr>]
19+
// [{ options }, (Request) => Response]
20+
| [Deno.ServeUnixOptions, Deno.ServeHandler<Deno.UnixAddr>]
21+
| [Deno.ServeTcpOptions | (Deno.ServeTcpOptions & Deno.TlsCertifiedKeyPem), Deno.ServeHandler<Deno.NetAddr>]
22+
// [{ handler: (Request) => Response }]
23+
| [Deno.ServeUnixOptions & Deno.ServeInit<Deno.UnixAddr>]
24+
| [(Deno.ServeTcpOptions | (Deno.ServeTcpOptions & Deno.TlsCertifiedKeyPem)) & Deno.ServeInit<Deno.NetAddr>];
25+
26+
const isServeOptWithFunction = (
27+
p: ServeParams,
28+
): p is
29+
| [Deno.ServeUnixOptions, Deno.ServeHandler<Deno.UnixAddr>]
30+
| [Deno.ServeTcpOptions | (Deno.ServeTcpOptions & Deno.TlsCertifiedKeyPem), Deno.ServeHandler<Deno.NetAddr>] =>
31+
p.length >= 2 && typeof p[1] === 'function' && !!p[0] && typeof p[0] === 'object';
32+
33+
const isServeInitOptions = (
34+
p: ServeParams,
35+
): p is
36+
| [Deno.ServeUnixOptions & Deno.ServeInit<Deno.UnixAddr>]
37+
| [(Deno.ServeTcpOptions | (Deno.ServeTcpOptions & Deno.TlsCertifiedKeyPem)) & Deno.ServeInit<Deno.NetAddr>] =>
38+
typeof p[0] === 'object' &&
39+
!!p[0] &&
40+
!isServeOptWithFunction(p) &&
41+
'handler' in p[0] &&
42+
typeof p[0].handler === 'function';
43+
44+
const isSimpleServeHandler = (p: ServeParams): p is [Deno.ServeHandler<Deno.NetAddr>] => typeof p[0] === 'function';
45+
46+
const isServeUnixHandler = (p: ServeParams): p is [Deno.ServeUnixOptions, Deno.ServeHandler<Deno.UnixAddr>] =>
47+
isServeOptWithFunction(p) && 'path' in p[0] && typeof p[0].path === 'string';
48+
49+
const isServeTcpHandler = (
50+
p: ServeParams,
51+
): p is [Deno.ServeTcpOptions | (Deno.ServeTcpOptions & Deno.TlsCertifiedKeyPem), Deno.ServeHandler<Deno.NetAddr>] =>
52+
isServeOptWithFunction(p) && (!('path' in p[0]) || typeof p[0].path !== 'string');
53+
54+
const isServeUnixInit = (p: ServeParams): p is [Deno.ServeUnixOptions & Deno.ServeInit<Deno.UnixAddr>] =>
55+
isServeInitOptions(p) && 'path' in p[0] && typeof p[0].path === 'string';
56+
57+
const isServeTcpInit = (
58+
p: ServeParams,
59+
): p is [(Deno.ServeTcpOptions | (Deno.ServeTcpOptions & Deno.TlsCertifiedKeyPem)) & Deno.ServeInit<Deno.NetAddr>] =>
60+
isServeInitOptions(p) && (!('path' in p[0]) || typeof p[0].path !== 'string');
61+
62+
63+
const applyHandlerWrap =
64+
<A extends Deno.Addr>(
65+
handler: Deno.ServeHandler<A>,
66+
options: DenoOptions,
67+
serveOptions?: Deno.ServeOptions<A>,
68+
): Deno.ServeHandler<A> =>
69+
async (request, info) =>
70+
await wrapDenoRequestHandler<A>(
71+
{
72+
options,
73+
request,
74+
info,
75+
serveOptions,
76+
},
77+
() => handler(request, info),
78+
);
79+
80+
const _denoServeIntegration = (() => {
81+
return {
82+
name: INTEGRATION_NAME,
83+
setupOnce() {
84+
setAsyncLocalStorageAsyncContextStrategy();
85+
Deno.serve = new Proxy(Deno.serve, {
86+
apply(target, thisArg, args: ServeParams) {
87+
const options: DenoOptions = getClient()?.getOptions() ?? {};
88+
if (isSimpleServeHandler(args)) {
89+
args[0] = applyHandlerWrap(args[0], options);
90+
} else if (isServeUnixHandler(args)) {
91+
args[1] = applyHandlerWrap(args[1], options, args[0]);
92+
} else if (isServeTcpHandler(args)) {
93+
args[1] = applyHandlerWrap(args[1], options, args[0]);
94+
} else if (isServeUnixInit(args)) {
95+
args[0].handler = applyHandlerWrap(args[0].handler, options, args[0]);
96+
} else if (isServeTcpInit(args)) {
97+
args[0].handler = applyHandlerWrap(args[0].handler, options, args[0]);
98+
}
99+
// if none of those matched, it'll crash, most likely.
100+
return target.apply(thisArg, args);
101+
},
102+
});
103+
},
104+
};
105+
}) satisfies IntegrationFn;
106+
107+
export const denoServeIntegration = defineIntegration(_denoServeIntegration);

packages/deno/src/sdk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { DenoClient } from './client';
1414
import { breadcrumbsIntegration } from './integrations/breadcrumbs';
1515
import { denoContextIntegration } from './integrations/context';
16+
import { denoServeIntegration } from './integrations/deno-serve';
1617
import { contextLinesIntegration } from './integrations/contextlines';
1718
import { globalHandlersIntegration } from './integrations/globalhandlers';
1819
import { normalizePathsIntegration } from './integrations/normalizepaths';
@@ -34,6 +35,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] {
3435
// Deno Specific
3536
breadcrumbsIntegration(),
3637
denoContextIntegration(),
38+
denoServeIntegration(),
3739
contextLinesIntegration(),
3840
normalizePathsIntegration(),
3941
globalHandlersIntegration(),

packages/deno/src/transports/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function makeFetchTransport(options: BaseTransportOptions): Transport {
1414
consoleSandbox(() => {
1515
// eslint-disable-next-line no-console
1616
console.warn(`Sentry SDK requires 'net' permission to send events.
17-
Run with '--allow-net=${url.host}' to grant the requires permissions.`);
17+
Run with '--allow-net=${url.host}' to grant the required permissions.`);
1818
});
1919
}
2020
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export type StreamingGuess = {
2+
isStreaming: boolean;
3+
};
4+
5+
/**
6+
* Classifies a Response as streaming or non-streaming.
7+
*
8+
* Heuristics:
9+
* - No body → not streaming
10+
* - Known streaming Content-Types → streaming (SSE, NDJSON, JSON streaming)
11+
* - text/plain without Content-Length → streaming (some AI APIs)
12+
* - Otherwise → not streaming (conservative default, including HTML/SSR)
13+
*
14+
* We avoid probing the stream to prevent blocking on transform streams (like injectTraceMetaTags)
15+
* or SSR streams that may not have data ready immediately.
16+
*/
17+
export function classifyResponseStreaming(res: Response): StreamingGuess {
18+
if (!res.body) {
19+
return { isStreaming: false };
20+
}
21+
22+
const contentType = res.headers.get('content-type') ?? '';
23+
const contentLength = res.headers.get('content-length');
24+
25+
// Streaming: Known streaming content types
26+
// - text/event-stream: Server-Sent Events (Vercel AI SDK, real-time APIs)
27+
// - application/x-ndjson, application/ndjson: Newline-delimited JSON
28+
// - application/stream+json: JSON streaming
29+
// - text/plain (without Content-Length): Some AI APIs use this for streaming text
30+
if (
31+
/^text\/event-stream\b/i.test(contentType) ||
32+
/^application\/(x-)?ndjson\b/i.test(contentType) ||
33+
/^application\/stream\+json\b/i.test(contentType) ||
34+
(/^text\/plain\b/i.test(contentType) && !contentLength)
35+
) {
36+
return { isStreaming: true };
37+
}
38+
39+
// Default: treat as non-streaming
40+
return { isStreaming: false };
41+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
continueTrace,
3+
withIsolationScope,
4+
startSpanManual,
5+
parseStringToURLObject,
6+
getHttpSpanDetailsFromUrlObject,
7+
httpHeadersToSpanAttributes,
8+
winterCGHeadersToDict,
9+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
10+
winterCGRequestToRequestData,
11+
captureException,
12+
setHttpStatus,
13+
} from '@sentry/core';
14+
import { init } from './sdk';
15+
import { DenoOptions } from './types';
16+
import { classifyResponseStreaming } from './utils/streaming';
17+
18+
export type RequestHandlerWrapperOptions<Addr extends Deno.Addr> = {
19+
request: Request;
20+
options: DenoOptions;
21+
info: Deno.ServeHandlerInfo<Addr>;
22+
serveOptions?: Deno.ServeOptions<Addr>;
23+
};
24+
25+
const assignIfSet = <T extends Record<string, unknown>, K extends keyof T>(
26+
obj: T,
27+
key: K,
28+
value: T[K] | undefined | null,
29+
) => {
30+
if (value !== undefined && value !== null) obj[key] = value;
31+
}
32+
33+
export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
34+
wrapperOptions: RequestHandlerWrapperOptions<Addr>,
35+
handler: () => Promise<Response> | Response,
36+
): Response | Promise<Response> => {
37+
return withIsolationScope(async isolationScope => {
38+
const { request, options, info } = wrapperOptions;
39+
40+
const client = init(options);
41+
isolationScope.setClient(client);
42+
43+
if (request.method === 'OPTIONS' || request.method === 'HEAD') {
44+
try {
45+
return await handler();
46+
} catch (e) {
47+
captureException(e, {
48+
mechanism: { handled: false, type: 'auto.http.deno' },
49+
});
50+
throw e;
51+
}
52+
}
53+
54+
const urlObject = parseStringToURLObject(request.url);
55+
const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'auto.http.deno', request);
56+
57+
assignIfSet(attributes, 'http.request.body.size', request.headers.get('content-length'));
58+
assignIfSet(attributes, 'user_agent.original', request.headers.get('user-agent'));
59+
60+
const sendDefaultPii = client.getOptions().sendDefaultPii ?? false;
61+
if (sendDefaultPii) {
62+
assignIfSet(
63+
attributes,
64+
'client.address',
65+
(info?.remoteAddr as Deno.NetAddr)?.hostname ?? (info?.remoteAddr as Deno.UnixAddr)?.path,
66+
);
67+
assignIfSet(attributes, 'client.port', (info?.remoteAddr as Deno.NetAddr)?.port);
68+
}
69+
70+
Object.assign(
71+
attributes,
72+
httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), client.getOptions().sendDefaultPii ?? false),
73+
);
74+
attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server';
75+
isolationScope.setSDKProcessingMetadata({
76+
normalizedRequest: winterCGRequestToRequestData(request),
77+
});
78+
79+
return continueTrace(
80+
{
81+
sentryTrace: request.headers.get('sentry-trace') || '',
82+
baggage: request.headers.get('baggage'),
83+
},
84+
() => {
85+
return startSpanManual({ name, attributes }, async span => {
86+
let res: Response;
87+
88+
try {
89+
res = await handler();
90+
setHttpStatus(span, res.status);
91+
} catch (e) {
92+
span.end();
93+
captureException(e, {
94+
mechanism: { handled: false, type: 'auto.http.deno' },
95+
});
96+
throw e;
97+
}
98+
// Classify response to detect actual streaming
99+
const classification = classifyResponseStreaming(res);
100+
if (classification.isStreaming && res.body) {
101+
// Streaming response detected - monitor consumption to keep span alive
102+
try {
103+
const [clientStream, monitorStream] = res.body.tee();
104+
105+
// Monitor stream consumption and end span when complete
106+
await (async () => {
107+
const reader = monitorStream.getReader();
108+
try {
109+
let done = false;
110+
while (!done) {
111+
const result = await reader.read();
112+
done = result.done;
113+
}
114+
} catch {
115+
// Stream error or cancellation - will end span in finally
116+
} finally {
117+
reader.releaseLock();
118+
span.end();
119+
}
120+
})();
121+
122+
// Return response with client stream
123+
return new Response(clientStream, {
124+
status: res.status,
125+
statusText: res.statusText,
126+
headers: res.headers,
127+
});
128+
} catch (e) {
129+
// tee() failed (e.g stream already locked) - fall back to non-streaming handling
130+
span.end();
131+
return res;
132+
}
133+
}
134+
135+
// Non-streaming response - end span immediately and return original
136+
span.end();
137+
return res;
138+
});
139+
},
140+
);
141+
});
142+
}

0 commit comments

Comments
 (0)