Skip to content

Commit 83977fa

Browse files
committed
feat(deno): instrument node:http on versions that support it
This adds a default integration for the `node:http` module's diagnostics channels, on Deno versions that support it. Client is enabled in 2.7.13+, Server is instrumented in 2.8.0+. If either is available, the instrumentation is added by default. If neither is available, then the instrumentation no-ops and warns about being pointless. Test verifies that the current Deno version is handled correctly. close: JS-2031 close: #20059
1 parent bc5324b commit 83977fa

5 files changed

Lines changed: 338 additions & 0 deletions

File tree

packages/deno/src/denoVersion.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { parseSemver } from '@sentry/core';
2+
3+
export const DENO_VERSION = parseSemver(typeof Deno !== 'undefined' ? (Deno.version?.deno ?? '') : '') as {
4+
major: number | undefined;
5+
minor: number | undefined;
6+
patch: number | undefined;
7+
};
8+
9+
/** Exported for testing */
10+
function gte(major: number, minor: number, patch: number): boolean {
11+
const { major: M, minor: m, patch: p } = DENO_VERSION;
12+
if (M === undefined || m === undefined || p === undefined) return false;
13+
if (M !== major) return M > major;
14+
if (m !== minor) return m > minor;
15+
return p >= patch;
16+
}
17+
18+
/** Whether `http.client.request.created` fires (Deno 2.7.13+). */
19+
export const HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED = gte(2, 7, 13);
20+
21+
/** Whether `http.server.request.start` fires (Deno 2.8.0+). */
22+
export const HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED = gte(2, 8, 0);

packages/deno/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export { DenoClient } from './client';
104104

