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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const DEPENDENTS: Dependent[] = [
ignoreExports: [
// Not needed for Astro
'setupFastifyErrorHandler',
'elysiaIntegration',
'withElysia',
],
},
{
Expand Down Expand Up @@ -75,6 +77,8 @@ const DEPENDENTS: Dependent[] = [
ignoreExports: [
// Not needed for Serverless
'setupFastifyErrorHandler',
'elysiaIntegration',
'withElysia',
],
},
{
Expand All @@ -84,6 +88,8 @@ const DEPENDENTS: Dependent[] = [
ignoreExports: [
// Not needed for Serverless
'setupFastifyErrorHandler',
'elysiaIntegration',
'withElysia',
],
},
{
Expand Down
2 changes: 2 additions & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export {
setupKoaErrorHandler,
connectIntegration,
setupConnectErrorHandler,
elysiaIntegration,
withElysia,
genericPoolIntegration,
graphqlIntegration,
knexIntegration,
Expand Down
2 changes: 1 addition & 1 deletion packages/node-core/src/utils/ensureIsWrapped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeClient>()?.getOptions();
if (
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
174 changes: 174 additions & 0 deletions packages/node/src/integrations/tracing/elysia/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, unknown>) => unknown) | null | undefined;

function loadElysiaOtelPlugin(): ((options?: Record<string, unknown>) => unknown) | null {
if (_cachedOtelPlugin !== undefined) {
return _cachedOtelPlugin;
}

try {
const _require = createRequire(`${process.cwd()}/`);
const mod = _require('@elysiajs/opentelemetry') as {
opentelemetry?: (options?: Record<string, unknown>) => 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<T extends ElysiaInstance>(app: T, options?: Partial<ElysiaHandlerOptions>): 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;
}
25 changes: 25 additions & 0 deletions packages/node/src/integrations/tracing/elysia/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface ElysiaErrorContext {
request: Request;
path: string;
route: string;
set: {
headers: Record<string, string>;
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;
}
Loading