Skip to content
Open
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,112 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

// Origin links for `use cache` in route handlers. Target behavior: a cache hit records a
// `cache.get` span carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put`
// span of the trace that filled the entry; unknown origin means no link. Not implemented yet —
// every test is `test.fail()`; shipping the feature should only require deleting those lines.

test('links a route handler cache hit to the trace that filled the entry', async ({ request }) => {
test.fail();

// A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key)
// even when the test is retried against the same server.
const id = crypto.randomUUID();

const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
return (
transactionEvent.transaction === 'GET /api/use-cache' &&
!!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false)
);
});

const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
return (
transactionEvent.transaction === 'GET /api/use-cache' &&
!!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
);
});

await request.get(`/api/use-cache?id=${id}`);
const missTx = await missTxPromise;

await request.get(`/api/use-cache?id=${id}`);
const hitTx = await hitTxPromise;

const putSpan = missTx.spans?.find(span => span.op === 'cache.put');
expect(putSpan).toBeDefined();

// A miss has no origin, and the SDK never guesses one.
const missGetSpan = missTx.spans?.find(span => span.op === 'cache.get');
expect(missGetSpan?.links).toBeUndefined();
expect(putSpan?.links).toBeUndefined();

const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true);
expect(hitGetSpan).toBeDefined();
expect(hitGetSpan?.links).toEqual([
{
trace_id: missTx.contexts?.trace?.trace_id,
span_id: putSpan!.span_id,
sampled: true,
attributes: { 'sentry.link.type': 'cache_origin' },
},
]);
});

test('moves the origin link to the refill trace after the entry expires', async ({ request }) => {
test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production');
test.fail();

const id = crypto.randomUUID();

const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
return (
transactionEvent.transaction === 'GET /api/use-cache-expiring' &&
!!transactionEvent.spans?.some(span => span.op === 'cache.put')
);
});

await request.get(`/api/use-cache-expiring?id=${id}`);
const fillTx = await fillTxPromise;

// Sleep past the entry's hard `expire` limit (2s), so the next read must discard and refill it.
await new Promise(resolve => setTimeout(resolve, 3_000));

// Registered after the fill transaction was consumed, so it only matches the refill.
const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
return (
transactionEvent.transaction === 'GET /api/use-cache-expiring' &&
!!transactionEvent.spans?.some(span => span.op === 'cache.put')
);
});

await request.get(`/api/use-cache-expiring?id=${id}`);
const refillTx = await refillTxPromise;

const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
return (
transactionEvent.transaction === 'GET /api/use-cache-expiring' &&
!!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
);
});

await request.get(`/api/use-cache-expiring?id=${id}`);
const hitTx = await hitTxPromise;

expect(refillTx.contexts?.trace?.trace_id).not.toBe(fillTx.contexts?.trace?.trace_id);

const refillPutSpan = refillTx.spans?.find(span => span.op === 'cache.put');
expect(refillPutSpan).toBeDefined();