105105
export { getDefaultIntegrations, init } from './sdk';
106106
export { denoServeIntegration } from './integrations/deno-serve';
107+
export { denoHttpIntegration } from './integrations/http';
108+
export type { DenoHttpIntegrationOptions } from './integrations/http';
107109
export { denoContextIntegration } from './integrations/context';
108110
export { globalHandlersIntegration } from './integrations/globalhandlers';
109111
export { normalizePathsIntegration } from './integrations/normalizepaths';
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { subscribe } from 'node:diagnostics_channel';
2+
import { errorMonitor } from 'node:events';
3+
import type { ClientRequest, RequestOptions } from 'node:http';
4+
import type { Integration, IntegrationFn, Span } from '@sentry/core';
5+
import {
6+
debug,
7+
defineIntegration,
8+
getHttpClientSubscriptions,
9+
getHttpServerSubscriptions,
10+
getRequestOptions,
11+
HTTP_ON_CLIENT_REQUEST,
12+
HTTP_ON_SERVER_REQUEST,
13+
} from '@sentry/core';
14+
import { setAsyncLocalStorageAsyncContextStrategy } from '../async';
15+
import {
16+
DENO_VERSION,
17+
HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED,
18+
HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED,
19+
} from '../denoVersion';
20+
21+
const INTEGRATION_NAME = 'DenoHttp';
22+
23+
export interface DenoHttpIntegrationOptions {
24+
/**
25+
* Whether breadcrumbs should be recorded for outgoing requests.
26+
*
27+
* @default `true`
28+
*/
29+
breadcrumbs?: boolean;
30+
31+
/**
32+
* Whether to create spans for incoming and outgoing HTTP requests.
33+
* Defaults to the client's tracing configuration (`hasSpansEnabled`).
34+
*/
35+
spans?: boolean;
36+
37+
/**
38+
* Whether to inject trace propagation headers (sentry-trace, baggage) into outgoing HTTP requests.
39+
*
40+
* When set to `false`, Sentry will not inject any trace propagation headers, but will still create breadcrumbs
41+
* (if `breadcrumbs` is enabled).
42+
*
43+
* @default `true`
44+
*/
45+
tracePropagation?: boolean;
46+
47+
/**
48+
* Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.)
49+
* when creating server spans.
50+
*
51+
* @default `true`
52+
*/
53+
ignoreStaticAssets?: boolean;
54+
55+
/**
56+
* Controls the maximum size of incoming HTTP request bodies attached to events.
57+
*
58+
* @default 'medium'
59+
*/
60+
maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always';
61+
62+
/**
63+
* Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`.
64+
*/
65+
ignoreRequestBody?: (url: string, request: RequestOptions) => boolean;
66+
67+
/**
68+
* Do not capture server spans for incoming HTTP requests whose URL path makes the given callback return `true`.
69+
*/
70+
ignoreIncomingRequests?: (urlPath: string, request: RequestOptions) => boolean;
71+
72+
/**
73+
* Do not capture breadcrumbs, spans, or propagate trace headers for outgoing HTTP requests where the given callback returns `true`.
74+
*/
75+
ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean;
76+
77+
/**
78+
* Hook invoked after the server span is created but before the request is handled.
79+
*/
80+
onIncomingSpanCreated?: (span: Span, request: unknown, response: unknown) => void;
81+
82+
/**
83+
* Hook invoked when the server span ends, before it is recorded.
84+
*/
85+
onIncomingSpanEnd?: (span: Span, request: unknown, response: unknown) => void;
86+
}
87+
88+
const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => {
89+
const breadcrumbs = options.breadcrumbs ?? true;
90+
const tracePropagation = options.tracePropagation ?? true;
91+
92+
return {
93+
name: INTEGRATION_NAME,
94+
setupOnce() {
95+
const denoVersion = DENO_VERSION.major !== undefined ? `${Deno.version.deno}` : 'unknown';
96+
97+
// Below 2.7.13 neither channel fires. Warn and bail without touching the ACS.
98+
if (!HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED && !HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED) {
99+
debug.warn(
100+
`denoHttpIntegration requires Deno 2.7.13+ (client) or 2.8.0+ (server) for node:http diagnostics channels; running on Deno ${denoVersion}. The integration is a no-op on this version.`,
101+
);
102+
return;
103+
}
104+
105+
// Wire up Deno's AsyncLocalStorage-backed ACS so the server subscription's
106+
// `withIsolationScope(clone, ...)` actually activates the cloned scope.
107+
// Without this, request isolation and span creation degrade silently.
108+
setAsyncLocalStorageAsyncContextStrategy();
109+
110+
if (HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED) {
111+
const { [HTTP_ON_SERVER_REQUEST]: onHttpServerRequest } = getHttpServerSubscriptions({
112+
// `spans` falls through to the client's tracing config when unset.
113+
spans: options.spans,
114+
ignoreStaticAssets: options.ignoreStaticAssets,
115+
ignoreIncomingRequests: options.ignoreIncomingRequests
116+
? (urlPath, request) => options.ignoreIncomingRequests!(urlPath, request as unknown as RequestOptions)
117+
: undefined,
118+
maxRequestBodySize: options.maxRequestBodySize ?? 'medium',
119+
ignoreRequestBody: options.ignoreRequestBody
120+
? (url, request) => options.ignoreRequestBody!(url, request as unknown as RequestOptions)
121+
: undefined,
122+
onSpanCreated: options.onIncomingSpanCreated,
123+
onSpanEnd: options.onIncomingSpanEnd,
124+
errorMonitor,
125+
sessions: false,
126+
});
127+
subscribe(HTTP_ON_SERVER_REQUEST, onHttpServerRequest);
128+
} else {
129+
debug.log(
130+
`denoHttpIntegration: server-side instrumentation requires Deno 2.8.0+; running on Deno ${denoVersion}. Client-side instrumentation is still active.`,
131+
);
132+
}
133+
134+
if (HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED) {
135+
const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequest } = getHttpClientSubscriptions({
136+
spans: options.spans,
137+
breadcrumbs,
138+
propagateTrace: tracePropagation,
139+
ignoreOutgoingRequests: options.ignoreOutgoingRequests
140+
? (url, request) => options.ignoreOutgoingRequests!(url, getRequestOptions(request as ClientRequest))
141+
: undefined,
142+
// Deno doesn't run OTel's http instrumentation, so there's no
143+
// double-wrap to detect; skip the warning to avoid loading the module.
144+
suppressOtelWarning: true,
145+
errorMonitor,
146+
});
147+
subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequest);
148+
}
149+
},
150+
};
151+
}) satisfies IntegrationFn;
152+
153+
/**
154+
* Instruments incoming and outgoing HTTP requests handled via the `node:http` module in Deno.
155+
*
156+
* Listens on Deno's `node:diagnostics_channel` for `http.server.request.start` and
157+
* `http.client.request.created`, then routes them through Sentry core's portable subscription
158+
* helpers (`getHttpServerSubscriptions`, `getHttpClientSubscriptions`) to create root server
159+
* spans, instrument client requests, and propagate distributed trace headers.
160+
*
161+
* For Deno-native `Deno.serve(...)` instrumentation, see {@link denoServeIntegration}.
162+
*/
163+
export const denoHttpIntegration = defineIntegration(_denoHttpIntegration) as (
164+
options?: DenoHttpIntegrationOptions,
165+
) => Integration & { name: 'DenoHttp'; setupOnce: () => void };

