diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts new file mode 100644 index 000000000000..0332efe522b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts @@ -0,0 +1,15 @@ +import { cacheLife } from 'next/cache'; +import type { NextRequest } from 'next/server'; + +async function getSwrValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + // After `revalidate` (2s), a read serves the stale value and triggers a background refill. + // `expire` is long so the entry stays valid for the whole test. + cacheLife({ stale: 5, revalidate: 2, expire: 300 }); + return { id, createdAt: Date.now() }; +} + +export async function GET(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id') ?? 'default-id'; + return Response.json(await getSwrValue(id)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts new file mode 100644 index 000000000000..50f88a41bdbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Background stale-while-revalidate refills. Target behavior: the revalidation gets its own trace, +// separate from the request trace that served the stale value. The revalidation trace links back to +// that request, and its `cache.put` becomes the `cache_origin` for future hits. + +/* + +Trace1 (fill) Trace2 (stale hit) Trace3 (hit) +|- put key:A <----link-----o get hit=true key:A |- get hit=true key:A + ^ | + | link (type TBD in the spec) | +TraceR: cache.revalidate --o (own trace) | +| | +|- put key:A <----------------------link-------------------o cache_origin + +*/ + +// Not implemented yet. Unlike cacheOriginLinks-nesting.spec.ts, this test cannot document the +// target behavior with `test.fail()`: it would wait for a `cache.revalidate` trace that never +// arrives and time out, and Playwright reports a timeout as a real failure even under +// `test.fail()`. So the test is `test.fixme()` until the SDK emits the revalidation trace. + +test('runs background revalidation in its own trace linked to the triggering request', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'SWR revalidation timing only holds in production'); + test.fixme(); + + const id = crypto.randomUUID(); + + const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const fillTx = await fillTxPromise; + + // Sleep past `revalidate` (2s) but not `expire`, so the next read serves the stale value and triggers a background refill. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const staleHitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + const revalidationTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'cache.revalidate'; + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const staleHitTx = await staleHitTxPromise; + + const fillPutSpan = fillTx.spans?.find(span => span.op === 'cache.put'); + expect(fillPutSpan).toBeDefined(); + + // The stale response still came from the original fill. + const staleHitGetSpan = staleHitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + expect(staleHitGetSpan).toBeDefined(); + expect(staleHitGetSpan?.links).toEqual([ + { + trace_id: fillTx.contexts?.trace?.trace_id, + span_id: fillPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); + + // The visitor never waited for the refill, so the refill is not part of the serving trace: the + // background revalidation is its own trace, linked back to the request that triggered it. + const revalidationTx = await revalidationTxPromise; + expect(revalidationTx.contexts?.trace?.trace_id).not.toBe(staleHitTx.contexts?.trace?.trace_id); + expect(revalidationTx.contexts?.trace?.links).toEqual([ + { + trace_id: staleHitTx.contexts?.trace?.trace_id, + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + sampled: true, + // No link type for cache spans specced yet, so assert only the link target. + attributes: { 'sentry.link.type': expect.any(String) }, + }, + ]); + + const revalidationPutSpan = revalidationTx.spans?.find(span => span.op === 'cache.put'); + expect(revalidationPutSpan).toBeDefined(); + + // The revalidation's `cache.put` becomes the `cache_origin` for future hits. + const hitAfterRefillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const hitAfterRefillTx = await hitAfterRefillTxPromise; + + const hitAfterRefillGetSpan = hitAfterRefillTx.spans?.find( + span => span.op === 'cache.get' && span.data?.['cache.hit'] === true, + ); + expect(hitAfterRefillGetSpan).toBeDefined(); + expect(hitAfterRefillGetSpan?.links).toEqual([ + { + trace_id: revalidationTx.contexts?.trace?.trace_id, + span_id: revalidationPutSpan!.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-swr/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts new file mode 100644 index 000000000000..0332efe522b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts @@ -0,0 +1,15 @@ +import { cacheLife } from 'next/cache'; +import type { NextRequest } from 'next/server'; + +async function getSwrValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + // After `revalidate` (2s), a read serves the stale value and triggers a background refill. + // `expire` is long so the entry stays valid for the whole test. + cacheLife({ stale: 5, revalidate: 2, expire: 300 }); + return { id, createdAt: Date.now() }; +} + +export async function GET(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id') ?? 'default-id'; + return Response.json(await getSwrValue(id)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts new file mode 100644 index 000000000000..b6aa21aa89c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils'; + +// Background stale-while-revalidate refills. Target behavior: the revalidation gets its own trace, +// separate from the request trace that served the stale value. The revalidation trace links back to +// that request, and its `cache.put` becomes the `cache_origin` for future hits. + +/* + +Trace1 (fill) Trace2 (stale hit) Trace3 (hit) +|- put key:A <----link-----o get hit=true key:A |- get hit=true key:A + ^ | + | link (type TBD in the spec) | +TraceR: cache.revalidate --o (own trace) | +| | +|- put key:A <----------------------link-------------------o cache_origin + +*/ + +// Not implemented yet. Unlike cacheOriginLinks-nesting.spec.ts, this test cannot document the +// target behavior with `test.fail()`: it would wait for a `cache.revalidate` trace that never +// arrives and time out, and Playwright reports a timeout as a real failure even under +// `test.fail()`. So the test is `test.fixme()` until the SDK emits the revalidation trace. + +test('runs background revalidation in its own trace linked to the triggering request', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'SWR revalidation timing only holds in production'); + test.fixme(); + + const id = crypto.randomUUID(); + + const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const fillSpans = await fillSpansPromise; + + // Sleep past `revalidate` (2s) but not `expire`, so the next read serves the stale value and + // triggers a background refill. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const staleHitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + // The visitor never waited for the refill, so the refill is not part of the serving trace: the + // background revalidation is its own trace with a `cache.revalidate` segment. + const revalidationSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => getSpanOp(span) === 'cache.revalidate' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const staleHitSpans = await staleHitSpansPromise; + + const fillPutSpan = findCacheSpan(fillSpans, 'cache.put'); + expect(fillPutSpan).toBeDefined(); + + // The stale response still came from the original fill. + const staleHitGetSpan = findCacheSpan(staleHitSpans, 'cache.get', true); + expect(staleHitGetSpan).toBeDefined(); + expect(staleHitGetSpan?.links).toEqual([ + { + trace_id: fillPutSpan!.trace_id, + span_id: fillPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); + + const revalidationSpans = await revalidationSpansPromise; + const revalidationSegment = revalidationSpans.find(span => span.is_segment && getSpanOp(span) === 'cache.revalidate'); + expect(revalidationSegment).toBeDefined(); + expect(revalidationSegment!.trace_id).not.toBe(staleHitGetSpan!.trace_id); + expect(revalidationSegment!.links).toEqual([ + { + trace_id: staleHitGetSpan!.trace_id, + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + sampled: true, + // No link type for cache spans specced yet, so assert only the link target. + attributes: { 'sentry.link.type': { value: expect.any(String), type: 'string' } }, + }, + ]); + + const revalidationPutSpan = findCacheSpan(revalidationSpans, 'cache.put'); + expect(revalidationPutSpan).toBeDefined(); + + // The revalidation's `cache.put` becomes the `cache_origin` for future hits. + const hitAfterRefillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) && + spansOfTrace.every(span => span.trace_id !== staleHitGetSpan!.trace_id) + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const hitAfterRefillSpans = await hitAfterRefillSpansPromise; + + const hitAfterRefillGetSpan = findCacheSpan(hitAfterRefillSpans, 'cache.get', true); + expect(hitAfterRefillGetSpan).toBeDefined(); + expect(hitAfterRefillGetSpan?.links).toEqual([ + { + trace_id: revalidationPutSpan!.trace_id, + span_id: revalidationPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +});