diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index e12b03c1a9dd..ef25f4c06949 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -9,11 +9,12 @@ import { getRequestOptions, HTTP_ON_CLIENT_REQUEST, HTTP_ON_SERVER_REQUEST, + type HttpInstrumentationOptions, } from '@sentry/core'; const INTEGRATION_NAME = 'DenoHttp' as const; -export interface DenoHttpIntegrationOptions { +export interface DenoHttpIntegrationOptions extends HttpInstrumentationOptions { /** * Whether breadcrumbs should be recorded for outgoing requests. * @@ -95,21 +96,16 @@ const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => { name: INTEGRATION_NAME, setupOnce() { 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, - ignoreRequestBody: options.ignoreRequestBody, + ...options, + // TODO: consolidate confusingly named HTTP instrumentation options onSpanCreated: options.onIncomingSpanCreated, onSpanEnd: options.onIncomingSpanEnd, errorMonitor, - sessions: false, }); subscribe(HTTP_ON_SERVER_REQUEST, onHttpServerRequest); const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequest } = getHttpClientSubscriptions({ - spans: options.spans, + ...options, breadcrumbs, propagateTrace: tracePropagation, ignoreOutgoingRequests: options.ignoreOutgoingRequests diff --git a/packages/deno/test/deno-http-sessions-disabled.test.ts b/packages/deno/test/deno-http-sessions-disabled.test.ts new file mode 100644 index 000000000000..163ec8dde196 --- /dev/null +++ b/packages/deno/test/deno-http-sessions-disabled.test.ts @@ -0,0 +1,64 @@ +// + +/** + * Lives in its own file because `setupOnce` runs once per process + * (`installedIntegrations` guards it) and the diagnostics channel + * subscription is global. Deno gives each test file a fresh module graph, + * so this is the only way to install `denoHttpIntegration` with + * non-default options after `deno-http.test.ts` has installed it with + * the defaults. + */ + +import * as http from 'node:http'; +import type { Envelope } from '@sentry/core'; +import { forEachEnvelopeItem, getIsolationScope, getMainCarrier } 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 { denoHttpIntegration, init } from '../build/esm/index.js'; +import { makeTestTransport } from './transport.ts'; + +Deno.test({ + name: 'denoHttpIntegration: node:http incoming request records no session when sessions: false', + async fn() { + getMainCarrier().__SENTRY__ = undefined; + + const envelopes: Envelope[] = []; + const client = init({ + dsn: 'https://username@domain/123', + release: '1.0.0', + integrations: [denoHttpIntegration({ sessions: false })], + transport: makeTestTransport(envelope => { + envelopes.push(envelope); + }), + }); + + // Captured inside the handler so we can tell "sessions were disabled" + // apart from "the request was never instrumented at all". + let isolatedTransactionName: string | undefined; + const server = http.createServer((_req, res) => { + isolatedTransactionName = getIsolationScope().getScopeData().transactionName; + 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}/health`); + assertEquals(await response.text(), 'ok'); + await new Promise(resolve => server.close(() => resolve())); + await client.flush(2_000); + + const itemTypes: string[] = []; + for (const envelope of envelopes) { + forEachEnvelopeItem(envelope, ([headers]) => { + itemTypes.push(headers.type); + }); + } + + assertEquals(isolatedTransactionName, 'GET /health'); + assert(!itemTypes.includes('sessions'), `expected no session envelope item, got: ${itemTypes.join(', ')}`); + assert(!itemTypes.includes('session'), `expected no session envelope item, got: ${itemTypes.join(', ')}`); + }, +}); diff --git a/packages/deno/test/deno-http-spans-disabled.test.ts b/packages/deno/test/deno-http-spans-disabled.test.ts new file mode 100644 index 000000000000..efd1f29755ff --- /dev/null +++ b/packages/deno/test/deno-http-spans-disabled.test.ts @@ -0,0 +1,130 @@ +// + +/** + * Lives in its own file because `setupOnce` runs once per process + * (`installedIntegrations` guards it) and the diagnostics channel + * subscription is global. Deno gives each test file a fresh module graph, + * so this is the only way to install `denoHttpIntegration` with + * `spans: false` after another file has installed it with the defaults. + * + * Both tests below share that single `spans: false` subscription. + */ + +import * as http from 'node:http'; +import type { TransactionEvent } from '@sentry/core'; +import { getIsolationScope, getMainCarrier } 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 type { DenoClient } from '../build/esm/index.js'; +import { denoHttpIntegration, init, startSpan } from '../build/esm/index.js'; + +/** + * `spans: false` must win over `tracesSampleRate: 1`, so tracing is on + * everywhere except the HTTP integration. Without it the option would be + * indistinguishable from tracing being off. + */ +function initWithSpansDisabled(transactions: TransactionEvent[]): DenoClient { + getMainCarrier().__SENTRY__ = undefined; + return init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + integrations: [denoHttpIntegration({ spans: false })], + beforeSendTransaction: (event: TransactionEvent) => { + transactions.push(event); + return null; + }, + }) as DenoClient; +} + +Deno.test({ + name: 'denoHttpIntegration: node:http outgoing request creates no http.client span when spans: false', + async fn() { + const transactions: TransactionEvent[] = []; + const client = initWithSpansDisabled(transactions); + + // Deno.serve for the target so this does not depend on the node:http + // server instrumentation. + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + // Captured so we can tell "spans were disabled" apart from "the client + // was never instrumented at all" -- header injection survives spans: false. + let sentryTraceHeader: string | null = null; + const target = Deno.serve( + { port: 0, signal: abortController.signal, onListen, hostname: '127.0.0.1' }, + (request: Request) => { + sentryTraceHeader = request.headers.get('sentry-trace'); + return new Response('pong'); + }, + ); + await listening; + + await startSpan({ name: 'parent', op: 'test' }, async () => { + await new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port: target.addr.port, path: '/ping', method: 'GET' }, res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + res.on('error', reject); + }); + req.on('error', reject); + req.end(); + }); + }); + + abortController.abort(); + await target.finished; + + // Event capture runs through the client's async processing queue, so + // drain it before reading the sink -- otherwise these assertions race. + await client.flush(5_000); + + // The parent span proves tracing itself is live, so an absent + // http.client span is the option working rather than tracing being off. + assert(sentryTraceHeader, 'expected an injected sentry-trace header, so the client was instrumented'); + const parent = transactions.find(t => t.transaction === 'parent'); + assert(parent, `expected the 'parent' transaction, got: ${transactions.map(t => t.transaction).join(', ')}`); + const childOps = parent!.spans?.map(s => s.op) ?? []; + assertEquals( + childOps.includes('http.client'), + false, + `expected no http.client span, got ops: ${childOps.join(', ')}`, + ); + }, +}); + +Deno.test({ + name: 'denoHttpIntegration: node:http incoming request creates no http.server transaction when spans: false', + async fn() { + const transactions: TransactionEvent[] = []; + const client = initWithSpansDisabled(transactions); + + // Captured inside the handler so we can tell "spans were disabled" apart + // from "the request was never instrumented at all". + let isolatedTransactionName: string | undefined; + const server = http.createServer((_req, res) => { + isolatedTransactionName = getIsolationScope().getScopeData().transactionName; + 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`); + assertEquals(await response.text(), 'ok'); + await new Promise(resolve => server.close(() => resolve())); + + // Drain the async processing queue first. Without this, "no http.server + // transaction yet" and "no http.server transaction at all" look alike, + // so the assertion below could pass while spans were still enabled. + await client.flush(5_000); + + // Request isolation still runs with spans off, so this proves the + // instrumentation saw the request. + assertEquals(isolatedTransactionName, 'GET /users/42'); + const ops = transactions.map(t => t.contexts?.trace?.op); + assertEquals(ops.includes('http.server'), false, `expected no http.server transaction, got ops: ${ops.join(', ')}`); + }, +}); diff --git a/packages/deno/test/deno-http.test.ts b/packages/deno/test/deno-http.test.ts index 3c784576b019..0944304ac980 100644 --- a/packages/deno/test/deno-http.test.ts +++ b/packages/deno/test/deno-http.test.ts @@ -1,13 +1,14 @@ // import * as http from 'node:http'; -import type { TransactionEvent } from '@sentry/core'; -import { getMainCarrier } from '@sentry/core'; +import type { Envelope, SessionAggregates, TransactionEvent } from '@sentry/core'; +import { forEachEnvelopeItem, getMainCarrier } 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 { init, startSpan } from '../build/esm/index.js'; +import { makeTestTransport } from './transport.ts'; function resetGlobals(): void { getMainCarrier().__SENTRY__ = undefined; @@ -110,6 +111,52 @@ Deno.test({ }, }); +Deno.test({ + name: 'denoHttpIntegration: node:http incoming request records a release-health session by default', + async fn() { + resetGlobals(); + const envelopes: Envelope[] = []; + const client = init({ + dsn: 'https://username@domain/123', + release: '1.0.0', + transport: makeTestTransport(envelope => { + envelopes.push(envelope); + }), + }); + + 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}/health`); + assertEquals(await response.text(), 'ok'); + await new Promise(resolve => server.close(() => resolve())); + await client.flush(2_000); + + let sessionAggregates: SessionAggregates | undefined; + for (const envelope of envelopes) { + forEachEnvelopeItem(envelope, item => { + const [headers, body] = item; + if (headers.type === 'sessions') { + sessionAggregates = body as SessionAggregates; + } + }); + } + + assertExists(sessionAggregates); + assertEquals(sessionAggregates.attrs?.release, '1.0.0'); + assertEquals(sessionAggregates.aggregates.length, 1); + assertEquals(sessionAggregates.aggregates[0]?.exited, 1); + assertEquals(sessionAggregates.aggregates[0]?.errored, 0); + assertEquals(sessionAggregates.aggregates[0]?.crashed, 0); + }, +}); + Deno.test({ name: 'denoHttpIntegration: node:http outgoing request creates a child http.client span', async fn() {