From 5614c35e0286ccbbfa78fe7c88c2a8a4b1345e66 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:08:54 +0200 Subject: [PATCH] test(e2e): Add trace test for background use cache revalidation --- .../app/api/use-cache-swr/route.ts | 15 +++ .../cacheOriginLinks-revalidation.spec.ts | 97 +++++++++++++++++ .../app/api/use-cache-swr/route.ts | 15 +++ .../cacheOriginLinks-revalidation.spec.ts | 102 ++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts 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..970d9f5fbc41 --- /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'; + // Past `revalidate` (2s) a read serves the stale value and triggers a background refill; + // `expire` is long so the entry never hard-expires during the 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..33b3cd488aa6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Background stale-while-revalidate refills. Target behavior: the revalidation runs in its own +// trace (not grafted onto the serving trace), links back to the request that triggered it, and +// becomes the `cache_origin` for future hits. Not implemented yet — the test is `test.fail()`. + +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.fail(); + + 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' }, + }, + ]); + + // Refill work the visitor never waited for is not grafted onto 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, + // The link type for "revalidation triggered by" is not final yet; the target trace is. + attributes: { 'sentry.link.type': expect.any(String) }, + }, + ]); + + const revalidationPutSpan = revalidationTx.spans?.find(span => span.op === 'cache.put'); + expect(revalidationPutSpan).toBeDefined(); + + // The revalidation becomes the 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..970d9f5fbc41 --- /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'; + // Past `revalidate` (2s) a read serves the stale value and triggers a background refill; + // `expire` is long so the entry never hard-expires during the 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..b4fd33222501 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts @@ -0,0 +1,102 @@ +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 runs in its own +// trace (not grafted onto the serving trace), links back to the request that triggered it, and +// becomes the `cache_origin` for future hits. Not implemented yet — the test is `test.fail()`. + +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.fail(); + + 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) + ); + }); + + // Refill work the visitor never waited for is not grafted onto 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, + // The link type for "revalidation triggered by" is not final yet; the target trace is. + attributes: { 'sentry.link.type': { value: expect.any(String), type: 'string' } }, + }, + ]); + + const revalidationPutSpan = findCacheSpan(revalidationSpans, 'cache.put'); + expect(revalidationPutSpan).toBeDefined(); + + // The revalidation becomes the 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, + }, + ]); +});