From 3d0e11caa83191a40199154605a875f6d639df14 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 22 Jul 2026 17:37:42 +0200 Subject: [PATCH 1/2] test(nextjs): Add zero-infra orchestrion instrumentations to e2e app Add e2e coverage for generic-pool, lru-memoizer, dataloader and knex orchestrion instrumentations in the nextjs-16-orchestrion app. None require new infra: knex reuses the existing Postgres container, the rest are pure-JS. dataloader and knex are opt-in integrations, so they are added explicitly in the server config. Fixes #22504 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/api/dataloader/route.ts | 15 +++ .../app/api/generic-pool/route.ts | 37 +++++ .../app/api/knex/route.ts | 33 +++++ .../app/api/lru-memoizer/route.ts | 38 ++++++ .../nextjs-16-orchestrion/package.json | 4 + .../sentry.server.config.ts | 3 + .../tests/instrumentations.test.ts | 126 ++++++++++++++++++ 7 files changed, 256 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/dataloader/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/generic-pool/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/knex/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/instrumentations.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/dataloader/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/dataloader/route.ts new file mode 100644 index 000000000000..00ae43a3cf4c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/dataloader/route.ts @@ -0,0 +1,15 @@ +import DataLoader from 'dataloader'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const loader = new DataLoader(async keys => keys.map((_, idx) => idx), { + cache: false, + name: 'usersLoader', + }); + + const user = await loader.load('user-1'); + + return NextResponse.json({ user }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/generic-pool/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/generic-pool/route.ts new file mode 100644 index 000000000000..01c190c29997 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/generic-pool/route.ts @@ -0,0 +1,37 @@ +import { createPool } from 'generic-pool'; +import { NextResponse } from 'next/server'; +import { Client } from 'pg'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const pool = createPool( + { + create: async () => { + const client = new Client({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: 'docker', + database: 'postgres', + }); + await client.connect(); + return client; + }, + destroy: async client => { + await client.end(); + }, + }, + { max: 2, min: 0 }, + ); + + try { + const client = await pool.acquire(); + await client.query('SELECT 1 + 1 AS solution'); + await pool.release(client); + return NextResponse.json({ status: 'ok' }); + } finally { + await pool.drain(); + await pool.clear(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/knex/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/knex/route.ts new file mode 100644 index 000000000000..ff3ddb017607 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/knex/route.ts @@ -0,0 +1,33 @@ +import knex from 'knex'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const db = knex({ + client: 'pg', + connection: { + host: 'localhost', + port: 5432, + user: 'postgres', + password: 'docker', + database: 'postgres', + }, + }); + + try { + await db.schema.dropTableIfExists('knex_users'); + await db.schema.createTable('knex_users', table => { + table.increments('id').primary(); + table.text('name').notNullable(); + }); + + await db('knex_users').insert({ name: 'bob' }); + await db('knex_users').select('*'); + + return NextResponse.json({ status: 'ok' }); + } finally { + await db.schema.dropTableIfExists('knex_users'); + await db.destroy(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts new file mode 100644 index 000000000000..6110cc5fd4c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts @@ -0,0 +1,38 @@ +import * as Sentry from '@sentry/nextjs'; +import memoizer from 'lru-memoizer'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +// lru-memoizer's only job (from the SDK's perspective) is to bind the active async context onto the +// memoized callback, so it runs in its originating span's context whenever the load resolves. The +// integration creates no spans — we assert the context restore instead. `load` captures its callback +// without resolving; we fire it later from outside the span, and record on the enclosing span whether +// the callback ran in that span's context. We wrap the check in our own span so it lands in the +// transaction's `spans` list (the route handler's active span is nested under Next's `http.server`). +export async function GET() { + let memoizerLoadCallback: (() => void) | undefined; + const memoizedFn = memoizer({ + load: (_param: unknown, callback: () => void) => { + memoizerLoadCallback = callback; + }, + hash: () => 'key', + }); + + const contextPreserved = await Sentry.startSpan( + { name: 'lru-memoizer-check', op: 'run' }, + span => + new Promise(resolve => { + memoizedFn({ foo: 'bar' }, () => { + const preserved = Sentry.getActiveSpan()?.spanContext().spanId === span.spanContext().spanId; + span.setAttribute('memoized.context_preserved', preserved); + resolve(preserved); + }); + + // Fire the load outside the span, so the assertion above proves the context was restored. + memoizerLoadCallback?.(); + }), + ); + + return NextResponse.json({ contextPreserved }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/package.json index 71bf2b466f1f..c697f2daeda8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/package.json @@ -18,7 +18,11 @@ "dependencies": { "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "dataloader": "2.2.2", + "generic-pool": "^3.9.0", "ioredis": "5.10.1", + "knex": "^2.5.1", + "lru-memoizer": "2.3.0", "mysql": "^2.18.1", "next": "16.2.10", "pg": "^8.13.1", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/sentry.server.config.ts index 085ed461ee07..c19e88472ae5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/sentry.server.config.ts @@ -12,4 +12,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, + // `generic-pool` and `lru-memoizer` are default integrations, but `dataloader` and `knex` are + // opt-in, so they must be added explicitly for their orchestrion channel subscribers to activate. + integrations: [Sentry.dataloaderIntegration(), Sentry.knexIntegration()], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/instrumentations.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/instrumentations.test.ts new file mode 100644 index 000000000000..00029800a5c3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/instrumentations.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments generic-pool automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/generic-pool' + ); + }); + + await fetch(`${baseURL}/api/generic-pool`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'generic-pool.acquire', + origin: 'auto.db.orchestrion.generic_pool', + status: 'ok', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.orchestrion.generic_pool', + }), + }), + ); +}); + +test('Instruments dataloader automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/dataloader' + ); + }); + + await fetch(`${baseURL}/api/dataloader`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + const loadSpan = spans.find(span => span.description === 'dataloader.load usersLoader'); + expect(loadSpan).toBeDefined(); + expect(loadSpan?.op).toBe('cache.get'); + expect(loadSpan?.origin).toBe('auto.db.orchestrion.dataloader'); + expect(loadSpan?.status).toBe('ok'); + expect(loadSpan?.data?.['cache.key']).toEqual(['user-1']); + + // The batch span opens on the deferred dispatch tick and links back to the load span. + const batchSpan = spans.find(span => span.description === 'dataloader.batch usersLoader'); + expect(batchSpan).toBeDefined(); + expect(batchSpan?.op).toBe('cache.get'); + expect(batchSpan?.origin).toBe('auto.db.orchestrion.dataloader'); + expect(batchSpan?.status).toBe('ok'); +}); + +test('Instruments knex automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/knex'; + }); + + await fetch(`${baseURL}/api/knex`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.knex', + status: 'ok', + description: 'insert into "knex_users" ("name") values (?)', + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'postgres', + 'sentry.origin': 'auto.db.orchestrion.knex', + 'sentry.op': 'db', + 'net.peer.name': 'localhost', + 'net.peer.port': 5432, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.knex', + status: 'ok', + description: 'select * from "knex_users"', + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.operation': 'select', + 'db.sql.table': 'knex_users', + 'db.statement': 'select * from "knex_users"', + 'sentry.origin': 'auto.db.orchestrion.knex', + 'sentry.op': 'db', + }), + }), + ); +}); + +// lru-memoizer's channel integration creates no spans — its only job is to restore the caller's async +// context onto the memoized callback. The route wraps the check in a `lru-memoizer-check` span and +// records whether the callback ran in that span's context, so we assert the attribute on that span. +test('Preserves async context through lru-memoizer via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/lru-memoizer' + ); + }); + + await fetch(`${baseURL}/api/lru-memoizer`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'lru-memoizer-check', + data: expect.objectContaining({ + 'memoized.context_preserved': true, + }), + }), + ); +}); From 0fc4a33ee2d3b4eaf10a035947bd23a6d643c1bf Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 22 Jul 2026 17:48:18 +0200 Subject: [PATCH 2/2] test(nextjs): Fire lru-memoizer load outside span to test context restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load callback was fired inside the startSpan callback, so the memoized callback could see the check span through normal async context propagation — the assertion passed even when orchestrion's context restore was broken. Fire the load after startSpan returns (outside the span's active context) so only the restore can make the callback observe the span, matching the node lru-memoizer integration test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/api/lru-memoizer/route.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts index 6110cc5fd4c2..3e9a89409c43 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts @@ -6,10 +6,14 @@ export const dynamic = 'force-dynamic'; // lru-memoizer's only job (from the SDK's perspective) is to bind the active async context onto the // memoized callback, so it runs in its originating span's context whenever the load resolves. The -// integration creates no spans — we assert the context restore instead. `load` captures its callback -// without resolving; we fire it later from outside the span, and record on the enclosing span whether -// the callback ran in that span's context. We wrap the check in our own span so it lands in the -// transaction's `spans` list (the route handler's active span is nested under Next's `http.server`). +// integration creates no spans — we assert the context restore instead. +// +// `load` captures its callback without resolving. We register the memoized call INSIDE the +// `lru-memoizer-check` span (so orchestrion captures that span as the context to restore), but fire +// the load AFTER `startSpan` returns — i.e. outside the span's active context. That's essential: if +// we fired it inside the span, the callback would see the span through normal async propagation and +// the assertion would pass even with orchestrion's context restore broken. Firing it outside means +// only the restore can make the callback observe the span. Mirrors the node lru-memoizer test. export async function GET() { let memoizerLoadCallback: (() => void) | undefined; const memoizedFn = memoizer({ @@ -19,20 +23,26 @@ export async function GET() { hash: () => 'key', }); - const contextPreserved = await Sentry.startSpan( + // `startSpan` invokes its callback synchronously, so `memoizerLoadCallback` is captured by the time + // it returns. We don't await here — the callback only fires once the load below runs. + const spanFinished = Sentry.startSpan( { name: 'lru-memoizer-check', op: 'run' }, span => - new Promise(resolve => { + new Promise(resolve => { memoizedFn({ foo: 'bar' }, () => { - const preserved = Sentry.getActiveSpan()?.spanContext().spanId === span.spanContext().spanId; - span.setAttribute('memoized.context_preserved', preserved); - resolve(preserved); + span.setAttribute( + 'memoized.context_preserved', + Sentry.getActiveSpan()?.spanContext().spanId === span.spanContext().spanId, + ); + resolve(); }); - - // Fire the load outside the span, so the assertion above proves the context was restored. - memoizerLoadCallback?.(); }), ); - return NextResponse.json({ contextPreserved }); + // Fire the load outside the span's context, so the assertion above proves the context was restored. + memoizerLoadCallback?.(); + + await spanFinished; + + return NextResponse.json({ status: 'ok' }); }