From 54718ffd70a051941827b668b3dc050c728cfa7f Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:39:26 +0200 Subject: [PATCH 1/5] feat(nextjs): Add `cache_origin` span links to `use cache` hit spans --- .../tests/cacheOriginLinks-nesting.spec.ts | 14 +-- .../tests/cacheOriginLinks-page.spec.ts | 11 +- .../cacheOriginLinks-routeHandler.spec.ts | 10 +- .../tests/cacheOriginLinks-nesting.spec.ts | 14 +-- .../tests/cacheOriginLinks-page.spec.ts | 11 +- .../cacheOriginLinks-routeHandler.spec.ts | 10 +- .../src/server/useCacheInstrumentation.ts | 93 +++++++++++++--- .../server/useCacheInstrumentation.test.ts | 103 +++++++++++++++++- 8 files changed, 197 insertions(+), 69 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index 5025d7d2e104..9c6245a06f5c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -3,15 +3,12 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. -// Not implemented yet — every test is `test.fail()` with the final expected assertions. // A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] -// param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the -// cache handlers, there is no `cache.get` span at all — then this stays failing until Next -// exposes RDC reads. +// param. These runtime-filled entries are read through the cache handlers here; entries served +// from the prerendered shell (Resume Data Cache) would produce no `cache.get` span at all until +// Next exposes RDC reads. test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -56,8 +53,6 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -101,7 +96,6 @@ test('links a cached leaf component under dynamic layouts to the trace that fill // spans point at two different origin traces. test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); @@ -180,8 +174,6 @@ test('links two cached levels to different origin traces after the layout expire test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts index a0acf25df078..6d3f36e297d0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -4,15 +4,13 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; // Origin links for `use cache` inside rendered pages (cached components and nested cached // functions). 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. Not implemented yet — every test is `test.fail()`. +// the entry. // Two cached sibling components are two cache entries (props are part of the key), so one request -// carries one `cache.get` hit span per section, each linking to its own fill. The components sit -// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach -// the cache handlers and produce no spans until Next.js exposes RDC reads. +// carries one `cache.get` hit span per section, each linking to its own fill. These runtime-filled entries are read +// through the cache handlers; entries served from the prerendered shell (Resume Data Cache) would +// produce no spans at all until Next.js exposes RDC reads. test('links each sibling component hit to the fill of its own entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -65,7 +63,6 @@ test('links each sibling component hit to the fill of its own entry', async ({ r test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); 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 index 2f604f0402f3..e1e7a468f7cd 100644 --- 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 @@ -1,14 +1,11 @@ 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. +// Origin links for `use cache` in route handlers: 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. 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(); @@ -55,7 +52,6 @@ test('links a route handler cache hit to the trace that filled the entry', async 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(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index d456686f24ec..abd984c429dc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -4,15 +4,12 @@ import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks- // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. -// Not implemented yet — every test is `test.fail()` with the final expected assertions. // A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] -// param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the -// cache handlers, there is no `cache.get` span at all — then this stays failing until Next -// exposes RDC reads. +// param. These runtime-filled entries are read through the cache handlers here; entries served +// from the prerendered shell (Resume Data Cache) would produce no `cache.get` span at all until +// Next exposes RDC reads. test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -57,8 +54,6 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -102,7 +97,6 @@ test('links a cached leaf component under dynamic layouts to the trace that fill // spans point at two different origin traces. test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); @@ -185,8 +179,6 @@ test('links two cached levels to different origin traces after the layout expire test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts index fd8a86a5c848..0bac1aeec817 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -5,15 +5,13 @@ import { CACHE_ORIGIN_LINK_ATTRIBUTES } from './cacheOriginLinks-utils'; // Origin links for `use cache` inside rendered pages (cached components and nested cached // functions). 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. Not implemented yet — every test is `test.fail()`. +// the entry. // Two cached sibling components are two cache entries (props are part of the key), so one request -// carries one `cache.get` hit span per section, each linking to its own fill. The components sit -// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach -// the cache handlers and produce no spans until Next.js exposes RDC reads. +// carries one `cache.get` hit span per section, each linking to its own fill. These runtime-filled entries are read +// through the cache handlers; entries served from the prerendered shell (Resume Data Cache) would +// produce no spans at all until Next.js exposes RDC reads. test('links each sibling component hit to the fill of its own entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -70,7 +68,6 @@ test('links each sibling component hit to the fill of its own entry', async ({ r test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); 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 index 09148406915d..cf1d5351d49b 100644 --- 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 @@ -2,14 +2,11 @@ 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. +// Origin links for `use cache` in route handlers: 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. 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(); @@ -56,7 +53,6 @@ test('links a route handler cache hit to the trace that filled the entry', async 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(); diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index 124e2c8b13ca..af75f3f2dcd4 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -1,7 +1,15 @@ import { createHash } from 'node:crypto'; -import { CACHE_ITEM_AGE, CACHE_OPERATION, CACHE_TAGS, CACHE_TTL } from '@sentry/conventions/attributes'; +import { + CACHE_HIT, + CACHE_ITEM_AGE, + CACHE_KEY, + CACHE_OPERATION, + CACHE_TAGS, + CACHE_TTL, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT } from '@sentry/conventions/op'; -import type { Span } from '@sentry/core'; +import type { Span, SpanContextData } from '@sentry/core'; import { CACHE_OPERATION_NAMES, debug, @@ -11,9 +19,8 @@ import { getClient, hasSpanStreamingEnabled, hasSpansEnabled, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + LRUMap, + SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanIsSampled, startSpan, timestampInSeconds, @@ -27,9 +34,15 @@ const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); +const SENTRY_CACHE_ORIGINS = Symbol.for('sentry.nextjs.cacheOrigins'); const INTEGRATION_NAME = 'NextjsUseCache'; const CACHE_SPAN_ORIGIN = 'auto.cache.nextjs'; +const CACHE_ORIGIN_LINK_TYPE = 'cache_origin'; + +// Hard memory bound for remembered fill origins: 2000 entries of a 12-char digest plus one span +// context stay well under 1 MB. +const CACHE_ORIGINS_MAX_SIZE = 2_000; // Next.js' `INFINITE_CACHE` sentinel. An `expire` at or above it means "never expires", which carries no signal as a TTL attribute. // https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/lib/constants.ts#L43-L46 @@ -62,6 +75,7 @@ type GlobalWithCacheHandlers = typeof globalThis & { [NEXT_PRIVATE_CACHE_HANDLER]?: UseCacheHandler; [SENTRY_CACHE_INSTRUMENTED]?: boolean; [SENTRY_WRAPPED_HANDLERS]?: WeakSet; + [SENTRY_CACHE_ORIGINS]?: LRUMap; }; /** @@ -82,9 +96,8 @@ function shouldRecordCacheSpan(): boolean { return !!activeSpan && spanIsSampled(activeSpan); } -function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: string, callback: (span: Span) => T): T { +function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, digest: string, callback: (span: Span) => T): T { const client = getClient(); - const digest = keyDigest(cacheKey); return startSpan( { @@ -92,8 +105,8 @@ function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: st name: client && hasSpanStreamingEnabled(client) ? op : digest, op, attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: [digest], + [SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN, + [CACHE_KEY]: [digest], [CACHE_OPERATION]: CACHE_OPERATION_NAMES[op], }, }, @@ -115,18 +128,19 @@ function isExpired(ageMs: number | undefined, expire: number | undefined): boole /** * A missing entry is a miss. Next.js' default handler also returns no entry for expired, evicted, - * or tag-invalidated entries, so those count as misses too. + * or tag-invalidated entries, so those count as misses too. Returns whether the read was a hit. */ -function setEntryAttributes(span: Span, entry: unknown): void { +function recordCacheEntry(span: Span, entry: unknown): boolean { if (entry === undefined) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, false); - return; + span.setAttribute(CACHE_HIT, false); + return false; } const { timestamp, expire, tags } = (entry ?? {}) as UseCacheEntry; const ageMs = typeof timestamp === 'number' ? timestampInSeconds() * 1000 - timestamp : undefined; - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isExpired(ageMs, expire)); + const hit = !isExpired(ageMs, expire); + span.setAttribute(CACHE_HIT, hit); if (ageMs !== undefined) { // Clamped: with a remote handler, the filling and the reading machine's clocks can drift. @@ -140,6 +154,32 @@ function setEntryAttributes(span: Span, entry: unknown): void { if (stringTags.length > 0) { span.setAttribute(CACHE_TAGS, stringTags); } + return hit; +} + +// Cache origins are scoped per-process (known limitation: no links across server instances — that would require storing metadata on `CacheEntry`). +// Hits on entries filled by another instance get no link. Lives on `globalThis` like the rest of the instrumentation state. +function getCacheOrigins(): LRUMap { + const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; + if (!globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]) { + globalWithCacheHandlers[SENTRY_CACHE_ORIGINS] = new LRUMap(CACHE_ORIGINS_MAX_SIZE); + } + return globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]; +} + +/** + * Links a cache hit to the fill that produced the entry. When the fill is unknown (process + * restart, table eviction, or an entry filled by another instance of a shared cache), the hit + * span carries no link — a wrong link is worse than no link. + */ +function linkCacheOrigin(span: Span, originKey: string): void { + const origin = getCacheOrigins().get(originKey); + if (origin) { + span.addLink({ + context: origin, + attributes: { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE }, + }); + } } function isCacheHandler(value: unknown): value is UseCacheHandler { @@ -160,6 +200,10 @@ function getWrappedHandlers(): WeakSet { return globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS]; } +// One origin namespace per wrapped handler, so identical cache keys in different cache stores +// (default, remote, private) can never link across stores. +let wrappedHandlerCount = 0; + function instrumentHandler(handler: unknown): void { // Runs inside Next.js' handler registration, which must never fail because of Sentry. try { @@ -168,17 +212,21 @@ function instrumentHandler(handler: unknown): void { return; } wrappedHandlers.add(handler); + const originKeyPrefix = `${wrappedHandlerCount++}:`; fill(handler, 'get', (originalGet: UseCacheHandler['get']) => { return function (this: UseCacheHandler, cacheKey: string, softTags?: string[]): Promise { if (!shouldRecordCacheSpan()) { return originalGet.call(this, cacheKey, softTags); } - return startCacheSpan(CACHE_GET, cacheKey, span => + const digest = keyDigest(cacheKey); + return startCacheSpan(CACHE_GET, digest, span => // `Promise.resolve` because custom handlers may return the entry synchronously. Promise.resolve(originalGet.call(this, cacheKey, softTags)).then(entry => { try { - setEntryAttributes(span, entry); + if (recordCacheEntry(span, entry)) { + linkCacheOrigin(span, originKeyPrefix + digest); + } } catch (error) { DEBUG_BUILD && debug.warn('Failed to read Next.js cache entry metadata', error); } @@ -193,9 +241,17 @@ function instrumentHandler(handler: unknown): void { if (!shouldRecordCacheSpan()) { return originalSet.call(this, cacheKey, pendingEntry); } + const digest = keyDigest(cacheKey); // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this // span covers producing and storing the entry, not just the write. - return startCacheSpan(CACHE_PUT, cacheKey, () => originalSet.call(this, cacheKey, pendingEntry)); + return startCacheSpan(CACHE_PUT, digest, span => + // Only successful writes are remembered as fill origins: a failed write leaves either + // no entry (the origin is never read) or the previous entry (whose origin still stands). + Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { + getCacheOrigins().set(originKeyPrefix + digest, span.spanContext()); + return result; + }), + ); }; }); } catch (error) { @@ -275,7 +331,8 @@ export function _instrumentUseCacheHandlers(): void { /** * Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function - * reads and fills show up in traces with hit/miss information. + * reads and fills show up in traces with hit/miss information. A hit additionally carries a + * `cache_origin` span link to the `cache.put` span of the trace that filled the entry. */ export const nextjsUseCacheIntegration = defineIntegration(() => { return { diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 0534725ab05b..710e86d12ee2 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -3,9 +3,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const setAttribute = vi.fn(); + const addLink = vi.fn(); + const state = { spanCount: 0 }; return { setAttribute, - startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => callback({ setAttribute })), + addLink, + state, + startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => { + const n = ++state.spanCount; + const spanContext = { traceId: `trace-${n}`, spanId: `span-${n}`, traceFlags: 1 }; + return callback({ setAttribute, addLink, spanContext: () => spanContext }); + }), activeSpan: undefined as object | undefined, sampled: true, }; @@ -25,6 +33,7 @@ const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); +const SENTRY_CACHE_ORIGINS = Symbol.for('sentry.nextjs.cacheOrigins'); function createHandler(entry?: unknown) { return { @@ -53,6 +62,7 @@ describe('instrumentUseCacheHandlers', () => { beforeEach(() => { mocks.activeSpan = {}; mocks.sampled = true; + mocks.state.spanCount = 0; }); afterEach(() => { @@ -65,6 +75,7 @@ describe('instrumentUseCacheHandlers', () => { NEXT_PRIVATE_CACHE_HANDLER, SENTRY_CACHE_INSTRUMENTED, SENTRY_WRAPPED_HANDLERS, + SENTRY_CACHE_ORIGINS, ]) { Reflect.deleteProperty(globalThis, symbol); } @@ -280,6 +291,96 @@ describe('instrumentUseCacheHandlers', () => { }); }); + describe('origin links', () => { + it('links a cache hit to the `cache.put` span of the fill', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.resolve({})); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledTimes(1); + expect(mocks.addLink).toHaveBeenCalledWith({ + context: { traceId: 'trace-1', spanId: 'span-1', traceFlags: 1 }, + attributes: { 'sentry.link.type': 'cache_origin' }, + }); + }); + + it('links to the most recent fill', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.resolve({})); + await handler.set('cache-key', Promise.resolve({})); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledWith( + expect.objectContaining({ context: { traceId: 'trace-2', spanId: 'span-2', traceFlags: 1 } }), + ); + }); + + it('does not link a miss', async () => { + const handler = installWithDefaultHandler(undefined); + + await handler.set('cache-key', Promise.resolve({})); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit whose fill is unknown', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit to a fill of the same cache key in a different handler', async () => { + const entry = { timestamp: nowMs() }; + const defaultHandler = createHandler(entry); + const remoteHandler = createHandler(entry); + setGlobal( + NEXT_CACHE_HANDLERS_MAP, + new Map([ + ['default', defaultHandler], + ['remote', remoteHandler], + ]), + ); + _instrumentUseCacheHandlers(); + + await defaultHandler.set('cache-key', Promise.resolve({})); + await remoteHandler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + + await defaultHandler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledTimes(1); + }); + + it('does not link a hit that was filled under a different cache key', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('other-key', Promise.resolve({})); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose write failed', async () => { + const handler = { + get: vi.fn(() => Promise.resolve({ timestamp: nowMs() })), + set: vi.fn(() => Promise.reject(new Error('write failed'))), + }; + setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); + _instrumentUseCacheHandlers(); + + await expect(handler.set('cache-key', Promise.resolve({}))).rejects.toThrow('write failed'); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + }); + it('creates a `cache.put` span around handler writes', async () => { const handler = installWithDefaultHandler(); From 2511f5abe2aa93c6d55e9677b5dbec54a732d1d9 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:17:24 +0200 Subject: [PATCH 2/5] feat(nextjs): Add `cache_origin` span links to `use cache` hit spans --- .../src/server/useCacheInstrumentation.ts | 27 +++++++++----- .../server/useCacheInstrumentation.test.ts | 35 +++++++++++++++++-- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index af75f3f2dcd4..12571feb72dc 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -168,9 +168,8 @@ function getCacheOrigins(): LRUMap { } /** - * Links a cache hit to the fill that produced the entry. When the fill is unknown (process - * restart, table eviction, or an entry filled by another instance of a shared cache), the hit - * span carries no link — a wrong link is worse than no link. + * Links a cache hit to the fill that produced the entry. + * When the fill is unknown (process restart, table eviction, entry filled by another instance), the hit span carries no link */ function linkCacheOrigin(span: Span, originKey: string): void { const origin = getCacheOrigins().get(originKey); @@ -238,17 +237,29 @@ function instrumentHandler(handler: unknown): void { fill(handler, 'set', (originalSet: UseCacheHandler['set']) => { return function (this: UseCacheHandler, cacheKey: string, pendingEntry: Promise): Promise { + const digest = keyDigest(cacheKey); + + // A successful write replaces the entry, so a remembered origin from a previous fill is now wrong. + // An unsampled fill has no span to link to -> remember nothing instead. if (!shouldRecordCacheSpan()) { - return originalSet.call(this, cacheKey, pendingEntry); + return Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { + getCacheOrigins().remove(originKeyPrefix + digest); + return result; + }); } - const digest = keyDigest(cacheKey); + // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this // span covers producing and storing the entry, not just the write. return startCacheSpan(CACHE_PUT, digest, span => // Only successful writes are remembered as fill origins: a failed write leaves either // no entry (the origin is never read) or the previous entry (whose origin still stands). Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { - getCacheOrigins().set(originKeyPrefix + digest, span.spanContext()); + if (span.isRecording()) { + getCacheOrigins().set(originKeyPrefix + digest, span.spanContext()); + } else { + // The `cache.put` span was dropped (e.g. via `ignoreSpans`) and never reaches Sentry, so a link to it would be unnecessary. + getCacheOrigins().remove(originKeyPrefix + digest); + } return result; }), ); @@ -331,8 +342,8 @@ export function _instrumentUseCacheHandlers(): void { /** * Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function - * reads and fills show up in traces with hit/miss information. A hit additionally carries a - * `cache_origin` span link to the `cache.put` span of the trace that filled the entry. + * reads and fills show up in traces with hit/miss information. + * A hit also adds a `cache_origin` span link to the `cache.put` span of the trace that filled the entry. */ export const nextjsUseCacheIntegration = defineIntegration(() => { return { diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 710e86d12ee2..c48b7956b933 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const setAttribute = vi.fn(); const addLink = vi.fn(); - const state = { spanCount: 0 }; + const state = { spanCount: 0, recording: true }; return { setAttribute, addLink, @@ -12,7 +12,8 @@ const mocks = vi.hoisted(() => { startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => { const n = ++state.spanCount; const spanContext = { traceId: `trace-${n}`, spanId: `span-${n}`, traceFlags: 1 }; - return callback({ setAttribute, addLink, spanContext: () => spanContext }); + const recording = state.recording; + return callback({ setAttribute, addLink, spanContext: () => spanContext, isRecording: () => recording }); }), activeSpan: undefined as object | undefined, sampled: true, @@ -63,6 +64,7 @@ describe('instrumentUseCacheHandlers', () => { mocks.activeSpan = {}; mocks.sampled = true; mocks.state.spanCount = 0; + mocks.state.recording = true; }); afterEach(() => { @@ -366,6 +368,35 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); + it('forgets a remembered origin when the entry is refilled without a sampled parent span', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.resolve({})); + + mocks.activeSpan = undefined; + await handler.set('cache-key', Promise.resolve({})); + mocks.activeSpan = {}; + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('forgets a remembered origin when the refill `cache.put` span is not recording', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.resolve({})); + + // e.g. the `cache.put` op is filtered via `ignoreSpans` + mocks.state.recording = false; + await handler.set('cache-key', Promise.resolve({})); + mocks.state.recording = true; + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + it('does not remember fills whose write failed', async () => { const handler = { get: vi.fn(() => Promise.resolve({ timestamp: nowMs() })), From 5806bac58180ddb9b58a367521349dbfdeedeaac Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:21:17 +0200 Subject: [PATCH 3/5] reword to 'broken' --- packages/nextjs/src/server/useCacheInstrumentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index 12571feb72dc..82b9509fdff1 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -257,7 +257,7 @@ function instrumentHandler(handler: unknown): void { if (span.isRecording()) { getCacheOrigins().set(originKeyPrefix + digest, span.spanContext()); } else { - // The `cache.put` span was dropped (e.g. via `ignoreSpans`) and never reaches Sentry, so a link to it would be unnecessary. + // The `cache.put` span was dropped (e.g. via `ignoreSpans`) and never reaches Sentry, so a link to it would be broken. getCacheOrigins().remove(originKeyPrefix + digest); } return result; From 916712fa23ae8e67fc51bc9c1bad9c489777a001 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:20:10 +0200 Subject: [PATCH 4/5] review suggestion --- .../src/server/useCacheInstrumentation.ts | 67 +++++++++++------ .../server/useCacheInstrumentation.test.ts | 74 ++++++++++++++----- 2 files changed, 100 insertions(+), 41 deletions(-) diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index 82b9509fdff1..399b00722877 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -75,9 +75,15 @@ type GlobalWithCacheHandlers = typeof globalThis & { [NEXT_PRIVATE_CACHE_HANDLER]?: UseCacheHandler; [SENTRY_CACHE_INSTRUMENTED]?: boolean; [SENTRY_WRAPPED_HANDLERS]?: WeakSet; - [SENTRY_CACHE_ORIGINS]?: LRUMap; + [SENTRY_CACHE_ORIGINS]?: LRUMap; }; +interface CacheOrigin { + context: SpanContextData; + /** The fill's `CacheEntry.timestamp`. Identifies the entry revision this origin wrote. */ + entryTimestamp: number; +} + /** * Cache keys are long serialized payloads (function id + arguments), so spans carry a digest * instead. This bounds span size, avoids leaking user data, and still groups identical keys. @@ -159,7 +165,7 @@ function recordCacheEntry(span: Span, entry: unknown): boolean { // Cache origins are scoped per-process (known limitation: no links across server instances — that would require storing metadata on `CacheEntry`). // Hits on entries filled by another instance get no link. Lives on `globalThis` like the rest of the instrumentation state. -function getCacheOrigins(): LRUMap { +function getCacheOrigins(): LRUMap { const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; if (!globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]) { globalWithCacheHandlers[SENTRY_CACHE_ORIGINS] = new LRUMap(CACHE_ORIGINS_MAX_SIZE); @@ -168,19 +174,44 @@ function getCacheOrigins(): LRUMap { } /** - * Links a cache hit to the fill that produced the entry. - * When the fill is unknown (process restart, table eviction, entry filled by another instance), the hit span carries no link + * Links a cache hit to the fill that wrote this entry revision (matched by timestamp). + * A hit with no matching origin (unsampled or dropped refill, another instance, process restart) + * gets no link instead of a stale one. */ -function linkCacheOrigin(span: Span, originKey: string): void { +function linkCacheOrigin(span: Span, originKey: string, entry: unknown): void { const origin = getCacheOrigins().get(originKey); - if (origin) { + const { timestamp } = (entry ?? {}) as UseCacheEntry; + if (origin && origin.entryTimestamp === timestamp) { span.addLink({ - context: origin, + context: origin.context, attributes: { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE }, }); } } +/** + * Remembers the `cache.put` span as the origin of the entry revision it wrote. The handler drains + * `pendingEntry` before the write resolves, so the read here resolves immediately. + * An entry without a numeric `timestamp` has no revision identity and is not remembered. + */ +function rememberCacheOrigin(originKey: string, span: Span, pendingEntry: Promise): void { + Promise.resolve(pendingEntry).then( + entry => { + try { + const { timestamp } = (entry ?? {}) as UseCacheEntry; + if (typeof timestamp === 'number') { + getCacheOrigins().set(originKey, { context: span.spanContext(), entryTimestamp: timestamp }); + } + } catch (error) { + DEBUG_BUILD && debug.warn('Failed to remember a Next.js cache fill origin', error); + } + }, + () => { + // A rejected entry was never stored, so there is nothing to remember. + }, + ); +} + function isCacheHandler(value: unknown): value is UseCacheHandler { return ( typeof value === 'object' && @@ -224,7 +255,7 @@ function instrumentHandler(handler: unknown): void { Promise.resolve(originalGet.call(this, cacheKey, softTags)).then(entry => { try { if (recordCacheEntry(span, entry)) { - linkCacheOrigin(span, originKeyPrefix + digest); + linkCacheOrigin(span, originKeyPrefix + digest, entry); } } catch (error) { DEBUG_BUILD && debug.warn('Failed to read Next.js cache entry metadata', error); @@ -237,28 +268,20 @@ function instrumentHandler(handler: unknown): void { fill(handler, 'set', (originalSet: UseCacheHandler['set']) => { return function (this: UseCacheHandler, cacheKey: string, pendingEntry: Promise): Promise { - const digest = keyDigest(cacheKey); - - // A successful write replaces the entry, so a remembered origin from a previous fill is now wrong. - // An unsampled fill has no span to link to -> remember nothing instead. if (!shouldRecordCacheSpan()) { - return Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { - getCacheOrigins().remove(originKeyPrefix + digest); - return result; - }); + return originalSet.call(this, cacheKey, pendingEntry); } + const digest = keyDigest(cacheKey); // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this // span covers producing and storing the entry, not just the write. return startCacheSpan(CACHE_PUT, digest, span => - // Only successful writes are remembered as fill origins: a failed write leaves either - // no entry (the origin is never read) or the previous entry (whose origin still stands). + // Only a successful write becomes a fill origin: a failed write leaves no entry or the + // previous one (whose origin still stands). A dropped span (`ignoreSpans`) never + // reaches Sentry, so a link to it would be broken. Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { if (span.isRecording()) { - getCacheOrigins().set(originKeyPrefix + digest, span.spanContext()); - } else { - // The `cache.put` span was dropped (e.g. via `ignoreSpans`) and never reaches Sentry, so a link to it would be broken. - getCacheOrigins().remove(originKeyPrefix + digest); + rememberCacheOrigin(originKeyPrefix + digest, span, pendingEntry); } return result; }), diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 30dc33c5cecd..51037a67c005 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -331,9 +331,10 @@ describe('instrumentUseCacheHandlers', () => { describe('origin links', () => { it('links a cache hit to the `cache.put` span of the fill', async () => { - const handler = installWithDefaultHandler({ timestamp: nowMs() }); + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); - await handler.set('cache-key', Promise.resolve({})); + await handler.set('cache-key', Promise.resolve(entry)); await handler.get('cache-key'); expect(mocks.addLink).toHaveBeenCalledTimes(1); @@ -344,10 +345,11 @@ describe('instrumentUseCacheHandlers', () => { }); it('links to the most recent fill', async () => { - const handler = installWithDefaultHandler({ timestamp: nowMs() }); + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); - await handler.set('cache-key', Promise.resolve({})); - await handler.set('cache-key', Promise.resolve({})); + await handler.set('cache-key', Promise.resolve({ timestamp: entry.timestamp - 1_000 })); + await handler.set('cache-key', Promise.resolve(entry)); await handler.get('cache-key'); expect(mocks.addLink).toHaveBeenCalledWith( @@ -358,7 +360,7 @@ describe('instrumentUseCacheHandlers', () => { it('does not link a miss', async () => { const handler = installWithDefaultHandler(undefined); - await handler.set('cache-key', Promise.resolve({})); + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() })); await handler.get('cache-key'); expect(mocks.addLink).not.toHaveBeenCalled(); @@ -385,7 +387,7 @@ describe('instrumentUseCacheHandlers', () => { ); _instrumentUseCacheHandlers(); - await defaultHandler.set('cache-key', Promise.resolve({})); + await defaultHandler.set('cache-key', Promise.resolve(entry)); await remoteHandler.get('cache-key'); expect(mocks.addLink).not.toHaveBeenCalled(); @@ -396,21 +398,23 @@ describe('instrumentUseCacheHandlers', () => { }); it('does not link a hit that was filled under a different cache key', async () => { - const handler = installWithDefaultHandler({ timestamp: nowMs() }); + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); - await handler.set('other-key', Promise.resolve({})); + await handler.set('other-key', Promise.resolve(entry)); await handler.get('cache-key'); expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('forgets a remembered origin when the entry is refilled without a sampled parent span', async () => { - const handler = installWithDefaultHandler({ timestamp: nowMs() }); + it('does not link a hit on an entry refilled without a sampled parent span', async () => { + const refill = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(refill); - await handler.set('cache-key', Promise.resolve({})); + await handler.set('cache-key', Promise.resolve({ timestamp: refill.timestamp - 1_000 })); mocks.activeSpan = undefined; - await handler.set('cache-key', Promise.resolve({})); + await handler.set('cache-key', Promise.resolve(refill)); mocks.activeSpan = {}; await handler.get('cache-key'); @@ -418,14 +422,15 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); - it('forgets a remembered origin when the refill `cache.put` span is not recording', async () => { - const handler = installWithDefaultHandler({ timestamp: nowMs() }); + it('does not link a hit on an entry whose refill `cache.put` span is not recording', async () => { + const refill = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(refill); - await handler.set('cache-key', Promise.resolve({})); + await handler.set('cache-key', Promise.resolve({ timestamp: refill.timestamp - 1_000 })); // e.g. the `cache.put` op is filtered via `ignoreSpans` mocks.state.recording = false; - await handler.set('cache-key', Promise.resolve({})); + await handler.set('cache-key', Promise.resolve(refill)); mocks.state.recording = true; await handler.get('cache-key'); @@ -433,15 +438,46 @@ describe('instrumentUseCacheHandlers', () => { expect(mocks.addLink).not.toHaveBeenCalled(); }); + it('does not link a hit on an entry refilled by another server instance', async () => { + const entryFromOtherInstance = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entryFromOtherInstance); + + await handler.set('cache-key', Promise.resolve({ timestamp: entryFromOtherInstance.timestamp - 1_000 })); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link hits on entries that carry no fill timestamp', async () => { + const entry = { expire: 3_600 }; + const handler = installWithDefaultHandler(entry); + + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose entry rejected even though the write resolved', async () => { + // Custom handlers can swallow a failed entry and resolve the write anyway. + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.reject(new Error('entry failed'))); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + it('does not remember fills whose write failed', async () => { + const entry = { timestamp: nowMs() }; const handler = { - get: vi.fn(() => Promise.resolve({ timestamp: nowMs() })), + get: vi.fn(() => Promise.resolve(entry)), set: vi.fn(() => Promise.reject(new Error('write failed'))), }; setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); _instrumentUseCacheHandlers(); - await expect(handler.set('cache-key', Promise.resolve({}))).rejects.toThrow('write failed'); + await expect(handler.set('cache-key', Promise.resolve(entry))).rejects.toThrow('write failed'); await handler.get('cache-key'); expect(mocks.addLink).not.toHaveBeenCalled(); From f85ca1c978d76d7c900704b8eab94081f7070444 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:24:42 +0200 Subject: [PATCH 5/5] add changelog item --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fbf2d36a92d..153b331fece2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, @andasan, @breken-ai, and @EmileBrunelle. Thank you for your contributions! +### Important Changes + +- **feat(nextjs): Add `cache_origin` span links to `use cache` hit spans ([#24821](https://github.com/getsentry/sentry-javascript/pull/24821))** + + A `cache.get` span for a `"use cache"` hit now carries a span link (`sentry.link.type: 'cache_origin'`) to the `cache.put` span of the request that filled the cache entry, connecting the trace that reads a cached value to the trace that produced it. + ## 11.1.0 ### Important Changes