Skip to content
Draft
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
Expand Up @@ -8,7 +8,6 @@ import { HydratedRouter } from 'react-router/dom';
const tracing = Sentry.reactRouterTracingIntegration({ useInstrumentationAPI: true });

Sentry.init({
traceLifecycle: 'static',
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: 'https://username@domain/123',
tunnel: `http://localhost:3031/`, // proxy server
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as Sentry from '@sentry/react-router';

Sentry.init({
traceLifecycle: 'static',
dsn: 'https://username@domain/123',
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
import { collectStreamedTrace, getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
import { APP_NAME } from '../constants';

test.describe('server - instrumentation API error capture', () => {
Expand All @@ -8,15 +8,15 @@ test.describe('server - instrumentation API error capture', () => {
return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing';
});

const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === 'GET /performance/error-loader';
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
return span.name === 'GET /performance/error-loader' && span.is_segment;
});

await page.goto(`/performance/error-loader`).catch(() => {
// Expected to fail due to loader error
});

const [error, transaction] = await Promise.all([errorPromise, txPromise]);
const [error, span] = await Promise.all([errorPromise, spanPromise]);

// Verify the error was captured with correct mechanism and transaction name
expect(error).toMatchObject({
Expand All @@ -36,58 +36,49 @@ test.describe('server - instrumentation API error capture', () => {
});

// Verify the transaction was also created with correct attributes
expect(transaction).toMatchObject({
transaction: 'GET /performance/error-loader',
contexts: {
trace: {
op: 'http.server',
origin: 'auto.http.react_router.instrumentation_api',
},
},
});
expect(span.name).toBe('GET /performance/error-loader');
expect(getSpanOp(span)).toBe('http.server');
expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api');
});

test('should include loader span in transaction even when loader throws', async ({ page }) => {
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === 'GET /performance/error-loader';
});
test('should include loader span in the segment even when loader throws', async ({ page }) => {
const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/error-loader');

await page.goto(`/performance/error-loader`).catch(() => {
// Expected to fail due to loader error
});

const transaction = await txPromise;
const spans = await spansPromise;

// Find the loader span
const loaderSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'loader');
const loaderSpan = spans.find(span => span.attributes['code.function.name']?.value === 'loader');

expect(loaderSpan).toMatchObject({
data: {
'sentry.origin': 'auto.function.react_router.instrumentation_api',
'sentry.op': 'function',
'code.function.name': 'loader',
},
op: 'function',
expect(loaderSpan).toBeDefined();
expect(getSpanOp(loaderSpan!)).toBe('function');
expect(loaderSpan!.attributes).toMatchObject({
'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' },
'sentry.op': { value: 'function', type: 'string' },
'code.function.name': { value: 'loader', type: 'string' },
});
});

test('error and transaction should share the same trace', async ({ page }) => {
test('error and segment span should share the same trace', async ({ page }) => {
const errorPromise = waitForError(APP_NAME, async errorEvent => {
return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing';
});

const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === 'GET /performance/error-loader';
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
return span.name === 'GET /performance/error-loader' && span.is_segment;
});

await page.goto(`/performance/error-loader`).catch(() => {
// Expected to fail due to loader error
});

const [error, transaction] = await Promise.all([errorPromise, txPromise]);
const [error, span] = await Promise.all([errorPromise, spanPromise]);

// Error and transaction should have the same trace_id
expect(error.contexts?.trace?.trace_id).toBe(transaction.contexts?.trace?.trace_id);
// Error and segment span should have the same trace_id
expect(error.contexts?.trace?.trace_id).toBe(span.trace_id);
});

// Skipped in dev: the action error is sometimes captured via the client instrumentation path
Expand All @@ -101,14 +92,14 @@ test.describe('server - instrumentation API error capture', () => {
return errorEvent.exception?.values?.[0]?.value === 'Action error for testing';
});

const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === 'POST /performance/error-action';
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
return span.name === 'POST /performance/error-action' && span.is_segment;
});

await page.goto(`/performance/error-action`);
await page.getByRole('button', { name: 'Trigger Error' }).click();

const [error, transaction] = await Promise.all([errorPromise, txPromise]);
const [error, span] = await Promise.all([errorPromise, spanPromise]);

expect(error).toMatchObject({
exception: {
Expand All @@ -126,31 +117,25 @@ test.describe('server - instrumentation API error capture', () => {
transaction: 'POST /performance/error-action',
});

expect(transaction).toMatchObject({
transaction: 'POST /performance/error-action',
contexts: {
trace: {
op: 'http.server',
origin: 'auto.http.react_router.instrumentation_api',
},
},
});
expect(span.name).toBe('POST /performance/error-action');
expect(getSpanOp(span)).toBe('http.server');
expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api');
});

test('should capture middleware errors with instrumentation API mechanism', async ({ page }) => {
const errorPromise = waitForError(APP_NAME, async errorEvent => {
return errorEvent.exception?.values?.[0]?.value === 'Middleware error for testing';
});

const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === 'GET /performance/error-middleware';
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
return span.name === 'GET /performance/error-middleware' && span.is_segment;
});

await page.goto(`/performance/error-middleware`).catch(() => {
// Expected to fail due to middleware error
});

const [error, transaction] = await Promise.all([errorPromise, txPromise]);
const [error, span] = await Promise.all([errorPromise, spanPromise]);

expect(error).toMatchObject({
exception: {
Expand All @@ -168,14 +153,8 @@ test.describe('server - instrumentation API error capture', () => {
transaction: 'GET /performance/error-middleware',
});

expect(transaction).toMatchObject({
transaction: 'GET /performance/error-middleware',
contexts: {
trace: {
op: 'http.server',
origin: 'auto.http.react_router.instrumentation_api',
},
},
});
expect(span.name).toBe('GET /performance/error-middleware');
expect(getSpanOp(span)).toBe('http.server');
expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api');
});
});
Original file line number Diff line number Diff line change
@@ -1,111 +1,90 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { collectStreamedTrace, getSpanOp } from '@sentry-internal/test-utils';
import { APP_NAME } from '../constants';

// Same spans in both runs, from two injectors: the build-time transform in the server bundle, and
// the runtime hook in `react-router dev`, where the drivers stay on Node's own loader.
test.describe('server - orchestrion db instrumentation', () => {
test('instruments ioredis automatically via orchestrion', async ({ page }) => {
const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
transactionEvent.transaction === 'GET /performance/db-ioredis'
);
});
const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/db-ioredis');

await page.goto('/performance/db-ioredis');

const transactionEvent = await transactionEventPromise;
const spans = transactionEvent.spans || [];
const spans = await spansPromise;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Child span collection can flake

Medium Severity

collectStreamedTrace is used without an isDone predicate in tests that then assert on child spans. That helper resolves as soon as the segment arrives, and streamed children can flush after it, so loader, middleware, redis, and mysql assertions can fail even when the spans are later sent. The same PR already waits for those children in collectUntilSegment, and the sibling react-router-7-framework tests do too. This is flagged because the review rules call out races when waiting on telemetry that can arrive in arbitrary order, including in lazy.server.test.ts.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 7d44686. Configure here.

const segmentSpan = spans.find(span => span.name === 'GET /performance/db-ioredis' && span.is_segment)!;

// The server transaction must come from the native instrumentation API (not the legacy handler),
// The server segment must come from the native instrumentation API (not the legacy handler),
// proving the orchestrion-injected db spans share context with the React Router server span.
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.react_router.instrumentation_api');
expect(getSpanOp(segmentSpan)).toBe('http.server');
expect(segmentSpan.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api');

const childSpans = spans.filter(span => !span.is_segment);

expect(spans).toContainEqual(
expect(childSpans).toContainEqual(
expect.objectContaining({
op: 'db.query',
origin: 'auto.db.redis',
description: 'set test-key [1 other arguments]',
name: 'set test-key [1 other arguments]',
status: 'ok',
data: expect.objectContaining({
'db.system.name': 'redis',
'db.operation.name': 'set',
'db.query.text': 'set test-key [1 other arguments]',
attributes: expect.objectContaining({
'sentry.op': { value: 'db.query', type: 'string' },
'sentry.origin': { value: 'auto.db.redis', type: 'string' },
'db.system.name': { value: 'redis', type: 'string' },
'db.operation.name': { value: 'set', type: 'string' },
'db.query.text': { value: 'set test-key [1 other arguments]', type: 'string' },
}),
}),
);
expect(spans).toContainEqual(
expect(childSpans).toContainEqual(
expect.objectContaining({
op: 'db.query',
origin: 'auto.db.redis',
description: 'get test-key',
name: 'get test-key',
status: 'ok',
data: expect.objectContaining({
'db.system.name': 'redis',
'db.operation.name': 'get',
'db.query.text': 'get test-key',
attributes: expect.objectContaining({
'sentry.op': { value: 'db.query', type: 'string' },
'sentry.origin': { value: 'auto.db.redis', type: 'string' },
'db.system.name': { value: 'redis', type: 'string' },
'db.operation.name': { value: 'get', type: 'string' },
'db.query.text': { value: 'get test-key', type: 'string' },
}),
}),
);

// Each command maps to exactly one span (no offline-queue duplicate).
const setSpans = spans.filter(span => span.description === 'set test-key [1 other arguments]');
const setSpans = spans.filter(span => span.name === 'set test-key [1 other arguments]');
expect(setSpans).toHaveLength(1);

// Every db span nests under the native instrumentation-API http.server transaction.
const rootSpanId = transactionEvent.contexts?.trace?.span_id;
const spanIds = new Set([rootSpanId, ...spans.map(span => span.span_id)]);
const dbSpans = spans.filter(span => span.origin === 'auto.db.redis');
Comment thread
cursor[bot] marked this conversation as resolved.
// Every db span nests under the native instrumentation-API http.server segment.
const spanIds = new Set(spans.filter(span => span.trace_id === segmentSpan.trace_id).map(span => span.span_id));
const dbSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.db.redis');
expect(dbSpans.every(span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id))).toBe(
true,
);
});

// Under span streaming the mysql span name is the query summary, so both queries below are named
// `SELECT`. `db.query.text` is what tells them apart.
test('instruments mysql automatically via orchestrion', async ({ page }) => {
const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' &&
transactionEvent.transaction === 'GET /performance/db-mysql'
);
});
const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/db-mysql');

await page.goto('/performance/db-mysql');

const transactionEvent = await transactionEventPromise;
const spans = transactionEvent.spans || [];
const spans = await spansPromise;

expect(spans).toContainEqual(
expect.objectContaining({
op: 'db',
origin: 'auto.db.mysql',
description: 'SELECT 1 + 1 AS solution',
status: 'ok',
data: expect.objectContaining({
'db.system.name': 'mysql',
'db.query.text': 'SELECT 1 + 1 AS solution',
'db.user': 'root',
'db.connection_string': expect.any(String),
'server.address': expect.any(String),
'server.port': 3306,
for (const queryText of ['SELECT 1 + 1 AS solution', 'SELECT NOW()']) {
expect(spans).toContainEqual(
expect.objectContaining({
name: 'SELECT',
status: 'ok',
attributes: expect.objectContaining({
'sentry.op': { value: 'db', type: 'string' },
'sentry.origin': { value: 'auto.db.mysql', type: 'string' },
'db.system.name': { value: 'mysql', type: 'string' },
'db.query.text': { value: queryText, type: 'string' },
'db.user': { value: 'root', type: 'string' },
'db.connection_string': { value: expect.any(String), type: 'string' },
'server.address': { value: expect.any(String), type: 'string' },
'server.port': { value: 3306, type: 'integer' },
}),
}),
}),
);
expect(spans).toContainEqual(
expect.objectContaining({
op: 'db',
origin: 'auto.db.mysql',
description: 'SELECT NOW()',
status: 'ok',
data: expect.objectContaining({
'db.system.name': 'mysql',
'db.query.text': 'SELECT NOW()',
'db.user': 'root',
'db.connection_string': expect.any(String),
'server.address': expect.any(String),
'server.port': 3306,
}),
}),
);
);
}
});
});
Loading
Loading