Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/server-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 1 addition & 2 deletions packages/hono/src/bun/middleware.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,7 +19,7 @@ export const sentry = <E extends Env>(app: Hono<E>, 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 ⤵

Expand Down
3 changes: 1 addition & 2 deletions packages/hono/src/cloudflare/middleware.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -45,7 +44,7 @@ export function sentry<E extends Env>(
? 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 ⤵

Expand Down
3 changes: 1 addition & 2 deletions packages/hono/src/deno/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,7 +19,7 @@ export const sentry = <E extends Env>(app: Hono<E>, 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 ⤵

Expand Down
3 changes: 1 addition & 2 deletions packages/hono/src/node/middleware.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,7 +43,7 @@ export const sentry = <E extends Env>(app: Hono<E>, 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 ⤵

Expand Down
60 changes: 60 additions & 0 deletions packages/hono/src/shared/connInfo.ts
Original file line number Diff line number Diff line change
@@ -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<HonoRuntime, (context: Context) => 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;
}
}
76 changes: 52 additions & 24 deletions packages/hono/src/shared/middlewareHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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<GetConnInfo>['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<string, unknown>): SpanAttributes {
return Object.fromEntries(
Object.entries(attributes).filter(([key, value]) => value !== undefined && existingAttributes[key] === undefined),
);
}

/**
* Response handler for Hono framework
*/
Expand Down
32 changes: 26 additions & 6 deletions packages/hono/test/bun/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
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';
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<typeof import('@sentry/bun')>('@sentry/bun');

Expand Down Expand Up @@ -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);
});
});
25 changes: 25 additions & 0 deletions packages/hono/test/cloudflare/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
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';
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 () => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading