Skip to content

Commit 3d82cdb

Browse files
authored
feat(node): Add Koa error handler (#11403)
Similarly to NestJS instrumentation, this PR adds: - `setupKoaErrorHandler` middleware to capture globally caught exceptions. - Koa end to end tests.
1 parent 658898c commit 3d82cdb

File tree

16 files changed

+1178
-1
lines changed

16 files changed

+1178
-1
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,7 @@ jobs:
10511051
# 'node-hapi-app',
10521052
'node-nestjs-app',
10531053
'node-exports-test-app',
1054+
'node-koa-app',
10541055
'vue-3',
10551056
'webpack-4',
10561057
'webpack-5'
Lines changed: 2 additions & 0 deletions
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
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
const Sentry = require('@sentry/node');
2+
3+
Sentry.init({
4+
environment: 'qa', // dynamic sampling bias to keep transactions
5+
dsn: process.env.E2E_TEST_DSN,
6+
includeLocalVariables: true,
7+
debug: true,
8+
tunnel: `http://localhost:3031/`, // proxy server
9+
tracesSampleRate: 1,
10+
tracePropagationTargets: ['http://localhost:3030', 'external-allowed'],
11+
});
12+
13+
const port1 = 3030;
14+
const port2 = 3040;
15+
16+
const Koa = require('koa');
17+
const Router = require('@koa/router');
18+
const http = require('http');
19+
20+
const app1 = new Koa();
21+
22+
Sentry.setupKoaErrorHandler(app1);
23+
24+
const router1 = new Router();
25+
26+
router1.get('/test-success', ctx => {
27+
ctx.body = { version: 'v1' };
28+
});
29+
30+
router1.get('/test-param/:param', ctx => {
31+
ctx.body = { paramWas: ctx.params.param };
32+
});
33+
34+
router1.get('/test-inbound-headers/:id', ctx => {
35+
const headers = ctx.request.headers;
36+
37+
ctx.body = {
38+
headers,
39+
id: ctx.params.id,
40+
};
41+
});
42+
43+
router1.get('/test-outgoing-http/:id', async ctx => {
44+
const id = ctx.params.id;
45+
const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`);
46+
47+
ctx.body = data;
48+
});
49+
50+
router1.get('/test-outgoing-fetch/:id', async ctx => {
51+
const id = ctx.params.id;
52+
const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`);
53+
const data = await response.json();
54+
55+
ctx.body = data;
56+
});
57+
58+
router1.get('/test-transaction', ctx => {
59+
Sentry.startSpan({ name: 'test-span' }, () => {
60+
Sentry.startSpan({ name: 'child-span' }, () => {});
61+
});
62+
63+
ctx.body = {};
64+
});
65+
66+
router1.get('/test-error', async ctx => {
67+
const exceptionId = Sentry.captureException(new Error('This is an error'));
68+
69+
await Sentry.flush(2000);
70+
71+
ctx.body = { exceptionId };
72+
});
73+
74+
router1.get('/test-exception', async ctx => {
75+
throw new Error('This is an exception');
76+
});
77+
78+
router1.get('/test-outgoing-fetch-external-allowed', async ctx => {
79+
const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`);
80+
const data = await fetchResponse.json();
81+
82+
ctx.body = data;
83+
});
84+
85+
router1.get('/test-outgoing-fetch-external-disallowed', async ctx => {
86+
const fetchResponse = await fetch(`http://localhost:${port2}/external-disallowed`);
87+
const data = await fetchResponse.json();
88+
89+
ctx.body = data;
90+
});
91+
92+
router1.get('/test-outgoing-http-external-allowed', async ctx => {
93+
const data = await makeHttpRequest(`http://localhost:${port2}/external-allowed`);
94+
ctx.body = data;
95+
});
96+
97+
router1.get('/test-outgoing-http-external-disallowed', async ctx => {
98+
const data = await makeHttpRequest(`http://localhost:${port2}/external-disallowed`);
99+
ctx.body = data;
100+
});
101+
102+
app1.use(router1.routes()).use(router1.allowedMethods());
103+
104+
app1.listen(port1);
105+
106+
const app2 = new Koa();
107+
const router2 = new Router();
108+
109+
router2.get('/external-allowed', ctx => {
110+
const headers = ctx.headers;
111+
ctx.body = { headers, route: '/external-allowed' };
112+
});
113+
114+
router2.get('/external-disallowed', ctx => {
115+
const headers = ctx.headers;
116+
ctx.body = { headers, route: '/external-disallowed' };
117+
});
118+
119+
app2.use(router2.routes()).use(router2.allowedMethods());
120+
app2.listen(port2);
121+
122+
function makeHttpRequest(url) {
123+
return new Promise(resolve => {
124+
const data = [];
125+
126+
http
127+
.request(url, httpRes => {
128+
httpRes.on('data', chunk => {
129+
data.push(chunk);
130+
});
131+
httpRes.on('error', error => {
132+
resolve({ error: error.message, url });
133+
});
134+
httpRes.on('end', () => {
135+
try {
136+
const json = JSON.parse(Buffer.concat(data).toString());
137+
resolve(json);
138+
} catch {
139+
resolve({ data: Buffer.concat(data).toString(), url });
140+
}
141+
});
142+
})
143+
.end();
144+
});
145+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "node-koa-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "node index.js",
7+
"test": "playwright test",
8+
"clean": "npx rimraf node_modules,pnpm-lock.yaml",
9+
"test:build": "pnpm install",
10+
"test:assert": "pnpm test"
11+
},
12+
"dependencies": {
13+
"@koa/router": "^12.0.1",
14+
"@sentry/node": "latest || *",
15+
"@sentry/types": "latest || *",
16+
"@types/node": "18.15.1",
17+
"koa": "^2.15.2",
18+
"typescript": "4.9.5"
19+
},
20+
"devDependencies": {
21+
"@sentry-internal/event-proxy-server": "link:../../../event-proxy-server",
22+
"@playwright/test": "^1.27.1",
23+
"ts-node": "10.9.1"
24+
},
25+
"volta": {
26+
"extends": "../../package.json"
27+
}
28+
}
Lines changed: 77 additions & 0 deletions
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 koaPort = 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:${koaPort}`,
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: koaPort,
73+
},
74+
],
75+
};
76+
77+
export default config;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/event-proxy-server';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'node-koa-app',
6+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/event-proxy-server';
3+
import axios, { AxiosError } from 'axios';
4+
5+
const authToken = process.env.E2E_TEST_AUTH_TOKEN;
6+
const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
7+
const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT;
8+
const EVENT_POLLING_TIMEOUT = 90_000;
9+
10+
test('Sends exception to Sentry', async ({ baseURL }) => {
11+
const { data } = await axios.get(`${baseURL}/test-error`);
12+
const { exceptionId } = data;
13+
14+
const url = `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionId}/`;
15+
16+
console.log(`Polling for error eventId: ${exceptionId}`);
17+
18+
await expect
19+
.poll(
20+
async () => {
21+
try {
22+
const response = await axios.get(url, { headers: { Authorization: `Bearer ${authToken}` } });
23+
24+
return response.status;
25+
} catch (e) {
26+
if (e instanceof AxiosError && e.response) {
27+
if (e.response.status !== 404) {
28+
throw e;
29+
} else {
30+
return e.response.status;
31+
}
32+
} else {
33+
throw e;
34+
}
35+
}
36+
},
37+
{ timeout: EVENT_POLLING_TIMEOUT },
38+
)
39+
.toBe(200);
40+
});
41+
42+
test('Sends correct error event', async ({ baseURL }) => {
43+
const errorEventPromise = waitForError('node-koa-app', event => {
44+
return !event.type && event.exception?.values?.[0]?.value === 'This is an exception';
45+
});
46+
47+
try {
48+
await axios.get(`${baseURL}/test-exception`);
49+
} catch {
50+
// this results in an error, but we don't care - we want to check the error event
51+
}
52+
53+
const errorEvent = await errorEventPromise;
54+
55+
expect(errorEvent.exception?.values).toHaveLength(1);
56+
expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception');
57+
58+
expect(errorEvent.request).toEqual({
59+
method: 'GET',
60+
cookies: {},
61+
headers: expect.any(Object),
62+
url: 'http://localhost:3030/test-exception',
63+
});
64+
65+
expect(errorEvent.transaction).toEqual('GET /test-exception');
66+
67+
expect(errorEvent.contexts?.trace).toEqual({
68+
trace_id: expect.any(String),
69+
span_id: expect.any(String),
70+
parent_span_id: expect.any(String),
71+
});
72+
});

0 commit comments

Comments
 (0)