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..3e9a89409c43 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/lru-memoizer/route.ts @@ -0,0 +1,48 @@ +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 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({ + load: (_param: unknown, callback: () => void) => { + memoizerLoadCallback = callback; + }, + hash: () => 'key', + }); + + // `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 => { + memoizedFn({ foo: 'bar' }, () => { + span.setAttribute( + 'memoized.context_preserved', + Sentry.getActiveSpan()?.spanContext().spanId === span.spanContext().spanId, + ); + resolve(); + }); + }), + ); + + // 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' }); +} 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, + }), + }), + ); +});