packages/deno/src/sdk.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import { DenoClient } from './client';
1616
import { breadcrumbsIntegration } from './integrations/breadcrumbs';
1717
import { denoContextIntegration } from './integrations/context';
1818
import { contextLinesIntegration } from './integrations/contextlines';
19+
import { HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED } from './denoVersion';
1920
import { denoServeIntegration } from './integrations/deno-serve';
21+
import { denoHttpIntegration } from './integrations/http';
2022
import { globalHandlersIntegration } from './integrations/globalhandlers';
2123
import { normalizePathsIntegration } from './integrations/normalizepaths';
2224
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
@@ -39,6 +41,10 @@ export function getDefaultIntegrations(_options: Options): Integration[] {
3941
breadcrumbsIntegration(),
4042
denoContextIntegration(),
4143
denoServeIntegration(),
44+
// node:http client diagnostics channels fire on Deno 2.7.13+
45+
// server channels arrive at 2.8.0+
46+
// Include in defaults if at least one is available
47+
...(HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED ? [denoHttpIntegration()] : []),
4248
contextLinesIntegration(),
4349
normalizePathsIntegration(),
4450
globalHandlersIntegration(),
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// <reference lib="deno.ns" />
2+
3+
import * as http from 'node:http';
4+
import type { TransactionEvent } from '@sentry/core';
5+
import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts';
6+
import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts';
7+
import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts';
8+
import type { DenoClient } from '../build/esm/index.js';
9+
import { getCurrentScope, getGlobalScope, getIsolationScope, init, startSpan } from '../build/esm/index.js';
10+
import {
11+
DENO_VERSION,
12+
HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED,
13+
HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED,
14+
} from '../build/esm/denoVersion.js';
15+
16+
function resetGlobals(): void {
17+
getCurrentScope().clear();
18+
getCurrentScope().setClient(undefined);
19+
getIsolationScope().clear();
20+
getGlobalScope().clear();
21+
}
22+
23+
function delay(ms: number): Promise<void> {
24+
return new Promise(resolve => setTimeout(resolve, ms));
25+
}
26+
27+
// Activation test — runs unconditionally so the default-inclusion gate itself
28+
// is verified on every Deno version. The skip conditions on the behavioral
29+
// tests below cover the runtime-feature absence; this one covers the gate.
30+
Deno.test('denoHttpIntegration: included in default integrations when client diagnostics channel is supported', () => {
31+
resetGlobals();
32+
const client = init({ dsn: 'https://username@domain/123' }) as DenoClient;
33+
const names = client.getOptions().integrations.map(i => i.name);
34+
35+
if (HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED) {
36+
assert(
37+
names.includes('DenoHttp'),
38+
`DenoHttp should be a default integration on Deno ${DENO_VERSION.major}.${DENO_VERSION.minor}.${DENO_VERSION.patch}, got ${names.join(', ')}`,
39+
);
40+
} else {
41+
assert(!names.includes('DenoHttp'), `DenoHttp should NOT be in defaults on Deno < 2.7.13, got ${names.join(', ')}`);
42+
}
43+
});
44+
45+
Deno.test({
46+
name: 'denoHttpIntegration: node:http incoming request creates an http.server transaction',
47+
ignore: !HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED,
48+
async fn() {
49+
resetGlobals();
50+
const transactions: TransactionEvent[] = [];
51+
init({
52+
dsn: 'https://username@domain/123',
53+
tracesSampleRate: 1,
54+
beforeSendTransaction: (event: TransactionEvent) => {
55+
transactions.push(event);
56+
return null;
57+
},
58+
});
59+
60+
const server = http.createServer((_req, res) => {
61+
res.end('ok');
62+
});
63+
const port: number = await new Promise(resolve => {
64+
server.listen(0, '127.0.0.1', () => {
65+
resolve((server.address() as { port: number }).port);
66+
});
67+
});
68+
69+
const response = await fetch(`http://127.0.0.1:${port}/users/42?x=1`);
70+
assertEquals(await response.text(), 'ok');
71+
72+
// Server-side `response.once('close')` fires shortly after the response
73+
// is sent. Give it a tick so the span ends before we tear down.
74+
await delay(50);
75+
await new Promise<void>(resolve => server.close(() => resolve()));
76+
77+
assertEquals(transactions.length, 1, `expected 1 transaction, got ${transactions.length}`);
78+
const txn = transactions[0]!;
79+
assertEquals(txn.contexts?.trace?.op, 'http.server');
80+
assertEquals(txn.transaction, 'GET /users/42');
81+
assertEquals(txn.contexts?.trace?.data?.['http.method'], 'GET');
82+
assertEquals(txn.contexts?.trace?.data?.['http.response.status_code'], 200);
83+
},
84+
});
85+
86+
Deno.test({
87+
name: 'denoHttpIntegration: node:http outgoing request creates a child http.client span',
88+
ignore: !HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED,
89+
async fn() {
90+
resetGlobals();
91+
const transactions: TransactionEvent[] = [];
92+
init({
93+
dsn: 'https://username@domain/123',
94+
tracesSampleRate: 1,
95+
beforeSendTransaction: (event: TransactionEvent) => {
96+
transactions.push(event);
97+
return null;
98+
},
99+
});
100+
101+
// Use Deno.serve for the target so the test does not depend on the
102+
// node:http server side (which only works on Deno 2.8.0+).
103+
const abortController = new AbortController();
104+
let onListen: ((_: unknown) => void) | undefined;
105+
const listening = new Promise(resolve => (onListen = resolve));
106+
const target = Deno.serve(
107+
{ port: 0, signal: abortController.signal, onListen, hostname: '127.0.0.1' },
108+
() => new Response('pong'),
109+
);
110+
await listening;
111+
const targetPort = target.addr.port;
112+
113+
// Make the outgoing node:http request inside an explicit parent span so
114+
// the http.client child span has somewhere to attach and the resulting
115+
// transaction is captured.
116+
await startSpan({ name: 'parent', op: 'test' }, async () => {
117+
await new Promise<void>((resolve, reject) => {
118+
const req = http.request({ host: '127.0.0.1', port: targetPort, path: '/ping', method: 'GET' }, res => {
119+
res.on('data', () => {});
120+
res.on('end', () => resolve());
121+
res.on('error', reject);
122+
});
123+
req.on('error', reject);
124+
req.end();
125+
});
126+
});
127+
128+
abortController.abort();
129+
await target.finished;
130+
131+
// Two transactions are possible: one for the parent span we created, and
132+
// one http.server transaction from Deno.serve. Find the parent.
133+
const parent = transactions.find(t => t.transaction === 'parent');
134+
assertExists(parent, `expected a 'parent' transaction, got: ${transactions.map(t => t.transaction).join(', ')}`);
135+
const httpClientSpan = parent!.spans?.find(s => s.op === 'http.client');
136+
assertExists(
137+
httpClientSpan,
138+
`expected an http.client child span, got ops: ${parent!.spans?.map(s => s.op).join(', ')}`,
139+
);
140+
assertEquals(httpClientSpan!.data?.['http.method'], 'GET');
141+
assertEquals(httpClientSpan!.data?.['http.response.status_code'], 200);
142+
},
143+
});

0 commit comments

Comments
 (0)