Skip to content

Commit c71922b

Browse files
authored
feat(node): Add connect instrumentation (#11651)
1 parent e474a57 commit c71922b

File tree

20 files changed

+526
-0
lines changed

20 files changed

+526
-0
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-nestjs-app',
10521052
'node-exports-test-app',
10531053
'node-koa-app',
1054+
'node-connect-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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "node-connect-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "ts-node src/app.ts",
7+
"test": "playwright test",
8+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
9+
"typecheck": "tsc",
10+
"test:build": "pnpm install && pnpm run typecheck",
11+
"test:assert": "pnpm test"
12+
},
13+
"dependencies": {
14+
"@sentry/node": "latest || *",
15+
"@sentry/types": "latest || *",
16+
"@sentry/core": "latest || *",
17+
"@sentry/utils": "latest || *",
18+
"@sentry/opentelemetry": "latest || *",
19+
"@types/node": "18.15.1",
20+
"connect": "3.7.0",
21+
"typescript": "4.9.5",
22+
"ts-node": "10.9.1"
23+
},
24+
"devDependencies": {
25+
"@sentry-internal/event-proxy-server": "link:../../../event-proxy-server",
26+
"@playwright/test": "^1.38.1"
27+
},
28+
"volta": {
29+
"extends": "../../package.json"
30+
}
31+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { PlaywrightTestConfig } from '@playwright/test';
2+
import { devices } from '@playwright/test';
3+
4+
const connectPort = 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: 10000,
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+
retries: 0,
26+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
27+
reporter: 'list',
28+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
29+
use: {
30+
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
31+
actionTimeout: 0,
32+
/* Base URL to use in actions like `await page.goto('/')`. */
33+
baseURL: `http://localhost:${connectPort}`,
34+
35+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
36+
trace: 'on-first-retry',
37+
},
38+
39+
/* Configure projects for major browsers */
40+
projects: [
41+
{
42+
name: 'chromium',
43+
use: {
44+
...devices['Desktop Chrome'],
45+
},
46+
},
47+
],
48+
49+
/* Run your local dev server before starting the tests */
50+
webServer: [
51+
{
52+
command: 'pnpm ts-node-script start-event-proxy.ts',
53+
port: eventProxyPort,
54+
},
55+
{
56+
command: 'pnpm start',
57+
port: connectPort,
58+
},
59+
],
60+
};
61+
62+
export default config;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type * as S from '@sentry/node';
2+
const Sentry = require('@sentry/node') as typeof S;
3+
4+
Sentry.init({
5+
environment: 'qa', // dynamic sampling bias to keep transactions
6+
dsn: process.env.E2E_TEST_DSN,
7+
integrations: [],
8+
tracesSampleRate: 1,
9+
tunnel: 'http://localhost:3031/', // proxy server
10+
tracePropagationTargets: ['http://localhost:3030', '/external-allowed'],
11+
});
12+
13+
import type * as H from 'http';
14+
import type C from 'connect';
15+
16+
const connect = require('connect') as typeof C;
17+
const http = require('http') as typeof H;
18+
19+
const app = connect();
20+
const port = 3030;
21+
22+
app.use('/test-success', (req, res, next) => {
23+
res.end(
24+
JSON.stringify({
25+
version: 'v1',
26+
}),
27+
);
28+
});
29+
30+
app.use('/test-error', async (req, res, next) => {
31+
const exceptionId = Sentry.captureException(new Error('Sentry Test Error'));
32+
33+
await Sentry.flush();
34+
35+
res.end(JSON.stringify({ exceptionId }));
36+
next();
37+
});
38+
39+
app.use('/test-exception', () => {
40+
throw new Error('This is an exception');
41+
});
42+
43+
app.use('/test-transaction', (req, res, next) => {
44+
Sentry.startSpan({ name: 'test-span' }, () => {});
45+
46+
res.end(
47+
JSON.stringify({
48+
version: 'v1',
49+
}),
50+
);
51+
52+
next();
53+
});
54+
55+
Sentry.setupConnectErrorHandler(app);
56+
57+
const server = http.createServer(app);
58+
59+
server.listen(port);
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-connect-app',
6+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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-connect-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+
});
71+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } 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 an API route transaction', async ({ baseURL }) => {
11+
const pageloadTransactionEventPromise = waitForTransaction('node-connect-app', transactionEvent => {
12+
return (
13+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
14+
transactionEvent?.transaction === 'GET /test-transaction'
15+
);
16+
});
17+
18+
await axios.get(`${baseURL}/test-transaction`);
19+
20+
const transactionEvent = await pageloadTransactionEventPromise;
21+
const transactionEventId = transactionEvent.event_id;
22+
23+
expect(transactionEvent.contexts?.trace).toEqual({
24+
data: {
25+
'sentry.source': 'route',
26+
'sentry.origin': 'auto.http.otel.http',
27+
'sentry.op': 'http.server',
28+
'sentry.sample_rate': 1,
29+
url: 'http://localhost:3030/test-transaction',
30+
'otel.kind': 'SERVER',
31+
'http.response.status_code': 200,
32+
'http.url': 'http://localhost:3030/test-transaction',
33+
'http.host': 'localhost:3030',
34+
'net.host.name': 'localhost',
35+
'http.method': 'GET',
36+
'http.scheme': 'http',
37+
'http.target': '/test-transaction',
38+
'http.user_agent': 'axios/1.6.7',
39+
'http.flavor': '1.1',
40+
'net.transport': 'ip_tcp',
41+
'net.host.ip': expect.any(String),
42+
'net.host.port': expect.any(Number),
43+
'net.peer.ip': expect.any(String),
44+
'net.peer.port': expect.any(Number),
45+
'http.status_code': 200,
46+
'http.status_text': 'OK',
47+
'http.route': '/test-transaction',
48+
},
49+
op: 'http.server',
50+
span_id: expect.any(String),
51+
status: 'ok',
52+
trace_id: expect.any(String),
53+
origin: 'auto.http.otel.http',
54+
});
55+
56+
expect(transactionEvent).toEqual(
57+
expect.objectContaining({
58+
spans: [
59+
{
60+
data: {
61+
'sentry.origin': 'manual',
62+
'otel.kind': 'INTERNAL',
63+
},
64+
description: 'test-span',
65+
parent_span_id: expect.any(String),
66+
span_id: expect.any(String),
67+
start_timestamp: expect.any(Number),
68+
status: 'ok',
69+
timestamp: expect.any(Number),
70+
trace_id: expect.any(String),
71+
origin: 'manual',
72+
},
73+
{
74+
data: {
75+
'sentry.origin': 'manual',
76+
'http.route': '/test-transaction',
77+
'connect.type': 'request_handler',
78+
'connect.name': '/test-transaction',
79+
'otel.kind': 'INTERNAL',
80+
},
81+
description: 'request handler - /test-transaction',
82+
parent_span_id: expect.any(String),
83+
span_id: expect.any(String),
84+
start_timestamp: expect.any(Number),
85+
status: 'ok',
86+
timestamp: expect.any(Number),
87+
trace_id: expect.any(String),
88+
origin: 'manual',
89+
},
90+
],
91+
transaction: 'GET /test-transaction',
92+
type: 'transaction',
93+
transaction_info: {
94+
source: 'route',
95+
},
96+
}),
97+
);
98+
99+
await expect
100+
.poll(
101+
async () => {
102+
try {
103+
const response = await axios.get(
104+
`https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
105+
{ headers: { Authorization: `Bearer ${authToken}` } },
106+
);
107+
108+
return response.status;
109+
} catch (e) {
110+
if (e instanceof AxiosError && e.response) {
111+
if (e.response.status !== 404) {
112+
throw e;
113+
} else {
114+
return e.response.status;
115+
}
116+
} else {
117+
throw e;
118+
}
119+
}
120+
},
121+
{
122+
timeout: EVENT_POLLING_TIMEOUT,
123+
},
124+
)
125+
.toBe(200);
126+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"types": ["node"],
4+
"esModuleInterop": true,
5+
"lib": ["dom", "dom.iterable", "esnext"],
6+
"strict": true,
7+
"noEmit": true
8+
},
9+
"include": ["*.ts"]
10+
}

dev-packages/node-integration-tests/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@types/pg": "^8.6.5",
3838
"apollo-server": "^3.11.1",
3939
"axios": "^1.6.7",
40+
"connect": "^3.7.0",
4041
"cors": "^2.8.5",
4142
"cron": "^3.1.6",
4243
"express": "^4.17.3",

0 commit comments

Comments
 (0)