diff --git a/CHANGELOG.md b/CHANGELOG.md index e82be6c72cf9..f03e3b724d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,14 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph `denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate. +- **feat(bun): Add incoming request options and request sessions to `bunServerIntegration`** + + `bunServerIntegration` now accepts the same options as `denoServeIntegration`: `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. The callbacks receive the web `Request`. + + `Bun.serve` now records a request session for each incoming request. Sessions are sent only when a `release` is set. To turn them off, set `bunServerIntegration({ sessions: false })`. + + `ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. + ## 11.8.0 ### Important Changes diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 46ff0fc30e25..35015447cce9 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,13 +1,15 @@ /* eslint-disable max-lines */ -import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core'; +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, @@ -22,7 +24,12 @@ import { filterCollectedUrlQuery, } from '@sentry/core'; import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; -import { classifyResponseStreaming, getClientIPAddress } from '@sentry/core/server'; +import { + classifyResponseStreaming, + getClientIPAddress, + isStaticAssetRequest, + startRequestSession, +} from '@sentry/core/server'; import type { Server, ServeOptions } from 'bun'; import { CLIENT_ADDRESS, @@ -55,32 +62,86 @@ export type BunServerIntegrationOptions = { * @default 'medium' */ maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. `request` is the incoming + * {@link Request}. The callback runs before the SDK reads the body. + */ + ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not capture server spans for incoming requests whose URL path makes the given callback return `true`. + * + * `urlPath` is the path of the request, including the query string. `request` is the incoming + * {@link Request}. Errors thrown by the handler are still captured. + */ + ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating server spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Whether to create spans for incoming requests. + * Errors are still captured and request data is still attached when this is `false`. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, before the request handler runs. + */ + onSpanCreated?: (span: Span, request: Request) => void; + + /** + * A hook that can be used to mutate the span one last time before it ends. + * For a streamed response, this runs when the stream finishes. + * It is not called when the request handler throws. + */ + onSpanEnd?: (span: Span, request: Request, response: Response) => void; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for + * incoming requests to track the health and crash-free rate of your releases in Sentry. + * + * For a streamed response, the session ends when the stream finishes. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; }; // Checks these options against the shared `ServerRequestOptions`. A new // shared field fails to compile here, until this integration supports // it or lists it as unsupported. -type _ServerRequestOptionsCheck = AssertTrue< - CheckServerRequestOptions< - BunServerIntegrationOptions, - Request, - Response, - // Not supported yet. - | 'spans' - | 'sessions' - | 'sessionFlushingDelayMS' - | 'ignoreRequestBody' - | 'ignoreIncomingRequests' - | 'ignoreStaticAssets' - | 'onSpanCreated' - | 'onSpanEnd' - > ->; +type _ServerRequestOptionsCheck = AssertTrue>; const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => { 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, setupOnce() { instrumentBunServe(); }, @@ -230,6 +291,59 @@ 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, @@ -239,8 +353,26 @@ function wrapRequestHandler( return withIsolationScope(async isolationScope => { const request = args[0]; const upperCaseMethod = request.method.toUpperCase(); + + 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') { - return target.apply(thisArg, args); + try { + return await target.apply(thisArg, args); + } catch (e) { + captureServeError(e); + throw e; + } finally { + endSession?.(); + } } const parsedUrl = parseStringToURLObject(request.url); @@ -267,7 +399,6 @@ function wrapRequestHandler( routeName = route; } - const client = getClient(); const dataCollection = client?.getDataCollectionOptions(); let socketAddress: { address: string; port: number } | undefined; @@ -301,24 +432,52 @@ function wrapRequestHandler( ipAddress: socketAddress?.address, }); - if (client && dataCollection) { - const configuredBodySize = client.getIntegrationByName( - INTEGRATION_NAME, - )?.maxRequestBodySize; + if (dataCollection) { const effectiveBodySize = - configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - if (upperCaseMethod !== 'GET' && effectiveBodySize !== 'none') { + 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( { sentryTrace: request.headers.get('sentry-trace') ?? '', baggage: request.headers.get('baggage'), }, - () => - startSpanManual( + 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. @@ -328,47 +487,49 @@ function wrapRequestHandler( : 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 { - const response = (await target.apply(thisArg, args)) as Response | undefined; - if (response?.status) { - setHttpStatus(span, response.status); - - isolationScope.setContext('response', { - status_code: response.status, - }); - - if (dataCollection) { - span.setAttributes( - httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response'), - ); - } - } - if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { - const body = monitorStream(response.body, endSpan, error => { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); - }); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } - endSpan(); - return response; + response = (await target.apply(thisArg, args)) as Response | undefined; } catch (e) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(e, { - mechanism: { - type: 'auto.http.bun.serve', - handled: false, - }, - }); + 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' }), + ); }, - ), + ); + }, ); }); } diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index bec2b892ea3a..5d846d3667d6 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,4 +1,4 @@ -import type { RequestEventData } from '@sentry/core'; +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'; import type { BunOptions } from '../../src'; @@ -21,9 +21,11 @@ describe('Bun Serve Integration', () => { init({ dsn: 'https://username@domain/123', defaultIntegrations: false, - ...options, + // Spans follow the client's tracing configuration. + tracesSampleRate: 1, transport: () => SentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => SentryCore.resolvedSyncPromise({})), + ...options, }); }; @@ -1195,4 +1197,475 @@ describe('Bun Serve Integration', () => { expect(normalizedRequest?.data).toBeUndefined(); }); }); + + describe('options', () => { + const captureBodySpy = spyOn(SentryCore, 'captureBodyFromWinterCGRequest'); + + beforeEach(() => { + captureBodySpy.mockClear(); + }); + + test('does not capture request bodies that ignoreRequestBody matches', async () => { + const ignoreRequestBody = (url: string, request: Request): boolean => + url.endsWith('/upload?id=1') && request.method === 'POST'; + setupClient({ integrations: [bunServerIntegration({ ignoreRequestBody })] }); + const server = Bun.serve({ port, fetch: async req => new Response(await req.text()) }); + try { + const ignored = await fetch(`http://localhost:${port}/upload?id=1`, { method: 'POST', body: 'secret' }); + expect(await ignored.text()).toBe('secret'); + expect(captureBodySpy).not.toHaveBeenCalled(); + + const captured = await fetch(`http://localhost:${port}/login`, { method: 'POST', body: 'hello' }); + expect(await captured.text()).toBe('hello'); + expect(captureBodySpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('does not capture the body and still responds when ignoreRequestBody throws', async () => { + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreRequestBody: () => { + throw new Error('filter failed'); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: async req => new Response(await req.text()) }); + try { + const response = await fetch(`http://localhost:${port}/login`, { method: 'POST', body: 'secret' }); + expect(await response.text()).toBe('secret'); + expect(captureBodySpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + + test('does not create spans for requests that ignoreIncomingRequests matches', async () => { + const urlPaths: string[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + urlPaths.push(urlPath); + return urlPath.startsWith('/health') && request instanceof Request; + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/health?probe=1`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(urlPaths).toEqual(['/health?probe=1', '/users']); + } finally { + await server.stop(true); + } + }); + + test('creates the span and still responds when ignoreIncomingRequests throws', async () => { + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreIncomingRequests: () => { + throw new Error('filter failed'); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + + test('does not create spans for static assets by default', async () => { + setupClient({ integrations: [bunServerIntegration()] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/favicon.ico`)).text()).toBe('ok'); + expect(await (await fetch(`http://localhost:${port}/assets/app.js`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + + // The filter only applies to GET. + await (await fetch(`http://localhost:${port}/favicon.ico`, { method: 'POST', body: 'x' })).text(); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('creates spans for static assets when ignoreStaticAssets is false', async () => { + setupClient({ integrations: [bunServerIntegration({ ignoreStaticAssets: false })] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/favicon.ico`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('does not create spans when spans is false, but still captures errors and request data', async () => { + setupClient({ integrations: [bunServerIntegration({ spans: false })] }); + let normalizedRequest: RequestEventData | undefined; + const error = new Error('handler failed'); + const server = Bun.serve({ + port, + fetch: () => { + normalizedRequest = SentryCore.getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest; + throw error; + }, + }); + try { + // `server.fetch` returns the handler error, which `bun:test` would report as unhandled from a real request. + await expect(server.fetch(new Request(`http://localhost:${port}/users`))).rejects.toBe(error); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(normalizedRequest?.url).toBe(`http://localhost:${port}/users`); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }); + } finally { + await server.stop(true); + } + }); + + test('does not create spans when tracing is disabled', async () => { + setupClient({ tracesSampleRate: undefined, integrations: [bunServerIntegration()] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(continueTraceSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('calls onSpanCreated and onSpanEnd', async () => { + const calls: string[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ + onSpanCreated: (span, request) => { + expect(span).toBe(mockSpan as unknown as SentryCore.Span); + calls.push(`created ${request.method} ${new URL(request.url).pathname}`); + }, + onSpanEnd: (span, request, response) => { + expect(span).toBe(mockSpan as unknown as SentryCore.Span); + calls.push(`end ${request.method} ${response.status}`); + }, + }), + ], + }); + const server = Bun.serve({ + port, + fetch: () => { + calls.push('handler'); + return new Response('ok', { status: 201 }); + }, + }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(calls).toEqual(['created GET /users', 'handler', 'end GET 201']); + } finally { + await server.stop(true); + } + }); + + test('calls onSpanEnd when a streamed response finishes', async () => { + const onSpanEndCalls: number[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ onSpanEnd: (_span, _request, response) => onSpanEndCalls.push(response.status) }), + ], + }); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(onSpanEndCalls).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + expect(onSpanEndCalls).toEqual([200]); + } finally { + await server.stop(true); + } + }); + + test.each(['onSpanCreated', 'onSpanEnd'] as const)( + 'still responds and ends the span when %s throws', + async hook => { + setupClient({ + integrations: [ + bunServerIntegration({ + [hook]: () => { + throw new Error(`${hook} failed`); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(endSpanSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }, + ); + + test.each(['OPTIONS', 'HEAD'])('captures errors thrown for %s requests', async method => { + const error = new Error(`${method} failed`); + const server = Bun.serve({ + port, + fetch: () => { + throw error; + }, + }); + try { + await expect(server.fetch(new Request(`http://localhost:${port}/`, { method }))).rejects.toBe(error); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }); + } finally { + await server.stop(true); + } + }); + }); + + describe('sessions', () => { + type SessionCounts = Omit; + + function setupSessionClient(options?: BunOptions): { + client: SentryCore.Client; + sessions: SessionCounts[]; + firstSession: Promise; + } { + const sessions: SessionCounts[] = []; + let resolveFirstSession: () => void; + const firstSession = new Promise(resolve => (resolveFirstSession = resolve)); + setupClient({ + release: '1.0.0', + integrations: [bunServerIntegration()], + ...options, + transport: () => + SentryCore.createTransport({ recordDroppedEvent: () => undefined }, request => { + const envelope = SentryCore.parseEnvelope(request.body) as Envelope; + SentryCore.forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'sessions') { + for (const { started: _started, ...counts } of (item[1] as { aggregates: AggregationCounts[] }) + .aggregates) { + sessions.push(counts); + } + resolveFirstSession(); + } + }); + return SentryCore.resolvedSyncPromise({}); + }), + }); + return { client: SentryCore.getClient()!, sessions, firstSession }; + } + + test('records an exited session for a successful request', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + + test('records an errored session for a handled exception', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ + port, + fetch: () => { + SentryCore.captureException(new Error('handled')); + return new Response('ok'); + }, + }); + try { + await (await fetch(`http://localhost:${port}/handled`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 1, crashed: 0 }]); + }); + + test('records a crashed session when the handler throws', async () => { + const { client, sessions } = setupSessionClient(); + const error = new Error('unhandled'); + const server = Bun.serve({ + port, + fetch: () => { + throw error; + }, + }); + try { + await expect(server.fetch(new Request(`http://localhost:${port}/throws`))).rejects.toBe(error); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 0, crashed: 1 }]); + }); + + test.each([ + ['when tracing is enabled', {}], + ['when tracing is disabled', { tracesSampleRate: undefined }], + ] as const)('records a crashed session when a streamed response source fails %s', async (_label, options) => { + const { client, sessions } = setupSessionClient(options); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + const error = new Error('stream interrupted'); + const text = response.text(); + controller!.error(error); + await expect(text).rejects.toBe(error); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 0, crashed: 1 }]); + }); + + test('records sessions for OPTIONS and HEAD requests', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/cors`, { method: 'OPTIONS' })).text(); + await (await fetch(`http://localhost:${port}/cors`, { method: 'HEAD' })).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 2, errored: 0, crashed: 0 }]); + }); + + test('records sessions for requests without a span', async () => { + const { client, sessions } = setupSessionClient({ + integrations: [bunServerIntegration({ ignoreIncomingRequests: urlPath => urlPath === '/health' })], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/health`)).text(); + await (await fetch(`http://localhost:${port}/favicon.ico`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(sessions).toEqual([{ exited: 2, errored: 0, crashed: 0 }]); + }); + + test('does not record sessions when sessions is false', async () => { + const { client, sessions } = setupSessionClient({ integrations: [bunServerIntegration({ sessions: false })] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([]); + }); + + test('sends the session aggregate after sessionFlushingDelayMS', async () => { + const { sessions, firstSession } = setupSessionClient({ + integrations: [bunServerIntegration({ sessionFlushingDelayMS: 50 })], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + + // No flush here. The timeout only bounds a failure. With the default 60s delay, no session + // arrives within it. + let timeoutId: ReturnType | undefined; + const timedOut = new Promise<'timed out'>(resolve => { + timeoutId = setTimeout(() => resolve('timed out'), 2_000); + }); + const result = await Promise.race([firstSession.then(() => 'sent' as const), timedOut]); + clearTimeout(timeoutId); + + expect(result).toBe('sent'); + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + + test.each([ + ['when tracing is enabled', {}], + ['when tracing is disabled', { tracesSampleRate: undefined }], + ] as const)('records a streamed response session when the stream finishes %s', async (_label, options) => { + const { client, sessions } = setupSessionClient(options); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + controller!.enqueue(new TextEncoder().encode('chunk\n')); + await client.flush(); + expect(sessions).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('chunk\n'); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + }); });