diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 574ece436506..279158558896 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -8,6 +8,7 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export { ServerRuntimeClient } from './server-runtime-client'; export type { ServerRuntimeOptions } from './types/options'; export { trpcMiddleware } from './trpc'; +export { getClientIPAddress } from './vendor/getIpAddress'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; export { isNodeEnv, loadModule } from './utils/node'; export { filenameIsInApp, node, nodeStackLineParser } from './utils/node-stack-trace'; diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts index f5044bc92fd5..a9265bcbe894 100644 --- a/packages/hono/src/bun/middleware.ts +++ b/packages/hono/src/bun/middleware.ts @@ -1,7 +1,6 @@ import type { BunOptions } from '@sentry/bun'; import { debug } from '@sentry/core'; import { init } from './sdk'; -import { getConnInfo } from 'hono/bun'; import type { Env, Hono, MiddlewareHandler } from 'hono'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; import { applyPatches } from '../shared/applyPatches'; @@ -20,7 +19,7 @@ export const sentry = (app: Hono, options: HonoBunOptions): Mi applyPatches(app); return async (context, next) => { - requestHandler(context, getConnInfo); + requestHandler(context, 'bun'); await next(); // Handler runs in between Request above ⤴ and Response below ⤵ diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 4ff5167f4bc1..da4b2bf586cd 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,6 +1,5 @@ import { withSentry, type CloudflareOptions } from '@sentry/cloudflare'; import { applySdkMetadata, debug } from '@sentry/core'; -import { getConnInfo } from 'hono/cloudflare-workers'; import type { Env, Hono, MiddlewareHandler } from 'hono'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactionPatterns'; @@ -45,7 +44,7 @@ export function sentry( ? options(context.env as E['Bindings']).shouldHandleError : options.shouldHandleError; - requestHandler(context, getConnInfo); + requestHandler(context, 'cloudflare'); await next(); // Handler runs in between Request above ⤴ and Response below ⤵ diff --git a/packages/hono/src/deno/middleware.ts b/packages/hono/src/deno/middleware.ts index 1466859dd94f..a8fe3685c3a6 100644 --- a/packages/hono/src/deno/middleware.ts +++ b/packages/hono/src/deno/middleware.ts @@ -2,7 +2,6 @@ import { debug } from '@sentry/core'; import type { DenoOptions } from '@sentry/deno'; import { init } from './sdk'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { getConnInfo } from 'hono/deno'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; import { applyPatches } from '../shared/applyPatches'; import type { SentryHonoMiddlewareOptions } from '../shared/types'; @@ -20,7 +19,7 @@ export const sentry = (app: Hono, options: HonoDenoOptions): M applyPatches(app); return async (context, next) => { - requestHandler(context, getConnInfo); + requestHandler(context, 'deno'); await next(); // Handler runs in between Request above ⤴ and Response below ⤵ diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index 0aaeb9604f61..bef91006b407 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,6 +1,5 @@ import { consoleSandbox, debug, getClient } from '@sentry/core'; import type { NodeOptions } from '@sentry/node'; -import { getConnInfo } from '@hono/node-server/conninfo'; import type { Env, Hono, MiddlewareHandler } from 'hono'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; import { applyPatches } from '../shared/applyPatches'; @@ -44,7 +43,7 @@ export const sentry = (app: Hono, options?: SentryHonoMiddlewa applyPatches(app); return async (context, next) => { - requestHandler(context, getConnInfo); + requestHandler(context, 'node'); await next(); // Handler runs in between Request above ⤴ and Response below ⤵ diff --git a/packages/hono/src/shared/connInfo.ts b/packages/hono/src/shared/connInfo.ts new file mode 100644 index 000000000000..4089284bb88a --- /dev/null +++ b/packages/hono/src/shared/connInfo.ts @@ -0,0 +1,60 @@ +import type { Context } from 'hono'; + +export type HonoRuntime = 'node' | 'bun' | 'deno' | 'cloudflare'; + +/** The client side of the request's connection. */ +export interface ConnInfo { + address?: string; + port?: number; + transport?: string; + addressType?: string; +} + +// What the runtime adapters pass to `app.fetch` as `c.env`. The readers copy Hono's `getConnInfo` helpers, +// which Hono 5 moved out of `hono`. The casts are unchecked, and `readConnInfo` catches a read that throws. +type NodeBindings = { + incoming?: { socket?: { remoteAddress?: string; remotePort?: number; remoteFamily?: string } }; +}; +type BunServer = { + requestIP?: (request: Request) => { address?: string; port?: number; family?: string } | null; +}; +type DenoServeHandlerInfo = { remoteAddr?: { hostname?: string; port?: number; transport?: string } }; + +const READERS: Record ConnInfo | undefined> = { + // https://github.com/honojs/node-server/blob/v2.1.5/src/conninfo.ts + node: context => { + const env = context.env as (NodeBindings & { server?: NodeBindings }) | undefined; + const socket = (env?.server ?? env)?.incoming?.socket; + return socket + ? { address: socket.remoteAddress, port: socket.remotePort, addressType: socket.remoteFamily } + : undefined; + }, + // https://github.com/honojs/hono/blob/v4.13.13/src/adapter/bun/conninfo.ts + bun: context => { + const env = context.env as (BunServer & { server?: BunServer }) | undefined; + const info = (env?.server ?? env)?.requestIP?.(context.req.raw); + return info ? { address: info.address, port: info.port, addressType: info.family } : undefined; + }, + // https://github.com/honojs/hono/blob/v4.13.13/src/adapter/deno/conninfo.ts + deno: context => { + const remoteAddr = (context.env as DenoServeHandlerInfo | undefined)?.remoteAddr; + return remoteAddr + ? { address: remoteAddr.hostname, port: remoteAddr.port, transport: remoteAddr.transport } + : undefined; + }, + // `c.env` holds the user's bindings here, so only the header is read. + // https://github.com/honojs/hono/blob/v4.13.13/src/adapter/cloudflare-workers/conninfo.ts + cloudflare: context => { + const address = context.req.raw.headers.get('cf-connecting-ip'); + return address ? { address } : undefined; + }, +}; + +/** Gives `undefined` when the runtime object is missing or a read throws. */ +export function readConnInfo(context: Context, runtime: HonoRuntime): ConnInfo | undefined { + try { + return READERS[runtime](context); + } catch { + return undefined; + } +} diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index 5f36ac386d92..92c1fd24a878 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -8,20 +8,32 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, updateSpanName, type Scope, + type SpanAttributes, + spanToJSON, + winterCGHeadersToDict, winterCGRequestToRequestData, } from '@sentry/core'; +import { getClientIPAddress } from '@sentry/core/server'; import type { Context } from 'hono'; import { hasFetchEvent } from '../utils/hono-context'; import { defaultShouldHandleError } from './defaultShouldHandleError'; import { resolveRouteName } from './resolveRouteName'; import { type SentryHonoMiddlewareOptions } from '../shared/types'; -import { type GetConnInfo } from 'hono/conninfo'; -import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import { type HonoRuntime, readConnInfo } from './connInfo'; +import { + CLIENT_ADDRESS, + CLIENT_PORT, + HTTP_ROUTE, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_TRANSPORT, + NETWORK_TYPE, +} from '@sentry/conventions/attributes'; /** * Request handler for Hono framework */ -export function requestHandler(context: Context, getConnInfo?: GetConnInfo): void { +export function requestHandler(context: Context, runtime: HonoRuntime): void { const isolationScope = getCurrentIsolationScope(); // Set a provisional route name as early as possible so events captured during the request already carry the route. @@ -32,9 +44,7 @@ export function requestHandler(context: Context, getConnInfo?: GetConnInfo): voi normalizedRequest: winterCGRequestToRequestData(hasFetchEvent(context) ? context.event.request : context.req.raw), }); - if (getConnInfo) { - setConnInfoAttributes(context, getConnInfo, isolationScope); - } + setConnInfoAttributes(context, runtime, isolationScope); } function getCurrentIsolationScope(): Scope { @@ -45,42 +55,60 @@ function getCurrentIsolationScope(): Scope { } /** - * Adds HTTP connection info (client IP, port, transport, address type) from Hono's `getConnInfo` - * helper to the root (server) span and the isolation scope. + * Adds HTTP connection info (client IP, port, transport, address type) from the runtime to the root + * (server) span and the isolation scope. + * + * Keeps attributes that the runtime SDK already set: it reads forwarding headers, while the runtime + * objects only know the socket peer, which can be a proxy. */ -function setConnInfoAttributes(context: Context, getConnInfo: GetConnInfo, isolationScope: Scope): void { +function setConnInfoAttributes(context: Context, runtime: HonoRuntime, isolationScope: Scope): void { const activeSpan = getActiveSpan(); if (!activeSpan) { return; } - let remote: ReturnType['remote'] | undefined; - try { - remote = getConnInfo(context).remote; - } catch { - // The helper can throw when the underlying socket/env is unavailable (e.g. unsupported runtime). + const connInfo = readConnInfo(context, runtime); + if (!connInfo) { return; } - const { address, port, transport, addressType } = remote || {}; + const { address, port, transport, addressType } = connInfo; // Only collect client IP if `userInfo` is enabled (this is primarily for setting data with `.setUser`, but in this case we cannot check for `dataCollection.headers` or similar) const ipAddress = address && getClient()?.getDataCollectionOptions().userInfo ? address : undefined; - getRootSpan(activeSpan).setAttributes({ - 'client.port': port, - 'network.peer.port': port, - 'network.transport': transport, - 'network.type': addressType?.toLowerCase(), - 'client.address': ipAddress, - 'network.peer.address': ipAddress, - }); + const rootSpan = getRootSpan(activeSpan); + const existingAttributes = spanToJSON(rootSpan).data ?? {}; + // Pair `client.port` only with the socket address: behind a proxy, the socket port is the proxy's. + const writesClientAddress = ipAddress !== undefined && existingAttributes[CLIENT_ADDRESS] === undefined; + rootSpan.setAttributes( + onlyNewAttributes( + { + [CLIENT_PORT]: writesClientAddress ? port : undefined, + [NETWORK_PEER_PORT]: port, + [NETWORK_TRANSPORT]: transport, + [NETWORK_TYPE]: addressType?.toLowerCase(), + [CLIENT_ADDRESS]: ipAddress, + [NETWORK_PEER_ADDRESS]: ipAddress, + }, + existingAttributes, + ), + ); if (ipAddress) { - isolationScope.setUser({ ...isolationScope.getUser(), ip_address: ipAddress }); + // Like `RequestData`, prefer the forwarding header: behind a proxy, the socket address is the proxy's. + const userIpAddress = getClientIPAddress(winterCGHeadersToDict(context.req.raw.headers)) || ipAddress; + isolationScope.setUser({ ...isolationScope.getUser(), ip_address: userIpAddress }); } } +/** Keeps the defined attributes that are not on the span yet. */ +function onlyNewAttributes(attributes: SpanAttributes, existingAttributes: Record): SpanAttributes { + return Object.fromEntries( + Object.entries(attributes).filter(([key, value]) => value !== undefined && existingAttributes[key] === undefined), + ); +} + /** * Response handler for Hono framework */ diff --git a/packages/hono/test/bun/middleware.test.ts b/packages/hono/test/bun/middleware.test.ts index 38bb8defa4e9..02dc7eda561b 100644 --- a/packages/hono/test/bun/middleware.test.ts +++ b/packages/hono/test/bun/middleware.test.ts @@ -1,3 +1,11 @@ +import { + CLIENT_ADDRESS, + CLIENT_PORT, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_TRANSPORT, + NETWORK_TYPE, +} from '@sentry/conventions/attributes'; import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; @@ -5,17 +13,12 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { sentry, type HonoBunOptions } from '../../src/bun/middleware'; import { init } from '../../src/bun/sdk'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; +import { requestConnInfoAttributes } from '../shared/requestConnInfoAttributes'; vi.mock('@sentry/bun', () => ({ init: vi.fn(), })); -// `hono/bun` eagerly imports Bun-only modules (e.g. SSG) that reference the `Bun` global, -// which is not available under Vitest/Node. We only use its `getConnInfo` helper. -vi.mock('hono/bun', () => ({ - getConnInfo: vi.fn(() => ({ remote: {} })), -})); - // eslint-disable-next-line @typescript-eslint/consistent-type-imports const { init: initBunMock } = await vi.importMock('@sentry/bun'); @@ -246,3 +249,20 @@ describe('Hono Bun Middleware', () => { }); }); }); + +describe('Hono Bun Middleware — connection info', () => { + it('reads the client address from `requestIP` of the server', async () => { + const middleware = sentry(new Hono(), { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + // `Bun.serve` passes the server to `app.fetch` as `env`. + const env = { requestIP: () => ({ address: '::1', port: 54321, family: 'IPv6' }) }; + + const attributes = await requestConnInfoAttributes(middleware, { env }); + + expect(attributes[CLIENT_ADDRESS]).toBe('::1'); + expect(attributes[CLIENT_PORT]).toBe(54321); + expect(attributes[NETWORK_PEER_ADDRESS]).toBe('::1'); + expect(attributes[NETWORK_PEER_PORT]).toBe(54321); + expect(attributes[NETWORK_TYPE]).toBe('ipv6'); + expect(attributes).not.toHaveProperty(NETWORK_TRANSPORT); + }); +}); diff --git a/packages/hono/test/cloudflare/middleware.test.ts b/packages/hono/test/cloudflare/middleware.test.ts index 64ca4056577b..92366bce7515 100644 --- a/packages/hono/test/cloudflare/middleware.test.ts +++ b/packages/hono/test/cloudflare/middleware.test.ts @@ -1,3 +1,11 @@ +import { + CLIENT_ADDRESS, + CLIENT_PORT, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_TRANSPORT, + NETWORK_TYPE, +} from '@sentry/conventions/attributes'; import * as SentryCloudflare from '@sentry/cloudflare'; import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; @@ -5,6 +13,7 @@ import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { sentry, type HonoCloudflareOptions } from '../../src/cloudflare/middleware'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; +import { requestConnInfoAttributes } from '../shared/requestConnInfoAttributes'; vi.mock('@sentry/cloudflare', { spy: true }); vi.mock('@sentry/core', async () => { @@ -197,3 +206,19 @@ describe('Hono Cloudflare Middleware', () => { }); }); }); + +describe('Hono Cloudflare Middleware — connection info', () => { + it('reads the `cf-connecting-ip` header', async () => { + const middleware = sentry(new Hono(), { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + const headers = { 'cf-connecting-ip': '203.0.113.5' }; + + const attributes = await requestConnInfoAttributes(middleware, { headers }); + + expect(attributes[CLIENT_ADDRESS]).toBe('203.0.113.5'); + expect(attributes[NETWORK_PEER_ADDRESS]).toBe('203.0.113.5'); + expect(attributes).not.toHaveProperty(CLIENT_PORT); + expect(attributes).not.toHaveProperty(NETWORK_PEER_PORT); + expect(attributes).not.toHaveProperty(NETWORK_TYPE); + expect(attributes).not.toHaveProperty(NETWORK_TRANSPORT); + }); +}); diff --git a/packages/hono/test/deno/middleware.test.ts b/packages/hono/test/deno/middleware.test.ts index 1254ab6fa9ea..134f535c2a62 100644 --- a/packages/hono/test/deno/middleware.test.ts +++ b/packages/hono/test/deno/middleware.test.ts @@ -1,3 +1,11 @@ +import { + CLIENT_ADDRESS, + CLIENT_PORT, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_TRANSPORT, + NETWORK_TYPE, +} from '@sentry/conventions/attributes'; import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; @@ -5,17 +13,12 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { sentry, type HonoDenoOptions } from '../../src/deno/middleware'; import { init } from '../../src/deno/sdk'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; +import { requestConnInfoAttributes } from '../shared/requestConnInfoAttributes'; vi.mock('@sentry/deno', () => ({ init: vi.fn(), })); -// `hono/deno` references the `Deno` global at module-eval time, which is not defined in the -// vitest (Node) environment. -vi.mock('hono/deno', () => ({ - getConnInfo: vi.fn(), -})); - // eslint-disable-next-line @typescript-eslint/consistent-type-imports const { init: initDenoMock } = await vi.importMock('@sentry/deno'); @@ -246,3 +249,20 @@ describe('Hono Deno Middleware', () => { }); }); }); + +describe('Hono Deno Middleware — connection info', () => { + it('reads the `remoteAddr` of the serve handler info', async () => { + const middleware = sentry(new Hono(), { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + // `Deno.serve` passes the `ServeHandlerInfo` to `app.fetch` as `env`. + const env = { remoteAddr: { hostname: '127.0.0.1', port: 54321, transport: 'tcp' } }; + + const attributes = await requestConnInfoAttributes(middleware, { env }); + + expect(attributes[CLIENT_ADDRESS]).toBe('127.0.0.1'); + expect(attributes[CLIENT_PORT]).toBe(54321); + expect(attributes[NETWORK_PEER_ADDRESS]).toBe('127.0.0.1'); + expect(attributes[NETWORK_PEER_PORT]).toBe(54321); + expect(attributes[NETWORK_TRANSPORT]).toBe('tcp'); + expect(attributes).not.toHaveProperty(NETWORK_TYPE); + }); +}); diff --git a/packages/hono/test/node/middleware.test.ts b/packages/hono/test/node/middleware.test.ts index 4355fb10e2e8..0ebe73083b6d 100644 --- a/packages/hono/test/node/middleware.test.ts +++ b/packages/hono/test/node/middleware.test.ts @@ -1,17 +1,22 @@ +import { + CLIENT_ADDRESS, + CLIENT_PORT, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_TRANSPORT, + NETWORK_TYPE, +} from '@sentry/conventions/attributes'; import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { sentry } from '../../src/node/middleware'; import { init } from '../../src/node/sdk'; +import { requestConnInfoAttributes } from '../shared/requestConnInfoAttributes'; vi.mock('@sentry/node', () => ({ init: vi.fn(), })); -vi.mock('@hono/node-server/conninfo', () => ({ - getConnInfo: vi.fn(() => ({ remote: {} })), -})); - // eslint-disable-next-line @typescript-eslint/consistent-type-imports const { init: initNodeMock } = await vi.importMock('@sentry/node'); @@ -232,3 +237,22 @@ describe('Hono Node Middleware', () => { }); }); }); + +describe('Hono Node Middleware — connection info', () => { + it('reads the socket of the incoming message', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const middleware = sentry(new Hono()); + consoleWarnSpy.mockRestore(); + // The `env` that `@hono/node-server` passes to `app.fetch`. + const env = { incoming: { socket: { remoteAddress: '203.0.113.5', remotePort: 54321, remoteFamily: 'IPv4' } } }; + + const attributes = await requestConnInfoAttributes(middleware, { env }); + + expect(attributes[CLIENT_ADDRESS]).toBe('203.0.113.5'); + expect(attributes[CLIENT_PORT]).toBe(54321); + expect(attributes[NETWORK_PEER_ADDRESS]).toBe('203.0.113.5'); + expect(attributes[NETWORK_PEER_PORT]).toBe(54321); + expect(attributes[NETWORK_TYPE]).toBe('ipv4'); + expect(attributes).not.toHaveProperty(NETWORK_TRANSPORT); + }); +}); diff --git a/packages/hono/test/shared/connInfo.test.ts b/packages/hono/test/shared/connInfo.test.ts new file mode 100644 index 000000000000..0bd13cf5f03f --- /dev/null +++ b/packages/hono/test/shared/connInfo.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readConnInfo } from '../../src/shared/connInfo'; +import type { Context } from 'hono'; + +function fakeContext({ env, headers }: { env?: unknown; headers?: Record } = {}): Context { + return { + env, + req: { raw: new Request('http://localhost/test', { headers }) }, + } as unknown as Context; +} + +const throwingGetter = (key: string): object => + Object.defineProperty({}, key, { + get() { + throw new Error('unavailable'); + }, + }); + +describe('readConnInfo', () => { + describe('node', () => { + const socket = { remoteAddress: '203.0.113.5', remotePort: 54321, remoteFamily: 'IPv4' }; + const expected = { address: '203.0.113.5', port: 54321, addressType: 'IPv4' }; + + it('reads the socket of `env.incoming`', () => { + expect(readConnInfo(fakeContext({ env: { incoming: { socket } } }), 'node')).toEqual(expected); + }); + + it('reads the socket of `env.server.incoming`', () => { + expect(readConnInfo(fakeContext({ env: { server: { incoming: { socket } } } }), 'node')).toEqual(expected); + }); + + it.each([ + { case: 'without `env`', env: undefined }, + { case: 'without an incoming message', env: {} }, + ])('returns undefined $case', ({ env }) => { + expect(readConnInfo(fakeContext({ env }), 'node')).toBeUndefined(); + }); + + it('returns undefined when the socket getter throws', () => { + expect(readConnInfo(fakeContext({ env: { incoming: throwingGetter('socket') } }), 'node')).toBeUndefined(); + }); + }); + + describe('bun', () => { + const requestIP = (): object => ({ address: '::1', port: 54321, family: 'IPv6' }); + const expected = { address: '::1', port: 54321, addressType: 'IPv6' }; + + it('calls `requestIP` of the server in `env`', () => { + expect(readConnInfo(fakeContext({ env: { requestIP } }), 'bun')).toEqual(expected); + }); + + it('calls `requestIP` of `env.server`', () => { + expect(readConnInfo(fakeContext({ env: { server: { requestIP } } }), 'bun')).toEqual(expected); + }); + + it('passes the raw request to `requestIP`', () => { + const spy = vi.fn(requestIP); + const context = fakeContext({ env: { requestIP: spy } }); + + readConnInfo(context, 'bun'); + + expect(spy).toHaveBeenCalledWith(context.req.raw); + }); + + it.each([ + { case: 'without a server', env: undefined }, + { case: 'without `requestIP`', env: {} }, + { case: 'when `requestIP` gives null', env: { requestIP: () => null } }, + ])('returns undefined $case', ({ env }) => { + expect(readConnInfo(fakeContext({ env }), 'bun')).toBeUndefined(); + }); + + it('returns undefined when `requestIP` throws', () => { + const env = { + requestIP: () => { + throw new Error('closed'); + }, + }; + + expect(readConnInfo(fakeContext({ env }), 'bun')).toBeUndefined(); + }); + }); + + describe('deno', () => { + it('reads `env.remoteAddr`', () => { + const env = { remoteAddr: { hostname: '127.0.0.1', port: 54321, transport: 'tcp' } }; + + expect(readConnInfo(fakeContext({ env }), 'deno')).toEqual({ + address: '127.0.0.1', + port: 54321, + transport: 'tcp', + }); + }); + + it.each([ + { case: 'without `env`', env: undefined }, + { case: 'without `remoteAddr`', env: {} }, + ])('returns undefined $case', ({ env }) => { + expect(readConnInfo(fakeContext({ env }), 'deno')).toBeUndefined(); + }); + + it('returns undefined when the `remoteAddr` getter throws', () => { + expect(readConnInfo(fakeContext({ env: throwingGetter('remoteAddr') }), 'deno')).toBeUndefined(); + }); + }); + + describe('cloudflare', () => { + it('reads the `cf-connecting-ip` header', () => { + const context = fakeContext({ headers: { 'cf-connecting-ip': '203.0.113.5' } }); + + expect(readConnInfo(context, 'cloudflare')).toEqual({ address: '203.0.113.5' }); + }); + + it('ignores the bindings in `env`', () => { + const env = { incoming: { socket: { remoteAddress: '10.0.0.1' } }, remoteAddr: { hostname: '10.0.0.1' } }; + + expect(readConnInfo(fakeContext({ env }), 'cloudflare')).toBeUndefined(); + }); + + it('returns undefined without the header', () => { + expect(readConnInfo(fakeContext(), 'cloudflare')).toBeUndefined(); + }); + }); +}); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts index 0ba38af16bc9..a5008853bac5 100644 --- a/packages/hono/test/shared/middlewareHandlers.test.ts +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -1,6 +1,16 @@ import * as SentryCore from '@sentry/core'; -import { HTTP_ROUTE } from '@sentry/conventions/attributes'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CLIENT_ADDRESS, + CLIENT_PORT, + HTTP_ROUTE, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_TRANSPORT, + NETWORK_TYPE, +} from '@sentry/conventions/attributes'; +import type { Context } from 'hono'; +import type { HonoRuntime } from '../../src/shared/connInfo'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { requestHandler, responseHandler } from '../../src/shared/middlewareHandlers'; vi.mock('hono/route', () => ({ @@ -50,8 +60,9 @@ vi.mock('@sentry/core', async () => { const getClientMock = SentryCore.getClient as ReturnType; const captureExceptionMock = SentryCore.captureException as ReturnType; const getActiveSpanMock = SentryCore.getActiveSpan as ReturnType; +const getRootSpanMock = SentryCore.getRootSpan as ReturnType; -function createMockContext(status: number, error?: Error): unknown { +function createMockContext(status: number, error?: Error): object { return { req: { method: 'GET', routeIndex: 0, raw: new Request('http://localhost/test') }, res: { status }, @@ -187,7 +198,7 @@ describe('responseHandler', () => { describe('transaction name', () => { it('sets transaction name on isolation scope', () => { // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + requestHandler(createMockContext(200) as any, 'node'); expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); }); @@ -196,7 +207,7 @@ describe('responseHandler', () => { getActiveSpanMock.mockReturnValue(mockRootSpan); // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + requestHandler(createMockContext(200) as any, 'node'); expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ 'sentry.source': 'route', @@ -206,18 +217,27 @@ describe('responseHandler', () => { }); }); +async function runMiddleware(context: unknown, { runtime = 'node' }: { runtime?: HonoRuntime } = {}): Promise { + requestHandler(context as Context, runtime); +} + describe('requestHandler — connection info', () => { - const activeSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; + let rootSpan: SentryCore.SentrySpan; beforeEach(() => { vi.clearAllMocks(); - rootSpanAttributes = {}; mockGetUser.mockReturnValue({}); - getActiveSpanMock.mockReturnValue(activeSpan); + rootSpan = new SentryCore.SentrySpan({ name: 'GET /test' }); + getActiveSpanMock.mockReturnValue(rootSpan); + getRootSpanMock.mockReturnValue(rootSpan); + }); + + afterEach(() => { + getRootSpanMock.mockReturnValue(mockRootSpan); }); - function getConnInfoStub(remote: Record): () => { remote: Record } { - return vi.fn(() => ({ remote })); + function getRootSpanAttributes(): Record { + return SentryCore.spanToJSON(rootSpan).data ?? {}; } function mockUserInfo(userInfo: boolean): void { @@ -226,40 +246,41 @@ describe('requestHandler — connection info', () => { }); } - it('sets non-PII attributes (port, transport, type) regardless of userInfo', () => { + // The `c.env` that `@hono/node-server` passes to `app.fetch`. + function createNodeContext(socket: Record, headers?: Record): object { + const context = createMockContext(200) as { req: { raw: Request } }; + context.req.raw = new Request('http://localhost/test', { headers }); + return Object.assign(context, { env: { incoming: { socket } } }); + } + + it('sets the peer port and address type regardless of userInfo', async () => { mockUserInfo(false); - const getConnInfo = getConnInfoStub({ port: 54321, transport: 'tcp', addressType: 'IPv4' }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createNodeContext({ remoteAddress: '203.0.113.5', remotePort: 54321, remoteFamily: 'IPv4' })); - expect(rootSpanAttributes['client.port']).toBe(54321); - expect(rootSpanAttributes['network.peer.port']).toBe(54321); - expect(rootSpanAttributes['network.transport']).toBe('tcp'); - expect(rootSpanAttributes['network.type']).toBe('ipv4'); - expect(rootSpanAttributes['client.address']).toBeUndefined(); + const attributes = getRootSpanAttributes(); + expect(attributes[NETWORK_PEER_PORT]).toBe(54321); + expect(attributes[NETWORK_TYPE]).toBe('ipv4'); }); - it('sets IP-bearing attributes and user.ip_address when userInfo is true', () => { + it('sets IP-bearing attributes and user.ip_address when userInfo is true', async () => { mockUserInfo(true); - const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443, addressType: 'IPv6' }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createNodeContext({ remoteAddress: '203.0.113.5', remotePort: 443, remoteFamily: 'IPv6' })); - expect(rootSpanAttributes['client.address']).toBe('203.0.113.5'); - expect(rootSpanAttributes['network.peer.address']).toBe('203.0.113.5'); - expect(rootSpanAttributes['network.type']).toBe('ipv6'); + const attributes = getRootSpanAttributes(); + expect(attributes[CLIENT_ADDRESS]).toBe('203.0.113.5'); + expect(attributes[CLIENT_PORT]).toBe(443); + expect(attributes[NETWORK_PEER_ADDRESS]).toBe('203.0.113.5'); + expect(attributes[NETWORK_TYPE]).toBe('ipv6'); expect(mockSetUser).toHaveBeenCalledWith({ ip_address: '203.0.113.5' }); }); - it('merges ip_address into the existing user without overwriting other fields', () => { + it('merges ip_address into the existing user without overwriting other fields', async () => { mockUserInfo(true); mockGetUser.mockReturnValue({ id: 'user-123', email: 'jane@example.com' }); - const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createNodeContext({ remoteAddress: '203.0.113.5', remotePort: 443 })); expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-123', @@ -268,69 +289,117 @@ describe('requestHandler — connection info', () => { }); }); - it('omits IP-bearing attributes when userInfo is false', () => { + it('omits IP-bearing attributes when userInfo is false', async () => { mockUserInfo(false); - const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 8080 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware( + createNodeContext({ remoteAddress: '203.0.113.5', remotePort: 8080 }, { 'x-forwarded-for': '198.51.100.7' }), + ); - expect(rootSpanAttributes['client.address']).toBeUndefined(); - expect(rootSpanAttributes['network.peer.address']).toBeUndefined(); + const attributes = getRootSpanAttributes(); + expect(attributes).not.toHaveProperty(CLIENT_ADDRESS); + expect(attributes).not.toHaveProperty(CLIENT_PORT); + expect(attributes).not.toHaveProperty(NETWORK_PEER_ADDRESS); expect(mockSetUser).not.toHaveBeenCalled(); // Non-PII data is still recorded. - expect(rootSpanAttributes['client.port']).toBe(8080); + expect(attributes[NETWORK_PEER_PORT]).toBe(8080); }); - it('sets no connection attributes when remote info is empty', () => { + it('does not write the attributes that the runtime does not give', async () => { mockUserInfo(true); - const getConnInfo = getConnInfoStub({}); + rootSpan.setAttribute(NETWORK_TRANSPORT, 'tcp'); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createNodeContext({ remotePort: 54321 })); - expect(rootSpanAttributes['client.port']).toBeUndefined(); - expect(rootSpanAttributes['network.peer.port']).toBeUndefined(); - expect(rootSpanAttributes['network.transport']).toBeUndefined(); - expect(rootSpanAttributes['network.type']).toBeUndefined(); - expect(rootSpanAttributes['client.address']).toBeUndefined(); + const attributes = getRootSpanAttributes(); + expect(attributes[NETWORK_TRANSPORT]).toBe('tcp'); + expect(attributes).not.toHaveProperty(CLIENT_ADDRESS); + expect(attributes).not.toHaveProperty(NETWORK_TYPE); expect(mockSetUser).not.toHaveBeenCalled(); }); - it('does not throw or set attributes when getConnInfo throws', () => { + it('does not throw or set attributes when a runtime object throws', async () => { mockUserInfo(true); - const getConnInfo = vi.fn(() => { - throw new Error('socket unavailable'); + const incoming = Object.defineProperty({}, 'socket', { + get() { + throw new Error('socket unavailable'); + }, }); + const context = Object.assign(createMockContext(200), { env: { incoming } }); - expect(() => - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any), - ).not.toThrow(); - expect(rootSpanAttributes['client.port']).toBeUndefined(); - expect(rootSpanAttributes['client.address']).toBeUndefined(); + await expect(runMiddleware(context)).resolves.toBeUndefined(); + expect(getRootSpanAttributes()).not.toHaveProperty(CLIENT_PORT); expect(mockSetUser).not.toHaveBeenCalled(); }); - it('does not set connection attributes when there is no active span', () => { + it('does not set connection attributes when there is no active span', async () => { mockUserInfo(true); getActiveSpanMock.mockReturnValue(null); - const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createNodeContext({ remoteAddress: '203.0.113.5', remotePort: 443 })); - expect(getConnInfo).not.toHaveBeenCalled(); - expect(rootSpanAttributes).toEqual({}); + const attributes = getRootSpanAttributes(); + expect(attributes).not.toHaveProperty(CLIENT_ADDRESS); + expect(attributes).not.toHaveProperty(NETWORK_PEER_PORT); + expect(mockSetUser).not.toHaveBeenCalled(); }); - it('is a no-op when getConnInfo is not provided', () => { + it('sets no connection attributes when the runtime objects are missing', async () => { mockUserInfo(true); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + await runMiddleware(createMockContext(200)); - expect(rootSpanAttributes['client.port']).toBeUndefined(); + const attributes = getRootSpanAttributes(); + expect(attributes).not.toHaveProperty(CLIENT_PORT); + expect(attributes).not.toHaveProperty(CLIENT_ADDRESS); expect(mockSetUser).not.toHaveBeenCalled(); }); + + it('sets the transport that the runtime gives', async () => { + mockUserInfo(false); + const context = Object.assign(createMockContext(200), { + env: { remoteAddr: { hostname: '203.0.113.5', port: 54321, transport: 'tcp' } }, + }); + + await runMiddleware(context, { runtime: 'deno' }); + + expect(getRootSpanAttributes()[NETWORK_TRANSPORT]).toBe('tcp'); + }); + + it('keeps attributes that are already on the root span', async () => { + mockUserInfo(true); + rootSpan.setAttributes({ [CLIENT_ADDRESS]: '198.51.100.7', [CLIENT_PORT]: 1234 }); + + await runMiddleware(createNodeContext({ remoteAddress: '10.0.0.1', remotePort: 54321 })); + + const attributes = getRootSpanAttributes(); + expect(attributes[CLIENT_ADDRESS]).toBe('198.51.100.7'); + expect(attributes[CLIENT_PORT]).toBe(1234); + expect(attributes[NETWORK_PEER_ADDRESS]).toBe('10.0.0.1'); + expect(attributes[NETWORK_PEER_PORT]).toBe(54321); + }); + + it('does not pair the socket port with a client.address that is already on the root span', async () => { + mockUserInfo(true); + // Set by the runtime SDK from a forwarding header, without a port. + rootSpan.setAttribute(CLIENT_ADDRESS, '198.51.100.7'); + + await runMiddleware(createNodeContext({ remoteAddress: '10.0.0.1', remotePort: 54321 })); + + const attributes = getRootSpanAttributes(); + expect(attributes[CLIENT_ADDRESS]).toBe('198.51.100.7'); + expect(attributes).not.toHaveProperty(CLIENT_PORT); + expect(attributes[NETWORK_PEER_PORT]).toBe(54321); + }); + + it('sets user.ip_address from a forwarding header before the socket address', async () => { + mockUserInfo(true); + + await runMiddleware( + createNodeContext({ remoteAddress: '10.0.0.1', remotePort: 54321 }, { 'x-forwarded-for': '198.51.100.7' }), + ); + + expect(mockSetUser).toHaveBeenCalledWith({ ip_address: '198.51.100.7' }); + expect(getRootSpanAttributes()[NETWORK_PEER_ADDRESS]).toBe('10.0.0.1'); + }); }); diff --git a/packages/hono/test/shared/requestConnInfoAttributes.ts b/packages/hono/test/shared/requestConnInfoAttributes.ts new file mode 100644 index 000000000000..eec61598d4a7 --- /dev/null +++ b/packages/hono/test/shared/requestConnInfoAttributes.ts @@ -0,0 +1,25 @@ +import * as SentryCore from '@sentry/core'; +import { Hono, type MiddlewareHandler } from 'hono'; +import { vi } from 'vitest'; + +/** Sends a request through `middleware` in a root span, with `userInfo` on, and returns the span attributes. */ +export async function requestConnInfoAttributes( + middleware: MiddlewareHandler, + { env, headers }: { env?: unknown; headers?: Record }, +): Promise> { + const app = new Hono(); + app.use(middleware); + app.get('/', c => c.text('ok')); + + const fakeClient = { getDataCollectionOptions: () => ({ userInfo: true }) }; + const getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue(fakeClient as unknown as SentryCore.Client); + const rootSpan = new SentryCore.SentrySpan({ name: 'GET /' }); + + try { + await SentryCore.withActiveSpan(rootSpan, () => app.request('/', { headers }, env)); + } finally { + getClientSpy.mockRestore(); + } + + return SentryCore.spanToJSON(rootSpan).data ?? {}; +}