diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 690e076ce309..7ca6ea546618 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -871,6 +871,7 @@ jobs: 'sveltekit', 'generic-ts3.8', 'node-experimental-fastify-app', + 'node-hapi-app', ] build-command: - false diff --git a/packages/e2e-tests/test-applications/node-hapi-app/.gitignore b/packages/e2e-tests/test-applications/node-hapi-app/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/e2e-tests/test-applications/node-hapi-app/.npmrc b/packages/e2e-tests/test-applications/node-hapi-app/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/packages/e2e-tests/test-applications/node-hapi-app/event-proxy-server.ts b/packages/e2e-tests/test-applications/node-hapi-app/event-proxy-server.ts new file mode 100644 index 000000000000..67cf80b4dabf --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/event-proxy-server.ts @@ -0,0 +1,253 @@ +import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/packages/e2e-tests/test-applications/node-hapi-app/package.json b/packages/e2e-tests/test-applications/node-hapi-app/package.json new file mode 100644 index 000000000000..1f667abc8987 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/package.json @@ -0,0 +1,29 @@ +{ + "name": "node-hapi-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node src/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules,pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@hapi/hapi": "21.3.2", + "@sentry/integrations": "latest || *", + "@sentry/node": "latest || *", + "@sentry/tracing": "latest || *", + "@sentry/types": "latest || *", + "@types/node": "18.15.1", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "^1.27.1", + "ts-node": "10.9.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/e2e-tests/test-applications/node-hapi-app/playwright.config.ts b/packages/e2e-tests/test-applications/node-hapi-app/playwright.config.ts new file mode 100644 index 000000000000..1b478c6ba6da --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/playwright.config.ts @@ -0,0 +1,77 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const hapiPort = 3030; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${hapiPort}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + // For now we only test Chrome! + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: 'pnpm start', + port: hapiPort, + }, + ], +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/node-hapi-app/src/app.js b/packages/e2e-tests/test-applications/node-hapi-app/src/app.js new file mode 100644 index 000000000000..4c71802c9be2 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/src/app.js @@ -0,0 +1,61 @@ +const Sentry = require('@sentry/node'); +const Hapi = require('@hapi/hapi'); + +const server = Hapi.server({ + port: 3030, + host: 'localhost', +}); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + integrations: [new Sentry.Integrations.Hapi({ server })], + debug: true, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +const init = async () => { + server.route({ + method: 'GET', + path: '/test-success', + handler: function (request, h) { + return { version: 'v1' }; + }, + }); + + server.route({ + method: 'GET', + path: '/test-param/{param}', + handler: function (request, h) { + return { paramWas: request.params.param }; + }, + }); + + server.route({ + method: 'GET', + path: '/test-error', + handler: async function (request, h) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + return { exceptionId }; + }, + }); + + server.route({ + method: 'GET', + path: '/test-failure', + handler: async function (request, h) { + throw new Error('This is an error'); + }, + }); +}; + +(async () => { + init(); + await server.start(); + console.log('Server running on %s', server.info.uri); +})(); diff --git a/packages/e2e-tests/test-applications/node-hapi-app/start-event-proxy.ts b/packages/e2e-tests/test-applications/node-hapi-app/start-event-proxy.ts new file mode 100644 index 000000000000..7a3ed463e2ae --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-hapi-app', +}); diff --git a/packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts b/packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts new file mode 100644 index 000000000000..0539ed6a3548 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/tests/server.test.ts @@ -0,0 +1,194 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { waitForError, waitForTransaction } from '../event-proxy-server'; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; +const EVENT_POLLING_TIMEOUT = 90_000; + +test('Sends captured exception to Sentry', async ({ baseURL }) => { + const { data } = await axios.get(`${baseURL}/test-error`); + const { exceptionId } = data; + + const url = `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionId}/`; + + console.log(`Polling for error eventId: ${exceptionId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get(url, { headers: { Authorization: `Bearer ${authToken}` } }); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { timeout: EVENT_POLLING_TIMEOUT }, + ) + .toBe(200); +}); + +test('Sends thrown error to Sentry', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-hapi-app', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'This is an error'; + }); + + try { + await axios.get(`${baseURL}/test-failure`); + } catch (e) {} + + const errorEvent = await errorEventPromise; + const errorEventId = errorEvent.event_id; + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${errorEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends successful transactions to Sentry', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-hapi-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'hapi.request' && transactionEvent?.transaction === '/test-success' + ); + }); + + await axios.get(`${baseURL}/test-success`); + + const transactionEvent = await pageloadTransactionEventPromise; + const transactionEventId = transactionEvent.event_id; + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends parameterized transactions to Sentry', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-hapi-app', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'hapi.request' && + transactionEvent?.transaction === '/test-param/{param}' + ); + }); + + await axios.get(`${baseURL}/test-param/123`); + + const transactionEvent = await pageloadTransactionEventPromise; + const transactionEventId = transactionEvent.event_id; + + expect(transactionEvent?.contexts?.trace?.op).toBe('hapi.request'); + expect(transactionEvent?.transaction).toBe('/test-param/{param}'); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends sentry-trace and baggage as response headers', async ({ baseURL }) => { + const data = await axios.get(`${baseURL}/test-success`); + + expect(data.headers).toHaveProperty('sentry-trace'); + expect(data.headers).toHaveProperty('baggage'); +}); + +test('Continues trace and baggage from incoming headers', async ({ baseURL }) => { + const traceContent = '12312012123120121231201212312012-1121201211212012-0'; + const baggageContent = 'sentry-release=2.0.0,sentry-environment=myEnv'; + + await axios.get(`${baseURL}/test-success`); + + const data = await axios.get(`${baseURL}/test-success`, { + headers: { + 'sentry-trace': traceContent, + baggage: baggageContent, + }, + }); + + expect(data.headers).toHaveProperty('sentry-trace'); + expect(data.headers).toHaveProperty('baggage'); + + expect(data.headers['sentry-trace']).toContain('12312012123120121231201212312012-'); + expect(data.headers['baggage']).toContain(baggageContent); +}); diff --git a/packages/e2e-tests/test-applications/node-hapi-app/tsconfig.json b/packages/e2e-tests/test-applications/node-hapi-app/tsconfig.json new file mode 100644 index 000000000000..17bd2c1f4c00 --- /dev/null +++ b/packages/e2e-tests/test-applications/node-hapi-app/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "outDir": "dist" + }, + "include": ["*.ts"] +} diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts index e14572b14b6a..67902742b2df 100644 --- a/packages/node-experimental/src/index.ts +++ b/packages/node-experimental/src/index.ts @@ -53,6 +53,7 @@ export { withScope, captureCheckIn, withMonitor, + hapiErrorPlugin, } from '@sentry/node'; export type { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 0a361f02f5d7..9dae268d1241 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -90,3 +90,5 @@ const INTEGRATIONS = { }; export { INTEGRATIONS as Integrations, Handlers }; + +export { hapiErrorPlugin } from './integrations/hapi'; diff --git a/packages/node/src/integrations/hapi/index.ts b/packages/node/src/integrations/hapi/index.ts new file mode 100644 index 000000000000..e8c582f52e0c --- /dev/null +++ b/packages/node/src/integrations/hapi/index.ts @@ -0,0 +1,171 @@ +import { + captureException, + configureScope, + continueTrace, + getActiveTransaction, + SDK_VERSION, + startTransaction, +} from '@sentry/core'; +import type { Integration } from '@sentry/types'; +import { dynamicSamplingContextToSentryBaggageHeader, fill } from '@sentry/utils'; + +import type { Boom, RequestEvent, ResponseObject, Server } from './types'; + +function isResponseObject(response: ResponseObject | Boom): response is ResponseObject { + return response && (response as ResponseObject).statusCode !== undefined; +} + +function isBoomObject(response: ResponseObject | Boom): response is Boom { + return response && (response as Boom).isBoom !== undefined; +} + +function isErrorEvent(event: RequestEvent): event is RequestEvent { + return event && (event as RequestEvent).error !== undefined; +} + +function sendErrorToSentry(errorData: object): void { + captureException(errorData, { + mechanism: { + type: 'hapi', + handled: false, + data: { + function: 'hapiErrorPlugin', + }, + }, + }); +} + +export const hapiErrorPlugin = { + name: 'SentryHapiErrorPlugin', + version: SDK_VERSION, + register: async function (serverArg: Record) { + const server = serverArg as unknown as Server; + + server.events.on('request', (request, event) => { + const transaction = getActiveTransaction(); + + if (request.response && isBoomObject(request.response)) { + sendErrorToSentry(request.response); + } else if (isErrorEvent(event)) { + sendErrorToSentry(event.error); + } + + if (transaction) { + transaction.setStatus('internal_error'); + transaction.finish(); + } + }); + }, +}; + +export const hapiTracingPlugin = { + name: 'SentryHapiTracingPlugin', + version: SDK_VERSION, + register: async function (serverArg: Record) { + const server = serverArg as unknown as Server; + + server.ext('onPreHandler', (request, h) => { + const transaction = continueTrace( + { + sentryTrace: request.headers['sentry-trace'] || undefined, + baggage: request.headers['baggage'] || undefined, + }, + transactionContext => { + return startTransaction({ + ...transactionContext, + op: 'hapi.request', + name: request.route.path, + description: `${request.route.method} ${request.path}`, + }); + }, + ); + + configureScope(scope => { + scope.setSpan(transaction); + }); + + return h.continue; + }); + + server.ext('onPreResponse', (request, h) => { + const transaction = getActiveTransaction(); + + if (request.response && isResponseObject(request.response) && transaction) { + const response = request.response as ResponseObject; + response.header('sentry-trace', transaction.toTraceparent()); + + const dynamicSamplingContext = dynamicSamplingContextToSentryBaggageHeader( + transaction.getDynamicSamplingContext(), + ); + + if (dynamicSamplingContext) { + response.header('baggage', dynamicSamplingContext); + } + } + + return h.continue; + }); + + server.ext('onPostHandler', (request, h) => { + const transaction = getActiveTransaction(); + + if (request.response && isResponseObject(request.response) && transaction) { + transaction.setHttpStatus(request.response.statusCode); + } + + if (transaction) { + transaction.finish(); + } + + return h.continue; + }); + }, +}; + +export type HapiOptions = { + /** Hapi server instance */ + server?: Record; +}; + +/** + * Hapi Framework Integration + */ +export class Hapi implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Hapi'; + + /** + * @inheritDoc + */ + public name: string; + + public _hapiServer: Server | undefined; + + public constructor(options?: HapiOptions) { + if (options?.server) { + const server = options.server as unknown as Server; + + this._hapiServer = server; + } + + this.name = Hapi.id; + } + + /** @inheritDoc */ + public setupOnce(): void { + if (!this._hapiServer) { + return; + } + + fill(this._hapiServer, 'start', (originalStart: () => void) => { + return async function (this: Server) { + await this.register(hapiTracingPlugin); + await this.register(hapiErrorPlugin); + const result = originalStart.apply(this); + return result; + }; + }); + } +} diff --git a/packages/node/src/integrations/hapi/types.ts b/packages/node/src/integrations/hapi/types.ts new file mode 100644 index 000000000000..d74c171ef441 --- /dev/null +++ b/packages/node/src/integrations/hapi/types.ts @@ -0,0 +1,278 @@ +/* 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 */ + +// 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'; +import type * as url from 'url'; + +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 }; + +type Dependencies = + | string + | string[] + | { + [key: string]: string; + }; + +interface PluginNameVersion { + name: string; + version?: string | undefined; +} + +interface PluginPackage { + pkg: any; +} + +interface PluginBase { + register: (server: Server, options: T) => void | Promise; + multiple?: boolean | undefined; + dependencies?: Dependencies | undefined; + requirements?: + | { + node?: string | undefined; + hapi?: string | undefined; + } + | undefined; + + once?: boolean | undefined; +} + +type Plugin = PluginBase & (PluginNameVersion | PluginPackage); + +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'; + export type HTTP_METHODS_PARTIAL = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'OPTIONS' + | 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; + }; +} + +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.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; +} + +interface ServerEventCriteria { + name: T; + channels?: string | string[] | undefined; + clone?: boolean | undefined; + count?: number | undefined; + filter?: string | string[] | { tags: string | string[]; all?: boolean | undefined } | undefined; + spread?: boolean | undefined; + tags?: boolean | undefined; +} + +export interface RequestEvent { + timestamp: string; + tags: string[]; + channel: 'internal' | 'app' | 'error'; + data: object; + error: object; +} + +type RequestEventHandler = (request: Request, event: RequestEvent, tags: { [key: string]: true }) => void; +interface ServerEvents { + on(criteria: 'request' | ServerEventCriteria<'request'>, listener: RequestEventHandler): void; +} + +type RouteRequestExtType = + | 'onPreAuth' + | 'onCredentials' + | 'onPostAuth' + | 'onPreHandler' + | 'onPostHandler' + | 'onPreResponse'; + +type ServerRequestExtType = RouteRequestExtType | 'onRequest'; + +export type Server = Record & { + events: ServerEvents; + ext(event: ServerRequestExtType, method: Lifecycle.Method, options?: Record): void; + initialize(): Promise; + register(plugins: Plugin | Array>, options?: Record): Promise; + start(): Promise; +}; + +interface ApplicationState {} + +type PeekListener = (chunk: string, encoding: string) => void; diff --git a/packages/node/src/integrations/index.ts b/packages/node/src/integrations/index.ts index 49820882fdc6..f2ac9c25b807 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -9,3 +9,4 @@ export { RequestData } from '@sentry/core'; export { LocalVariables } from './localvariables'; export { Undici } from './undici'; export { Spotlight } from './spotlight'; +export { Hapi } from './hapi';