diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 9004aa9e2fbf..15a3e9beda35 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -27,6 +27,7 @@ "@aws-sdk/client-s3": "^3.552.0", "@google/genai": "^1.20.0", "@hapi/hapi": "^21.3.10", + "@hono/node-server": "^1.19.4", "@nestjs/common": "11.1.3", "@nestjs/core": "11.1.3", "@nestjs/platform-express": "11.1.3", @@ -49,6 +50,7 @@ "express": "^4.21.1", "generic-pool": "^3.9.0", "graphql": "^16.3.0", + "hono": "^4.9.8", "http-terminator": "^3.2.0", "ioredis": "^5.4.1", "kafkajs": "2.2.4", diff --git a/dev-packages/node-integration-tests/suites/tracing/hono/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/hono/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hono/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/hono/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/hono/scenario.mjs new file mode 100644 index 000000000000..7ef73c3d22c3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hono/scenario.mjs @@ -0,0 +1,196 @@ +import { serve } from '@hono/node-server'; +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-core-integration-tests'; +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const app = new Hono(); + +Sentry.setupHonoErrorHandler(app); + +// Global middleware to capture all requests +app.use(async function global(c, next) { + await next(); +}); + +const basePaths = ['/sync', '/async']; +const methods = ['get', 'post', 'put', 'delete', 'patch']; + +basePaths.forEach(basePath => { + // Sub-path middleware to capture all requests under the basePath + app.use(`${basePath}/*`, async function base(c, next) { + await next(); + }); + + const baseApp = new Hono(); + methods.forEach(method => { + baseApp[method]('/', c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }); + + baseApp[method]( + '/middleware', + // anonymous middleware + async (c, next) => { + await next(); + }, + c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }, + ); + + // anonymous middleware + baseApp[method]('/middleware/separately', async (c, next) => { + await next(); + }); + + baseApp[method]('/middleware/separately', async c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }); + + baseApp.all('/all', c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }); + + baseApp.all( + '/all/middleware', + // anonymous middleware + async (c, next) => { + await next(); + }, + c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }, + ); + + // anonymous middleware + baseApp.all('/all/middleware/separately', async (c, next) => { + await next(); + }); + + baseApp.all('/all/middleware/separately', async c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }); + + baseApp.on(method, '/on', c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }); + + baseApp.on( + method, + '/on/middleware', + // anonymous middleware + async (c, next) => { + await next(); + }, + c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }, + ); + + // anonymous middleware + baseApp.on(method, '/on/middleware/separately', async (c, next) => { + await next(); + }); + + baseApp.on(method, '/on/middleware/separately', async c => { + const response = c.text('response 200'); + if (basePath === '/sync') return response; + return Promise.resolve(response); + }); + + baseApp[method]('/401', () => { + const response = new HTTPException(401, { message: 'response 401' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp.all('/all/401', () => { + const response = new HTTPException(401, { message: 'response 401' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp.on(method, '/on/401', () => { + const response = new HTTPException(401, { message: 'response 401' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp[method]('/402', () => { + const response = new HTTPException(402, { message: 'response 402' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp.all('/all/402', () => { + const response = new HTTPException(402, { message: 'response 402' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp.on(method, '/on/402', () => { + const response = new HTTPException(402, { message: 'response 402' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp[method]('/403', () => { + const response = new HTTPException(403, { message: 'response 403' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp.all('/all/403', () => { + const response = new HTTPException(403, { message: 'response 403' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp.on(method, '/on/403', () => { + const response = new HTTPException(403, { message: 'response 403' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp[method]('/500', () => { + const response = new HTTPException(500, { message: 'response 500' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp.all('/all/500', () => { + const response = new HTTPException(500, { message: 'response 500' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + + baseApp.on(method, '/on/500', () => { + const response = new HTTPException(500, { message: 'response 500' }); + if (basePath === '/sync') throw response; + return Promise.reject(response); + }); + }); + + app.route(basePath, baseApp); +}); + +const port = 8787; +serve({ fetch: app.fetch, port }); +sendPortToRunner(port); diff --git a/dev-packages/node-integration-tests/suites/tracing/hono/test.ts b/dev-packages/node-integration-tests/suites/tracing/hono/test.ts new file mode 100644 index 000000000000..67d0ff8b56fb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hono/test.ts @@ -0,0 +1,251 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('hono tracing', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + describe.each(['/sync', '/async'] as const)('when using %s route', route => { + describe.each(['get', 'post', 'put', 'delete', 'patch'] as const)('when using %s method', method => { + describe.each(['/', '/all', '/on'])('when using %s path', path => { + test('should handle transaction', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: `${method.toUpperCase()} ${route}${path === '/' ? '' : path}`, + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'sentryRequestMiddleware', + 'hono.type': 'middleware', + }), + description: 'sentryRequestMiddleware', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'sentryErrorMiddleware', + 'hono.type': 'middleware', + }), + description: 'sentryErrorMiddleware', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'global', + 'hono.type': 'middleware', + }), + description: 'global', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'base', + 'hono.type': 'middleware', + }), + description: 'base', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': `${route}${path === '/' ? '' : path}`, + 'hono.type': 'request_handler', + }), + description: `${route}${path === '/' ? '' : path}`, + op: 'request_handler.hono', + origin: 'auto.http.otel.hono', + }), + ]), + }, + }) + .start(); + runner.makeRequest(method, `${route}${path === '/' ? '' : path}`); + await runner.completed(); + }); + + test('should handle transaction with anonymous middleware', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: `${method.toUpperCase()} ${route}${path === '/' ? '' : path}/middleware`, + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'sentryRequestMiddleware', + 'hono.type': 'middleware', + }), + description: 'sentryRequestMiddleware', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'sentryErrorMiddleware', + 'hono.type': 'middleware', + }), + description: 'sentryErrorMiddleware', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'global', + 'hono.type': 'middleware', + }), + description: 'global', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'base', + 'hono.type': 'middleware', + }), + description: 'base', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'anonymous', + 'hono.type': 'middleware', + }), + description: 'anonymous', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': `${route}${path === '/' ? '' : path}/middleware`, + 'hono.type': 'request_handler', + }), + description: `${route}${path === '/' ? '' : path}/middleware`, + op: 'request_handler.hono', + origin: 'auto.http.otel.hono', + }), + ]), + }, + }) + .start(); + runner.makeRequest(method, `${route}${path === '/' ? '' : path}/middleware`); + await runner.completed(); + }); + + test('should handle transaction with separate middleware', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: `${method.toUpperCase()} ${route}${path === '/' ? '' : path}/middleware/separately`, + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'sentryRequestMiddleware', + 'hono.type': 'middleware', + }), + description: 'sentryRequestMiddleware', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'sentryErrorMiddleware', + 'hono.type': 'middleware', + }), + description: 'sentryErrorMiddleware', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'global', + 'hono.type': 'middleware', + }), + description: 'global', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'base', + 'hono.type': 'middleware', + }), + description: 'base', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': 'anonymous', + 'hono.type': 'middleware', + }), + description: 'anonymous', + op: 'middleware.hono', + origin: 'auto.http.otel.hono', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'hono.name': `${route}${path === '/' ? '' : path}/middleware/separately`, + 'hono.type': 'request_handler', + }), + description: `${route}${path === '/' ? '' : path}/middleware/separately`, + op: 'request_handler.hono', + origin: 'auto.http.otel.hono', + }), + ]), + }, + }) + .start(); + runner.makeRequest(method, `${route}${path === '/' ? '' : path}/middleware/separately`); + await runner.completed(); + }); + + test('should handle returned errors for %s path', async () => { + const runner = createRunner() + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'auto.middleware.hono', + handled: false, + }, + type: 'Error', + value: 'response 500', + }, + ], + }, + }, + }) + .start(); + runner.makeRequest(method, `${route}${path === '/' ? '' : path}/500`, { expectError: true }); + await runner.completed(); + }); + + test.each(['/401', '/402', '/403', '/does-not-exist'])( + 'should ignores error %s path by default', + async (subPath: string) => { + const runner = createRunner() + .expect({ + transaction: { + transaction: `${method.toUpperCase()} ${route}`, + }, + }) + .start(); + runner.makeRequest(method, `${route}${path === '/' ? '' : path}${subPath}`, { expectError: true }); + runner.makeRequest(method, route); + await runner.completed(); + }, + ); + }); + }); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index dfe27dc5a4a5..b88f65982956 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -167,7 +167,7 @@ type StartResult = { getLogs(): string[]; getPort(): number | undefined; makeRequest( - method: 'get' | 'post', + method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, ): Promise; @@ -655,7 +655,7 @@ export function createRunner(...paths: string[]) { return scenarioServerPort; }, makeRequest: async function ( - method: 'get' | 'post', + method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, ): Promise { @@ -674,7 +674,7 @@ export function createRunner(...paths: string[]) { if (process.env.DEBUG) log('making request', method, url, headers, body); try { - const res = await fetch(url, { headers, method, body }); + const res = await fetch(url, { headers, method: method.toUpperCase(), body }); if (!res.ok) { if (!expectError) { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index de4079c4b5c4..d39cb5e4484d 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -64,6 +64,7 @@ export { winterCGHeadersToDict, graphqlIntegration, hapiIntegration, + honoIntegration, httpIntegration, // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration, @@ -116,6 +117,7 @@ export { setupConnectErrorHandler, setupExpressErrorHandler, setupHapiErrorHandler, + setupHonoErrorHandler, setupKoaErrorHandler, setUser, spanToBaggageHeader, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 0cbe5879b02e..cfab7b72754b 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -111,6 +111,8 @@ export { createSentryWinstonTransport, hapiIntegration, setupHapiErrorHandler, + honoIntegration, + setupHonoErrorHandler, spotlightIntegration, initOpenTelemetry, spanToJSON, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index b1c4854e5026..68a1e2b6d6ff 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -130,6 +130,8 @@ export { prismaIntegration, hapiIntegration, setupHapiErrorHandler, + honoIntegration, + setupHonoErrorHandler, spotlightIntegration, initOpenTelemetry, spanToJSON, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index fc0fe353b919..ac0f41079017 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -109,6 +109,8 @@ export { prismaIntegration, hapiIntegration, setupHapiErrorHandler, + honoIntegration, + setupHonoErrorHandler, spotlightIntegration, initOpenTelemetry, spanToJSON, diff --git a/packages/node-core/src/utils/ensureIsWrapped.ts b/packages/node-core/src/utils/ensureIsWrapped.ts index a73c087ebaf2..921d01da8207 100644 --- a/packages/node-core/src/utils/ensureIsWrapped.ts +++ b/packages/node-core/src/utils/ensureIsWrapped.ts @@ -9,7 +9,7 @@ import { isCjs } from './detection'; */ export function ensureIsWrapped( maybeWrappedFunction: unknown, - name: 'express' | 'connect' | 'fastify' | 'hapi' | 'koa', + name: 'express' | 'connect' | 'fastify' | 'hapi' | 'koa' | 'hono', ): void { const clientOptions = getClient()?.getOptions(); if ( diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 853ec8dbac2f..67e00660c2a1 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -15,6 +15,7 @@ export { postgresIntegration } from './integrations/tracing/postgres'; export { postgresJsIntegration } from './integrations/tracing/postgresjs'; export { prismaIntegration } from './integrations/tracing/prisma'; export { hapiIntegration, setupHapiErrorHandler } from './integrations/tracing/hapi'; +export { honoIntegration, setupHonoErrorHandler } from './integrations/tracing/hono'; export { koaIntegration, setupKoaErrorHandler } from './integrations/tracing/koa'; export { connectIntegration, setupConnectErrorHandler } from './integrations/tracing/connect'; export { knexIntegration } from './integrations/tracing/knex'; diff --git a/packages/node/src/integrations/tracing/hono/constants.ts b/packages/node/src/integrations/tracing/hono/constants.ts new file mode 100644 index 000000000000..5814f5e950f2 --- /dev/null +++ b/packages/node/src/integrations/tracing/hono/constants.ts @@ -0,0 +1,13 @@ +export const AttributeNames = { + HONO_TYPE: 'hono.type', + HONO_NAME: 'hono.name', +} as const; + +export type AttributeNames = (typeof AttributeNames)[keyof typeof AttributeNames]; + +export const HonoTypes = { + MIDDLEWARE: 'middleware', + REQUEST_HANDLER: 'request_handler', +} as const; + +export type HonoTypes = (typeof HonoTypes)[keyof typeof HonoTypes]; diff --git a/packages/node/src/integrations/tracing/hono/index.ts b/packages/node/src/integrations/tracing/hono/index.ts index 8876d26b829e..4249871b8acf 100644 --- a/packages/node/src/integrations/tracing/hono/index.ts +++ b/packages/node/src/integrations/tracing/hono/index.ts @@ -1,11 +1,62 @@ -import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; -import { generateInstrumentOnce } from '@sentry/node-core'; +import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + captureException, + debug, + defineIntegration, + getDefaultIsolationScope, + getIsolationScope, + httpRequestToRequestData, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, +} from '@sentry/core'; +import { ensureIsWrapped, generateInstrumentOnce } from '@sentry/node-core'; +import { DEBUG_BUILD } from '../../../debug-build'; +import { AttributeNames } from './constants'; import { HonoInstrumentation } from './instrumentation'; +import type { Context, MiddlewareHandler, MiddlewareHandlerInterface, Next } from './types'; const INTEGRATION_NAME = 'Hono'; -export const instrumentHono = generateInstrumentOnce(INTEGRATION_NAME, () => new HonoInstrumentation()); +function addHonoSpanAttributes(span: Span): void { + const attributes = spanToJSON(span).data; + const type = attributes[AttributeNames.HONO_TYPE]; + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] || !type) { + return; + } + + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.hono', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${type}.hono`, + }); + + const name = attributes[AttributeNames.HONO_NAME]; + if (typeof name === 'string') { + span.updateName(name); + } + + if (getIsolationScope() === getDefaultIsolationScope()) { + DEBUG_BUILD && debug.warn('Isolation scope is default isolation scope - skipping setting transactionName'); + return; + } + + const route = attributes[ATTR_HTTP_ROUTE]; + const method = attributes[ATTR_HTTP_REQUEST_METHOD]; + if (typeof route === 'string' && typeof method === 'string') { + getIsolationScope().setTransactionName(`${method} ${route}`); + } +} + +export const instrumentHono = generateInstrumentOnce( + INTEGRATION_NAME, + () => + new HonoInstrumentation({ + responseHook: span => { + addHonoSpanAttributes(span); + }, + }), +); const _honoIntegration = (() => { return { @@ -33,3 +84,67 @@ const _honoIntegration = (() => { * ``` */ export const honoIntegration = defineIntegration(_honoIntegration); + +interface HonoHandlerOptions { + /** + * Callback method deciding whether error should be captured and sent to Sentry + * @param error Captured Hono error + */ + shouldHandleError: (context: Context) => boolean; +} + +function honoRequestHandler(): MiddlewareHandler { + return async function sentryRequestMiddleware(context: Context, next: Next): Promise { + const normalizedRequest = httpRequestToRequestData(context.req); + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest }); + await next(); + }; +} + +function defaultShouldHandleError(context: Context): boolean { + const statusCode = context.res.status; + return statusCode >= 500; +} + +function honoErrorHandler(options?: Partial): MiddlewareHandler { + return async function sentryErrorMiddleware(context: Context, next: Next): Promise { + await next(); + + const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; + if (shouldHandleError(context)) { + (context.res as { sentry?: string }).sentry = captureException(context.error, { + mechanism: { + type: 'auto.middleware.hono', + handled: false, + }, + }); + } + }; +} + +/** + * Add a Hono error handler to capture errors to Sentry. + * + * @param app The Hono instances + * @param options Configuration options for the handler + * + * @example + * ```javascript + * const Sentry = require('@sentry/node'); + * const { Hono } = require("hono"); + * + * const app = new Hono(); + * + * Sentry.setupHonoErrorHandler(app); + * + * // Add your routes, etc. + * ``` + */ +export function setupHonoErrorHandler( + app: { use: MiddlewareHandlerInterface }, + options?: Partial, +): void { + app.use(honoRequestHandler()); + app.use(honoErrorHandler(options)); + ensureIsWrapped(app.use, 'hono'); +} diff --git a/packages/node/src/integrations/tracing/hono/instrumentation.ts b/packages/node/src/integrations/tracing/hono/instrumentation.ts index 81e062560051..9a55eaac2776 100644 --- a/packages/node/src/integrations/tracing/hono/instrumentation.ts +++ b/packages/node/src/integrations/tracing/hono/instrumentation.ts @@ -1,15 +1,39 @@ +import type { Span } from '@opentelemetry/api'; +import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import type { HandlerInterface, Hono, HonoInstance, MiddlewareHandlerInterface, OnHandlerInterface } from './types'; +import { isThenable } from '@sentry/core'; +import { AttributeNames, HonoTypes } from './constants'; +import type { + Context, + Handler, + HandlerInterface, + Hono, + HonoInstance, + MiddlewareHandler, + MiddlewareHandlerInterface, + Next, + OnHandlerInterface, +} from './types'; const PACKAGE_NAME = '@sentry/instrumentation-hono'; const PACKAGE_VERSION = '0.0.1'; +export interface HonoResponseHookFunction { + (span: Span): void; +} + +export interface HonoInstrumentationConfig extends InstrumentationConfig { + /** Function for adding custom span attributes from the response */ + responseHook?: HonoResponseHookFunction; +} + /** * Hono instrumentation for OpenTelemetry */ -export class HonoInstrumentation extends InstrumentationBase { - public constructor() { - super(PACKAGE_NAME, PACKAGE_VERSION, {}); +export class HonoInstrumentation extends InstrumentationBase { + public constructor(config: HonoInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); } /** @@ -28,7 +52,7 @@ export class HonoInstrumentation extends InstrumentationBase { // eslint-disable-next-line @typescript-eslint/no-this-alias const instrumentation = this; - moduleExports.Hono = class HonoWrapper extends moduleExports.Hono { + class WrappedHono extends moduleExports.Hono { public constructor(...args: unknown[]) { super(...args); @@ -42,7 +66,15 @@ export class HonoInstrumentation extends InstrumentationBase { instrumentation._wrap(this, 'on', instrumentation._patchOnHandler()); instrumentation._wrap(this, 'use', instrumentation._patchMiddlewareHandler()); } - }; + } + + try { + moduleExports.Hono = WrappedHono; + } catch { + // This is a workaround for environments where direct assignment is not allowed. + return { ...moduleExports, Hono: WrappedHono }; + } + return moduleExports; } @@ -50,10 +82,28 @@ export class HonoInstrumentation extends InstrumentationBase { * Patches the route handler to instrument it. */ private _patchHandler(): (original: HandlerInterface) => HandlerInterface { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + return function (original: HandlerInterface) { return function wrappedHandler(this: HonoInstance, ...args: unknown[]) { - // TODO: Add OpenTelemetry tracing logic here - return original.apply(this, args); + if (typeof args[0] === 'string') { + const path = args[0]; + if (args.length === 1) { + return original.apply(this, [path]); + } + + const handlers = args.slice(1); + return original.apply(this, [ + path, + ...handlers.map(handler => instrumentation._wrapHandler(handler as Handler | MiddlewareHandler)), + ]); + } + + return original.apply( + this, + args.map(handler => instrumentation._wrapHandler(handler as Handler | MiddlewareHandler)), + ); }; }; } @@ -62,10 +112,16 @@ export class HonoInstrumentation extends InstrumentationBase { * Patches the 'on' handler to instrument it. */ private _patchOnHandler(): (original: OnHandlerInterface) => OnHandlerInterface { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + return function (original: OnHandlerInterface) { return function wrappedHandler(this: HonoInstance, ...args: unknown[]) { - // TODO: Add OpenTelemetry tracing logic here - return original.apply(this, args); + const handlers = args.slice(2); + return original.apply(this, [ + ...args.slice(0, 2), + ...handlers.map(handler => instrumentation._wrapHandler(handler as Handler | MiddlewareHandler)), + ]); }; }; } @@ -74,11 +130,124 @@ export class HonoInstrumentation extends InstrumentationBase { * Patches the middleware handler to instrument it. */ private _patchMiddlewareHandler(): (original: MiddlewareHandlerInterface) => MiddlewareHandlerInterface { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + return function (original: MiddlewareHandlerInterface) { return function wrappedHandler(this: HonoInstance, ...args: unknown[]) { - // TODO: Add OpenTelemetry tracing logic here - return original.apply(this, args); + if (typeof args[0] === 'string') { + const path = args[0]; + if (args.length === 1) { + return original.apply(this, [path]); + } + + const handlers = args.slice(1); + return original.apply(this, [ + path, + ...handlers.map(handler => instrumentation._wrapHandler(handler as MiddlewareHandler)), + ]); + } + + return original.apply( + this, + args.map(handler => instrumentation._wrapHandler(handler as MiddlewareHandler)), + ); }; }; } + + /** + * Wraps a handler or middleware handler to apply instrumentation. + */ + private _wrapHandler(handler: Handler | MiddlewareHandler): Handler | MiddlewareHandler { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + + return function (this: unknown, c: Context, next: Next) { + if (!instrumentation.isEnabled()) { + return handler.apply(this, [c, next]); + } + + const path = c.req.path; + const span = instrumentation.tracer.startSpan(path); + + return context.with(trace.setSpan(context.active(), span), () => { + return instrumentation._safeExecute( + () => { + const result = handler.apply(this, [c, next]); + if (isThenable(result)) { + return result.then(result => { + const type = instrumentation._determineHandlerType(result); + span.setAttributes({ + [AttributeNames.HONO_TYPE]: type, + [AttributeNames.HONO_NAME]: type === HonoTypes.REQUEST_HANDLER ? path : handler.name || 'anonymous', + }); + instrumentation.getConfig().responseHook?.(span); + return result; + }); + } else { + const type = instrumentation._determineHandlerType(result); + span.setAttributes({ + [AttributeNames.HONO_TYPE]: type, + [AttributeNames.HONO_NAME]: type === HonoTypes.REQUEST_HANDLER ? path : handler.name || 'anonymous', + }); + instrumentation.getConfig().responseHook?.(span); + return result; + } + }, + () => span.end(), + error => { + instrumentation._handleError(span, error); + span.end(); + }, + ); + }); + }; + } + + /** + * Safely executes a function and handles errors. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _safeExecute(execute: () => any, onSuccess: () => void, onFailure: (error: unknown) => void): () => any { + try { + const result = execute(); + + if (isThenable(result)) { + result.then( + () => onSuccess(), + (error: unknown) => onFailure(error), + ); + } else { + onSuccess(); + } + + return result; + } catch (error: unknown) { + onFailure(error); + throw error; + } + } + + /** + * Determines the handler type based on the result. + * @param result + * @private + */ + private _determineHandlerType(result: unknown): HonoTypes { + return result === undefined ? HonoTypes.MIDDLEWARE : HonoTypes.REQUEST_HANDLER; + } + + /** + * Handles errors by setting the span status and recording the exception. + */ + private _handleError(span: Span, error: unknown): void { + if (error instanceof Error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.recordException(error); + } + } } diff --git a/packages/node/src/integrations/tracing/hono/types.ts b/packages/node/src/integrations/tracing/hono/types.ts index 3d7e057859f1..9873f80afa66 100644 --- a/packages/node/src/integrations/tracing/hono/types.ts +++ b/packages/node/src/integrations/tracing/hono/types.ts @@ -1,11 +1,14 @@ // Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/request.ts#L30 export type HonoRequest = { path: string; + method: string; }; // Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/context.ts#L291 export type Context = { req: HonoRequest; + res: Response; + error: Error | undefined; }; // Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L36C1-L36C39 @@ -15,7 +18,7 @@ export type Next = () => Promise; export type Handler = (c: Context, next: Next) => Promise | Response; // Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L80 -export type MiddlewareHandler = (c: Context, next: Next) => Promise | Response | void; +export type MiddlewareHandler = (c: Context, next: Next) => Promise; // Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L109 export type HandlerInterface = { diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index e4dd84fc266e..dd9d9ac8df2b 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -10,6 +10,7 @@ import { genericPoolIntegration, instrumentGenericPool } from './genericPool'; import { googleGenAIIntegration, instrumentGoogleGenAI } from './google-genai'; import { graphqlIntegration, instrumentGraphql } from './graphql'; import { hapiIntegration, instrumentHapi } from './hapi'; +import { honoIntegration, instrumentHono } from './hono'; import { instrumentKafka, kafkaIntegration } from './kafka'; import { instrumentKoa, koaIntegration } from './koa'; import { instrumentLruMemoizer, lruMemoizerIntegration } from './lrumemoizer'; @@ -33,6 +34,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { expressIntegration(), fastifyIntegration(), graphqlIntegration(), + honoIntegration(), mongoIntegration(), mongooseIntegration(), mysqlIntegration(), @@ -70,6 +72,7 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentFastify, instrumentFastifyV3, instrumentHapi, + instrumentHono, instrumentKafka, instrumentKoa, instrumentLruMemoizer, diff --git a/yarn.lock b/yarn.lock index 46e96703fc7e..b2f40a0bd2c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4596,6 +4596,11 @@ "@hapi/bourne" "^3.0.0" "@hapi/hoek" "^11.0.2" +"@hono/node-server@^1.19.4": + version "1.19.4" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.4.tgz#2721cda094f7c080ee985494ac3e074f16c503eb" + integrity sha512-AWKQZ/YkHUBSHeL/5Ld8FWgUs6wFf4TxGYxqp9wLZxRdFuHBpXmgOq+CuDoL4vllkZLzovCf5HBJnypiy3EtHA== + "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" @@ -18604,6 +18609,11 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" +hono@^4.9.8: + version "4.9.8" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.9.8.tgz#1710981135ec775fe26fab5ea6535b403e92bcc3" + integrity sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg== + hookable@^5.5.3: version "5.5.3" resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d"