// The hit read the refilled entry, so the link points at the refill trace, not the first fill.
const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true);
expect(hitGetSpan).toBeDefined();
expect(hitGetSpan?.links).toEqual([
{
trace_id: refillTx.contexts?.trace?.trace_id,
span_id: refillPutSpan!.span_id,
sampled: true,
attributes: { 'sentry.link.type': 'cache_origin' },
},
]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { cacheLife } from 'next/cache';
import type { NextRequest } from 'next/server';

async function getExpiringValue(id: string): Promise<{ id: string; createdAt: number }> {
'use cache';
// Hard-expires after 2 seconds, so a delayed second request exercises the expired-entry path.
cacheLife({ revalidate: 1, expire: 2 });
return { id, createdAt: Date.now() };
}

export async function GET(request: NextRequest) {
const id = request.nextUrl.searchParams.get('id') ?? 'default-id';
return Response.json(await getExpiringValue(id));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cacheLife, cacheTag } from 'next/cache';
import type { NextRequest } from 'next/server';

async function getCachedValue(id: string): Promise<{ id: string; createdAt: number }> {
'use cache';
// The 'hours' profile has a finite `expire` (1 day), so the entry carries a real TTL.
cacheLife('hours');
cacheTag('e2e-use-cache-tag');
await new Promise(resolve => setTimeout(resolve, 100));
return { id, createdAt: Date.now() };
}

export async function GET(request: NextRequest) {
// The id ends up in the cache key (it is an argument of the cached function), so tests get a
// guaranteed cache miss by passing a fresh id.
const id = request.nextUrl.searchParams.get('id') ?? 'default-id';
return Response.json(await getCachedValue(id));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils';

// Origin links for `use cache` in route handlers. Target behavior: a cache hit records a
// `cache.get` span carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put`
// span of the trace that filled the entry; unknown origin means no link. Not implemented yet —
// every test is `test.fail()`; shipping the feature should only require deleting those lines.

test('links a route handler cache hit to the trace that filled the entry', async ({ request }) => {
test.fail();

// A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key)
// even when the test is retried against the same server.
const id = crypto.randomUUID();

const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
return (
spansOfTrace.some(span => span.name === 'GET /api/use-cache' && span.is_segment) &&
spansOfTrace.some(span => getSpanOp(span) === 'cache.put')
);
});

await request.get(`/api/use-cache?id=${id}`);
const missSpans = await missSpansPromise;

const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
return (
spansOfTrace.some(span => span.name === 'GET /api/use-cache' && span.is_segment) &&
spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true)
);
});

await request.get(`/api/use-cache?id=${id}`);
const hitSpans = await hitSpansPromise;

const putSpan = findCacheSpan(missSpans, 'cache.put');
expect(putSpan).toBeDefined();

// A miss has no origin, and the SDK never guesses one.
const missGetSpan = findCacheSpan(missSpans, 'cache.get', false);
expect(missGetSpan?.links).toBeUndefined();
expect(putSpan?.links).toBeUndefined();

const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true);
expect(hitGetSpan).toBeDefined();
expect(hitGetSpan?.links).toEqual([
{
trace_id: putSpan!.trace_id,
span_id: putSpan!.span_id,
sampled: true,
attributes: CACHE_ORIGIN_LINK_ATTRIBUTES,
},
]);
});

test('moves the origin link to the refill trace after the entry expires', async ({ request }) => {
test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production');
test.fail();

const id = crypto.randomUUID();

const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
return (
spansOfTrace.some(span => span.name === 'GET /api/use-cache-expiring' && span.is_segment) &&
spansOfTrace.some(span => getSpanOp(span) === 'cache.put')
);
});

await request.get(`/api/use-cache-expiring?id=${id}`);
const fillSpans = await fillSpansPromise;

// Sleep past the entry's hard `expire` limit (2s), so the next read must discard and refill it.
await new Promise(resolve => setTimeout(resolve, 3_000));

// Registered after the fill trace was consumed, so it only matches the refill.
const refillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
return (
spansOfTrace.some(span => span.name === 'GET /api/use-cache-expiring' && span.is_segment) &&
spansOfTrace.some(span => getSpanOp(span) === 'cache.put') &&
spansOfTrace.every(span => span.trace_id !== fillSpans[0]!.trace_id)
);
});

await request.get(`/api/use-cache-expiring?id=${id}`);
const refillSpans = await refillSpansPromise;

const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
return (
spansOfTrace.some(span => span.name === 'GET /api/use-cache-expiring' && span.is_segment) &&
spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true)
);
});

await request.get(`/api/use-cache-expiring?id=${id}`);
const hitSpans = await hitSpansPromise;

const refillPutSpan = findCacheSpan(refillSpans, 'cache.put');
expect(refillPutSpan).toBeDefined();
expect(refillPutSpan!.trace_id).not.toBe(fillSpans[0]!.trace_id);

// The hit read the refilled entry, so the link points at the refill trace, not the first fill.
const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true);
expect(hitGetSpan).toBeDefined();
expect(hitGetSpan?.links).toEqual([
{
trace_id: refillPutSpan!.trace_id,
span_id: refillPutSpan!.span_id,
sampled: true,
attributes: CACHE_ORIGIN_LINK_ATTRIBUTES,
},
]);
});
Loading