From b71910d625eaf8484bbd3b9e710beab867cf6445 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 20 Aug 2026 11:25:41 +0200 Subject: [PATCH 1/4] feat(node): Auto-register Hapi error handler on server start The Hapi error handler now registers itself automatically when the server starts, so `setupHapiErrorHandler` no longer needs to be called. The handler logic moves to `@sentry/server-utils` and is wired via orchestrion channels on `@hapi/hapi`'s `start`/`initialize` methods; `setupHapiErrorHandler` is kept as a deprecated, idempotent delegate for backwards compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- MIGRATION.md | 6 + .../test-applications/node-hapi/src/app.js | 1 - .../suites/tracing/hapi/scenario.mjs | 2 - packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/elysia/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + .../node/src/integrations/tracing/hapi.ts | 17 ++ .../src/integrations/tracing/hapi/index.ts | 66 ----- .../src/integrations/tracing/hapi/types.ts | 226 ------------------ packages/remix/src/server/index.ts | 1 + packages/server-utils/src/index.ts | 7 + .../integrations/hapi/hapi-error-handler.ts | 62 +++++ .../src/integrations/{ => hapi}/hapi-types.ts | 27 +++ .../src/integrations/{ => hapi}/hapi-utils.ts | 2 +- .../integrations/{hapi.ts => hapi/index.ts} | 34 ++- .../src/orchestrion/config/hapi.ts | 18 ++ .../hapi-error-handler.test.ts | 128 ++++++++++ .../tracing-channel/hapi-utils.test.ts | 2 +- packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + 23 files changed, 307 insertions(+), 300 deletions(-) create mode 100644 packages/node/src/integrations/tracing/hapi.ts delete mode 100644 packages/node/src/integrations/tracing/hapi/index.ts delete mode 100644 packages/node/src/integrations/tracing/hapi/types.ts create mode 100644 packages/server-utils/src/integrations/hapi/hapi-error-handler.ts rename packages/server-utils/src/integrations/{ => hapi}/hapi-types.ts (75%) rename packages/server-utils/src/integrations/{ => hapi}/hapi-utils.ts (98%) rename packages/server-utils/src/integrations/{hapi.ts => hapi/index.ts} (68%) create mode 100644 packages/server-utils/test/integrations/tracing-channel/hapi-error-handler.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index 5c3984abae46..ab8fb2127721 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -285,6 +285,12 @@ Affected SDKs: `@sentry/node` and all dependents. The new channel-based instrumentations (using `orchestrion` instead of `import-in-the-middle`) are now the default. They were available opt-in in v10. This unlocks instrumenting at run and build time, which enables instrumentation at deployment targets like Vercel and Netlify, as well as using instrumentations on non-Node runtimes like Cloudflare, Bun and Deno. For most users this requires no changes. +### `setupHapiErrorHandler` is deprecated (Hapi errors are captured automatically) + +Affected SDKs: `@sentry/node` and all dependents that re-export it (e.g. `@sentry/aws-serverless`, `@sentry/google-cloud-serverless`, `@sentry/astro`, `@sentry/remix`, `@sentry/solidstart`, `@sentry/sveltekit`, `@sentry/bun`, `@sentry/elysia`). + +The Hapi error handler is now registered automatically when your server starts, so you no longer need to call `setupHapiErrorHandler` yourself. The function is deprecated and will be removed in a future major version; you should no longer call it. + ### Initializing via `--require` is no longer supported Affected SDKs: `@sentry/node` and all dependents. diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/src/app.js b/dev-packages/e2e-tests/test-applications/node-hapi/src/app.js index 7ca52a8b658f..8526846eafd8 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/src/app.js +++ b/dev-packages/e2e-tests/test-applications/node-hapi/src/app.js @@ -118,7 +118,6 @@ const init = async () => { (async () => { init(); - await Sentry.setupHapiErrorHandler(server); await server.start(); console.log('Server running on %s', server.info.uri); })(); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs index 9148e4092fe1..d081af0a4295 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs @@ -1,6 +1,5 @@ import Boom from '@hapi/boom'; import Hapi from '@hapi/hapi'; -import * as Sentry from '@sentry/node'; import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; const port = 5999; @@ -67,7 +66,6 @@ const run = async () => { // Server extension produces a `middleware` span. server.ext('onPreResponse', (request, h) => h.continue); - await Sentry.setupHapiErrorHandler(server); await server.start(); sendPortToRunner(port); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index e5d78a233dec..37b44c251244 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -124,6 +124,7 @@ export { setAttribute, setAttributes, setupExpressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, setupKoaErrorHandler, setUser, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index aa392eb3ec4d..7aa1616f5ee5 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -121,6 +121,7 @@ export { childProcessIntegration, createSentryWinstonTransport, hapiIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, spotlightIntegration, initOpenTelemetry, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index b54adc166a21..93beb0bfe2f9 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -139,6 +139,7 @@ export { getOtlpTracesEndpoint, processSessionIntegration, hapiIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, spotlightIntegration, initOpenTelemetry, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 8786ab737f17..cb42ab73aea3 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -116,6 +116,7 @@ export { prismaIntegration, processSessionIntegration, hapiIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, spotlightIntegration, initOpenTelemetry, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 17fcf89c8f16..ae3c6f82fbc4 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -119,6 +119,7 @@ export { getOtlpTracesEndpoint, processSessionIntegration, hapiIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, spotlightIntegration, initOpenTelemetry, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3483dbff8163..0effc2380747 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -42,6 +42,7 @@ export { instrumentStateGraph, instrumentStateGraphCompile, } from '@sentry/server-utils'; +// oxlint-disable-next-line typescript/no-deprecated export { setupHapiErrorHandler } from './integrations/tracing/hapi'; export { setupKoaErrorHandler } from './integrations/tracing/koa'; export { diff --git a/packages/node/src/integrations/tracing/hapi.ts b/packages/node/src/integrations/tracing/hapi.ts new file mode 100644 index 000000000000..9675331995e7 --- /dev/null +++ b/packages/node/src/integrations/tracing/hapi.ts @@ -0,0 +1,17 @@ +import { attachHapiErrorHandler } from '@sentry/server-utils'; + +/** + * Add a Hapi plugin to capture errors to Sentry. + * + * @deprecated The error handler is now registered automatically when the Hapi + * server starts (via the orchestrion `@hapi/hapi` instrumentation), so calling + * this is no longer necessary. It remains a safe, idempotent operation when the + * handler is already attached, and is kept for setups where auto-registration is + * unavailable. This will be removed in a future major version. + * + * @param server The Hapi server to attach the error handler to + */ +export async function setupHapiErrorHandler(server: unknown): Promise { + // oxlint-disable-next-line typescript/no-deprecated + attachHapiErrorHandler(server as Parameters[0]); +} diff --git a/packages/node/src/integrations/tracing/hapi/index.ts b/packages/node/src/integrations/tracing/hapi/index.ts deleted file mode 100644 index 6de0344f27d8..000000000000 --- a/packages/node/src/integrations/tracing/hapi/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { captureException, debug, getDefaultIsolationScope, getIsolationScope, SDK_VERSION } from '@sentry/core'; -import { DEBUG_BUILD } from '../../../debug-build'; -import type { Request, RequestEvent, Server } from './types'; - -function isErrorEvent(event: unknown): event is RequestEvent { - return !!(event && typeof event === 'object' && 'error' in event && event.error); -} - -function sendErrorToSentry(errorData: object): void { - captureException(errorData, { - mechanism: { - type: 'auto.function.hapi', - handled: false, - }, - }); -} - -export const hapiErrorPlugin = { - name: 'SentryHapiErrorPlugin', - version: SDK_VERSION, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - register: async function (serverArg: Record) { - const server = serverArg as unknown as Server; - - server.events.on({ name: 'request', channels: ['error'] }, (request: Request, event: RequestEvent) => { - if (getIsolationScope() !== getDefaultIsolationScope()) { - const route = request.route; - if (route.path) { - getIsolationScope().setTransactionName(`${route.method.toUpperCase()} ${route.path}`); - } - } else { - DEBUG_BUILD && - debug.warn('Isolation scope is still the default isolation scope - skipping setting transactionName'); - } - - if (isErrorEvent(event)) { - sendErrorToSentry(event.error); - } - }); - }, -}; - -/** - * Add a Hapi plugin to capture errors to Sentry. - * - * @param server The Hapi server to attach the error handler to - * - * @example - * ```javascript - * const Sentry = require('@sentry/node'); - * const Hapi = require('@hapi/hapi'); - * - * const init = async () => { - * const server = Hapi.server(); - * - * // all your routes here - * - * await Sentry.setupHapiErrorHandler(server); - * - * await server.start(); - * }; - * ``` - */ -export async function setupHapiErrorHandler(server: Server): Promise { - await server.register(hapiErrorPlugin); -} diff --git a/packages/node/src/integrations/tracing/hapi/types.ts b/packages/node/src/integrations/tracing/hapi/types.ts deleted file mode 100644 index 0702ce8040d4..000000000000 --- a/packages/node/src/integrations/tracing/hapi/types.ts +++ /dev/null @@ -1,226 +0,0 @@ -/* eslint-disable @typescript-eslint/no-misused-new */ -/* eslint-disable @typescript-eslint/naming-convention */ -/* eslint-disable @typescript-eslint/unified-signatures */ -/* eslint-disable @typescript-eslint/no-empty-interface */ -/* eslint-disable @typescript-eslint/no-namespace */ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -// Vendored and simplified from: -// - @types/hapi__hapi -// v17.8.9999 -// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/c73060bd14bb74a2f1906ccfc714d385863bc07d/types/hapi/v17/index.d.ts -// -// - @types/podium -// v1.0.9999 -// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/c73060bd14bb74a2f1906ccfc714d385863bc07d/types/podium/index.d.ts -// -// - @types/boom -// v7.3.9999 -// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/c73060bd14bb74a2f1906ccfc714d385863bc07d/types/boom/v4/index.d.ts - -import type * as stream from 'stream'; - -interface Podium { - new (events?: Events[]): Podium; - new (events?: Events): Podium; - - registerEvent(events: Events[]): void; - registerEvent(events: Events): void; - - registerPodium?(podiums: Podium[]): void; - registerPodium?(podiums: Podium): void; - - emit( - criteria: string | { name: string; channel?: string | undefined; tags?: string | string[] | undefined }, - data: any, - callback?: () => void, - ): void; - - on(criteria: string | Criteria, listener: Listener): void; - addListener(criteria: string | Criteria, listener: Listener): void; - once(criteria: string | Criteria, listener: Listener): void; - removeListener(name: string, listener: Listener): Podium; - removeAllListeners(name: string): Podium; - hasListeners(name: string): boolean; -} - -export interface Boom extends Error { - isBoom: boolean; - isServer: boolean; - message: string; - output: Output; - reformat: () => string; - isMissing?: boolean | undefined; - data: Data; -} - -export interface Output { - statusCode: number; - headers: { [index: string]: string }; - payload: Payload; -} - -export interface Payload { - statusCode: number; - error: string; - message: string; - attributes?: any; -} - -export type Events = string | EventOptionsObject | Podium; - -export interface EventOptionsObject { - name: string; - channels?: string | string[] | undefined; - clone?: boolean | undefined; - spread?: boolean | undefined; - tags?: boolean | undefined; - shared?: boolean | undefined; -} - -export interface CriteriaObject { - name: string; - block?: boolean | number | undefined; - channels?: string | string[] | undefined; - clone?: boolean | undefined; - count?: number | undefined; - filter?: string | string[] | CriteriaFilterOptionsObject | undefined; - spread?: boolean | undefined; - tags?: boolean | undefined; - listener?: Listener | undefined; -} - -export interface CriteriaFilterOptionsObject { - tags?: string | string[] | undefined; - all?: boolean | undefined; -} - -export type Criteria = string | CriteriaObject; - -export interface Listener { - (data: any, tags?: Tags, callback?: () => void): void; -} - -export type Tags = { [tag: string]: boolean }; - -interface UserCredentials {} - -interface AppCredentials {} - -interface AuthCredentials { - scope?: string[] | undefined; - user?: UserCredentials | undefined; - app?: AppCredentials | undefined; -} - -interface RequestAuth { - artifacts: object; - credentials: AuthCredentials; - error: Error; - isAuthenticated: boolean; - isAuthorized: boolean; - mode: string; - strategy: string; -} - -interface RequestEvents extends Podium { - on(criteria: 'peek', listener: PeekListener): void; - on(criteria: 'finish' | 'disconnect', listener: (data: undefined) => void): void; - once(criteria: 'peek', listener: PeekListener): void; - once(criteria: 'finish' | 'disconnect', listener: (data: undefined) => void): void; -} - -namespace Lifecycle { - export type Method = (request: Request, h: ResponseToolkit, err?: Error) => ReturnValue; - export type ReturnValue = ReturnValueTypes | Promise; - export type ReturnValueTypes = - | (null | string | number | boolean) - | Buffer - | (Error | Boom) - | stream.Stream - | (object | object[]) - | symbol - | ResponseToolkit; - export type FailAction = 'error' | 'log' | 'ignore' | Method; -} - -namespace Util { - export interface Dictionary { - [key: string]: T; - } - - export type HTTP_METHODS_PARTIAL_LOWERCASE = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'query'; - export type HTTP_METHODS_PARTIAL = - | 'GET' - | 'POST' - | 'PUT' - | 'PATCH' - | 'DELETE' - | 'OPTIONS' - | 'QUERY' - | HTTP_METHODS_PARTIAL_LOWERCASE; - export type HTTP_METHODS = 'HEAD' | 'head' | HTTP_METHODS_PARTIAL; -} - -interface RequestRoute { - method: Util.HTTP_METHODS_PARTIAL; - path: string; - vhost?: string | string[] | undefined; - realm: any; - fingerprint: string; - - auth: { - access(request: Request): boolean; - }; -} - -export interface Request extends Podium { - app: ApplicationState; - readonly auth: RequestAuth; - events: RequestEvents; - readonly headers: Util.Dictionary; - readonly path: string; - response: ResponseObject | Boom | null; - readonly route: RequestRoute; - readonly url: URL; -} - -interface ResponseObjectHeaderOptions { - append?: boolean | undefined; - separator?: string | undefined; - override?: boolean | undefined; - duplicate?: boolean | undefined; -} - -export interface ResponseObject extends Podium { - readonly statusCode: number; - header(name: string, value: string, options?: ResponseObjectHeaderOptions): ResponseObject; -} - -interface ResponseToolkit { - readonly continue: symbol; -} - -export interface RequestEvent { - timestamp: string; - tags: string[]; - channel: 'internal' | 'app' | 'error'; - data: object; - error: object; -} - -interface ServerEvents { - on(criteria: any, listener: any): void; -} - -export type Server = Record & { - events: ServerEvents; - register: any; - ext(event: any, method: Lifecycle.Method, options?: Record): void; - initialize(): Promise; - start(): Promise; -}; - -interface ApplicationState {} - -type PeekListener = (chunk: string, encoding: string) => void; diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index c68743fa0dd5..0296cabbb708 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -96,6 +96,7 @@ export { setAttribute, setAttributes, setupExpressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, setupKoaErrorHandler, setUser, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 237693d45c14..34c3dfc3ea1a 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -1,6 +1,8 @@ export * from './exports'; // Exports using diagnostics channels +import { attachHapiErrorHandler as _attachHapiErrorHandler } from './integrations/hapi/hapi-error-handler'; + export { prismaIntegration } from './prisma'; export { bindTracingChannelToSpan } from './tracing-channel'; export type { TracingChannelPayloadWithSpan } from './tracing-channel'; @@ -14,3 +16,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated instrumentFastify, } from './integrations/fastify'; + +/** + * @deprecated This is a temporary export to avoid breaking changes. It will be removed in the next major version. + */ +export const attachHapiErrorHandler = _attachHapiErrorHandler; diff --git a/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts new file mode 100644 index 000000000000..dc15d068ad65 --- /dev/null +++ b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts @@ -0,0 +1,62 @@ +import { + addNonEnumerableProperty, + captureException, + debug, + getDefaultIsolationScope, + getIsolationScope, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { HapiRequest, HapiRequestEvent, HapiServer, HapiServerEvents } from './hapi-types'; + +// Marks a server's shared event emitter as already carrying the Sentry error +// listener, so repeat attachments only ever register a single listener — whether +// reached via the `start` and `initialize` channels, a plugin clone that shares +// the same emitter, or a lingering manual `setupHapiErrorHandler` call. +const ERROR_HANDLER_ATTACHED = '__SENTRY_HAPI_ERROR_HANDLER_ATTACHED__'; + +type MarkedServerEvents = HapiServerEvents & { [ERROR_HANDLER_ATTACHED]?: boolean }; + +function isErrorEvent(event: HapiRequestEvent): boolean { + return !!(event && typeof event === 'object' && 'error' in event && event.error); +} + +/** + * Attach a Sentry error listener to a Hapi server's shared event emitter. + * + * The listener sets the isolation scope's transaction name from the errored + * route and captures the error. It is attached once per server: hapi shares one + * event emitter (`core.events`) across the root server and every plugin clone, + * so a single listener covers all requests. + * + * Idempotent — the emitter is marked so auto-registration (via the `start` / + * `initialize` channels) and any explicit `setupHapiErrorHandler` call never + * stack up multiple listeners. + */ +export function attachHapiErrorHandler(server: HapiServer): void { + const events = server?.events as MarkedServerEvents | undefined; + if (!events || events[ERROR_HANDLER_ATTACHED]) { + return; + } + addNonEnumerableProperty(events, ERROR_HANDLER_ATTACHED, true); + + events.on({ name: 'request', channels: ['error'] }, (request: HapiRequest, event: HapiRequestEvent) => { + if (getIsolationScope() !== getDefaultIsolationScope()) { + const route = request.route; + if (route?.path) { + getIsolationScope().setTransactionName(`${route.method.toUpperCase()} ${route.path}`); + } + } else { + DEBUG_BUILD && + debug.warn('Isolation scope is still the default isolation scope - skipping setting transactionName'); + } + + if (isErrorEvent(event)) { + captureException(event.error, { + mechanism: { + type: 'auto.function.hapi', + handled: false, + }, + }); + } + }); +} diff --git a/packages/server-utils/src/integrations/hapi-types.ts b/packages/server-utils/src/integrations/hapi/hapi-types.ts similarity index 75% rename from packages/server-utils/src/integrations/hapi-types.ts rename to packages/server-utils/src/integrations/hapi/hapi-types.ts index 898795c17f94..58be3608db05 100644 --- a/packages/server-utils/src/integrations/hapi-types.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-types.ts @@ -76,6 +76,33 @@ export const HapiLayerType = { export const HapiLifecycleMethodNames = new Set(LIFECYCLE_EXT_POINTS); +/** The `request`/`error` event payload passed to the error listener. */ +export interface HapiRequestEvent { + error?: unknown; + [key: string]: unknown; +} + +/** The subset of a hapi request the error listener reads. */ +export interface HapiRequest { + route: { path?: string; method: string }; + [key: string]: unknown; +} + +/** The shared hapi server event emitter (`core.events`, a Podium instance). */ +export interface HapiServerEvents { + on( + criteria: { name: string; channels: string[] }, + listener: (request: HapiRequest, event: HapiRequestEvent) => void, + ): void; + [key: string]: unknown; +} + +/** The subset of a hapi server the error handler needs. */ +export interface HapiServer { + events: HapiServerEvents; + [key: string]: unknown; +} + export enum AttributeNames { HAPI_TYPE = 'hapi.type', PLUGIN_NAME = 'hapi.plugin.name', diff --git a/packages/server-utils/src/integrations/hapi-utils.ts b/packages/server-utils/src/integrations/hapi/hapi-utils.ts similarity index 98% rename from packages/server-utils/src/integrations/hapi-utils.ts rename to packages/server-utils/src/integrations/hapi/hapi-utils.ts index 4b4af542f247..d947cfecae4f 100644 --- a/packages/server-utils/src/integrations/hapi-utils.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-utils.ts @@ -26,7 +26,7 @@ import type { // eslint-disable-next-line typescript/no-deprecated -- TODO(v11): Replace deprecated attributes import { HTTP_METHOD, HTTP_ROUTE } from '@sentry/conventions/attributes'; import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; -import { setHttpServerSpanRouteAttribute } from '../utils/setHttpServerSpanRouteAttribute'; +import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; type SpanAttributes = Record; diff --git a/packages/server-utils/src/integrations/hapi.ts b/packages/server-utils/src/integrations/hapi/index.ts similarity index 68% rename from packages/server-utils/src/integrations/hapi.ts rename to packages/server-utils/src/integrations/hapi/index.ts index da6182bc83bd..5b54437bc9ce 100644 --- a/packages/server-utils/src/integrations/hapi.ts +++ b/packages/server-utils/src/integrations/hapi/index.ts @@ -1,9 +1,11 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { CHANNELS } from '../orchestrion/channels'; -import { hapiModuleNames } from '../orchestrion/config/hapi'; -import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import { CHANNELS } from '../../orchestrion/channels'; +import { hapiModuleNames } from '../../orchestrion/config/hapi'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { attachHapiErrorHandler } from './hapi-error-handler'; +import type { HapiServer } from './hapi-types'; import { wrapExtArguments, wrapRouteArguments } from './hapi-utils'; // NOTE: same name as the OTel integration by design — when enabled, the OTel @@ -24,6 +26,14 @@ interface HapiChannelContext { self?: { realm?: { plugin?: string } }; } +/** + * The `start`/`initialize` channel `context` shape: `self` is the live server + * we attach the auto-registered error listener to. + */ +interface HapiServerContext { + self?: HapiServer; +} + const _hapiIntegration = (() => { return { name: INTEGRATION_NAME, @@ -60,6 +70,24 @@ function instrumentHapi(): void { asyncEnd() {}, error() {}, }); + + // Auto-register the error handler when the server boots + // `attachHapiErrorHandler` is idempotent, so hooking both `start` and `initialize` is safe. + const attachOnStart = { + start(rawCtx: unknown) { + const server = (rawCtx as HapiServerContext).self; + if (server) { + attachHapiErrorHandler(server); + } + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }; + + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_START).subscribe(attachOnStart); + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_INITIALIZE).subscribe(attachOnStart); } /** diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 90b8bde0085c..d5aa9052058c 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -16,6 +16,22 @@ export const hapiConfig = [ module: { name: '@hapi/hapi', versionRange: '>=17.0.0 <22.0.0', filePath: 'lib/server.js' }, functionQuery: { methodName: 'ext', kind: 'Sync' }, }, + // `start`/`initialize` give us the live server via `ctx.self` so we can attach + // the error listener automatically. We hook both because `start()` calls the + // private `_core._start()` (never the public `initialize` method), while + // test/serverless flows may only call `initialize()`. Only the synchronous + // `start` event is used — to read `ctx.self` — so `Sync` suffices even though + // both methods return a promise. + { + channelName: 'start', + module: { name: '@hapi/hapi', versionRange: '>=17.0.0 <22.0.0', filePath: 'lib/server.js' }, + functionQuery: { methodName: 'start', kind: 'Sync' }, + }, + { + channelName: 'initialize', + module: { name: '@hapi/hapi', versionRange: '>=17.0.0 <22.0.0', filePath: 'lib/server.js' }, + functionQuery: { methodName: 'initialize', kind: 'Sync' }, + }, ] satisfies InstrumentationConfig[]; export const hapiModuleNames = getModuleNames(hapiConfig); @@ -23,4 +39,6 @@ export const hapiModuleNames = getModuleNames(hapiConfig); export const hapiChannels = { HAPI_ROUTE: 'orchestrion:@hapi/hapi:route', HAPI_EXT: 'orchestrion:@hapi/hapi:ext', + HAPI_START: 'orchestrion:@hapi/hapi:start', + HAPI_INITIALIZE: 'orchestrion:@hapi/hapi:initialize', } as const; diff --git a/packages/server-utils/test/integrations/tracing-channel/hapi-error-handler.test.ts b/packages/server-utils/test/integrations/tracing-channel/hapi-error-handler.test.ts new file mode 100644 index 000000000000..d4c82865492e --- /dev/null +++ b/packages/server-utils/test/integrations/tracing-channel/hapi-error-handler.test.ts @@ -0,0 +1,128 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { attachHapiErrorHandler } from '../../../src/integrations/hapi/hapi-error-handler'; +import type { HapiRequest, HapiRequestEvent, HapiServer } from '../../../src/integrations/hapi/hapi-types'; + +type Listener = (request: HapiRequest, event: HapiRequestEvent) => void; + +interface FakeServer { + server: HapiServer; + onSpy: MockInstance; + /** The listener registered on the `request`/`error` event, if any. */ + getListener: () => Listener | undefined; +} + +function makeServer(): FakeServer { + let listener: Listener | undefined; + const onSpy = vi.fn((_criteria: unknown, cb: Listener) => { + listener = cb; + }); + const server = { events: { on: onSpy } } as unknown as HapiServer; + return { server, onSpy, getListener: () => listener }; +} + +function makeRequest(path?: string, method = 'get'): HapiRequest { + return { route: { path, method } } as HapiRequest; +} + +describe('attachHapiErrorHandler', () => { + let captureExceptionSpy: MockInstance; + let setTransactionNameSpy: MockInstance; + let isolationScope: { setTransactionName: MockInstance }; + let defaultIsolationScope: { setTransactionName: MockInstance }; + + beforeEach(() => { + setTransactionNameSpy = vi.fn(); + isolationScope = { setTransactionName: setTransactionNameSpy }; + defaultIsolationScope = { setTransactionName: vi.fn() }; + + captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'id'); + vi.spyOn(SentryCore, 'getIsolationScope').mockReturnValue(isolationScope as unknown as SentryCore.Scope); + vi.spyOn(SentryCore, 'getDefaultIsolationScope').mockReturnValue( + defaultIsolationScope as unknown as SentryCore.Scope, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registers a single listener for the `request`/`error` event', () => { + const { server, onSpy } = makeServer(); + + attachHapiErrorHandler(server); + + expect(onSpy).toHaveBeenCalledTimes(1); + expect(onSpy).toHaveBeenCalledWith({ name: 'request', channels: ['error'] }, expect.any(Function)); + }); + + it('is idempotent across repeat calls on the same server', () => { + const { server, onSpy } = makeServer(); + + attachHapiErrorHandler(server); + attachHapiErrorHandler(server); + + expect(onSpy).toHaveBeenCalledTimes(1); + }); + + it('attaches once per shared event emitter (e.g. plugin clones)', () => { + const { server, onSpy } = makeServer(); + // A plugin clone is a different server object sharing the same `events`. + const clone = { events: server.events } as unknown as HapiServer; + + attachHapiErrorHandler(server); + attachHapiErrorHandler(clone); + + expect(onSpy).toHaveBeenCalledTimes(1); + }); + + it('does not throw when the server has no event emitter', () => { + expect(() => attachHapiErrorHandler({} as HapiServer)).not.toThrow(); + expect(() => attachHapiErrorHandler(undefined as unknown as HapiServer)).not.toThrow(); + }); + + it('sets a parameterized transaction name and captures the error', () => { + const { server, getListener } = makeServer(); + attachHapiErrorHandler(server); + const error = new Error('boom'); + + getListener()?.(makeRequest('/users/{id}'), { error } as HapiRequestEvent); + + expect(setTransactionNameSpy).toHaveBeenCalledWith('GET /users/{id}'); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.function.hapi', handled: false }, + }); + }); + + it('does not set the transaction name when the isolation scope is still the default', () => { + vi.spyOn(SentryCore, 'getIsolationScope').mockReturnValue(defaultIsolationScope as unknown as SentryCore.Scope); + const { server, getListener } = makeServer(); + attachHapiErrorHandler(server); + + getListener()?.(makeRequest('/users/{id}'), { error: new Error('boom') } as HapiRequestEvent); + + expect(setTransactionNameSpy).not.toHaveBeenCalled(); + expect(defaultIsolationScope.setTransactionName).not.toHaveBeenCalled(); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); + + it('does not set the transaction name when the route has no path', () => { + const { server, getListener } = makeServer(); + attachHapiErrorHandler(server); + + getListener()?.(makeRequest(undefined), { error: new Error('boom') } as HapiRequestEvent); + + expect(setTransactionNameSpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); + + it('does not capture when the event carries no error', () => { + const { server, getListener } = makeServer(); + attachHapiErrorHandler(server); + + getListener()?.(makeRequest('/users/{id}'), {} as HapiRequestEvent); + + expect(setTransactionNameSpy).toHaveBeenCalledWith('GET /users/{id}'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts b/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts index 71c994f886d3..63b1c36310e1 100644 --- a/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts +++ b/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getExtMetadata, getRouteMetadata } from '../../../src/integrations/hapi-utils'; +import { getExtMetadata, getRouteMetadata } from '../../../src/integrations/hapi/hapi-utils'; describe('getRouteMetadata', () => { const route = { path: '/users/{id}', method: 'get' } as any; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 72cbcf7ad78c..e7fd4fdcbd28 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -100,6 +100,7 @@ export { setAttribute, setAttributes, setupExpressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, setupKoaErrorHandler, setUser, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 9d9040bfb9b4..edaf36db7d67 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -97,6 +97,7 @@ export { setAttribute, setAttributes, setupExpressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, setupKoaErrorHandler, setUser, From c88eb54d4218034d168f838f0b67b290c33c23c9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 20 Aug 2026 11:26:08 +0200 Subject: [PATCH 2/4] spans only --- .../src/integrations/hapi/index.ts | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/server-utils/src/integrations/hapi/index.ts b/packages/server-utils/src/integrations/hapi/index.ts index 5b54437bc9ce..d5eb3b473c69 100644 --- a/packages/server-utils/src/integrations/hapi/index.ts +++ b/packages/server-utils/src/integrations/hapi/index.ts @@ -1,6 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, hasSpansEnabled } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { hapiModuleNames } from '../../orchestrion/config/hapi'; import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; @@ -46,30 +46,32 @@ const _hapiIntegration = (() => { }) satisfies IntegrationFn; function instrumentHapi(): void { - // `subscribe` requires all five lifecycle hooks. We only act on `start`, - // which orchestrion fires synchronously with the live args array — that's - // the moment we mutate the handlers in place. - diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ - start(rawCtx) { - const ctx = rawCtx as HapiChannelContext; - wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); + if (hasSpansEnabled()) { + // `subscribe` requires all five lifecycle hooks. We only act on `start`, + // which orchestrion fires synchronously with the live args array — that's + // the moment we mutate the handlers in place. + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); - diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ - start(rawCtx) { - const ctx = rawCtx as HapiChannelContext; - wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); + } // Auto-register the error handler when the server boots // `attachHapiErrorHandler` is idempotent, so hooking both `start` and `initialize` is safe. From 54993f9b63b7a93dc1904dce00c10fe9197116b1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 20 Aug 2026 12:25:39 +0200 Subject: [PATCH 3/4] Revert "spans only" This reverts commit c88eb54d4218034d168f838f0b67b290c33c23c9. --- .../src/integrations/hapi/index.ts | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/server-utils/src/integrations/hapi/index.ts b/packages/server-utils/src/integrations/hapi/index.ts index d5eb3b473c69..5b54437bc9ce 100644 --- a/packages/server-utils/src/integrations/hapi/index.ts +++ b/packages/server-utils/src/integrations/hapi/index.ts @@ -1,6 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, hasSpansEnabled } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; import { CHANNELS } from '../../orchestrion/channels'; import { hapiModuleNames } from '../../orchestrion/config/hapi'; import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; @@ -46,32 +46,30 @@ const _hapiIntegration = (() => { }) satisfies IntegrationFn; function instrumentHapi(): void { - if (hasSpansEnabled()) { - // `subscribe` requires all five lifecycle hooks. We only act on `start`, - // which orchestrion fires synchronously with the live args array — that's - // the moment we mutate the handlers in place. - diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ - start(rawCtx) { - const ctx = rawCtx as HapiChannelContext; - wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); + // `subscribe` requires all five lifecycle hooks. We only act on `start`, + // which orchestrion fires synchronously with the live args array — that's + // the moment we mutate the handlers in place. + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); - diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ - start(rawCtx) { - const ctx = rawCtx as HapiChannelContext; - wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, - }); - } + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); // Auto-register the error handler when the server boots // `attachHapiErrorHandler` is idempotent, so hooking both `start` and `initialize` is safe. From 98d690d2a65c935e968cdfc21bd370070cd37c6d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 20 Aug 2026 14:03:52 +0200 Subject: [PATCH 4/4] add should handle --- .../integrations/hapi/hapi-error-handler.ts | 14 +++-- .../src/integrations/hapi/hapi-types.ts | 20 +++++++ .../src/integrations/hapi/hapi-utils.ts | 43 ++++++++++++++- .../src/integrations/hapi/index.ts | 35 ++++++++++-- .../hapi-error-handler.test.ts | 54 ++++++++++++++++++- 5 files changed, 155 insertions(+), 11 deletions(-) diff --git a/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts index dc15d068ad65..0b3efd5b1db6 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts @@ -6,7 +6,8 @@ import { getIsolationScope, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; -import type { HapiRequest, HapiRequestEvent, HapiServer, HapiServerEvents } from './hapi-types'; +import type { HapiRequest, HapiRequestEvent, HapiServer, HapiServerEvents, HapiShouldHandleError } from './hapi-types'; +import { defaultShouldHandleError } from './hapi-utils'; // Marks a server's shared event emitter as already carrying the Sentry error // listener, so repeat attachments only ever register a single listener — whether @@ -31,8 +32,15 @@ function isErrorEvent(event: HapiRequestEvent): boolean { * Idempotent — the emitter is marked so auto-registration (via the `start` / * `initialize` channels) and any explicit `setupHapiErrorHandler` call never * stack up multiple listeners. + * + * `shouldHandleError` gates which errors are captured (defaults to + * {@link defaultShouldHandleError}); the integration threads its option through + * here, while the deprecated `setupHapiErrorHandler` relies on the default. */ -export function attachHapiErrorHandler(server: HapiServer): void { +export function attachHapiErrorHandler( + server: HapiServer, + shouldHandleError: HapiShouldHandleError = defaultShouldHandleError, +): void { const events = server?.events as MarkedServerEvents | undefined; if (!events || events[ERROR_HANDLER_ATTACHED]) { return; @@ -50,7 +58,7 @@ export function attachHapiErrorHandler(server: HapiServer): void { debug.warn('Isolation scope is still the default isolation scope - skipping setting transactionName'); } - if (isErrorEvent(event)) { + if (isErrorEvent(event) && shouldHandleError(event.error, request)) { captureException(event.error, { mechanism: { type: 'auto.function.hapi', diff --git a/packages/server-utils/src/integrations/hapi/hapi-types.ts b/packages/server-utils/src/integrations/hapi/hapi-types.ts index 58be3608db05..0afb23cc1448 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-types.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-types.ts @@ -82,12 +82,32 @@ export interface HapiRequestEvent { [key: string]: unknown; } +/** + * The final response attached to a hapi request. On error it is a Boom object + * (`isBoom`, with the HTTP status under `output.statusCode`); otherwise a normal + * response carrying `statusCode`. Both are read to derive the status for + * `shouldHandleError`. + */ +export interface HapiResponse { + statusCode?: number; + isBoom?: boolean; + output?: { statusCode?: number }; +} + /** The subset of a hapi request the error listener reads. */ export interface HapiRequest { route: { path?: string; method: string }; + response?: HapiResponse; [key: string]: unknown; } +/** + * Callback deciding whether an error surfaced by hapi should be captured and + * sent to Sentry. Receives the error and the hapi request (whose `response` + * carries the resolved HTTP status). + */ +export type HapiShouldHandleError = (error: unknown, request: HapiRequest) => boolean; + /** The shared hapi server event emitter (`core.events`, a Podium instance). */ export interface HapiServerEvents { on( diff --git a/packages/server-utils/src/integrations/hapi/hapi-utils.ts b/packages/server-utils/src/integrations/hapi/hapi-utils.ts index d947cfecae4f..ffb3436de0b8 100644 --- a/packages/server-utils/src/integrations/hapi/hapi-utils.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-utils.ts @@ -9,10 +9,11 @@ * is replaced with `getActiveSpan()`. */ -import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { getActiveSpan, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { WEB_SERVER_MIDDLEWARE_SPAN_OP } from '@sentry/conventions/op'; import type { + HapiRequest, LifecycleMethod, PatchableExtMethod, PatchableServerRoute, @@ -28,6 +29,46 @@ import { HTTP_METHOD, HTTP_ROUTE } from '@sentry/conventions/attributes'; import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; +/** + * Default function deciding whether an error should be sent to Sentry. + * + * Captures 5xx errors and any error whose status can't be resolved; skips 3xx + * and 4xx (client errors / redirects) and 2xx-and-below outliers are captured + * as they usually signal an unmapped thrown error. Mirrors the defaults used by + * the other server framework integrations. + */ +export function defaultShouldHandleError(error: unknown, request: HapiRequest): boolean { + const statusCode = getResponseStatusCode(request, error); + if (typeof statusCode !== 'number') { + return true; + } + // 3xx and 4xx errors are not sent by default. + return statusCode >= 500 || statusCode <= 299; +} + +/** + * Resolve the HTTP status for an errored hapi request: prefer the resolved + * response (Boom `output.statusCode`, else `statusCode`), falling back to a Boom + * error passed directly. + */ +function getResponseStatusCode(request: HapiRequest, error: unknown): number | undefined { + const response = request.response; + if (isObjectLike(response)) { + if (response.isBoom && isObjectLike(response.output) && typeof response.output.statusCode === 'number') { + return response.output.statusCode; + } + if (typeof response.statusCode === 'number') { + return response.statusCode; + } + } + + if (isObjectLike(error) && isObjectLike(error.output) && typeof error.output.statusCode === 'number') { + return error.output.statusCode; + } + + return undefined; +} + type SpanAttributes = Record; interface SpanMetadata { diff --git a/packages/server-utils/src/integrations/hapi/index.ts b/packages/server-utils/src/integrations/hapi/index.ts index 5b54437bc9ce..b10fa44311eb 100644 --- a/packages/server-utils/src/integrations/hapi/index.ts +++ b/packages/server-utils/src/integrations/hapi/index.ts @@ -5,13 +5,38 @@ import { CHANNELS } from '../../orchestrion/channels'; import { hapiModuleNames } from '../../orchestrion/config/hapi'; import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { attachHapiErrorHandler } from './hapi-error-handler'; -import type { HapiServer } from './hapi-types'; +import type { HapiServer, HapiShouldHandleError } from './hapi-types'; import { wrapExtArguments, wrapRouteArguments } from './hapi-utils'; // NOTE: same name as the OTel integration by design — when enabled, the OTel // 'Hapi' integration is omitted from the default set. const INTEGRATION_NAME = 'Hapi' as const; +interface HapiIntegrationOptions { + /** + * Callback deciding whether an error should be captured and sent to Sentry. + * + * By default, 5xx errors (and errors without a resolvable status) are sent, + * while 3xx and 4xx errors are not. The hapi request's `response` carries the + * resolved HTTP status. + * + * @example + * + * ```javascript + * Sentry.init({ + * integrations: [ + * Sentry.hapiIntegration({ + * shouldHandleError(_error, request) { + * return (request.response?.output?.statusCode ?? request.response?.statusCode ?? 500) >= 500; + * }, + * }), + * ], + * }); + * ``` + */ + shouldHandleError: HapiShouldHandleError; +} + /** * The shape orchestrion's transform attaches to the `@hapi/hapi` route/ext * tracing-channel `context` objects. @@ -34,18 +59,18 @@ interface HapiServerContext { self?: HapiServer; } -const _hapiIntegration = (() => { +const _hapiIntegration = (({ shouldHandleError }: Partial = {}) => { return { name: INTEGRATION_NAME, setup(client) { - invokeOrchestrionInstrumentation(client, hapiModuleNames, instrumentHapi, [], { + invokeOrchestrionInstrumentation(client, hapiModuleNames, instrumentHapi, [shouldHandleError], { requiresTracingChannelBinding: false, }); }, }; }) satisfies IntegrationFn; -function instrumentHapi(): void { +function instrumentHapi(shouldHandleError?: HapiShouldHandleError): void { // `subscribe` requires all five lifecycle hooks. We only act on `start`, // which orchestrion fires synchronously with the live args array — that's // the moment we mutate the handlers in place. @@ -77,7 +102,7 @@ function instrumentHapi(): void { start(rawCtx: unknown) { const server = (rawCtx as HapiServerContext).self; if (server) { - attachHapiErrorHandler(server); + attachHapiErrorHandler(server, shouldHandleError); } }, end() {}, diff --git a/packages/server-utils/test/integrations/tracing-channel/hapi-error-handler.test.ts b/packages/server-utils/test/integrations/tracing-channel/hapi-error-handler.test.ts index d4c82865492e..1a4f1a92f986 100644 --- a/packages/server-utils/test/integrations/tracing-channel/hapi-error-handler.test.ts +++ b/packages/server-utils/test/integrations/tracing-channel/hapi-error-handler.test.ts @@ -21,8 +21,8 @@ function makeServer(): FakeServer { return { server, onSpy, getListener: () => listener }; } -function makeRequest(path?: string, method = 'get'): HapiRequest { - return { route: { path, method } } as HapiRequest; +function makeRequest(path?: string, method = 'get', response?: HapiRequest['response']): HapiRequest { + return { route: { path, method }, response } as HapiRequest; } describe('attachHapiErrorHandler', () => { @@ -125,4 +125,54 @@ describe('attachHapiErrorHandler', () => { expect(setTransactionNameSpy).toHaveBeenCalledWith('GET /users/{id}'); expect(captureExceptionSpy).not.toHaveBeenCalled(); }); + + it('captures 5xx errors by default', () => { + const { server, getListener } = makeServer(); + attachHapiErrorHandler(server); + const error = new Error('boom'); + + getListener()?.(makeRequest('/users/{id}', 'get', { isBoom: true, output: { statusCode: 500 } }), { + error, + } as HapiRequestEvent); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.function.hapi', handled: false }, + }); + }); + + it('does not capture 4xx errors by default, but still sets the transaction name', () => { + const { server, getListener } = makeServer(); + attachHapiErrorHandler(server); + + getListener()?.(makeRequest('/users/{id}', 'get', { isBoom: true, output: { statusCode: 404 } }), { + error: new Error('not found'), + } as HapiRequestEvent); + + expect(setTransactionNameSpy).toHaveBeenCalledWith('GET /users/{id}'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('does not capture 3xx responses by default', () => { + const { server, getListener } = makeServer(); + attachHapiErrorHandler(server); + + getListener()?.(makeRequest('/users/{id}', 'get', { statusCode: 302 }), { + error: new Error('redirect'), + } as HapiRequestEvent); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('uses a custom shouldHandleError passed to the handler', () => { + const shouldHandleError = vi.fn().mockReturnValue(false); + const { server, getListener } = makeServer(); + attachHapiErrorHandler(server, shouldHandleError); + const error = new Error('boom'); + const request = makeRequest('/users/{id}', 'get', { isBoom: true, output: { statusCode: 500 } }); + + getListener()?.(request, { error } as HapiRequestEvent); + + expect(shouldHandleError).toHaveBeenCalledWith(error, request); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); });