diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts new file mode 100644 index 000000000000..2f604f0402f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts @@ -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' }, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-expiring/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-expiring/route.ts new file mode 100644 index 000000000000..ca237ca4785f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-expiring/route.ts @@ -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)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache/route.ts new file mode 100644 index 000000000000..dd5f63024523 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache/route.ts @@ -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)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts new file mode 100644 index 000000000000..09148406915d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts @@ -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, + }, + ]); +});