diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index e6fdde530c81..f4eae76c587a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -96,6 +96,8 @@ export { metrics, logger, consoleLoggingIntegration, + patchExpressModule, + setupExpressErrorHandler, } from '@sentry/core'; export { DenoClient } from './client'; diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index adb4d39a74d0..7faa44600039 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -1,6 +1,5 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler'; import { wrapDenoRequestHandler } from '../wrap-deno-request-handler'; @@ -48,7 +47,6 @@ const _denoServeIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { - setAsyncLocalStorageAsyncContextStrategy(); Deno.serve = new Proxy(Deno.serve, { apply(target, thisArg, args: ServeParams) { if (isSimpleHandler(args)) { diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index a40055002f57..674fb346710d 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -21,6 +21,7 @@ import { normalizePathsIntegration } from './integrations/normalizepaths'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeFetchTransport } from './transports'; import type { DenoOptions } from './types'; +import { setAsyncLocalStorageAsyncContextStrategy } from './async'; /** Get the default integrations for the Deno SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { @@ -110,5 +111,7 @@ export function init(options: DenoOptions = {}): Client { setupOpenTelemetryTracer(); } + setAsyncLocalStorageAsyncContextStrategy(); + return client; } diff --git a/packages/deno/test/express.test.ts b/packages/deno/test/express.test.ts new file mode 100644 index 000000000000..b762577744f5 --- /dev/null +++ b/packages/deno/test/express.test.ts @@ -0,0 +1,156 @@ +// +// @ts-types="npm:@types/express@4.17.21" + +import type { ErrorEvent, TransactionEvent } from '@sentry/core'; +import * as Sentry from '../build/esm/index.js'; +import { assertEquals, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; + +const PORT = Number(process.env.PORT || 15443); + +// express only works on deno using node:http, and we have to manually +// start a span in order to get it to create child spans for routes. +import { createServer } from 'node:http'; +import type { Server } from 'node:http'; +import * as express from 'express'; + +const transactionEvents: TransactionEvent[] = []; +const errorEvents: ErrorEvent[] = []; + +Sentry.init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, +}); +Sentry.patchExpressModule({ express }); + +const app = (express as unknown as { default: typeof express }).default(); +let server: Server; +Deno.test('verify express server behavior', async () => { + async function main() { + app.use(async (_, res, next) => { + res.setHeader('content-type', 'text/html'); + res.setHeader('connection', 'close'); + // simulate doing some work so that the requests overlap + await new Promise(res => setTimeout(res, 50)); + next(); + }); + + app.get('/user/:user/error/:error', (req, res) => { + Sentry.captureMessage(`Error for user ${req.params.user}: ${req.params.error}`); + const userId = req.params.user || 'unknown'; + Sentry.setUser({ id: userId }); + Sentry.setTag('request.id', userId || 'unknown'); + res.send(`error ${req.params.error} for user ${req.params.user}`); + }); + app.get('/user/:user', (req, res) => { + const userId = req.params.user || 'unknown'; + Sentry.setUser({ id: userId }); + Sentry.setTag('request.id', userId || 'unknown'); + res.setHeader('connection', 'close'); + res.send(`Hello, ${req.params.user}`); + }); + + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + server = createServer((req, res) => { + // TODO: this should be done with a node:http integration + Sentry.withIsolationScope(async scope => { + scope.setClient(Sentry.getClient()); + return Sentry.continueTrace( + { + sentryTrace: String(req.headers['sentry-trace'] || ''), + baggage: req.headers.baggage?.toString(), + }, + () => { + Sentry.startSpan( + { + name: 'http.server', + }, + async () => { + app(req, res); + return new Promise(resolve => res.on('finish', resolve)); + }, + ); + }, + ); + }); + }).listen(PORT, onListen); + await p; + } + + let responses: [Response, Response, Response]; + let responseText: [string, string, string]; + + await main(); + + responses = await Promise.all([ + fetch(`http://localhost:15443/user/user1/error/true`), + fetch(`http://localhost:15443/user/user2`), + fetch(`http://localhost:15443/user/user3/error/true`), + ]); + responseText = await Promise.all([responses[0].text(), responses[1].text(), responses[2].text()]); + server.close(); + + const expectText = ['error true for user user1', 'Hello, user2', 'error true for user user3']; + assertEquals(responseText[0], expectText[0]); + assertEquals(responseText[1], expectText[1]); + assertEquals(responseText[2], expectText[2]); + + assertEquals(transactionEvents.length, 3); + assertEquals(errorEvents.length, 2); + // verify that each transaction has expected spans, and that each is separate + const seen: string[] = []; + for (const tx of transactionEvents) { + if (!tx.contexts) { + throw new Error('no context for transaction!'); + } + const { trace } = tx.contexts; + if (!trace) { + throw new Error('no trace in transaction'); + } + assertEquals(trace.parent_span_id, undefined); + const { span_id, trace_id } = trace; + for (const s of seen) { + assertNotEquals(trace_id, s); + } + seen.push(trace_id); + assertNotEquals(span_id, undefined); + if (!Array.isArray(tx.spans)) { + throw new Error('no spans in transaction'); + } + const results: { description: string; op: string }[] = []; + let whichRoute: string = ''; + for (const span of tx.spans) { + const { op, description, origin, parent_span_id } = span; + assertEquals(parent_span_id, span_id); + assertEquals(span.trace_id, trace_id); + assertEquals(origin, 'auto.http.express'); + assertEquals(typeof op, 'string'); + assertEquals(typeof description, 'string'); + results.push({ op, description } as { op: string; description: string }); + if (op === 'request_handler.express') whichRoute = description as string; + } + const expect = [ + { op: 'middleware.express', description: 'query' }, + { op: 'middleware.express', description: 'expressInit' }, + { op: 'middleware.express', description: '' }, + { op: 'request_handler.express', description: whichRoute }, + ]; + assertEquals(results.length, expect.length); + for (let i = 0; i < expect.length; i++) { + const e = expect[i]; + const t = results[i]; + if (!e) throw new Error('got unexpected result'); + if (!t) throw new Error('failed to get expected result'); + assertEquals(t.op, e.op); + assertEquals(t.description, e.description); + } + } +});