diff --git a/MIGRATION.md b/MIGRATION.md index 85b3a0d6bd13..14ce065bd552 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -293,6 +293,12 @@ Affected SDKs: `@sentry/node` and all dependents that re-export it (e.g. `@sentr The Koa error handler is now registered automatically when your app starts, so you no longer need to call `setupKoaErrorHandler`. The function is deprecated and will be removed in a future major version; you should no longer call it. +### `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/instrument-should-handle-error.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-should-handle-error.mjs new file mode 100644 index 000000000000..193ef0a6fb36 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-should-handle-error.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + // Drop the "Dropped error" — the default predicate would capture it (it's a 5xx). This custom + // predicate must win over the default one installed by the earlier `setupHapiErrorHandler` call. + Sentry.hapiIntegration({ + shouldHandleError: error => error?.message !== 'Dropped error', + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario-should-handle-error.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario-should-handle-error.mjs new file mode 100644 index 000000000000..be04618254c0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario-should-handle-error.mjs @@ -0,0 +1,33 @@ +import Hapi from '@hapi/hapi'; +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const run = async () => { + const server = Hapi.server({ + host: 'localhost', + port: 0, + }); + + server.route({ + method: 'GET', + path: '/dropped', + handler: () => new Error('Dropped error'), + }); + + server.route({ + method: 'GET', + path: '/captured', + handler: () => new Error('Captured error'), + }); + + // Runs BEFORE `server.start()` and installs the default predicate. The integration's + // auto-registration (with the custom predicate) only fires at `server.start()`, so the custom + // predicate must still take precedence over this earlier default-valued attach. + await Sentry.setupHapiErrorHandler(server); + + await server.start(); + + sendPortToRunner(server.info.port); +}; + +run(); 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/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts index 720269556c62..057abb980951 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts @@ -135,4 +135,37 @@ describe('hapi auto-instrumentation', () => { await runner.completed(); }); }); + + // Regression test: a `setupHapiErrorHandler` call before `server.start()` installs the default + // predicate. The integration's own auto-registration (with a custom `shouldHandleError`) fires later, + // at `server.start()`, and must still win. The custom predicate drops "Dropped error" (which the + // default would capture), so only the "Captured error" sentinel should come through — if the default + // predicate were active, "Dropped error" would be captured first and fail this assertion. + createEsmAndCjsTests( + __dirname, + 'scenario-should-handle-error.mjs', + 'instrument-should-handle-error.mjs', + (createRunner, test) => { + test('integration `shouldHandleError` overrides an earlier default-valued `setupHapiErrorHandler`', async () => { + const runner = createRunner() + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Captured error', + }, + ], + }, + }, + }) + .start(); + await runner.makeRequest('get', '/dropped', { expectError: true }); + await runner.makeRequest('get', '/captured', { expectError: true }); + await runner.completed(); + }); + }, + ); }); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 0053d66501e9..e3c3c6ec5f1f 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -125,6 +125,7 @@ export { setAttribute, setAttributes, setupExpressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupHapiErrorHandler, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 4e17556429b3..994ac3e54d34 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -123,6 +123,7 @@ export { workerThreadsIntegration, 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 54eb736a643f..d63946b847dc 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -140,6 +140,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 30dc2366b639..2a0a92eed4c1 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -117,6 +117,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 7e881f230cc0..f2bda4d03851 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -120,6 +120,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 2cce01ee3335..90a09b00b1ed 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'; // oxlint-disable-next-line typescript/no-deprecated -- deprecated but still re-exported for backwards compatibility export { setupKoaErrorHandler } from './integrations/tracing/koa'; 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 df993e07978b..41ed4c21f5ad 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, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 35240de5aaef..b907b081fee5 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -4,6 +4,7 @@ export * from './exports'; export { detectOrchestrionSetup } from './orchestrion/detect'; // oxlint-disable-next-line typescript/no-deprecated -- re-exported so the deprecated `setupKoaErrorHandler` can delegate export { attachKoaErrorHandler } from './integrations/koa/koa-error-handler'; +import { attachHapiErrorHandler as _attachHapiErrorHandler } from './integrations/hapi/hapi-error-handler'; export { bindTracingChannelToSpan } from './tracing-channel'; export type { TracingChannelPayloadWithSpan } from './tracing-channel'; export type { InstrumentationConfig } from './orchestrion/apmTypes'; @@ -20,6 +21,11 @@ export { 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; + // Integrations export { prismaIntegration } from './integrations/prisma'; export { amqplibIntegration } from './integrations/amqplib'; 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..d79b35b4502a --- /dev/null +++ b/packages/server-utils/src/integrations/hapi/hapi-error-handler.ts @@ -0,0 +1,92 @@ +import { + addNonEnumerableProperty, + captureException, + debug, + getDefaultIsolationScope, + getIsolationScope, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { HapiRequest, HapiRequestEvent, HapiServer, HapiServerEvents, HapiShouldHandleError } from './hapi-types'; +import { defaultShouldHandleError } from './hapi-utils'; + +// Holds the Sentry error-handling state on a server's shared event emitter. Its +// presence means the single Sentry error listener is already attached, so repeat +// attachments only ever register one listener — whether reached via the `start` +// and `initialize` channels, a plugin clone that shares the same emitter, or a +// manual `setupHapiErrorHandler` call. +// +// The `shouldHandleError` predicate lives on this shared object (rather than +// being captured by the listener) so a later attach carrying an explicit +// predicate can upgrade it in place: the integration's configured +// `shouldHandleError` must win even when a default-valued attach (e.g. the +// deprecated `setupHapiErrorHandler`, which precedes `server.start()`) ran first. +const ERROR_HANDLER_STATE = '__SENTRY_HAPI_ERROR_HANDLER_STATE__'; + +interface HapiErrorHandlerState { + shouldHandleError: HapiShouldHandleError; +} + +type MarkedServerEvents = HapiServerEvents & { [ERROR_HANDLER_STATE]?: HapiErrorHandlerState }; + +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 carries the handler state, 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}). When omitted, a previously installed + * predicate is left untouched; when provided, it overrides whatever was set + * before — so the integration's configured predicate wins over a prior + * default-valued attach, regardless of ordering. + */ +export function attachHapiErrorHandler(server: HapiServer, shouldHandleError?: HapiShouldHandleError): void { + const events = server?.events as MarkedServerEvents | undefined; + if (!events) { + return; + } + + const existingState = events[ERROR_HANDLER_STATE]; + if (existingState) { + // Listener already attached: only an explicit predicate upgrades it, so a + // later default-valued attach never downgrades a configured one. + if (shouldHandleError) { + existingState.shouldHandleError = shouldHandleError; + } + return; + } + + const state: HapiErrorHandlerState = { shouldHandleError: shouldHandleError ?? defaultShouldHandleError }; + addNonEnumerableProperty(events, ERROR_HANDLER_STATE, state); + + 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) && state.shouldHandleError(event.error, request)) { + 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 61% rename from packages/server-utils/src/integrations/hapi-types.ts rename to packages/server-utils/src/integrations/hapi/hapi-types.ts index 898795c17f94..0afb23cc1448 100644 --- a/packages/server-utils/src/integrations/hapi-types.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-types.ts @@ -76,6 +76,53 @@ 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 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( + 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 85% rename from packages/server-utils/src/integrations/hapi-utils.ts rename to packages/server-utils/src/integrations/hapi/hapi-utils.ts index 83ab15372097..f4ea3a6d43fe 100644 --- a/packages/server-utils/src/integrations/hapi-utils.ts +++ b/packages/server-utils/src/integrations/hapi/hapi-utils.ts @@ -13,6 +13,7 @@ import { getActiveSpan, getClient, hasSpanStreamingEnabled, + isObjectLike, ROUTER_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, @@ -20,6 +21,7 @@ import { import { SENTRY_OP } from '@sentry/conventions/attributes'; import { MIDDLEWARE } from '@sentry/conventions/op'; import type { + HapiRequest, LifecycleMethod, PatchableExtMethod, PatchableServerRoute, @@ -32,7 +34,47 @@ import type { } from './hapi-types'; import { HTTP_REQUEST_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'; + +/** + * 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; diff --git a/packages/server-utils/src/integrations/hapi.ts b/packages/server-utils/src/integrations/hapi/index.ts similarity index 52% rename from packages/server-utils/src/integrations/hapi.ts rename to packages/server-utils/src/integrations/hapi/index.ts index 0084ad9a1bf1..70c462ca0324 100644 --- a/packages/server-utils/src/integrations/hapi.ts +++ b/packages/server-utils/src/integrations/hapi/index.ts @@ -1,15 +1,42 @@ 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, 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. @@ -24,18 +51,26 @@ interface HapiChannelContext { self?: { realm?: { plugin?: string } }; } -const _hapiIntegration = (() => { +/** + * 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 = (({ 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. @@ -60,6 +95,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, shouldHandleError); + } + }, + 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 3acb5ca711b8..b3f5a66a53ea 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -17,6 +17,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); @@ -24,4 +40,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/hapi-utils.test.ts b/packages/server-utils/test/integrations/hapi-utils.test.ts index 0bc03f907807..9eec9f183c46 100644 --- a/packages/server-utils/test/integrations/hapi-utils.test.ts +++ b/packages/server-utils/test/integrations/hapi-utils.test.ts @@ -1,6 +1,6 @@ import { setCurrentClient } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; -import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi-utils'; +import { getExtMetadata, getRouteMetadata } from '../../src/integrations/hapi/hapi-utils'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; describe('getRouteMetadata', () => { diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 8bd3f34a3a53..7eb31148ce1b 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, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 3f9aa1236ff0..804f992c86d1 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, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler,