diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5127b48f4ae3..610df2e2a88a 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -25,6 +25,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.552.0", "@hapi/hapi": "^21.3.10", + "@hono/node-server": "^1.18.2", "@nestjs/common": "11.1.3", "@nestjs/core": "11.1.3", "@nestjs/platform-express": "11.1.3", @@ -45,6 +46,7 @@ "express": "^4.21.1", "generic-pool": "^3.9.0", "graphql": "^16.3.0", + "hono": "^4.8.12", "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..125bd8d4daa4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hono/scenario.mjs @@ -0,0 +1,163 @@ +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); + }, + ); + + 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); + }, + ); + + 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); + }, + ); + + 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..8389cb9ee320 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hono/test.ts @@ -0,0 +1,179 @@ +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 returned errors for %s path', async () => { + const runner = createRunner() + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + 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 44118747c45c..0defc14112de 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -159,7 +159,7 @@ type StartResult = { childHasExited(): boolean; getLogs(): string[]; makeRequest( - method: 'get' | 'post', + method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, ): Promise; @@ -544,7 +544,7 @@ export function createRunner(...paths: string[]) { return logs; }, makeRequest: async function ( - method: 'get' | 'post', + method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, ): Promise { @@ -563,7 +563,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/node-core/src/utils/ensureIsWrapped.ts b/packages/node-core/src/utils/ensureIsWrapped.ts index 70253d9debb7..f70825f70b48 100644 --- a/packages/node-core/src/utils/ensureIsWrapped.ts +++ b/packages/node-core/src/utils/ensureIsWrapped.ts @@ -9,7 +9,7 @@ import { createMissingInstrumentationContext } from './createMissingInstrumentat */ 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 bba0f98bc75e..71d78e9cdffc 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 new file mode 100644 index 000000000000..e34f297bc8a6 --- /dev/null +++ b/packages/node/src/integrations/tracing/hono/index.ts @@ -0,0 +1,151 @@ +import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + captureException, + debug, + defineIntegration, + getClient, + 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()); + +const _honoIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentHono(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for [Hono](https://hono.dev/). + * + * If you also want to capture errors, you need to call `setupHonoErrorHandler(app)` after you set up your Hono server. + * + * For more information, see the [hono documentation](https://docs.sentry.io/platforms/javascript/guides/hono/). + * + * @example + * ```javascript + * const Sentry = require('@sentry/node'); + * + * Sentry.init({ + * integrations: [Sentry.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: 'hono', + handled: false, + }, + }); + } + }; +} + +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}`); + } +} + +/** + * 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)); + + const client = getClient(); + if (client) { + client.on('spanStart', span => { + addHonoSpanAttributes(span); + }); + } + + ensureIsWrapped(app.use, 'hono'); +} diff --git a/packages/node/src/integrations/tracing/hono/instrumentation.ts b/packages/node/src/integrations/tracing/hono/instrumentation.ts new file mode 100644 index 000000000000..644bdb4eb5ff --- /dev/null +++ b/packages/node/src/integrations/tracing/hono/instrumentation.ts @@ -0,0 +1,240 @@ +import type { Span } from '@opentelemetry/api'; +import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +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'; + +/** + * Hono instrumentation for OpenTelemetry + */ +export class HonoInstrumentation extends InstrumentationBase { + public constructor() { + super(PACKAGE_NAME, PACKAGE_VERSION, {}); + } + + /** + * Initialize the instrumentation. + */ + public init(): InstrumentationNodeModuleDefinition[] { + return [ + new InstrumentationNodeModuleDefinition('hono', ['>=4.0.0 <5'], moduleExports => this._patch(moduleExports)), + ]; + } + + /** + * Patches the module exports to instrument Hono. + */ + private _patch(moduleExports: { Hono: Hono }): { Hono: Hono } { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + + class WrappedHono extends moduleExports.Hono { + public constructor(...args: unknown[]) { + super(...args); + + instrumentation._wrap(this, 'get', instrumentation._patchHandler()); + instrumentation._wrap(this, 'post', instrumentation._patchHandler()); + instrumentation._wrap(this, 'put', instrumentation._patchHandler()); + instrumentation._wrap(this, 'delete', instrumentation._patchHandler()); + instrumentation._wrap(this, 'options', instrumentation._patchHandler()); + instrumentation._wrap(this, 'patch', instrumentation._patchHandler()); + instrumentation._wrap(this, 'all', instrumentation._patchHandler()); + 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; + } + + /** + * 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[]) { + 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, index) => + instrumentation._wrapHandler( + index + 1 === handlers.length ? HonoTypes.REQUEST_HANDLER : HonoTypes.MIDDLEWARE, + handler as Handler | MiddlewareHandler, + ), + ), + ]); + } + + return original.apply( + this, + args.map((handler, index) => + instrumentation._wrapHandler( + index + 1 === args.length ? HonoTypes.REQUEST_HANDLER : HonoTypes.MIDDLEWARE, + handler as Handler | MiddlewareHandler, + ), + ), + ); + }; + }; + } + + /** + * 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[]) { + const handlers = args.slice(2); + return original.apply(this, [ + ...args.slice(0, 2), + ...handlers.map((handler, index) => + instrumentation._wrapHandler( + index + 1 === handlers.length ? HonoTypes.REQUEST_HANDLER : HonoTypes.MIDDLEWARE, + handler as Handler | MiddlewareHandler, + ), + ), + ]); + }; + }; + } + + /** + * 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[]) { + 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(HonoTypes.MIDDLEWARE, handler as MiddlewareHandler), + ), + ]); + } + + return original.apply( + this, + args.map(handler => instrumentation._wrapHandler(HonoTypes.MIDDLEWARE, handler as MiddlewareHandler)), + ); + }; + }; + } + + /** + * Wraps a handler or middleware handler to apply instrumentation. + */ + private _wrapHandler(type: HonoTypes, 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 spanName = `${type.replace('_', ' ')} - ${path}`; + const span = instrumentation.tracer.startSpan(spanName, { + attributes: { + [AttributeNames.HONO_TYPE]: type, + [AttributeNames.HONO_NAME]: type === 'request_handler' ? path : handler.name || 'anonymous', + }, + }); + + return context.with(trace.setSpan(context.active(), span), () => { + return instrumentation._safeExecute( + () => handler.apply(this, [c, next]), + () => 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 ( + result && + typeof result === 'object' && + typeof Object.getOwnPropertyDescriptor(result, 'then')?.value === 'function' + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + result.then( + () => onSuccess(), + (error: unknown) => onFailure(error), + ); + } else { + onSuccess(); + } + + return result; + } catch (error: unknown) { + onFailure(error); + throw error; + } + } + + /** + * 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 new file mode 100644 index 000000000000..9873f80afa66 --- /dev/null +++ b/packages/node/src/integrations/tracing/hono/types.ts @@ -0,0 +1,53 @@ +// 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 +export type Next = () => Promise; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L73 +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; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L109 +export type HandlerInterface = { + (...handlers: (Handler | MiddlewareHandler)[]): HonoInstance; + (path: string, ...handlers: (Handler | MiddlewareHandler)[]): HonoInstance; +}; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L1071 +export type OnHandlerInterface = { + (method: string | string[], path: string | string[], ...handlers: (Handler | MiddlewareHandler)[]): HonoInstance; +}; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L679 +export type MiddlewareHandlerInterface = { + (...handlers: MiddlewareHandler[]): HonoInstance; + (path: string, ...handlers: MiddlewareHandler[]): HonoInstance; +}; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/hono-base.ts#L99 +export interface HonoInstance { + get: HandlerInterface; + post: HandlerInterface; + put: HandlerInterface; + delete: HandlerInterface; + options: HandlerInterface; + patch: HandlerInterface; + all: HandlerInterface; + on: OnHandlerInterface; + use: MiddlewareHandlerInterface; +} + +export type Hono = new (...args: unknown[]) => HonoInstance; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 6035cf3669f8..77915d4fe542 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -8,6 +8,7 @@ import { firebaseIntegration, instrumentFirebase } from './firebase'; import { genericPoolIntegration, instrumentGenericPool } from './genericPool'; 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'; @@ -31,6 +32,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { expressIntegration(), fastifyIntegration(), graphqlIntegration(), + honoIntegration(), mongoIntegration(), mongooseIntegration(), mysqlIntegration(), @@ -65,6 +67,7 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentFastify, instrumentFastifyV3, instrumentHapi, + instrumentHono, instrumentKafka, instrumentKoa, instrumentLruMemoizer, diff --git a/yarn.lock b/yarn.lock index fa9e973445dc..aaea025f9a98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4571,6 +4571,11 @@ "@hapi/bourne" "^3.0.0" "@hapi/hoek" "^11.0.2" +"@hono/node-server@^1.18.2": + version "1.18.2" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.18.2.tgz#a7195b3e956a3a8025b170c69f2e135303dcce2e" + integrity sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA== + "@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" @@ -18426,6 +18431,11 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" +hono@^4.8.12: + version "4.8.12" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.8.12.tgz#9f4729f257f00136881a63cc166b29bd5da38944" + integrity sha512-MQSKk1Mg7b74k8l+A025LfysnLtXDKkE4pLaSsYRQC5iy85lgZnuyeQ1Wynair9mmECzoLu+FtJtqNZSoogBDQ== + hookable@^5.5.3: version "5.5.3" resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" @@ -28567,7 +28577,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2"