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);
+ }
+ }
+});