Skip to content

Commit daf7ff4

Browse files
chargomeclaude
andauthored
test(nextjs): Add zero-infra orchestrion instrumentations to e2e app (#22507)
Adds e2e coverage for `generic-pool`, `lru-memoizer`, `dataloader` and `knex` orchestrion instrumentations in the `nextjs-16-orchestrion` app. No new infra — `knex` reuses the existing Postgres container, the rest are pure-JS. `dataloader` and `knex` are opt-in, so they're added explicitly in the server config. Closes #22504 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f45152c commit daf7ff4

7 files changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import DataLoader from 'dataloader';
2+
import { NextResponse } from 'next/server';
3+
4+
export const dynamic = 'force-dynamic';
5+
6+
export async function GET() {
7+
const loader = new DataLoader<string, number>(async keys => keys.map((_, idx) => idx), {
8+
cache: false,
9+
name: 'usersLoader',
10+
});
11+
12+
const user = await loader.load('user-1');
13+
14+
return NextResponse.json({ user });
15+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createPool } from 'generic-pool';
2+
import { NextResponse } from 'next/server';
3+
import { Client } from 'pg';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
export async function GET() {
8+
const pool = createPool(
9+
{
10+
create: async () => {
11+
const client = new Client({
12+
host: 'localhost',
13+
port: 5432,
14+
user: 'postgres',
15+
password: 'docker',
16+
database: 'postgres',
17+
});
18+
await client.connect();
19+
return client;
20+
},
21+
destroy: async client => {
22+
await client.end();
23+
},
24+
},
25+
{ max: 2, min: 0 },
26+
);
27+
28+
try {
29+
const client = await pool.acquire();
30+
await client.query('SELECT 1 + 1 AS solution');
31+
await pool.release(client);
32+
return NextResponse.json({ status: 'ok' });
33+
} finally {
34+
await pool.drain();
35+
await pool.clear();
36+
}
37+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import knex from 'knex';
2+
import { NextResponse } from 'next/server';
3+
4+
export const dynamic = 'force-dynamic';
5+
6+
export async function GET() {
7+
const db = knex({
8+
client: 'pg',
9+
connection: {
10+
host: 'localhost',
11+
port: 5432,
12+
user: 'postgres',
13+
password: 'docker',
14+
database: 'postgres',
15+
},
16+
});
17+
18+
try {
19+
await db.schema.dropTableIfExists('knex_users');
20+
await db.schema.createTable('knex_users', table => {
21+
table.increments('id').primary();
22+
table.text('name').notNullable();
23+
});
24+
25+
await db('knex_users').insert({ name: 'bob' });
26+
await db('knex_users').select('*');
27+
28+
return NextResponse.json({ status: 'ok' });
29+
} finally {
30+
await db.schema.dropTableIfExists('knex_users');
31+
await db.destroy();
32+
}
33+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
import memoizer from 'lru-memoizer';
3+
import { NextResponse } from 'next/server';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
// lru-memoizer's only job (from the SDK's perspective) is to bind the active async context onto the
8+
// memoized callback, so it runs in its originating span's context whenever the load resolves. The
9+
// integration creates no spans — we assert the context restore instead.
10+
//
11+
// `load` captures its callback without resolving. We register the memoized call INSIDE the
12+
// `lru-memoizer-check` span (so orchestrion captures that span as the context to restore), but fire
13+
// the load AFTER `startSpan` returns — i.e. outside the span's active context. That's essential: if
14+
// we fired it inside the span, the callback would see the span through normal async propagation and
15+
// the assertion would pass even with orchestrion's context restore broken. Firing it outside means
16+
// only the restore can make the callback observe the span. Mirrors the node lru-memoizer test.
17+
export async function GET() {
18+
let memoizerLoadCallback: (() => void) | undefined;
19+
const memoizedFn = memoizer({
20+
load: (_param: unknown, callback: () => void) => {
21+
memoizerLoadCallback = callback;
22+
},
23+
hash: () => 'key',
24+
});
25+
26+
// `startSpan` invokes its callback synchronously, so `memoizerLoadCallback` is captured by the time
27+
// it returns. We don't await here — the callback only fires once the load below runs.
28+
const spanFinished = Sentry.startSpan(
29+
{ name: 'lru-memoizer-check', op: 'run' },
30+
span =>
31+
new Promise<void>(resolve => {
32+
memoizedFn({ foo: 'bar' }, () => {
33+
span.setAttribute(
34+
'memoized.context_preserved',
35+
Sentry.getActiveSpan()?.spanContext().spanId === span.spanContext().spanId,
36+
);
37+
resolve();
38+
});
39+
}),
40+
);
41+
42+
// Fire the load outside the span's context, so the assertion above proves the context was restored.
43+
memoizerLoadCallback?.();
44+
45+
await spanFinished;
46+
47+
return NextResponse.json({ status: 'ok' });
48+
}

dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
"dependencies": {
1919
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
2020
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
21+
"dataloader": "2.2.2",
22+
"generic-pool": "^3.9.0",
2123
"ioredis": "5.10.1",
24+
"knex": "^2.5.1",
25+
"lru-memoizer": "2.3.0",
2226
"mysql": "^2.18.1",
2327
"next": "16.2.10",
2428
"pg": "^8.13.1",

dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/sentry.server.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@ Sentry.init({
1212
dsn: 'https://public@dsn.ingest.sentry.io/1337',
1313
tunnel: 'http://localhost:3031/', // proxy server
1414
tracesSampleRate: 1.0,
15+
// `generic-pool` and `lru-memoizer` are default integrations, but `dataloader` and `knex` are
16+
// opt-in, so they must be added explicitly for their orchestrion channel subscribers to activate.
17+
integrations: [Sentry.dataloaderIntegration(), Sentry.knexIntegration()],
1518
});
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/test-utils';
3+
4+
test('Instruments generic-pool automatically via orchestrion', async ({ baseURL }) => {
5+
const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => {
6+
return (
7+
transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/generic-pool'
8+
);
9+
});
10+
11+
await fetch(`${baseURL}/api/generic-pool`);
12+
13+
const transactionEvent = await transactionEventPromise;
14+
15+
const spans = transactionEvent.spans || [];
16+
17+
expect(spans).toContainEqual(
18+
expect.objectContaining({
19+
description: 'generic-pool.acquire',
20+
origin: 'auto.db.orchestrion.generic_pool',
21+
status: 'ok',
22+
data: expect.objectContaining({
23+
'sentry.origin': 'auto.db.orchestrion.generic_pool',
24+
}),
25+
}),
26+
);
27+
});
28+
29+
test('Instruments dataloader automatically via orchestrion', async ({ baseURL }) => {
30+
const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => {
31+
return (
32+
transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/dataloader'
33+
);
34+
});
35+
36+
await fetch(`${baseURL}/api/dataloader`);
37+
38+
const transactionEvent = await transactionEventPromise;
39+
40+
const spans = transactionEvent.spans || [];
41+
42+
const loadSpan = spans.find(span => span.description === 'dataloader.load usersLoader');
43+
expect(loadSpan).toBeDefined();
44+
expect(loadSpan?.op).toBe('cache.get');
45+
expect(loadSpan?.origin).toBe('auto.db.orchestrion.dataloader');
46+
expect(loadSpan?.status).toBe('ok');
47+
expect(loadSpan?.data?.['cache.key']).toEqual(['user-1']);
48+
49+
// The batch span opens on the deferred dispatch tick and links back to the load span.
50+
const batchSpan = spans.find(span => span.description === 'dataloader.batch usersLoader');
51+
expect(batchSpan).toBeDefined();
52+
expect(batchSpan?.op).toBe('cache.get');
53+
expect(batchSpan?.origin).toBe('auto.db.orchestrion.dataloader');
54+
expect(batchSpan?.status).toBe('ok');
55+
});
56+
57+
test('Instruments knex automatically via orchestrion', async ({ baseURL }) => {
58+
const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => {
59+
return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/knex';
60+
});
61+
62+
await fetch(`${baseURL}/api/knex`);
63+
64+
const transactionEvent = await transactionEventPromise;
65+
66+
const spans = transactionEvent.spans || [];
67+
68+
expect(spans).toContainEqual(
69+
expect.objectContaining({
70+
op: 'db',
71+
origin: 'auto.db.orchestrion.knex',
72+
status: 'ok',
73+
description: 'insert into "knex_users" ("name") values (?)',
74+
data: expect.objectContaining({
75+
'db.system': 'postgresql',
76+
'db.name': 'postgres',
77+
'sentry.origin': 'auto.db.orchestrion.knex',
78+
'sentry.op': 'db',
79+
'net.peer.name': 'localhost',
80+
'net.peer.port': 5432,
81+
}),
82+
}),
83+
);
84+
expect(spans).toContainEqual(
85+
expect.objectContaining({
86+
op: 'db',
87+
origin: 'auto.db.orchestrion.knex',
88+
status: 'ok',
89+
description: 'select * from "knex_users"',
90+
data: expect.objectContaining({
91+
'db.system': 'postgresql',
92+
'db.operation': 'select',
93+
'db.sql.table': 'knex_users',
94+
'db.statement': 'select * from "knex_users"',
95+
'sentry.origin': 'auto.db.orchestrion.knex',
96+
'sentry.op': 'db',
97+
}),
98+
}),
99+
);
100+
});
101+
102+
// lru-memoizer's channel integration creates no spans — its only job is to restore the caller's async
103+
// context onto the memoized callback. The route wraps the check in a `lru-memoizer-check` span and
104+
// records whether the callback ran in that span's context, so we assert the attribute on that span.
105+
test('Preserves async context through lru-memoizer via orchestrion', async ({ baseURL }) => {
106+
const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => {
107+
return (
108+
transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/lru-memoizer'
109+
);
110+
});
111+
112+
await fetch(`${baseURL}/api/lru-memoizer`);
113+
114+
const transactionEvent = await transactionEventPromise;
115+
116+
const spans = transactionEvent.spans || [];
117+
118+
expect(spans).toContainEqual(
119+
expect.objectContaining({
120+
description: 'lru-memoizer-check',
121+
data: expect.objectContaining({
122+
'memoized.context_preserved': true,
123+
}),
124+
}),
125+
);
126+
});

0 commit comments

Comments
 (0)