Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, number>(async keys => keys.map((_, idx) => idx), {
cache: false,
name: 'usersLoader',
});

const user = await loader.load('user-1');

return NextResponse.json({ user });
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<void>(resolve => {
memoizedFn({ foo: 'bar' }, () => {
span.setAttribute(
'memoized.context_preserved',
Sentry.getActiveSpan()?.spanContext().spanId === span.spanContext().spanId,
);
resolve();
});
}),
);
Comment thread
chargome marked this conversation as resolved.

// 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' });
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
});
Original file line number Diff line number Diff line change
@@ -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,
}),
}),
);
});
Loading