Skip to content

Commit c0c5eca

Browse files
authored
feat(node): Add Hapi Integration (#9539)
Resolves: #9344 Adds a new node integration for Hapi framework. Also exports a Hapi plugin to capture errors when the tracing instrumentation from `node-experimental` is used. Can be used with `node-experimental` ([Sample Error Event](https://sentry-sdks.sentry.io/issues/4624554372/?project=4506162118983680&query=is%3Aunresolved&referrer=issue-stream&statsPeriod=1h&stream_index=0)) like: ```typescript const Sentry = require('@sentry/node-experimental'); Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1.0, }); const Hapi = require('@hapi/hapi'); const init = async () => { const server = Hapi.server({ port: 3000, host: 'localhost' }); await server.register(Sentry.hapiErrorPlugin) server.route({ method: 'GET', path: '/', handler: (request, h) => { throw new Error('My Hapi Sentry error!'); } }); await server.start(); }; ``` Also can be used from `@sentry/node` with tracing ([Errored Transaction](https://sentry-sdks.sentry.io/performance/node-hapi:8a633340fc724472bb44aae4c7572827/?project=4506162118983680&query=&referrer=performance-transaction-summary&statsPeriod=1h&transaction=%2F&unselectedSeries=p100%28%29&unselectedSeries=avg%28%29), [Successful Transaction](https://sentry-sdks.sentry.io/performance/node-hapi:deeb79f0c6bf41c68c776833c4629e6e/?project=4506162118983680&query=&referrer=performance-transaction-summary&statsPeriod=1h&transaction=%2F&unselectedSeries=p100%28%29&unselectedSeries=avg%28%29)) and error tracking ([Event](https://sentry-sdks.sentry.io/issues/4626919129/?project=4506162118983680&query=is%3Aunresolved&referrer=issue-stream&statsPeriod=1h&stream_index=0)) like: ```typescript 'use strict'; const Sentry = require('@sentry/node'); const Hapi = require('@hapi/hapi'); const init = async () => { const server = Hapi.server({ port: 3000, host: 'localhost' }); Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1.0, integrations: [ new Sentry.Integrations.Hapi({server}), ], debug: true, }); server.route({ method: 'GET', path: '/', handler: (request, h) => { return 'Hello World!'; } }); await server.start(); }; ```
1 parent 01a4cc9 commit c0c5eca

File tree

15 files changed

+1087
-0
lines changed

15 files changed

+1087
-0
lines changed

.github/workflows/build.yml

+1
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,7 @@ jobs:
872872
'sveltekit',
873873
'generic-ts3.8',
874874
'node-experimental-fastify-app',
875+
'node-hapi-app',
875876
]
876877
build-command:
877878
- false
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import type { Envelope, EnvelopeItem, Event } from '@sentry/types';
2+
import { parseEnvelope } from '@sentry/utils';
3+
import * as fs from 'fs';
4+
import * as http from 'http';
5+
import * as https from 'https';
6+
import type { AddressInfo } from 'net';
7+
import * as os from 'os';
8+
import * as path from 'path';
9+
import * as util from 'util';
10+
import * as zlib from 'zlib';
11+
12+
const readFile = util.promisify(fs.readFile);
13+
const writeFile = util.promisify(fs.writeFile);
14+
15+
interface EventProxyServerOptions {
16+
/** Port to start the event proxy server at. */
17+
port: number;
18+
/** The name for the proxy server used for referencing it with listener functions */
19+
proxyServerName: string;
20+
}
21+
22+
interface SentryRequestCallbackData {
23+
envelope: Envelope;
24+
rawProxyRequestBody: string;
25+
rawSentryResponseBody: string;
26+
sentryResponseStatusCode?: number;
27+
}
28+
29+
/**
30+
* Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel`
31+
* option to this server (like this `tunnel: http://localhost:${port option}/`).
32+
*/
33+
export async function startEventProxyServer(options: EventProxyServerOptions): Promise<void> {
34+
const eventCallbackListeners: Set<(data: string) => void> = new Set();
35+
36+
const proxyServer = http.createServer((proxyRequest, proxyResponse) => {
37+
const proxyRequestChunks: Uint8Array[] = [];
38+
39+
proxyRequest.addListener('data', (chunk: Buffer) => {
40+
proxyRequestChunks.push(chunk);
41+
});
42+
43+
proxyRequest.addListener('error', err => {
44+
throw err;
45+
});
46+
47+
proxyRequest.addListener('end', () => {
48+
const proxyRequestBody =
49+
proxyRequest.headers['content-encoding'] === 'gzip'
50+
? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString()
51+
: Buffer.concat(proxyRequestChunks).toString();
52+
53+
let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]);
54+
55+
if (!envelopeHeader.dsn) {
56+
throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.');
57+
}
58+
59+
const { origin, pathname, host } = new URL(envelopeHeader.dsn);
60+
61+
const projectId = pathname.substring(1);
62+
const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`;
63+
64+
proxyRequest.headers.host = host;
65+
66+
const sentryResponseChunks: Uint8Array[] = [];
67+
68+
const sentryRequest = https.request(
69+
sentryIngestUrl,
70+
{ headers: proxyRequest.headers, method: proxyRequest.method },
71+
sentryResponse => {
72+
sentryResponse.addListener('data', (chunk: Buffer) => {
73+
proxyResponse.write(chunk, 'binary');
74+
sentryResponseChunks.push(chunk);
75+
});
76+
77+
sentryResponse.addListener('end', () => {
78+
eventCallbackListeners.forEach(listener => {
79+
const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString();
80+
81+
const data: SentryRequestCallbackData = {
82+
envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()),
83+
rawProxyRequestBody: proxyRequestBody,
84+
rawSentryResponseBody,
85+
sentryResponseStatusCode: sentryResponse.statusCode,
86+
};
87+
88+
listener(Buffer.from(JSON.stringify(data)).toString('base64'));
89+
});
90+
proxyResponse.end();
91+
});
92+
93+
sentryResponse.addListener('error', err => {
94+
throw err;
95+
});
96+
97+
proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers);
98+
},
99+
);
100+
101+
sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary');
102+
sentryRequest.end();
103+
});
104+
});
105+
106+
const proxyServerStartupPromise = new Promise<void>(resolve => {
107+
proxyServer.listen(options.port, () => {
108+
resolve();
109+
});
110+
});
111+
112+
const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => {
113+
eventCallbackResponse.statusCode = 200;
114+
eventCallbackResponse.setHeader('connection', 'keep-alive');
115+
116+
const callbackListener = (data: string): void => {
117+
eventCallbackResponse.write(data.concat('\n'), 'utf8');
118+
};
119+
120+
eventCallbackListeners.add(callbackListener);
121+
122+
eventCallbackRequest.on('close', () => {
123+
eventCallbackListeners.delete(callbackListener);
124+
});
125+
126+
eventCallbackRequest.on('error', () => {
127+
eventCallbackListeners.delete(callbackListener);
128+
});
129+
});
130+
131+
const eventCallbackServerStartupPromise = new Promise<void>(resolve => {
132+
eventCallbackServer.listen(0, () => {
133+
const port = String((eventCallbackServer.address() as AddressInfo).port);
134+
void registerCallbackServerPort(options.proxyServerName, port).then(resolve);
135+
});
136+
});
137+
138+
await eventCallbackServerStartupPromise;
139+
await proxyServerStartupPromise;
140+
return;
141+
}
142+
143+
export async function waitForRequest(
144+
proxyServerName: string,
145+
callback: (eventData: SentryRequestCallbackData) => Promise<boolean> | boolean,
146+
): Promise<SentryRequestCallbackData> {
147+
const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName);
148+
149+
return new Promise<SentryRequestCallbackData>((resolve, reject) => {
150+
const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => {
151+
let eventContents = '';
152+
153+
response.on('error', err => {
154+
reject(err);
155+
});
156+
157+
response.on('data', (chunk: Buffer) => {
158+
const chunkString = chunk.toString('utf8');
159+
chunkString.split('').forEach(char => {
160+
if (char === '\n') {
161+
const eventCallbackData: SentryRequestCallbackData = JSON.parse(
162+
Buffer.from(eventContents, 'base64').toString('utf8'),
163+
);
164+
const callbackResult = callback(eventCallbackData);
165+
if (typeof callbackResult !== 'boolean') {
166+
callbackResult.then(
167+
match => {
168+
if (match) {
169+
response.destroy();
170+
resolve(eventCallbackData);
171+
}
172+
},
173+
err => {
174+
throw err;
175+
},
176+
);
177+
} else if (callbackResult) {
178+
response.destroy();
179+
resolve(eventCallbackData);
180+
}
181+
eventContents = '';
182+
} else {
183+
eventContents = eventContents.concat(char);
184+
}
185+
});
186+
});
187+
});
188+
189+
request.end();
190+
});
191+
}
192+
193+
export function waitForEnvelopeItem(
194+
proxyServerName: string,
195+
callback: (envelopeItem: EnvelopeItem) => Promise<boolean> | boolean,
196+
): Promise<EnvelopeItem> {
197+
return new Promise((resolve, reject) => {
198+
waitForRequest(proxyServerName, async eventData => {
199+
const envelopeItems = eventData.envelope[1];
200+
for (const envelopeItem of envelopeItems) {
201+
if (await callback(envelopeItem)) {
202+
resolve(envelopeItem);
203+
return true;
204+
}
205+
}
206+
return false;
207+
}).catch(reject);
208+
});
209+
}
210+
211+
export function waitForError(
212+
proxyServerName: string,
213+
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
214+
): Promise<Event> {
215+
return new Promise((resolve, reject) => {
216+
waitForEnvelopeItem(proxyServerName, async envelopeItem => {
217+
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
218+
if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) {
219+
resolve(envelopeItemBody as Event);
220+
return true;
221+
}
222+
return false;
223+
}).catch(reject);
224+
});
225+
}
226+
227+
export function waitForTransaction(
228+
proxyServerName: string,
229+
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
230+
): Promise<Event> {
231+
return new Promise((resolve, reject) => {
232+
waitForEnvelopeItem(proxyServerName, async envelopeItem => {
233+
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
234+
if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) {
235+
resolve(envelopeItemBody as Event);
236+
return true;
237+
}
238+
return false;
239+
}).catch(reject);
240+
});
241+
}
242+
243+
const TEMP_FILE_PREFIX = 'event-proxy-server-';
244+
245+
async function registerCallbackServerPort(serverName: string, port: string): Promise<void> {
246+
const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`);
247+
await writeFile(tmpFilePath, port, { encoding: 'utf8' });
248+
}
249+
250+
function retrieveCallbackServerPort(serverName: string): Promise<string> {
251+
const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`);
252+
return readFile(tmpFilePath, 'utf8');
253+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "node-hapi-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"build": "tsc",
7+
"start": "node src/app.js",
8+
"test": "playwright test",
9+
"clean": "npx rimraf node_modules,pnpm-lock.yaml",
10+
"test:build": "pnpm install",
11+
"test:assert": "pnpm test"
12+
},
13+
"dependencies": {
14+
"@hapi/hapi": "21.3.2",
15+
"@sentry/integrations": "latest || *",
16+
"@sentry/node": "latest || *",
17+
"@sentry/tracing": "latest || *",
18+
"@sentry/types": "latest || *",
19+
"@types/node": "18.15.1",
20+
"typescript": "4.9.5"
21+
},
22+
"devDependencies": {
23+
"@playwright/test": "^1.27.1",
24+
"ts-node": "10.9.1"
25+
},
26+
"volta": {
27+
"extends": "../../package.json"
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { PlaywrightTestConfig } from '@playwright/test';
2+
import { devices } from '@playwright/test';
3+
4+
const hapiPort = 3030;
5+
const eventProxyPort = 3031;
6+
7+
/**
8+
* See https://playwright.dev/docs/test-configuration.
9+
*/
10+
const config: PlaywrightTestConfig = {
11+
testDir: './tests',
12+
/* Maximum time one test can run for. */
13+
timeout: 150_000,
14+
expect: {
15+
/**
16+
* Maximum time expect() should wait for the condition to be met.
17+
* For example in `await expect(locator).toHaveText();`
18+
*/
19+
timeout: 5000,
20+
},
21+
/* Run tests in files in parallel */
22+
fullyParallel: true,
23+
/* Fail the build on CI if you accidentally left test.only in the source code. */
24+
forbidOnly: !!process.env.CI,
25+
/* Retry on CI only */
26+
retries: 0,
27+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
28+
reporter: 'list',
29+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
30+
use: {
31+
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
32+
actionTimeout: 0,
33+
34+
/* Base URL to use in actions like `await page.goto('/')`. */
35+
baseURL: `http://localhost:${hapiPort}`,
36+
37+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
38+
trace: 'on-first-retry',
39+
},
40+
41+
/* Configure projects for major browsers */
42+
projects: [
43+
{
44+
name: 'chromium',
45+
use: {
46+
...devices['Desktop Chrome'],
47+
},
48+
},
49+
// For now we only test Chrome!
50+
// {
51+
// name: 'firefox',
52+
// use: {
53+
// ...devices['Desktop Firefox'],
54+
// },
55+
// },
56+
// {
57+
// name: 'webkit',
58+
// use: {
59+
// ...devices['Desktop Safari'],
60+
// },
61+
// },
62+
],
63+
64+
/* Run your local dev server before starting the tests */
65+
webServer: [
66+
{
67+
command: 'pnpm ts-node-script start-event-proxy.ts',
68+
port: eventProxyPort,
69+
},
70+
{
71+
command: 'pnpm start',
72+
port: hapiPort,
73+
},
74+
],
75+
};
76+
77+
export default config;

0 commit comments

Comments
 (0)