From ae650f077d2939251af327598177bbf210ee617f Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 19 May 2026 08:09:14 -0700 Subject: [PATCH] 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 --- packages/deno/src/denoVersion.ts | 22 +++ packages/deno/src/index.ts | 2 + packages/deno/src/integrations/http.ts | 170 +++++++++++++++++++++ packages/deno/src/sdk.ts | 8 + packages/deno/test/deno-http.test.ts | 198 +++++++++++++++++++++++++ 5 files changed, 400 insertions(+) create mode 100644 packages/deno/src/denoVersion.ts create mode 100644 packages/deno/src/integrations/http.ts create mode 100644 packages/deno/test/deno-http.test.ts diff --git a/packages/deno/src/denoVersion.ts b/packages/deno/src/denoVersion.ts new file mode 100644 index 000000000000..8d2f0d1790a7 --- /dev/null +++ b/packages/deno/src/denoVersion.ts @@ -0,0 +1,22 @@ +import { parseSemver } from '@sentry/core'; + +export const DENO_VERSION = parseSemver(typeof Deno !== 'undefined' ? (Deno.version?.deno ?? '') : '') as { + major: number | undefined; + minor: number | undefined; + patch: number | undefined; +}; + +/** Exported for testing */ +function gte(major: number, minor: number, patch: number): boolean { + const { major: M, minor: m, patch: p } = DENO_VERSION; + if (M === undefined || m === undefined || p === undefined) return false; + if (M !== major) return M > major; + if (m !== minor) return m > minor; + return p >= patch; +} + +/** Whether `http.client.request.created` fires (Deno 2.7.13+). */ +export const HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED = gte(2, 7, 13); + +/** Whether `http.server.request.start` fires (Deno 2.8.0+). */ +export const HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED = gte(2, 8, 0); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 424aad03d3d3..d06f05f8e4f1 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -104,6 +104,8 @@ export { DenoClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; +export { denoHttpIntegration } from './integrations/http'; +export type { DenoHttpIntegrationOptions } from './integrations/http'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; export { normalizePathsIntegration } from './integrations/normalizepaths'; diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts new file mode 100644 index 000000000000..d09f0f3ba356 --- /dev/null +++ b/packages/deno/src/integrations/http.ts @@ -0,0 +1,170 @@ +import { subscribe } from 'node:diagnostics_channel'; +import { errorMonitor } from 'node:events'; +import type { ClientRequest, RequestOptions } from 'node:http'; +import type { HttpIncomingMessage, Integration, IntegrationFn, Span } from '@sentry/core'; +import { + debug, + defineIntegration, + getHttpClientSubscriptions, + getHttpServerSubscriptions, + getRequestOptions, + HTTP_ON_CLIENT_REQUEST, + HTTP_ON_SERVER_REQUEST, +} from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; +import { + DENO_VERSION, + HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, + HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED, +} from '../denoVersion'; + +const INTEGRATION_NAME = 'DenoHttp'; + +export interface DenoHttpIntegrationOptions { + /** + * Whether breadcrumbs should be recorded for outgoing requests. + * + * @default `true` + */ + breadcrumbs?: boolean; + + /** + * Whether to create spans for incoming and outgoing HTTP requests. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * Whether to inject trace propagation headers (sentry-trace, baggage) into outgoing HTTP requests. + * + * When set to `false`, Sentry will not inject any trace propagation headers, but will still create breadcrumbs + * (if `breadcrumbs` is enabled). + * + * @default `true` + */ + tracePropagation?: boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating server spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * @default 'medium' + */ + maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * + * The `request` parameter is the incoming `node:http` {@link IncomingMessage} — use `request.url`, + * `request.method`, `request.headers`, etc. + */ + ignoreRequestBody?: (url: string, request: HttpIncomingMessage) => boolean; + + /** + * Do not capture server spans for incoming HTTP requests whose URL path makes the given callback return `true`. + * + * The `request` parameter is the incoming `node:http` {@link IncomingMessage} — use `request.url`, + * `request.method`, `request.headers`, etc. + */ + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; + + /** + * Do not capture breadcrumbs, spans, or propagate trace headers for outgoing HTTP requests where the given callback returns `true`. + * + * The `request` parameter is the outgoing {@link RequestOptions} — use `request.hostname`, `request.path`, + * `request.method`, `request.headers`, etc. + */ + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + + /** + * Hook invoked after the server span is created but before the request is handled. + */ + onIncomingSpanCreated?: (span: Span, request: unknown, response: unknown) => void; + + /** + * Hook invoked when the server span ends, before it is recorded. + */ + onIncomingSpanEnd?: (span: Span, request: unknown, response: unknown) => void; +} + +const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => { + const breadcrumbs = options.breadcrumbs ?? true; + const tracePropagation = options.tracePropagation ?? true; + + return { + name: INTEGRATION_NAME, + setupOnce() { + const denoVersion = DENO_VERSION.major !== undefined ? `${Deno.version.deno}` : 'unknown'; + + // Below 2.7.13 neither channel fires. Warn and bail without touching the ACS. + if (!HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED && !HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED) { + debug.warn( + `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.`, + ); + return; + } + + // Wire up Deno's AsyncLocalStorage-backed ACS so the server subscription's + // `withIsolationScope(clone, ...)` actually activates the cloned scope. + // Without this, request isolation and span creation degrade silently. + setAsyncLocalStorageAsyncContextStrategy(); + + if (HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED) { + const { [HTTP_ON_SERVER_REQUEST]: onHttpServerRequest } = getHttpServerSubscriptions({ + // `spans` falls through to the client's tracing config when unset. + spans: options.spans, + ignoreStaticAssets: options.ignoreStaticAssets, + ignoreIncomingRequests: options.ignoreIncomingRequests, + maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + ignoreRequestBody: options.ignoreRequestBody, + onSpanCreated: options.onIncomingSpanCreated, + onSpanEnd: options.onIncomingSpanEnd, + errorMonitor, + sessions: false, + }); + subscribe(HTTP_ON_SERVER_REQUEST, onHttpServerRequest); + } else { + debug.log( + `denoHttpIntegration: server-side instrumentation requires Deno 2.8.0+; running on Deno ${denoVersion}. Client-side instrumentation is still active.`, + ); + } + + if (HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED) { + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequest } = getHttpClientSubscriptions({ + spans: options.spans, + breadcrumbs, + propagateTrace: tracePropagation, + ignoreOutgoingRequests: options.ignoreOutgoingRequests + ? (url, request) => options.ignoreOutgoingRequests!(url, getRequestOptions(request as ClientRequest)) + : undefined, + // Deno doesn't run OTel's http instrumentation, so there's no + // double-wrap to detect; skip the warning to avoid loading the module. + suppressOtelWarning: true, + errorMonitor, + }); + subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequest); + } + }, + }; +}) satisfies IntegrationFn; + +/** + * Instruments incoming and outgoing HTTP requests handled via the `node:http` module in Deno. + * + * Listens on Deno's `node:diagnostics_channel` for `http.server.request.start` and + * `http.client.request.created`, then routes them through Sentry core's portable subscription + * helpers (`getHttpServerSubscriptions`, `getHttpClientSubscriptions`) to create root server + * spans, instrument client requests, and propagate distributed trace headers. + * + * For Deno-native `Deno.serve(...)` instrumentation, see {@link denoServeIntegration}. + */ +export const denoHttpIntegration = defineIntegration(_denoHttpIntegration) as ( + options?: DenoHttpIntegrationOptions, +) => Integration & { name: 'DenoHttp'; setupOnce: () => void }; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 177c2e91234d..62e7dc6a2e70 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -16,7 +16,9 @@ import { DenoClient } from './client'; import { breadcrumbsIntegration } from './integrations/breadcrumbs'; import { denoContextIntegration } from './integrations/context'; import { contextLinesIntegration } from './integrations/contextlines'; +import { HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED } from './denoVersion'; import { denoServeIntegration } from './integrations/deno-serve'; +import { denoHttpIntegration } from './integrations/http'; import { globalHandlersIntegration } from './integrations/globalhandlers'; import { normalizePathsIntegration } from './integrations/normalizepaths'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; @@ -39,6 +41,12 @@ export function getDefaultIntegrations(_options: Options): Integration[] { breadcrumbsIntegration(), denoContextIntegration(), denoServeIntegration(), + // node:http client diagnostics channels fire on Deno 2.7.13+ + // server channels arrive at 2.8.0+ + // Include in defaults if at least one is available + ...(HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED || HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED + ? [denoHttpIntegration()] + : []), contextLinesIntegration(), normalizePathsIntegration(), globalHandlersIntegration(), diff --git a/packages/deno/test/deno-http.test.ts b/packages/deno/test/deno-http.test.ts new file mode 100644 index 000000000000..a67be1fc5d71 --- /dev/null +++ b/packages/deno/test/deno-http.test.ts @@ -0,0 +1,198 @@ +// + +import * as http from 'node:http'; +import type { TransactionEvent } from '@sentry/core'; +import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; +import type { DenoClient } from '../build/esm/index.js'; +import { getCurrentScope, getGlobalScope, getIsolationScope, init, startSpan } from '../build/esm/index.js'; +import { + DENO_VERSION, + HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, + HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED, +} from '../build/esm/denoVersion.js'; + +function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); +} + +/** + * `beforeSendTransaction` hook plus a `waitFor(predicate)` helper + * resolves when a matching transaction arrives (or has already arrived) + */ +function transactionSink(): { + transactions: TransactionEvent[]; + beforeSendTransaction: (event: TransactionEvent) => null; + waitFor: (predicate: (event: TransactionEvent) => boolean) => Promise; +} { + const transactions: TransactionEvent[] = []; + const waiters: { predicate: (e: TransactionEvent) => boolean; resolve: (e: TransactionEvent) => void }[] = []; + return { + transactions, + beforeSendTransaction(event) { + transactions.push(event); + for (let i = waiters.length - 1; i >= 0; i--) { + const w = waiters[i]!; + if (w.predicate(event)) { + waiters.splice(i, 1); + w.resolve(event); + } + } + return null; + }, + waitFor(predicate) { + const already = transactions.find(predicate); + if (already) return Promise.resolve(already); + return new Promise(resolve => { + waiters.push({ predicate, resolve }); + }); + }, + }; +} + +// Bind a promise so a real "never arrives" bug fails the test. +function withTimeout(p: Promise, ms: number, what: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${what} after ${ms}ms`)), ms); + }); + // Clear the timer on either resolution so Deno's leak detector is happy. + return Promise.race([p, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +// Activation gate — split into two skip-mirrored tests so each run exercises +// exactly one assertion. CI on a supported Deno verifies inclusion; CI on an +// unsupported Deno verifies exclusion. +Deno.test({ + name: 'denoHttpIntegration: included in default integrations on Deno >= 2.7.13', + ignore: !HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, + fn() { + resetGlobals(); + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; + const names = client.getOptions().integrations.map(i => i.name); + assert( + names.includes('DenoHttp'), + `DenoHttp should be a default integration on Deno ${DENO_VERSION.major}.${DENO_VERSION.minor}.${DENO_VERSION.patch}, got ${names.join(', ')}`, + ); + }, +}); + +Deno.test({ + name: 'denoHttpIntegration: NOT in default integrations on Deno < 2.7.13', + ignore: HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, + fn() { + resetGlobals(); + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; + const names = client.getOptions().integrations.map(i => i.name); + assert( + !names.includes('DenoHttp'), + `DenoHttp should NOT be in defaults on Deno ${DENO_VERSION.major}.${DENO_VERSION.minor}.${DENO_VERSION.patch} (< 2.7.13), got ${names.join(', ')}`, + ); + }, +}); + +Deno.test({ + name: 'denoHttpIntegration: node:http incoming request creates an http.server transaction', + ignore: !HTTP_SERVER_DIAGNOSTICS_CHANNEL_SUPPORTED, + async fn() { + resetGlobals(); + const sink = transactionSink(); + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: sink.beforeSendTransaction, + }); + + const server = http.createServer((_req, res) => { + res.end('ok'); + }); + const port: number = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + resolve((server.address() as { port: number }).port); + }); + }); + + const response = await fetch(`http://127.0.0.1:${port}/users/42?x=1`); + assertEquals(await response.text(), 'ok'); + + // Wait on the real completion signal (transaction event flowed through + // beforeSendTransaction), not a fixed sleep. Bounded so a "never arrives" + // regression fails the test instead of hanging. + const txn = await withTimeout( + sink.waitFor(t => t.contexts?.trace?.op === 'http.server'), + 5000, + 'http.server transaction', + ); + + await new Promise(resolve => server.close(() => resolve())); + + assertEquals(txn.transaction, 'GET /users/42'); + assertEquals(txn.contexts?.trace?.data?.['http.method'], 'GET'); + assertEquals(txn.contexts?.trace?.data?.['http.response.status_code'], 200); + }, +}); + +Deno.test({ + name: 'denoHttpIntegration: node:http outgoing request creates a child http.client span', + ignore: !HTTP_CLIENT_DIAGNOSTICS_CHANNEL_SUPPORTED, + async fn() { + resetGlobals(); + const sink = transactionSink(); + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: sink.beforeSendTransaction, + }); + + // Use Deno.serve for the target so the test does not depend on the + // node:http server side (which only works on Deno 2.8.0+). + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const target = Deno.serve( + { port: 0, signal: abortController.signal, onListen, hostname: '127.0.0.1' }, + () => new Response('pong'), + ); + await listening; + const targetPort = target.addr.port; + + // Make the outgoing node:http request inside an explicit parent span so + // the http.client child span has somewhere to attach and txn is captured + await startSpan({ name: 'parent', op: 'test' }, async () => { + await new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port: targetPort, path: '/ping', method: 'GET' }, res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + res.on('error', reject); + }); + req.on('error', reject); + req.end(); + }); + }); + + // Wait on the real completion signal + // Note: Deno.serve's own http.server transaction may arrive first + const parent = await withTimeout( + sink.waitFor(t => t.transaction === 'parent'), + 5000, + "'parent' transaction", + ); + + abortController.abort(); + await target.finished; + + const httpClientSpan = parent.spans?.find(s => s.op === 'http.client'); + assertExists( + httpClientSpan, + `expected an http.client child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`, + ); + assertEquals(httpClientSpan!.data?.['http.method'], 'GET'); + assertEquals(httpClientSpan!.data?.['http.response.status_code'], 200); + }, +});