From 73fdfc290db558c1c48b4c01b3b15a8c6ed6b460 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 15:12:39 -0700 Subject: [PATCH] ref(bun): Use the shared request handler wrapper for `Bun.serve` Moves `Bun.serve` onto `wrapServerRequestHandler`, which `Deno.serve` uses since the previous PR. Bun keeps only what is unique to it: the patches for `fetch`, `routes` and `server.reload`, the route template, the route parameters, and the socket address from `server.requestIP()`. Its copy of the stream monitor moves out, because the wrapper uses the same code from `@sentry/server-utils`. The wrapper gets two optional fields for this. `routeName` passes the matched route to core's span helper, which sets `http.route`, the `route` source and the span name. `attributes` adds span attributes that only the runtime knows, here `url.path.parameter.*`. Bun built its own span attributes, which did not follow the Sentry conventions for `http.server` spans. The conventions say that server instrumentation sets the matched route in `http.route`, and that `url.template` is for `http.client` spans and client-side routing. The host goes in `server.address`, as on Node, Deno and Cloudflare. So Bun spans change: - `url.template` becomes `http.route`. Routes without parameters now also get `http.route` and the `route` source. - `url.domain` becomes `server.address`. - A request to `/` gets the `route` source and the name `GET /`, as on the other runtimes, because the path is already low cardinality. - Spans also get `user_agent.original` and `http.request.body.size`. - Errors in `OPTIONS` and `HEAD` requests now carry the request data. The wrapper now also keeps every `Set-Cookie` header of a response. `Headers.forEach` lists each one on its own, so a plain dict kept only the last one. This fixes Deno too, and Cloudflare once it moves. Part of #25577 Part of JS-3975 Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/bun/src/integrations/bunserver.ts | 368 +++--------------- packages/bun/src/utils/streaming.ts | 40 -- .../bun/test/integrations/bunserver.test.ts | 51 ++- .../src/utils/wrapServerRequestHandler.ts | 21 +- .../utils/wrapServerRequestHandler.test.ts | 42 +- 5 files changed, 151 insertions(+), 371 deletions(-) delete mode 100644 packages/bun/src/utils/streaming.ts diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 35015447cce9..d34539325a30 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,54 +1,9 @@ -/* eslint-disable max-lines */ import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span, SpanAttributes } from '@sentry/core'; -import { - captureBodyFromWinterCGRequest, - captureException, - continueTrace, - debug, - defineIntegration, - getClient, - getUrlFragment, - getUrlQuery, - hasSpansEnabled, - hasSpanStreamingEnabled, - httpHeadersToSpanAttributes, - HTTP_SPAN_NAME_FALLBACK, - isURLObjectRelative, - parseStringToURLObject, - setHttpStatus, - SPAN_STATUS_ERROR, - startSpanManual, - winterCGRequestToRequestData, - withIsolationScope, - filterCollectedUrl, - filterCollectedUrlQuery, -} from '@sentry/core'; -import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; -import { - classifyResponseStreaming, - getClientIPAddress, - isStaticAssetRequest, - startRequestSession, -} from '@sentry/core/server'; +import { debug, defineIntegration, getClient, withIsolationScope } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions, ServerRequestOptions } from '@sentry/core/server'; +import { URL_PATH_PARAMETER_KEY_BASE } from '@sentry/conventions/attributes'; +import { wrapServerRequestHandler } from '@sentry/server-utils'; import type { Server, ServeOptions } from 'bun'; -import { - CLIENT_ADDRESS, - CLIENT_PORT, - HTTP_REQUEST_METHOD, - NETWORK_PROTOCOL_NAME, - SENTRY_OP, - SENTRY_SEGMENT_NAME_SOURCE, - URL_DOMAIN, - URL_FRAGMENT, - URL_FULL, - URL_PATH, - URL_PORT, - URL_QUERY, - URL_SCHEME, - SENTRY_ORIGIN, -} from '@sentry/conventions/attributes'; -import { HTTP_SERVER } from '@sentry/conventions/op'; -import { monitorStream } from '../utils/streaming'; const INTEGRATION_NAME = 'BunServer' as const; @@ -130,18 +85,35 @@ export type BunServerIntegrationOptions = { // it or lists it as unsupported. type _ServerRequestOptionsCheck = AssertTrue>; -const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => { +// Every shared option is a required key, so a new one fails to compile below until the integration +// passes it on. +type BunServerRequestOptions = { + [K in keyof Required>]: ServerRequestOptions[K]; +}; + +interface BunServerIntegration extends Integration { + requestOptions: BunServerRequestOptions; +} + +/** The incoming request options of the client's `bunServerIntegration`, if it has one. */ +function getBunServerRequestOptions(client: Client): ServerRequestOptions | undefined { + return client.getIntegrationByName(INTEGRATION_NAME)?.requestOptions; +} + +const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}): BunServerIntegration => { return { name: INTEGRATION_NAME, - maxRequestBodySize: options.maxRequestBodySize, - ignoreRequestBody: options.ignoreRequestBody, - ignoreIncomingRequests: options.ignoreIncomingRequests, - ignoreStaticAssets: options.ignoreStaticAssets ?? true, - spans: options.spans, - onSpanCreated: options.onSpanCreated, - onSpanEnd: options.onSpanEnd, - sessions: options.sessions ?? true, - sessionFlushingDelayMS: options.sessionFlushingDelayMS, + requestOptions: { + maxRequestBodySize: options.maxRequestBodySize, + ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, + }, setupOnce() { instrumentBunServe(); }, @@ -291,59 +263,6 @@ type RouteHandler = Extract< Function >; -function captureServeError(error: unknown): void { - captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); -} - -// A user callback that throws must not fail the request or leave the span open. -function callUserCallback(name: string, callback: () => T, fallback: T): T { - try { - return callback(); - } catch (e) { - debug.error(`Error in the Bun.serve ${name} callback.`, e); - return fallback; - } -} - -function shouldCreateSpan( - client: Client | undefined, - integration: BunServerIntegrationOptions | undefined, - request: Request, - urlPath: string, -): boolean { - if (!(integration?.spans ?? (client ? hasSpansEnabled(client.getOptions()) : false))) { - return false; - } - if (integration?.ignoreStaticAssets && request.method.toUpperCase() === 'GET' && isStaticAssetRequest(urlPath)) { - return false; - } - const ignoreIncomingRequests = integration?.ignoreIncomingRequests; - return !( - ignoreIncomingRequests && - callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false) - ); -} - -/** - * Calls `onDone` when the response is done: at once, or when a streamed body finishes. - * Returns the response to send. - */ -function onResponseDone(response: T, onDone: () => void, onStreamError: () => void): T { - if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { - const body = monitorStream(response.body, onDone, error => { - onStreamError(); - captureServeError(error); - }); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) as T; - } - onDone(); - return response; -} - function wrapRequestHandler( target: T, thisArg: unknown, @@ -352,186 +271,36 @@ function wrapRequestHandler( ): Promise>> { return withIsolationScope(async isolationScope => { const request = args[0]; - const upperCaseMethod = request.method.toUpperCase(); + const runHandler = (): Promise => Promise.resolve(target.apply(thisArg, args) as Response); const client = getClient(); - const integration = client?.getIntegrationByName(INTEGRATION_NAME); - const endSession = - client && integration?.sessions - ? startRequestSession(client, { - requestIsolationScope: isolationScope, - sessionFlushingDelayMS: integration.sessionFlushingDelayMS, - }) - : undefined; - - if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { - try { - return await target.apply(thisArg, args); - } catch (e) { - captureServeError(e); - throw e; - } finally { - endSession?.(); - } - } - - const parsedUrl = parseStringToURLObject(request.url); - const attributes = getSpanAttributesFromParsedUrl(parsedUrl, request); - - let routeName = parsedUrl?.pathname || '/'; - if (request.params) { - Object.keys(request.params).forEach(key => { - attributes[`url.path.parameter.${key}`] = (request.params as Record)[key]; - }); - - // If a route has parameters, it's a parameterized route - if (route) { - attributes[SENTRY_SEGMENT_NAME_SOURCE] = 'route'; - attributes['url.template'] = route; - routeName = route; - } + if (!client) { + debug.warn('Cannot instrument Bun.serve request. No client defined.'); + return runHandler(); } - // Handle wildcard routes - if (route?.endsWith('/*')) { - attributes[SENTRY_SEGMENT_NAME_SOURCE] = 'route'; - attributes['url.template'] = route; - routeName = route; - } - - const dataCollection = client?.getDataCollectionOptions(); - - let socketAddress: { address: string; port: number } | undefined; - if (dataCollection) { - const headers = request.headers.toJSON(); - // Bun passes the `Server` as the second argument to both `fetch` and route handlers, except - // when the handler runs through `server.fetch()`. - socketAddress = getRequestIP(args[1], request); - - if (dataCollection.userInfo) { - // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` - // stays unset then. - const forwardedAddress = getClientIPAddress(headers); - if (forwardedAddress) { - attributes[CLIENT_ADDRESS] = forwardedAddress; - } else if (socketAddress) { - attributes[CLIENT_ADDRESS] = socketAddress.address; - attributes[CLIENT_PORT] = socketAddress.port; - } - } - - Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); + const attributes: SpanAttributes = {}; + for (const [key, value] of Object.entries(request.params ?? {})) { + attributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = String(value); } - // describes the OSI application-layer protocol (http), not the scheme (might be https) - attributes[NETWORK_PROTOCOL_NAME] = 'http'; - - isolationScope.setSDKProcessingMetadata({ - normalizedRequest: winterCGRequestToRequestData(request), - ipAddress: socketAddress?.address, - }); - - if (dataCollection) { - const effectiveBodySize = - integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - const ignoreRequestBody = integration?.ignoreRequestBody; - if ( - upperCaseMethod !== 'GET' && - effectiveBodySize !== 'none' && - // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. - !( - ignoreRequestBody && - callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true) - ) - ) { - await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); - } - } - - const urlPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : '/'; - const createSpan = shouldCreateSpan(client, integration, request, urlPath); - - return continueTrace( + return wrapServerRequestHandler( { - sentryTrace: request.headers.get('sentry-trace') ?? '', - baggage: request.headers.get('baggage'), - }, - async () => { - if (!createSpan) { - let response: Response | undefined; - try { - response = (await target.apply(thisArg, args)) as Response | undefined; - } catch (e) { - captureServeError(e); - endSession?.(); - throw e; - } - if (response?.status) { - isolationScope.setContext('response', { status_code: response.status }); - } - return onResponseDone( - response, - () => endSession?.(), - () => undefined, - ); - } - - return startSpanManual( - { - attributes: { ...attributes, [SENTRY_OP]: HTTP_SERVER }, - // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. - name: - attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client) - ? `${request.method} ${routeName}` - : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK, - }, - async (span, endSpan) => { - const { onSpanCreated, onSpanEnd } = integration ?? {}; - if (onSpanCreated) { - callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined); - } - - let response: Response | undefined; - try { - response = (await target.apply(thisArg, args)) as Response | undefined; - } catch (e) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureServeError(e); - endSpan(); - endSession?.(); - throw e; - } - - if (response?.status) { - setHttpStatus(span, response.status); - - isolationScope.setContext('response', { - status_code: response.status, - }); - - if (dataCollection) { - span.setAttributes(httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response')); - } - } - - const finalResponse = response; - return onResponseDone( - response, - () => { - if (onSpanEnd && finalResponse) { - callUserCallback('onSpanEnd', () => onSpanEnd(span, request, finalResponse), undefined); - } - endSpan(); - endSession?.(); - }, - () => span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }), - ); - }, - ); + request, + client, + isolationScope, + options: getBunServerRequestOptions(client), + origin: 'auto.http.bun.serve', + mechanism: { type: 'auto.http.bun.serve', handled: false }, + // Bun passes the `Server` as the second argument to both `fetch` and route handlers, except + // when the handler runs through `server.fetch()`. + getSocketAddress: () => getRequestIP(args[1], request), + routeName: route, + attributes, }, + runHandler, ); - }); + }) as Promise>>; } function getRequestIP( @@ -548,36 +317,3 @@ function getRequestIP( return undefined; } } - -function getSpanAttributesFromParsedUrl( - parsedUrl: ReturnType, - request: Request, -): SpanAttributes { - const attributes: SpanAttributes = { - [SENTRY_ORIGIN]: 'auto.http.bun.serve', - [HTTP_REQUEST_METHOD]: request.method || 'GET', - [SENTRY_SEGMENT_NAME_SOURCE]: 'url', - }; - - if (parsedUrl) { - attributes[URL_QUERY] = filterCollectedUrlQuery(getUrlQuery(parsedUrl.search)); - attributes[URL_FRAGMENT] = getUrlFragment(parsedUrl.hash); - if (parsedUrl.pathname) { - attributes[URL_PATH] = parsedUrl.pathname; - } - if (!isURLObjectRelative(parsedUrl)) { - attributes[URL_FULL] = filterCollectedUrl(parsedUrl.href); - if (parsedUrl.port) { - attributes[URL_PORT] = parsedUrl.port; - } - if (parsedUrl.protocol) { - attributes[URL_SCHEME] = parsedUrl.protocol; - } - if (parsedUrl.hostname) { - attributes[URL_DOMAIN] = parsedUrl.hostname; - } - } - } - - return attributes; -} diff --git a/packages/bun/src/utils/streaming.ts b/packages/bun/src/utils/streaming.ts deleted file mode 100644 index d19466a6a035..000000000000 --- a/packages/bun/src/utils/streaming.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** Monitor the source without reading ahead or treating cancellation as a source failure. */ -export function monitorStream( - stream: ReadableStream, - onDone: () => void, - onError: (error: unknown) => void, -): ReadableStream { - const reader = stream.getReader(); - // A cancelled reader closes normally; only a source failure should be captured as an error. - void reader.closed.then(onDone, error => { - onError(error); - onDone(); - }); - - return new ReadableStream( - { - async pull(controller) { - try { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - reader.releaseLock(); - } else { - controller.enqueue(value); - } - } catch (error) { - controller.error(error); - reader.releaseLock(); - } - }, - async cancel(reason) { - try { - await reader.cancel(reason); - } finally { - reader.releaseLock(); - } - }, - }, - { highWaterMark: 0 }, - ); -} diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 5d846d3667d6..8d0ea53e2902 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,3 +1,9 @@ +import { + HTTP_ROUTE, + SENTRY_SEGMENT_NAME_SOURCE, + SERVER_ADDRESS, + URL_PATH_PARAMETER_KEY_BASE, +} from '@sentry/conventions/attributes'; import type { AggregationCounts, Envelope, RequestEventData } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; @@ -228,7 +234,7 @@ describe('Bun Serve Integration', () => { 'url.full': `http://localhost:${port}/users?id=123`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', 'http.request.header.accept': ['*/*'], 'http.request.header.accept-encoding': ['gzip, deflate, br, zstd'], 'http.request.header.connection': ['keep-alive'], @@ -266,12 +272,12 @@ describe('Bun Serve Integration', () => { 'sentry.op': 'http.server', 'sentry.origin': 'auto.http.bun.serve', 'http.request.method': 'POST', - 'sentry.segment.name.source': 'url', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', 'url.path': '/', 'url.full': `http://localhost:${port}/`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', 'http.request.header.accept': ['*/*'], 'http.request.header.accept-encoding': ['gzip, deflate, br, zstd'], 'http.request.header.connection': ['keep-alive'], @@ -279,7 +285,7 @@ describe('Bun Serve Integration', () => { 'http.request.header.host': [expect.any(String)], 'http.request.header.user-agent': [expect.stringContaining('Bun')], }), - name: 'POST', + name: 'POST /', }, expect.any(Function), ); @@ -390,7 +396,7 @@ describe('Bun Serve Integration', () => { 'url.full': `http://localhost:${port}/api/test`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', // HTTP headers as span attributes following OpenTelemetry semantic conventions 'http.request.header.user-agent': ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'], 'http.request.header.content-type': ['application/json'], @@ -463,13 +469,13 @@ describe('Bun Serve Integration', () => { 'sentry.origin': 'auto.http.bun.serve', 'http.request.method': 'GET', 'sentry.segment.name.source': 'route', - 'url.template': '/users/:id', - 'url.path.parameter.id': '123', + [HTTP_ROUTE]: '/users/:id', + [`${URL_PATH_PARAMETER_KEY_BASE}.id`]: '123', 'url.path': '/users/123', 'url.full': `http://localhost:${port}/users/123`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', }), name: 'GET /users/:id', }), @@ -479,6 +485,31 @@ describe('Bun Serve Integration', () => { await server.stop(); }); + test('sets http.route for a route without parameters', async () => { + const server = Bun.serve({ + routes: { + '/api/health': () => new Response('ok'), + }, + port, + }); + + const response = await fetch(`http://localhost:${port}/api/health`); + expect(await response.text()).toBe('ok'); + + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [HTTP_ROUTE]: '/api/health', + }), + name: 'GET /api/health', + }), + expect.any(Function), + ); + + await server.stop(); + }); + test('handles wildcard routes correctly', async () => { const server = Bun.serve({ routes: { @@ -502,12 +533,12 @@ describe('Bun Serve Integration', () => { 'sentry.origin': 'auto.http.bun.serve', 'http.request.method': 'GET', 'sentry.segment.name.source': 'route', - 'url.template': '/api/*', + [HTTP_ROUTE]: '/api/*', 'url.path': '/api/users/123', 'url.full': `http://localhost:${port}/api/users/123`, 'url.port': port.toString(), 'url.scheme': 'http:', - 'url.domain': 'localhost', + [SERVER_ADDRESS]: 'localhost', }), name: 'GET /api/*', }), diff --git a/packages/server-utils/src/utils/wrapServerRequestHandler.ts b/packages/server-utils/src/utils/wrapServerRequestHandler.ts index 9702af119181..bbe269c45262 100644 --- a/packages/server-utils/src/utils/wrapServerRequestHandler.ts +++ b/packages/server-utils/src/utils/wrapServerRequestHandler.ts @@ -8,7 +8,7 @@ import { USER_AGENT_ORIGINAL, } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; -import type { Client, Mechanism, Scope, Span } from '@sentry/core'; +import type { Client, Mechanism, Scope, Span, SpanAttributes } from '@sentry/core'; import { captureBodyFromWinterCGRequest, captureException, @@ -49,6 +49,10 @@ export interface ServerRequestHandlerConfig { mechanism: Mechanism; /** The address and port of the socket, if the runtime knows them. */ getSocketAddress?: () => { address: string; port?: number } | undefined; + /** The matched route of the server framework, e.g. `/users/:id`. Sets `http.route` and the span name. */ + routeName?: string; + /** Span attributes that only the runtime knows, e.g. the route parameters. */ + attributes?: SpanAttributes; } /** @@ -141,7 +145,7 @@ export async function wrapServerRequestHandler( 'server', config.origin, request, - undefined, + config.routeName, client, ); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. @@ -179,6 +183,7 @@ export async function wrapServerRequestHandler( // describes the OSI application-layer protocol (http), not the scheme (might be https) attributes[NETWORK_PROTOCOL_NAME] = 'http'; Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); + Object.assign(attributes, config.attributes); attributes[SENTRY_OP] = HTTP_SERVER; return startSpanManual({ name, attributes }, async span => { @@ -205,7 +210,7 @@ export async function wrapServerRequestHandler( setHttpStatus(span, response.status); isolationScope.setContext('response', { status_code: response.status }); span.setAttributes( - httpHeadersToSpanAttributes(winterCGHeadersToDict(response.headers), dataCollection, 'response'), + httpHeadersToSpanAttributes(responseHeadersToDict(response.headers), dataCollection, 'response'), ); return onResponseDone( @@ -268,3 +273,13 @@ function onResponseDone(response: Response, mechanism: Mechanism, onDone: () => onDone(); return response; } + +// `Headers.forEach` lists each `Set-Cookie` header on its own, so a plain dict keeps only the last one. +function responseHeadersToDict(headers: Headers): Record { + const dict: Record = winterCGHeadersToDict(headers); + const setCookie = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() : []; + if (setCookie.length) { + dict['set-cookie'] = setCookie; + } + return dict; +} diff --git a/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts index 4acd34c92bbc..e2a26d0b5ba4 100644 --- a/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts +++ b/packages/server-utils/test/utils/wrapServerRequestHandler.test.ts @@ -1,3 +1,4 @@ +import { HTTP_ROUTE, SENTRY_SEGMENT_NAME_SOURCE, URL_PATH_PARAMETER_KEY_BASE } from '@sentry/conventions/attributes'; import type { Event } from '@sentry/core'; import { getIsolationScope, setCurrentClient, withIsolationScope } from '@sentry/core'; import { beforeAll, describe, expect, it, vi } from 'vitest'; @@ -8,7 +9,10 @@ import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; const MECHANISM = { handled: false, type: 'auto.http.test' }; -function setup(options: ServerRequestHandlerConfig['options'] = {}): { +function setup( + options: ServerRequestHandlerConfig['options'] = {}, + extra: Partial = {}, +): { events: Event[]; transactions: Event[]; serve: (request: Request, handler: () => Response | Promise) => Promise; @@ -35,7 +39,7 @@ function setup(options: ServerRequestHandlerConfig['options'] = {}): { const serve = (request: Request, handler: () => Response | Promise): Promise => withIsolationScope(isolationScope => wrapServerRequestHandler( - { request, client, isolationScope, options, origin: 'auto.http.test', mechanism: MECHANISM }, + { request, client, isolationScope, options, origin: 'auto.http.test', mechanism: MECHANISM, ...extra }, handler, ), ); @@ -154,4 +158,38 @@ describe('wrapServerRequestHandler', () => { expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual(MECHANISM); expect(transactions).toHaveLength(0); }); + + it('names the span after the route and adds the runtime attributes', async () => { + const { transactions, serve } = setup( + {}, + { routeName: '/users/:id', attributes: { [`${URL_PATH_PARAMETER_KEY_BASE}.id`]: '42' } }, + ); + + const response = await serve(new Request('https://example.com/users/42'), () => new Response('ok')); + expect(await response.text()).toBe('ok'); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + expect(transactions[0]?.transaction).toBe('GET /users/:id'); + expect(transactions[0]?.contexts?.trace?.data).toEqual( + expect.objectContaining({ + [HTTP_ROUTE]: '/users/:id', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [`${URL_PATH_PARAMETER_KEY_BASE}.id`]: '42', + }), + ); + }); + + it('keeps every Set-Cookie header of the response', async () => { + const { transactions, serve } = setup(); + const headers = new Headers(); + headers.append('set-cookie', 'first=1'); + headers.append('set-cookie', 'second=2'); + + const response = await serve(new Request('https://example.com/login'), () => new Response('ok', { headers })); + expect(await response.text()).toBe('ok'); + await vi.waitFor(() => expect(transactions).toHaveLength(1)); + + const setCookie = transactions[0]?.contexts?.trace?.data?.['http.response.header.set-cookie'] as string[]; + expect(setCookie.map(cookie => cookie.split('=')[0])).toEqual(['first', 'second']); + }); });