diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx
index 49ae52f933e9..c6c86ac7f1f4 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx
@@ -20,7 +20,6 @@ import { StrictMode, startTransition, useEffect } from 'react';
import { hydrateRoot } from 'react-dom/client';
Sentry.init({
- traceLifecycle: 'static',
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: window.ENV.SENTRY_DSN,
integrations: [
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx
index 40de0390d6ac..cbd57201976d 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx
@@ -1,13 +1,6 @@
-import { Link, useSearchParams } from '@remix-run/react';
-import * as Sentry from '@sentry/remix';
+import { Link } from '@remix-run/react';
export default function Index() {
- const [searchParams] = useSearchParams();
-
- if (searchParams.get('tag')) {
- Sentry.setTag('sentry_test', searchParams.get('tag'));
- }
-
return (
{
- const transactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => {
- return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/';
+test('Sends a pageload span to Sentry', async ({ page }) => {
+ const spanPromise = waitForStreamedSpan('create-remix-app-v2', span => {
+ return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/';
});
await page.goto('/');
- const transactionEvent = await transactionPromise;
+ const span = await spanPromise;
- expect(transactionEvent).toBeDefined();
- expect(transactionEvent.contexts?.trace?.data).toEqual(
- expect.objectContaining({
- 'sentry.segment.name.source': 'route',
- 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/),
- 'url.path': '/',
- 'url.template': '/',
- }),
- );
+ expect(span.attributes).toMatchObject({
+ 'sentry.segment.name.source': { value: 'route', type: 'string' },
+ 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' },
+ 'url.path': { value: '/', type: 'string' },
+ 'url.template': { value: '/', type: 'string' },
+ });
});
-test('Sends a navigation transaction to Sentry', async ({ page }) => {
- const transactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => {
- return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id';
+test('Sends a navigation span to Sentry', async ({ page }) => {
+ const spanPromise = waitForStreamedSpan('create-remix-app-v2', span => {
+ return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/user/:id';
});
await page.goto('/');
@@ -31,17 +28,14 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => {
const linkElement = page.locator('id=navigation');
await linkElement.click();
- const transactionEvent = await transactionPromise;
+ const span = await spanPromise;
- expect(transactionEvent).toBeDefined();
- expect(transactionEvent.contexts?.trace?.data).toEqual(
- expect.objectContaining({
- 'sentry.segment.name.source': 'route',
- 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/),
- 'url.path': '/user/5',
- 'url.template': '/user/:id',
- }),
- );
+ expect(span.attributes).toMatchObject({
+ 'sentry.segment.name.source': { value: 'route', type: 'string' },
+ 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' },
+ 'url.path': { value: '/user/5', type: 'string' },
+ 'url.template': { value: '/user/:id', type: 'string' },
+ });
});
test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => {
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts
index 28d38d56db88..52d3c69d8f61 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts
@@ -1,94 +1,81 @@
import { expect, test } from '@playwright/test';
-import { waitForTransaction } from '@sentry-internal/test-utils';
+import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
+import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
+
+function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean {
+ return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path;
+}
// Orchestrion force-bundles + transforms mysql/ioredis at build time, and the databases
// are booted via docker-compose in the Playwright global setup.
test.describe('orchestrion DB instrumentation', () => {
test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => {
- const transactionEventPromise = waitForTransaction('create-remix-app-v2', transactionEvent => {
- return (
- transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-ioredis')
- );
- });
+ const spansPromise = collectStreamedSpans(
+ 'create-remix-app-v2',
+ spans =>
+ spans.some(isSegmentFor('/db-ioredis')) && spans.filter(span => getSpanOp(span) === 'db.query').length >= 2,
+ );
await fetch(`${baseURL}/db-ioredis`);
- const transactionEvent = await transactionEventPromise;
+ const spans = await spansPromise;
+ const redisSpans = spans.filter(span => getSpanOp(span) === 'db.query');
- const spans = transactionEvent.spans || [];
-
- expect(spans).toContainEqual(
+ // With span streaming `db.query.text` carries the key, so the command span is named
+ // `{db.operation.name} {server.address}:{server.port}` instead.
+ expect(redisSpans).toContainEqual(
expect.objectContaining({
- op: 'db.query',
- origin: 'auto.db.redis',
- description: '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.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(redisSpans).toContainEqual(
expect.objectContaining({
- op: 'db.query',
- origin: 'auto.db.redis',
- description: '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.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' },
}),
}),
);
});
test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => {
- const transactionEventPromise = waitForTransaction('create-remix-app-v2', transactionEvent => {
- return (
- transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-mysql')
- );
- });
+ const spansPromise = collectStreamedSpans(
+ 'create-remix-app-v2',
+ spans => spans.some(isSegmentFor('/db-mysql')) && spans.filter(span => getSpanOp(span) === 'db').length >= 2,
+ );
await fetch(`${baseURL}/db-mysql`);
- const transactionEvent = await transactionEventPromise;
-
- const spans = transactionEvent.spans || [];
+ const spans = await spansPromise;
+ const mysqlSpans = spans.filter(span => getSpanOp(span) === 'db');
- 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,
+ // With span streaming the span name is the low-cardinality query summary; the statement
+ // stays in `db.query.text`.
+ for (const query of ['SELECT 1 + 1 AS solution', 'SELECT NOW()']) {
+ expect(mysqlSpans).toContainEqual(
+ expect.objectContaining({
+ name: 'SELECT',
+ status: 'ok',
+ attributes: expect.objectContaining({
+ 'sentry.origin': { value: 'auto.db.mysql', type: 'string' },
+ 'db.system.name': { value: 'mysql', type: 'string' },
+ 'db.query.text': { value: query, 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,
- }),
- }),
- );
+ );
+ }
});
});
diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts
index 915f813db060..c489ceee5cdd 100644
--- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts
+++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/server-transactions.test.ts
@@ -1,58 +1,56 @@
import { expect, test } from '@playwright/test';
-import { waitForTransaction } from '@sentry-internal/test-utils';
+import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
+import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
test.describe.configure({ mode: 'serial' });
-test('Sends parameterized transaction name to Sentry', async ({ page }) => {
- const transactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => {
- return transactionEvent.contexts?.trace?.op === 'http.server';
+test('Sends a parameterized span name to Sentry', async ({ page }) => {
+ const spanPromise = waitForStreamedSpan('create-remix-app-v2', span => {
+ return getSpanOp(span) === 'http.server' && span.is_segment && span.name === 'GET user/:id';
});
await page.goto('/user/123');
- const transaction = await transactionPromise;
+ const span = await spanPromise;
- expect(transaction).toBeDefined();
- expect(transaction.transaction).toBe('GET user/:id');
+ expect(span.attributes['http.route']?.value).toBe('user/:id');
});
-test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
- // We use this to identify the transactions
- const testTag = crypto.randomUUID();
-
- const httpServerTransactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => {
- return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag;
- });
-
- const pageLoadTransactionPromise = waitForTransaction('create-remix-app-v2', transactionEvent => {
- return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag;
+test('Links the server and client spans of one page load', async ({ page }) => {
+ // Streamed spans are buffered before they flush, so spans from an earlier page load can still be
+ // arriving here. The document advertises its own trace in the `sentry-trace` meta tag, so that is
+ // what tells this page load's spans apart rather than the op or the URL.
+ const streamedSpans: SerializedStreamedSpan[] = [];
+ void waitForStreamedSpans('create-remix-app-v2', spans => {
+ streamedSpans.push(...spans);
+ return false;
});
- page.goto(`/?tag=${testTag}`);
-
- const pageloadTransaction = await pageLoadTransactionPromise;
- const httpServerTransaction = await httpServerTransactionPromise;
-
- expect(pageloadTransaction).toBeDefined();
- expect(httpServerTransaction).toBeDefined();
-
- const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id;
- const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id;
- const loaderSpanId = httpServerTransaction?.spans?.find(
- span => span.data && span.data['code.function.name'] === 'loader',
- )?.span_id;
-
- const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
- const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
- const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
-
- expect(httpServerTransaction.transaction).toBe('GET /');
- expect(pageloadTransaction.transaction).toBe('/');
-
- expect(httpServerTraceId).toBeDefined();
- expect(httpServerSpanId).toBeDefined();
-
- expect(pageLoadTraceId).toEqual(httpServerTraceId);
- expect(pageLoadParentSpanId).toEqual(loaderSpanId);
- expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
+ await page.goto('/');
+
+ // Remix injects the meta tag from inside the root loader, so the span it names is the loader span.
+ const sentryTrace = await page.getAttribute('meta[name="sentry-trace"]', 'content');
+ const [traceId, loaderSpanId] = (sentryTrace ?? '').split('-');
+ expect(traceId).toMatch(/^[a-f0-9]{32}$/);
+ expect(loaderSpanId).toMatch(/^[a-f0-9]{16}$/);
+
+ // The client continues the server trace, so its pageload span hangs off the span the meta tag
+ // names. Selecting it that way, rather than by op, is what makes the trace assertion below mean
+ // something: a pageload that failed to continue the trace would have no parent at all.
+ const findPageloadSpan = () =>
+ streamedSpans.find(
+ span => getSpanOp(span) === 'pageload' && span.is_segment && span.parent_span_id === loaderSpanId,
+ );
+ await expect.poll(findPageloadSpan).toBeDefined();
+ expect(findPageloadSpan()!.trace_id).toBe(traceId);
+ expect(findPageloadSpan()!.name).toBe('/');
+
+ const findServerSegmentSpan = () =>
+ streamedSpans.find(span => getSpanOp(span) === 'http.server' && span.is_segment && span.trace_id === traceId);
+ await expect.poll(findServerSegmentSpan).toBeDefined();
+ expect(findPageloadSpan()!.span_id).not.toBe(findServerSegmentSpan()!.span_id);
+
+ const loaderSpan = streamedSpans.find(span => span.span_id === loaderSpanId);
+ expect(loaderSpan).toBeDefined();
+ expect(loaderSpan!.attributes['code.function.name']?.value).toBe('loader');
});