diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 17c6f714c499..1ddb8e57c0b3 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -41,6 +41,8 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Astro 'setupFastifyErrorHandler', + 'elysiaIntegration', + 'withElysia', ], }, { @@ -75,6 +77,8 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Serverless 'setupFastifyErrorHandler', + 'elysiaIntegration', + 'withElysia', ], }, { @@ -84,6 +88,8 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // Not needed for Serverless 'setupFastifyErrorHandler', + 'elysiaIntegration', + 'withElysia', ], }, { diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c2990e6262a7..27f53493dc4c 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -123,6 +123,8 @@ export { setupKoaErrorHandler, connectIntegration, setupConnectErrorHandler, + elysiaIntegration, + withElysia, genericPoolIntegration, graphqlIntegration, knexIntegration, diff --git a/packages/node-core/src/utils/ensureIsWrapped.ts b/packages/node-core/src/utils/ensureIsWrapped.ts index 921d01da8207..3d7e34c428fd 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' | 'hono', + name: 'express' | 'connect' | 'fastify' | 'hapi' | 'koa' | 'hono' | 'elysia', ): void { const clientOptions = getClient()?.getOptions(); if ( diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8458dee5f6a7..e01fb1a0a8a3 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -16,6 +16,7 @@ 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 { elysiaIntegration, withElysia } from './integrations/tracing/elysia'; 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/elysia/index.ts b/packages/node/src/integrations/tracing/elysia/index.ts new file mode 100644 index 000000000000..1e6b62489350 --- /dev/null +++ b/packages/node/src/integrations/tracing/elysia/index.ts @@ -0,0 +1,174 @@ +import type { ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'; +import type { IntegrationFn } from '@sentry/core'; +import { + captureException, + debug, + defineIntegration, + getDefaultIsolationScope, + getIsolationScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; +import { SentrySpanProcessor } from '@sentry/opentelemetry'; +import { createRequire } from 'module'; +import { DEBUG_BUILD } from '../../../debug-build'; +import type { ElysiaErrorContext, ElysiaInstance } from './types'; + +const ELYSIA_LIFECYCLE_OP_MAP: Record = { + Request: 'middleware.elysia', + Parse: 'middleware.elysia', + Transform: 'middleware.elysia', + BeforeHandle: 'middleware.elysia', + Handle: 'request_handler.elysia', + AfterHandle: 'middleware.elysia', + MapResponse: 'middleware.elysia', + AfterResponse: 'middleware.elysia', + Error: 'middleware.elysia', +}; + +const SENTRY_ORIGIN = 'auto.http.otel.elysia'; + +/** + * A custom span processor that filters out empty spans and enriches the span attributes with Sentry attributes. + */ +class ElysiaSentrySpanProcessor extends SentrySpanProcessor { + public override onEnd(span: Span & ReadableSpan): void { + // Elysia produces empty spans as children of lifecycle spans, we want to filter those out. + if (!span.name && Object.keys(span.attributes).length === 0) { + return; + } + + // Enrich the span attributes with Sentry attributes. + const op = ELYSIA_LIFECYCLE_OP_MAP[span.name]; + if (op) { + span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = SENTRY_ORIGIN; + } + + super.onEnd(span); + } +} + +const INTEGRATION_NAME = 'Elysia'; + +const _elysiaIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // No-op: tracing is applied per-instance via withElysia + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry instrumentation for [Elysia](https://elysiajs.com/). + * + * Tracing is powered by Elysia's first-party `@elysiajs/opentelemetry` plugin, + * which is automatically applied when you call `withElysia(app)`. + * + * @example + * ```javascript + * const Sentry = require('@sentry/node'); + * + * Sentry.init({ + * integrations: [Sentry.elysiaIntegration()], + * }) + * ``` + */ +export const elysiaIntegration = defineIntegration(_elysiaIntegration); + +interface ElysiaHandlerOptions { + shouldHandleError: (context: ElysiaErrorContext) => boolean; +} + +function defaultShouldHandleError(context: ElysiaErrorContext): boolean { + const status = context.set.status; + if (status === undefined) { + return true; + } + const statusCode = typeof status === 'string' ? parseInt(status, 10) : status; + return statusCode >= 500; +} + +let _cachedOtelPlugin: ((options?: Record) => unknown) | null | undefined; + +function loadElysiaOtelPlugin(): ((options?: Record) => unknown) | null { + if (_cachedOtelPlugin !== undefined) { + return _cachedOtelPlugin; + } + + try { + const _require = createRequire(`${process.cwd()}/`); + const mod = _require('@elysiajs/opentelemetry') as { + opentelemetry?: (options?: Record) => unknown; + }; + _cachedOtelPlugin = mod.opentelemetry ?? null; + } catch { + DEBUG_BUILD && + debug.warn( + 'Could not load `@elysiajs/opentelemetry` package. Please install it to enable tracing for Elysia: `bun add @elysiajs/opentelemetry`', + ); + _cachedOtelPlugin = null; + } + + return _cachedOtelPlugin; +} + +/** + * Integrate Sentry with an Elysia app for error handling, request context, + * and tracing. Returns the app instance for chaining. + * + * This function: + * 1. Applies `@elysiajs/opentelemetry` for tracing (if installed) + * 2. Registers `onRequest` for request context + * 3. Registers `onError` for error capturing (with `{ as: 'global' }`) + * + * Should be called at the **start** of the chain before defining routes. + * + * @param app The Elysia instance + * @param options Configuration options + * @returns The same Elysia instance for chaining + * + * @example + * ```javascript + * const Sentry = require('@sentry/bun'); + * const { Elysia } = require('elysia'); + * + * Sentry.withElysia(new Elysia()) + * .get('/', () => 'Hello World') + * .listen(3000); + * ``` + */ +export function withElysia(app: T, options?: Partial): T { + const otelPlugin = loadElysiaOtelPlugin(); + if (otelPlugin) { + app.use(otelPlugin({ spanProcessors: [new ElysiaSentrySpanProcessor()] })); + } + + app.onRequest((context: { request: Request }) => { + const isolationScope = getIsolationScope(); + if (isolationScope !== getDefaultIsolationScope()) { + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: { + method: context.request.method, + url: context.request.url, + headers: Object.fromEntries(context.request.headers.entries()), + }, + }); + } + }); + + app.onError({ as: 'global' }, (context: ElysiaErrorContext) => { + const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; + if (shouldHandleError(context)) { + captureException(context.error, { + mechanism: { + type: 'elysia', + handled: false, + }, + }); + } + }); + + return app; +} diff --git a/packages/node/src/integrations/tracing/elysia/types.ts b/packages/node/src/integrations/tracing/elysia/types.ts new file mode 100644 index 000000000000..fc2192eb671a --- /dev/null +++ b/packages/node/src/integrations/tracing/elysia/types.ts @@ -0,0 +1,25 @@ +export interface ElysiaErrorContext { + request: Request; + path: string; + route: string; + set: { + headers: Record; + status?: number | string; + redirect?: string; + }; + error: Error; + code: string; +} + +/** + * Loose Elysia instance interface containing only the methods Sentry calls. + * Intentionally minimal so it's compatible with any Elysia version/generics. + */ +export interface ElysiaInstance { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + use: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onRequest: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onError: (...args: any[]) => any; +}