diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index a61f7dbf1a3f..119818a5066e 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -21,8 +21,11 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import type { ServeOptions } from 'bun'; +import type { Server, ServeOptions } from 'bun'; import { + CLIENT_ADDRESS, + CLIENT_PORT, + NETWORK_PROTOCOL_NAME, SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_DOMAIN, @@ -242,6 +245,23 @@ function wrapRequestHandler( const client = getClient(); const dataCollection = client?.getDataCollectionOptions(); + 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. + const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + // Bun passes the `Server` as the second argument to both `fetch` and route handlers. + const socketAddress = getRequestIP(args[1], request); + if (forwardedFor || socketAddress?.address) { + attributes[CLIENT_ADDRESS] = forwardedFor || socketAddress?.address; + } + if (socketAddress?.port) { + attributes[CLIENT_PORT] = socketAddress.port; + } + } + + // describes the OSI application-layer protocol (http), not the scheme (might be https) + attributes[NETWORK_PROTOCOL_NAME] = 'http'; + if (dataCollection) { Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), dataCollection)); } @@ -308,6 +328,18 @@ function wrapRequestHandler( }); } +function getRequestIP(server: unknown, request: Request): { address: string; port: number } | undefined { + if (typeof (server as Partial | undefined)?.requestIP !== 'function') { + return undefined; + } + try { + return (server as Server).requestIP(request) ?? undefined; + } catch { + // `requestIP` throws for requests that did not come from this server's socket. + return undefined; + } +} + function getSpanAttributesFromParsedUrl( parsedUrl: ReturnType, request: Request, diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 27edcc0d88e3..0e13f57ff4d1 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -521,6 +521,85 @@ describe('Bun Serve Integration', () => { }); describe('data collection', () => { + test('captures client address, port and protocol by default', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; + expect(attributes?.['client.address']).toMatch(/^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/); + expect(attributes?.['client.port']).toEqual(expect.any(Number)); + expect(attributes?.['network.protocol.name']).toBe('http'); + }); + + test('captures client address on route handlers', async () => { + const server = Bun.serve({ + routes: { + '/users/:id': req => new Response(`User ${req.params.id}`), + }, + port, + }); + + await fetch(`http://localhost:${port}/users/123`); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; + expect(attributes?.['client.address']).toEqual(expect.any(String)); + expect(attributes?.['client.port']).toEqual(expect.any(Number)); + }); + + test('prefers the first x-forwarded-for address over the socket address', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'X-Forwarded-For': '203.0.113.7, 10.0.0.1' }, + }); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; + expect(attributes?.['client.address']).toBe('203.0.113.7'); + }); + + test('does not capture client address when userInfo collection is disabled', async () => { + setupClient({ dataCollection: { userInfo: false } }); + + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'X-Forwarded-For': '203.0.113.7' }, + }); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; + expect(attributes?.['client.address']).toBeUndefined(); + expect(attributes?.['client.port']).toBeUndefined(); + expect(attributes?.['network.protocol.name']).toBe('http'); + }); + test('keeps PII request headers when dataCollection enables full header collection', async () => { setupClient({ dataCollection: { httpHeaders: { request: true, response: true } } });