Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ app.use(
dsn: process.env.E2E_TEST_DSN,
environment: 'qa',
tracesSampleRate: 1.0,
dataCollection: { userInfo: true },
tunnel: 'http://localhost:3031/',
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ app.use(
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tracesSampleRate: 1.0,
dataCollection: { userInfo: true },
tunnel: 'http://localhost:3031/',
})),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ Sentry.init({
dsn: process.env.E2E_TEST_DSN,
environment: 'qa',
tracesSampleRate: 1.0,
dataCollection: { userInfo: true },
tunnel: 'http://localhost:3031/',
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { APP_NAME } from './constants';
import { APP_NAME, RUNTIME } from './constants';

test('sends a transaction for the index route', async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
Expand Down Expand Up @@ -28,6 +28,73 @@ test('sends a transaction for a parameterized route', async ({ baseURL }) => {
expect(transaction.contexts?.trace?.op).toBe('http.server');
});

test('attaches HTTP connection info to the server transaction', async ({ baseURL, page }) => {
page.on('console', msg => {
console.log(`PAGE LOG: ${msg.text()}`);
});
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /';
});

const response = await fetch(`${baseURL}/`);
expect(response.status).toBe(200);

const transaction = await transactionPromise;
const data = transaction.contexts?.trace?.data ?? {};

expect(data['client.address']).toEqual(expect.any(String));
expect(data['network.peer.address']).toBe(data['client.address']);

if (RUNTIME === 'node' || RUNTIME === 'bun') {
// Node (@hono/node-server) and Bun expose socket-level port and address family.
expect(data['client.port']).toEqual(expect.any(Number));
expect(data['network.peer.port']).toBe(data['client.port']);
expect(data['network.type']).toMatch(/^ipv[46]$/);
} else if (RUNTIME === 'cloudflare') {
// Cloudflare Workers expose no port, address family, or transport.
// This could change in the future and checking for the absence of these fields allows us to notice if/when that happens.
expect(data['client.port']).toBeUndefined();
expect(data['network.peer.port']).toBeUndefined();
expect(data['network.type']).toBeUndefined();
} else {
throw new Error(`No tests for runtime: ${RUNTIME}`);
}

// Only available in `hono/deno`
expect(data['network.transport']).toBeUndefined();
});

// Regression guard against connection info attributes.
// The conninfo middleware must only *add* attributes, never replace or clear existing ones.
// These are the baseline attributes the server transaction carries *without* the conninfo feature
test("preserves the baseline network.* server span attributes that the SDK sends without Hono's conninfo", async ({
baseURL,
}) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /';
});

const response = await fetch(`${baseURL}/`);
expect(response.status).toBe(200);

const transaction = await transactionPromise;
const data = transaction.contexts?.trace?.data ?? {};

if (RUNTIME === 'node') {
expect(data['net.host.name']).toBe('localhost');
expect(data['net.transport']).toBe('ip_tcp');
expect(data['net.host.ip']).toEqual(expect.any(String));
expect(data['net.peer.ip']).toEqual(expect.any(String));
expect(data['net.peer.port']).toEqual(expect.any(Number));
} else if (RUNTIME === 'bun') {
// Doesn't set net.*, network.*, or client.* attributes
} else if (RUNTIME === 'cloudflare') {
expect(data['network.protocol.name']).toBe('HTTP/1.1');
} else {
throw new Error(`No tests for runtime: ${RUNTIME}`);
}
});

test('sends a transaction for a route that throws', async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes('/error/');
Expand Down
5 changes: 5 additions & 0 deletions packages/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.x",
"@hono/node-server": "^1.x",
"@sentry/bun": "10.57.0",
"@sentry/cloudflare": "10.57.0",
"@sentry/node": "10.57.0",
Expand All @@ -92,6 +93,9 @@
"@cloudflare/workers-types": {
"optional": true
},
"@hono/node-server": {
"optional": true
},
"@sentry/bun": {
"optional": true
},
Expand All @@ -104,6 +108,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "4.20250922.0",
"@hono/node-server": "^1.19.10",
"@types/node": "^18.19.1",
"wrangler": "4.62.0"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/hono/src/bun/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type BaseTransportOptions, debug, type Options } 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 @@ -18,7 +19,7 @@ export const sentry = <E extends Env>(app: Hono<E>, options: HonoBunOptions): Mi
applyPatches(app);

return async (context, next) => {
requestHandler(context);
requestHandler(context, getConnInfo);

await next(); // Handler runs in between Request above ⤴ and Response below ⤵

Expand Down
3 changes: 2 additions & 1 deletion packages/hono/src/cloudflare/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { withSentry } from '@sentry/cloudflare';
import { applySdkMetadata, type BaseTransportOptions, debug, type Options } 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 @@ -44,7 +45,7 @@ export function sentry<E extends Env>(
? options(context.env as E['Bindings']).shouldHandleError
: options.shouldHandleError;

requestHandler(context);
requestHandler(context, getConnInfo);

await next(); // Handler runs in between Request above ⤴ and Response below ⤵

Expand Down
3 changes: 2 additions & 1 deletion packages/hono/src/node/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type BaseTransportOptions, consoleSandbox, debug, getClient, type Options } from '@sentry/core';
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 @@ -42,7 +43,7 @@ export const sentry = <E extends Env>(app: Hono<E>, options?: SentryHonoMiddlewa
applyPatches(app);

return async (context, next) => {
requestHandler(context);
requestHandler(context, getConnInfo);

await next(); // Handler runs in between Request above ⤴ and Response below ⤵

Expand Down
44 changes: 43 additions & 1 deletion packages/hono/src/shared/middlewareHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import { routePath } from 'hono/route';
import { hasFetchEvent } from '../utils/hono-context';
import { defaultShouldHandleError } from './defaultShouldHandleError';
import { type SentryHonoMiddlewareOptions } from '../shared/types';
import { type GetConnInfo } from 'hono/conninfo';

/**
* Request handler for Hono framework
*/
export function requestHandler(context: Context): void {
export function requestHandler(context: Context, getConnInfo?: GetConnInfo): void {
const defaultScope = getDefaultIsolationScope();
const currentIsolationScope = getIsolationScope();

Expand All @@ -29,6 +30,47 @@ export function requestHandler(context: Context): void {
isolationScope.setSDKProcessingMetadata({
normalizedRequest: winterCGRequestToRequestData(hasFetchEvent(context) ? context.event.request : context.req.raw),
});

if (getConnInfo) {
setConnInfoAttributes(context, getConnInfo, isolationScope);
}
}

/**
* Adds HTTP connection info (client IP, port, transport, address type) from Hono's `getConnInfo`
* helper to the root (server) span and the isolation scope.
*/
function setConnInfoAttributes(context: Context, getConnInfo: GetConnInfo, 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).
return;
}

const { address, port, transport, addressType } = remote || {};

// 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,
});
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
s1gr1d marked this conversation as resolved.

if (ipAddress) {
isolationScope.setUser({ ...isolationScope.getUser(), ip_address: ipAddress });
}
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/hono/test/bun/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ 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
4 changes: 4 additions & 0 deletions packages/hono/test/node/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ 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<typeof import('@sentry/node')>('@sentry/node');

Expand Down
Loading
Loading