Skip to content

Commit 668ea47

Browse files
msonnbcodex
andauthored
test(e2e): Update live ingestion verification for span streaming (#24165)
Verify streamed spans against Sentry ingestion using the captured trace ID, since streaming no longer produces a transaction event to look up. Preserve the original transaction and error ingestion checks in a separate node-express-send-to-sentry-static app with the static trace lifecycle. Both apps remain in the optional E2E suite. part of #23800 --------- Co-authored-by: GPT-6 <codex@openai.com>
1 parent f8619a5 commit 668ea47

10 files changed

Lines changed: 319 additions & 24 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "node-express-send-to-sentry-static-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"build": "tsc",
7+
"start": "node dist/app.js",
8+
"test": "playwright test",
9+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
10+
"test:build": "pnpm install && pnpm build",
11+
"test:assert": "pnpm test"
12+
},
13+
"dependencies": {
14+
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
15+
"@types/express": "4.17.17",
16+
"@types/node": "^18.19.1",
17+
"express": "^4.21.2",
18+
"typescript": "~5.0.0"
19+
},
20+
"devDependencies": {
21+
"@playwright/test": "~1.56.0"
22+
},
23+
"volta": {
24+
"extends": "../../package.json"
25+
},
26+
"sentryTest": {
27+
"optional": true
28+
}
29+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { devices } from '@playwright/test';
2+
3+
const expressPort = 3030;
4+
5+
/**
6+
* See https://playwright.dev/docs/test-configuration.
7+
*/
8+
const config = {
9+
testDir: './tests',
10+
/* Maximum time one test can run for. Spans take ~2min to become queryable via the trace endpoint. */
11+
timeout: 210_000,
12+
expect: {
13+
/**
14+
* Maximum time expect() should wait for the condition to be met.
15+
* For example in `await expect(locator).toHaveText();`
16+
*/
17+
timeout: 5000,
18+
},
19+
/* Run tests in files in parallel */
20+
fullyParallel: true,
21+
/* Fail the build on CI if you accidentally left test.only in the source code. */
22+
forbidOnly: !!process.env.CI,
23+
/* Retry on CI only */
24+
retries: 0,
25+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
26+
reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list',
27+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
28+
use: {
29+
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
30+
actionTimeout: 0,
31+
32+
/* Base URL to use in actions like `await page.goto('/')`. */
33+
baseURL: `http://localhost:${expressPort}`,
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+
// For now we only test Chrome!
48+
// {
49+
// name: 'firefox',
50+
// use: {
51+
// ...devices['Desktop Firefox'],
52+
// },
53+
// },
54+
// {
55+
// name: 'webkit',
56+
// use: {
57+
// ...devices['Desktop Safari'],
58+
// },
59+
// },
60+
],
61+
62+
/* Run your local dev server before starting the tests */
63+
webServer: [
64+
{
65+
command: 'pnpm start',
66+
port: expressPort,
67+
},
68+
],
69+
};
70+
71+
export default config;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as Sentry from '@sentry/node';
2+
3+
let lastTransactionId: string | undefined;
4+
let lastTransactionTraceId: string | undefined;
5+
let lastErrorTraceId: string | undefined;
6+
7+
Sentry.init({
8+
traceLifecycle: 'static',
9+
environment: 'qa', // dynamic sampling bias to keep transactions
10+
dsn: process.env.E2E_TEST_DSN,
11+
includeLocalVariables: true,
12+
tracesSampleRate: 1,
13+
beforeSend(event) {
14+
lastErrorTraceId = event.contexts?.trace?.trace_id;
15+
return event;
16+
},
17+
beforeSendTransaction(event) {
18+
lastTransactionId = event.event_id;
19+
lastTransactionTraceId = event.contexts?.trace?.trace_id;
20+
return event;
21+
},
22+
});
23+
24+
import express from 'express';
25+
26+
const app = express();
27+
const port = 3030;
28+
29+
app.get('/test-success', function (req, res) {
30+
res.send({ version: 'v1' });
31+
});
32+
33+
app.get('/test-param/:param', function (req, res) {
34+
res.send({ paramWas: req.params.param });
35+
});
36+
37+
app.get('/test-transaction', function (req, res) {
38+
Sentry.withActiveSpan(null, async () => {
39+
Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => {
40+
Sentry.startSpan({ name: 'test-span' }, () => undefined);
41+
});
42+
43+
await Sentry.flush();
44+
45+
res.send({
46+
transactionId: lastTransactionId,
47+
traceId: lastTransactionTraceId,
48+
});
49+
});
50+
});
51+
52+
app.get('/test-error', async function (req, res) {
53+
const exceptionId = Sentry.captureException(new Error('This is an error'));
54+
55+
await Sentry.flush(2000);
56+
57+
res.send({ exceptionId, traceId: lastErrorTraceId });
58+
});
59+
60+
app.get('/test-exception/:id', function (req, _res) {
61+
throw new Error(`This is an exception with id ${req.params.id}`);
62+
});
63+
64+
app.get('/test-local-variables-uncaught', function (req, res) {
65+
const randomVariableToRecord = Math.random();
66+
throw new Error(`Uncaught Local Variable Error - ${JSON.stringify({ randomVariableToRecord })}`);
67+
});
68+
69+
app.get('/test-local-variables-caught', function (req, res) {
70+
const randomVariableToRecord = Math.random();
71+
72+
let exceptionId: string;
73+
try {
74+
throw new Error('Local Variable Error');
75+
} catch (e) {
76+
exceptionId = Sentry.captureException(e);
77+
}
78+
79+
res.send({ exceptionId, randomVariableToRecord });
80+
});
81+
82+
Sentry.setupExpressErrorHandler(app);
83+
84+
// @ts-ignore
85+
app.use(function onError(err, req, res, next) {
86+
// The error id is attached to `res.sentry` to be returned
87+
// and optionally displayed to the user for support.
88+
res.statusCode = 500;
89+
res.end(res.sentry + '\n');
90+
});
91+
92+
app.listen(port, () => {
93+
console.log(`Example app listening on port ${port}`);
94+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { expect, test } from '@playwright/test';
2+
import { EVENT_POLLING_OPTIONS, findErrorInTrace, findTransactionInTrace } from './utils/sentry-api';
3+
4+
test('Sends exception to Sentry', async ({ baseURL }) => {
5+
const response = await fetch(`${baseURL}/test-error`);
6+
const { exceptionId, traceId } = await response.json();
7+
8+
console.log(`Polling for error eventId: ${exceptionId} in trace: ${traceId}`);
9+
10+
await expect.poll(() => findErrorInTrace(traceId, exceptionId), EVENT_POLLING_OPTIONS).toBeDefined();
11+
});
12+
13+
test('Sends transaction to Sentry', async ({ baseURL }) => {
14+
const response = await fetch(`${baseURL}/test-transaction`);
15+
const { transactionId, traceId } = await response.json();
16+
17+
console.log(`Polling for transaction eventId: ${transactionId} in trace: ${traceId}`);
18+
19+
await expect
20+
.poll(() => findTransactionInTrace(traceId, transactionId), EVENT_POLLING_OPTIONS)
21+
.toMatchObject({ op: 'e2e-test' });
22+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
const authToken = process.env.E2E_TEST_AUTH_TOKEN;
2+
const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
3+
4+
/**
5+
* Spans only become queryable once they have made it through to EAP, which takes
6+
* noticeably longer than the error pipeline (~2min vs ~20s when this was measured).
7+
*/
8+
export const EVENT_POLLING_OPTIONS = { timeout: 180_000, intervals: [5_000] };
9+
10+
/**
11+
* A node of the span tree returned by the organization trace endpoint. Spans, errors and
12+
* occurrences all share this shape and are discriminated by `event_type`.
13+
*/
14+
export interface TraceItem {
15+
event_id?: string;
16+
/** On spans this is the event id of the transaction the span belongs to. */
17+
transaction_id?: string;
18+
event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check';
19+
op?: string;
20+
is_transaction?: boolean;
21+
children?: TraceItem[];
22+
errors?: TraceItem[];
23+
occurrences?: TraceItem[];
24+
}
25+
26+
export async function fetchTrace(traceId: string): Promise<TraceItem[]> {
27+
const response = await fetch(
28+
`https://sentry.io/api/0/organizations/${sentryTestOrgSlug}/trace/${traceId}/?statsPeriod=1h`,
29+
{ headers: { Authorization: `Bearer ${authToken}` } },
30+
);
31+
32+
// The trace endpoint is org scoped, so the auth token needs `org:read` on top of the
33+
// project scopes the other assertions rely on. That never resolves by waiting, so fail
34+
// loudly instead of polling until the timeout and reporting it as a missing event.
35+
if (response.status === 401 || response.status === 403) {
36+
throw new Error(
37+
`Trace lookup for ${traceId} was rejected with ${response.status}: ${await response.text()}. ` +
38+
'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.',
39+
);
40+
}
41+
42+
// Empty traces and the occasional rate limit are expected while polling, so treat anything
43+
// else that is not a success as "not there yet" -- but log it, since a rejected request and
44+
// a trace that has not landed are otherwise indistinguishable.
45+
if (!response.ok) {
46+
console.log(`Trace lookup for ${traceId} returned ${response.status}: ${await response.text()}`);
47+
return [];
48+
}
49+
50+
return await response.json();
51+
}
52+
53+
/**
54+
* Errors attach to whichever span was active when they were captured, and relocate from the
55+
* top level into that span once it lands, so a given event can surface at any depth.
56+
*/
57+
export function flattenTrace(items: TraceItem[]): TraceItem[] {
58+
return items.flatMap(item => [
59+
item,
60+
...flattenTrace(item.children ?? []),
61+
...flattenTrace(item.errors ?? []),
62+
...flattenTrace(item.occurrences ?? []),
63+
]);
64+
}
65+
66+
export async function findErrorInTrace(traceId: string, eventId: string): Promise<TraceItem | undefined> {
67+
return flattenTrace(await fetchTrace(traceId)).find(item => item.event_type === 'error' && item.event_id === eventId);
68+
}
69+
70+
export async function findTransactionInTrace(traceId: string, eventId: string): Promise<TraceItem | undefined> {
71+
return flattenTrace(await fetchTrace(traceId)).find(item => item.is_transaction && item.transaction_id === eventId);
72+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"compilerOptions": {
3+
"types": ["node"],
4+
"esModuleInterop": true,
5+
"lib": ["es2018"],
6+
"strict": true,
7+
"outDir": "dist",
8+
"skipLibCheck": true
9+
},
10+
"include": ["src/**/*.ts"]
11+
}

‎dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/src/app.ts‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
import * as Sentry from '@sentry/node';
22

3-
let lastTransactionId: string | undefined;
4-
let lastTransactionTraceId: string | undefined;
3+
let lastSpanTraceId: string | undefined;
54
let lastErrorTraceId: string | undefined;
65

76
Sentry.init({
8-
traceLifecycle: 'static',
9-
environment: 'qa', // dynamic sampling bias to keep transactions
7+
environment: 'qa', // dynamic sampling bias to keep traces
108
dsn: process.env.E2E_TEST_DSN,
119
includeLocalVariables: true,
1210
tracesSampleRate: 1,
1311
beforeSend(event) {
1412
lastErrorTraceId = event.contexts?.trace?.trace_id;
1513
return event;
1614
},
17-
beforeSendTransaction(event) {
18-
lastTransactionId = event.event_id;
19-
lastTransactionTraceId = event.contexts?.trace?.trace_id;
20-
return event;
15+
beforeSendSpan(span) {
16+
if (span.name === 'test-span') {
17+
lastSpanTraceId = span.trace_id;
18+
}
19+
return span;
2120
},
2221
});
2322

@@ -34,17 +33,16 @@ app.get('/test-param/:param', function (req, res) {
3433
res.send({ paramWas: req.params.param });
3534
});
3635

37-
app.get('/test-transaction', function (req, res) {
36+
app.get('/test-span', function (req, res) {
3837
Sentry.withActiveSpan(null, async () => {
39-
Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => {
40-
Sentry.startSpan({ name: 'test-span' }, () => undefined);
38+
Sentry.startSpan({ name: 'test-span', op: 'e2e-test' }, () => {
39+
Sentry.startSpan({ name: 'test-child-span' }, () => undefined);
4140
});
4241

4342
await Sentry.flush();
4443

4544
res.send({
46-
transactionId: lastTransactionId,
47-
traceId: lastTransactionTraceId,
45+
traceId: lastSpanTraceId,
4846
});
4947
});
5048
});
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { EVENT_POLLING_OPTIONS, findErrorInTrace, findTransactionInTrace } from './utils/sentry-api';
2+
import { EVENT_POLLING_OPTIONS, findErrorInTrace, findSpanInTrace } from './utils/sentry-api';
33

44
test('Sends exception to Sentry', async ({ baseURL }) => {
55
const response = await fetch(`${baseURL}/test-error`);
@@ -10,13 +10,13 @@ test('Sends exception to Sentry', async ({ baseURL }) => {
1010
await expect.poll(() => findErrorInTrace(traceId, exceptionId), EVENT_POLLING_OPTIONS).toBeDefined();
1111
});
1212

13-
test('Sends transaction to Sentry', async ({ baseURL }) => {
14-
const response = await fetch(`${baseURL}/test-transaction`);
15-
const { transactionId, traceId } = await response.json();
13+
test('Sends streamed span to Sentry', async ({ baseURL }) => {
14+
const response = await fetch(`${baseURL}/test-span`);
15+
const { traceId } = await response.json();
1616

17-
console.log(`Polling for transaction eventId: ${transactionId} in trace: ${traceId}`);
17+
console.log(`Polling for streamed span in trace: ${traceId}`);
1818

1919
await expect
20-
.poll(() => findTransactionInTrace(traceId, transactionId), EVENT_POLLING_OPTIONS)
20+
.poll(() => findSpanInTrace(traceId, 'e2e-test'), EVENT_POLLING_OPTIONS)
2121
.toMatchObject({ op: 'e2e-test' });
2222
});

0 commit comments

Comments
 (0)