From 6a1c6467569cb19c5243465b38576082014587d7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Jul 2026 11:10:49 +0200 Subject: [PATCH] test(nextjs): Add MongoDB and modern SQL driver orchestrion instrumentations Add e2e coverage for mongodb, mongoose, mysql2 and postgres.js orchestrion instrumentations in the nextjs-16-orchestrion app. mongodb/mongoose use a new mongo container; mysql2 and postgres.js reuse the existing MySQL and Postgres containers. `mysql2` is pinned below 3.20.0 (like `ioredis`) so the orchestrion path is exercised rather than the driver's native diagnostics channels. Also split the app's e2e tests into one file per instrumented library for readability. Fixes #22505 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/api/db-mongodb/route.ts | 20 ++ .../app/api/db-mongoose/route.ts | 22 ++ .../app/api/db-mysql2/route.ts | 22 ++ .../app/api/db-postgresjs/route.ts | 23 +++ .../nextjs-16-orchestrion/docker-compose.yml | 16 +- .../nextjs-16-orchestrion/package.json | 6 +- .../tests/dataloader.test.ts | 30 +++ .../tests/db-page.test.ts | 51 +++++ .../nextjs-16-orchestrion/tests/db.test.ts | 192 ------------------ .../tests/generic-pool.test.ts | 27 +++ .../tests/instrumentations.test.ts | 126 ------------ .../tests/ioredis.test.ts | 44 ++++ .../nextjs-16-orchestrion/tests/knex.test.ts | 47 +++++ .../tests/lru-memoizer.test.ts | 28 +++ .../tests/mongodb.test.ts | 43 ++++ .../tests/mongoose.test.ts | 43 ++++ .../nextjs-16-orchestrion/tests/mysql.test.ts | 54 +++++ .../tests/mysql2.test.ts | 45 ++++ .../nextjs-16-orchestrion/tests/pg.test.ts | 49 +++++ .../tests/postgresjs.test.ts | 49 +++++ 20 files changed, 617 insertions(+), 320 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mongodb/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mongoose/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mysql2/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-postgresjs/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/dataloader.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/db-page.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/db.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/generic-pool.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/instrumentations.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/ioredis.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/knex.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/lru-memoizer.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mongodb.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mongoose.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mysql.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mysql2.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/pg.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/postgresjs.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mongodb/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mongodb/route.ts new file mode 100644 index 000000000000..558cb486cce5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mongodb/route.ts @@ -0,0 +1,20 @@ +import { MongoClient } from 'mongodb'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const client = new MongoClient('mongodb://localhost:27017'); + + try { + await client.connect(); + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rear Window' }); + await collection.findOne({ title: 'Rear Window' }); + + return NextResponse.json({ status: 'ok' }); + } finally { + await client.close(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mongoose/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mongoose/route.ts new file mode 100644 index 000000000000..3e189359f349 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mongoose/route.ts @@ -0,0 +1,22 @@ +import mongoose, { Schema } from 'mongoose'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + // The `test` database matches the mongoose scenario in node-integration-tests. + await mongoose.connect('mongodb://localhost:27017/test'); + + // Guard against model recompilation across requests in the same worker. + const BlogPost = mongoose.models.BlogPost || mongoose.model('BlogPost', new Schema({ title: String })); + + try { + const post = new BlogPost({ title: 'Rear Window' }); + await post.save(); + await BlogPost.findOne({ title: 'Rear Window' }); + + return NextResponse.json({ status: 'ok' }); + } finally { + await mongoose.disconnect(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mysql2/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mysql2/route.ts new file mode 100644 index 000000000000..3fba61b82470 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-mysql2/route.ts @@ -0,0 +1,22 @@ +import mysql from 'mysql2/promise'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +// `mysql2` reuses the same MySQL container as the legacy `mysql` driver — it supports both auth plugins. +export async function GET() { + const connection = await mysql.createConnection({ + host: 'localhost', + port: 3306, + user: 'root', + password: 'docker', + }); + + try { + await connection.query('SELECT 1 + 1 AS solution'); + await connection.execute('SELECT 42 AS answer'); + return NextResponse.json({ status: 'ok' }); + } finally { + await connection.end(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-postgresjs/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-postgresjs/route.ts new file mode 100644 index 000000000000..14bed85d396f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/app/api/db-postgresjs/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import postgres from 'postgres'; + +export const dynamic = 'force-dynamic'; + +// postgres.js reuses the same Postgres container as the `pg` driver. +export async function GET() { + const sql = postgres({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: 'docker', + database: 'postgres', + }); + + try { + await sql`SELECT 1 + 1 AS solution`; + await sql`SELECT * from generate_series(1, 3) as x`; + return NextResponse.json({ status: 'ok' }); + } finally { + await sql.end(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/docker-compose.yml index 72726a8b7252..72ef1929d608 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/docker-compose.yml @@ -32,7 +32,8 @@ services: restart: always container_name: e2e-tests-nextjs-16-orchestrion-mysql # The `mysql` 2.x driver doesn't speak MySQL 8's default - # `caching_sha2_password` auth, so force the legacy plugin. + # `caching_sha2_password` auth, so force the legacy plugin. `mysql2` supports + # both, so it connects to this same container unchanged. command: ['--default-authentication-plugin=mysql_native_password'] ports: - '3306:3306' @@ -44,3 +45,16 @@ services: timeout: 3s retries: 30 start_period: 10s + + mongo: + image: mongo:7 + restart: always + container_name: e2e-tests-nextjs-16-orchestrion-mongo + ports: + - '27017:27017' + healthcheck: + test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s 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 c697f2daeda8..30b78bd745b1 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 @@ -14,7 +14,7 @@ "test:build-webpack": "pnpm install && pnpm build-webpack", "test:assert": "pnpm test:prod" }, - "//": "Pin `ioredis` to 5.10.1 because that's the last version before it publishes its own `ioredis:*` diagnostics channels; orchestrion's ioredis config covers `<5.11.0`.", + "//": "Pin `ioredis` to 5.10.1 and `mysql2` to 3.19.1: both are the last versions before the driver publishes its own native diagnostics channels; orchestrion's configs cover `ioredis <5.11.0` and `mysql2 <3.20.0`.", "dependencies": { "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", @@ -23,9 +23,13 @@ "ioredis": "5.10.1", "knex": "^2.5.1", "lru-memoizer": "2.3.0", + "mongodb": "^6.4.0", + "mongoose": "^7.8.11", "mysql": "^2.18.1", + "mysql2": "3.19.1", "next": "16.2.10", "pg": "^8.13.1", + "postgres": "^3.4.7", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/dataloader.test.ts new file mode 100644 index 000000000000..9b224a27ac4c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/dataloader.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +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'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/db-page.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/db-page.test.ts new file mode 100644 index 000000000000..9e2ae16e15c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/db-page.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments DB calls made during server-side rendering of a page', async ({ page }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-page'; + }); + + await page.goto('/db-page'); + await expect(page.locator('#answer')).toHaveText('answer: 42'); + await expect(page.locator('#cached')).toHaveText('cached: 42'); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + // One page render produces spans from both injection paths: pg (externalized → runtime module + // hook) and ioredis (bundle-safe allowlisted → build-time loader). + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.postgres', + description: 'SELECT 40 + 2 AS answer', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.statement': 'SELECT 40 + 2 AS answer', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.redis', + description: 'set page-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'set page-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.redis', + description: 'get page-key', + status: 'ok', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/db.test.ts deleted file mode 100644 index 449b7df8052a..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/db.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-redis' - ); - }); - - await fetch(`${baseURL}/api/db-redis`); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); - expect(transactionEvent.transaction).toEqual('GET /api/db-redis'); - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.redis', - description: 'set test-key [1 other arguments]', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'set test-key [1 other arguments]', - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.redis', - description: 'get test-key', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'get test-key', - }), - }), - ); -}); - -test('Instruments pg automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-pg'; - }); - - await fetch(`${baseURL}/api/db-pg`); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.postgres', - description: 'SELECT 1 + 1 AS solution', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'postgresql', - 'db.statement': 'SELECT 1 + 1 AS solution', - 'db.user': 'postgres', - 'db.name': 'postgres', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 5432, - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.postgres', - description: 'SELECT NOW()', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'postgresql', - 'db.statement': 'SELECT NOW()', - 'db.user': 'postgres', - 'db.name': 'postgres', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 5432, - }), - }), - ); -}); - -test('Instruments DB calls made during server-side rendering of a page', async ({ page }) => { - const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-page'; - }); - - await page.goto('/db-page'); - await expect(page.locator('#answer')).toHaveText('answer: 42'); - await expect(page.locator('#cached')).toHaveText('cached: 42'); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - // One page render produces spans from both injection paths: pg (externalized → runtime module - // hook) and ioredis (bundle-safe allowlisted → build-time loader). - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.postgres', - description: 'SELECT 40 + 2 AS answer', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'postgresql', - 'db.statement': 'SELECT 40 + 2 AS answer', - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.redis', - description: 'set page-key [1 other arguments]', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'set page-key [1 other arguments]', - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.redis', - description: 'get page-key', - status: 'ok', - }), - ); -}); - -// Unlike ioredis (bundle-safe allowlisted → bundled + transformed by the build-time loader), -// `pg` and `mysql` stay externalized and are instrumented by the orchestrion runtime module hook -// on require — which works because the SDK also externalizes the `@apm-js-collab/*` transformer -// packages. Same spans either way, different injection path. (`mysql` in particular MUST stay -// external: Turbopack cannot bundle it correctly — its wire protocol breaks even untransformed.) -test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mysql' - ); - }); - - await fetch(`${baseURL}/api/db-mysql`); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT 1 + 1 AS solution', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'db.statement': 'SELECT 1 + 1 AS solution', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 3306, - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT NOW()', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'db.statement': 'SELECT NOW()', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 3306, - }), - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/generic-pool.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/generic-pool.test.ts new file mode 100644 index 000000000000..90b47484d203 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/generic-pool.test.ts @@ -0,0 +1,27 @@ +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', + }), + }), + ); +}); 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 deleted file mode 100644 index 00029800a5c3..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/instrumentations.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -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, - }), - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/ioredis.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/ioredis.test.ts new file mode 100644 index 000000000000..9bfff822cb8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/ioredis.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-redis' + ); + }); + + await fetch(`${baseURL}/api/db-redis`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /api/db-redis'); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.redis', + description: 'set test-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'set test-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.redis', + description: 'get test-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'get test-key', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/knex.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/knex.test.ts new file mode 100644 index 000000000000..97ac3075c732 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/knex.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +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', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/lru-memoizer.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/lru-memoizer.test.ts new file mode 100644 index 000000000000..d612033991c3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/lru-memoizer.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// 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, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mongodb.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mongodb.test.ts new file mode 100644 index 000000000000..9353010ff6a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mongodb.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments mongodb automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mongodb' + ); + }); + + await fetch(`${baseURL}/api/db-mongodb`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mongo', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': 'insert', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mongo', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': 'find', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mongoose.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mongoose.test.ts new file mode 100644 index 000000000000..a9063cd2c3c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mongoose.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments mongoose automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mongoose' + ); + }); + + await fetch(`${baseURL}/api/db-mongoose`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mongoose', + description: 'mongoose.BlogPost.save', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mongoose', + 'db.name': 'test', + 'db.mongodb.collection': 'blogposts', + 'db.operation': 'save', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mongoose', + description: 'mongoose.BlogPost.findOne', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mongoose', + 'db.operation': 'findOne', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mysql.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mysql.test.ts new file mode 100644 index 000000000000..872fdf8b82e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mysql.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Unlike ioredis (bundle-safe allowlisted → bundled + transformed by the build-time loader), +// `pg` and `mysql` stay externalized and are instrumented by the orchestrion runtime module hook +// on require — which works because the SDK also externalizes the `@apm-js-collab/*` transformer +// packages. Same spans either way, different injection path. (`mysql` in particular MUST stay +// external: Turbopack cannot bundle it correctly — its wire protocol breaks even untransformed.) +test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mysql' + ); + }); + + await fetch(`${baseURL}/api/db-mysql`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT NOW()', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mysql2.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mysql2.test.ts new file mode 100644 index 000000000000..63f14e40b977 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/mysql2.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments mysql2 automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mysql2' + ); + }); + + await fetch(`${baseURL}/api/db-mysql2`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql2', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); + // `execute` is instrumented identically to `query`. + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql2', + description: 'SELECT 42 AS answer', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT 42 AS answer', + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/pg.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/pg.test.ts new file mode 100644 index 000000000000..87d90c8dddfc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/pg.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Instruments pg automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-pg'; + }); + + await fetch(`${baseURL}/api/db-pg`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.postgres', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.statement': 'SELECT 1 + 1 AS solution', + 'db.user': 'postgres', + 'db.name': 'postgres', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 5432, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.postgres', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.statement': 'SELECT NOW()', + 'db.user': 'postgres', + 'db.name': 'postgres', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 5432, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/postgresjs.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/postgresjs.test.ts new file mode 100644 index 000000000000..49b660f03cfc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-orchestrion/tests/postgresjs.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// postgres.js uses the STABLE semconv attribute keys (`db.system.name`, `server.*`, `db.query.text`), +// unlike the other drivers here which use the legacy keys. +test('Instruments postgres.js automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nextjs-16-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /api/db-postgresjs' + ); + }); + + await fetch(`${baseURL}/api/db-postgresjs`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + // postgres.js sanitizes inline literals to `?` (unlike `pg`, which preserves them here). + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.postgresjs', + description: 'SELECT ? + ? AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'postgres', + 'db.query.text': 'SELECT ? + ? AS solution', + 'db.operation.name': 'SELECT', + 'db.namespace': 'postgres', + 'server.address': 'localhost', + 'server.port': 5432, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.postgresjs', + description: 'SELECT * from generate_series(?, ?) as x', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + }), + }), + ); +});