diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx
new file mode 100644
index 000000000000..a61f15ee7e3f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx
@@ -0,0 +1,25 @@
+import type { ReactNode } from 'react';
+import { cacheLife } from 'next/cache';
+
+// The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss
+// and the entry cannot come from a build-time fill. `children` passes through as an uncached hole.
+export default async function CachedMidLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ id: string }>;
+}) {
+ 'use cache';
+ cacheLife('hours');
+ const { id } = await params;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return (
+
+
+ {id}:{Date.now()}
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx
new file mode 100644
index 000000000000..94196e6e5ee8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx
@@ -0,0 +1,6 @@
+import { headers } from 'next/headers';
+
+export default async function Page() {
+ await headers();
+ return Dynamic leaf: {Date.now()}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx
new file mode 100644
index 000000000000..8e7de36d156a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx
@@ -0,0 +1,17 @@
+import { cacheLife } from 'next/cache';
+
+async function CachedLeaf({ id }: { id: string }) {
+ 'use cache';
+ cacheLife('hours');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return (
+
+ {id}:{Date.now()}
+
+ );
+}
+
+export default async function Page({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return ;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx
new file mode 100644
index 000000000000..412587fd8f33
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx
@@ -0,0 +1,7 @@
+import type { ReactNode } from 'react';
+import { headers } from 'next/headers';
+
+export default async function DynamicLayout({ children }: { children: ReactNode }) {
+ await headers();
+ return {children}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx
new file mode 100644
index 000000000000..84a6395b39ac
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx
@@ -0,0 +1,11 @@
+import { Suspense, type ReactNode } from 'react';
+
+// The Suspense boundary lets the nested layouts/pages below use request APIs (`headers()`,
+// runtime params) without tripping the Cache Components prerender guards.
+export default function Layout({ children }: { children: ReactNode }) {
+ return (
+
+ Loading...
}>{children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx
new file mode 100644
index 000000000000..2b0b77b6e3a9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+import { cacheLife } from 'next/cache';
+
+// Hard-expires after 2s while the page's cached component lives for hours, so one request can
+// refill the layout while hitting the component: two cached levels with different origin traces.
+export default async function ShortLivedLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ id: string }>;
+}) {
+ 'use cache';
+ cacheLife({ revalidate: 1, expire: 2 });
+ const { id } = await params;
+ return (
+
+
+ {id}:{Date.now()}
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx
new file mode 100644
index 000000000000..18913cbe9790
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx
@@ -0,0 +1,19 @@
+import { headers } from 'next/headers';
+import { cacheLife } from 'next/cache';
+
+async function LongLivedComponent({ id }: { id: string }) {
+ 'use cache';
+ cacheLife('hours');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return (
+
+ {id}:{Date.now()}
+
+ );
+}
+
+export default async function Page({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ await headers();
+ return ;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx
new file mode 100644
index 000000000000..ec8d5e21d947
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx
@@ -0,0 +1,6 @@
+import { headers } from 'next/headers';
+
+export default async function Page() {
+ await headers();
+ return Route a: {Date.now()}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx
new file mode 100644
index 000000000000..9a1b84b2ca28
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx
@@ -0,0 +1,6 @@
+import { headers } from 'next/headers';
+
+export default async function Page() {
+ await headers();
+ return Route b: {Date.now()}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx
new file mode 100644
index 000000000000..220fdcf92ca8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+import { cacheLife } from 'next/cache';
+
+// One cache entry (keyed by id) shared by the sibling routes `a` and `b` below.
+export default async function SharedLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ id: string }>;
+}) {
+ 'use cache';
+ cacheLife('hours');
+ const { id } = await params;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return (
+
+
+ {id}:{Date.now()}
+
+ {children}
+
+ );
+}
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
new file mode 100644
index 000000000000..5025d7d2e104
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts
@@ -0,0 +1,221 @@
+import { expect, test } from '@playwright/test';
+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.
+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 => {
+ return (
+ transactionEvent.transaction === 'GET /cached-mid-layout/[id]' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false)
+ );
+ });
+
+ const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /cached-mid-layout/[id]' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
+ );
+ });
+
+ await request.get(`/cached-mid-layout/${id}`);
+ const missTx = await missTxPromise;
+
+ await request.get(`/cached-mid-layout/${id}`);
+ const hitTx = await hitTxPromise;
+
+ // The layout is the only cached entry on this route.
+ const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put');
+ expect(new Set(putSpans.map(span => span.description)).size).toBe(1);
+
+ const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true);
+ expect(hitGetSpan).toBeDefined();
+ expect(hitGetSpan?.description).toBe(putSpans[0]!.description);
+ expect(hitGetSpan?.links).toEqual([
+ {
+ trace_id: missTx.contexts?.trace?.trace_id,
+ span_id: putSpans[0]!.span_id,
+ sampled: true,
+ attributes: { 'sentry.link.type': 'cache_origin' },
+ },
+ ]);
+});
+
+// Inverse nesting: all layouts above are dynamic, only the leaf component is cached — the leaf
+// entry is the only span that carries a link.
+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 => {
+ return (
+ transactionEvent.transaction === 'GET /dynamic-layouts/[id]/layout-cached-leaf' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false)
+ );
+ });
+
+ const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /dynamic-layouts/[id]/layout-cached-leaf' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
+ );
+ });
+
+ await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`);
+ const missTx = await missTxPromise;
+
+ await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`);
+ const hitTx = await hitTxPromise;
+
+ const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put');
+ expect(new Set(putSpans.map(span => span.description)).size).toBe(1);
+
+ const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true);
+ expect(hitGetSpan).toBeDefined();
+ expect(hitGetSpan?.description).toBe(putSpans[0]!.description);
+ expect(hitGetSpan?.links).toEqual([
+ {
+ trace_id: missTx.contexts?.trace?.trace_id,
+ span_id: putSpans[0]!.span_id,
+ sampled: true,
+ attributes: { 'sentry.link.type': 'cache_origin' },
+ },
+ ]);
+});
+
+// Nested levels with different lifetimes: after the layout expired, request 2 refills the layout
+// while the component still hits. Request 3 then hits both entries, and its two `cache.get`
+// 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();
+
+ const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.put')
+ );
+ });
+
+ await request.get(`/mixed-lifetimes/${id}`);
+ const fillTx = await fillTxPromise;
+
+ // Layout + component entry.
+ const fillPutSpans = (fillTx.spans ?? []).filter(span => span.op === 'cache.put');
+ expect(new Set(fillPutSpans.map(span => span.description)).size).toBe(2);
+
+ // Sleep past the layout's `expire` (2s); the component entry stays valid for hours.
+ await new Promise(resolve => setTimeout(resolve, 3_000));
+
+ const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.put') &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
+ );
+ });
+
+ await request.get(`/mixed-lifetimes/${id}`);
+ const refillTx = await refillTxPromise;
+
+ const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' &&
+ (transactionEvent.spans?.filter(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true).length ??
+ 0) >= 2
+ );
+ });
+
+ await request.get(`/mixed-lifetimes/${id}`);
+ const hitTx = await hitTxPromise;
+
+ const layoutPutSpan = refillTx.spans?.find(span => span.op === 'cache.put');
+ expect(layoutPutSpan).toBeDefined();
+ const componentPutSpan = fillPutSpans.find(span => span.description !== layoutPutSpan!.description);
+ expect(componentPutSpan).toBeDefined();
+ expect(refillTx.contexts?.trace?.trace_id).not.toBe(fillTx.contexts?.trace?.trace_id);
+
+ const layoutHitSpan = hitTx.spans?.find(
+ span => span.op === 'cache.get' && span.description === layoutPutSpan!.description,
+ );
+ expect(layoutHitSpan?.links).toEqual([
+ {
+ trace_id: refillTx.contexts?.trace?.trace_id,
+ span_id: layoutPutSpan!.span_id,
+ sampled: true,
+ attributes: { 'sentry.link.type': 'cache_origin' },
+ },
+ ]);
+
+ const componentHitSpan = hitTx.spans?.find(
+ span => span.op === 'cache.get' && span.description === componentPutSpan!.description,
+ );
+ expect(componentHitSpan?.links).toEqual([
+ {
+ trace_id: fillTx.contexts?.trace?.trace_id,
+ span_id: componentPutSpan!.span_id,
+ sampled: true,
+ attributes: { 'sentry.link.type': 'cache_origin' },
+ },
+ ]);
+});
+
+// Routes `a` and `b` share one layout entry: a hit on `b` links to the fill trace of `a`, so the
+// origin is a different transaction than the serving one.
+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 => {
+ return (
+ transactionEvent.transaction === 'GET /shared-layout/[id]/a' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.put')
+ );
+ });
+
+ await request.get(`/shared-layout/${id}/a`);
+ const fillTx = await fillTxPromise;
+
+ const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => {
+ return (
+ transactionEvent.transaction === 'GET /shared-layout/[id]/b' &&
+ !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true)
+ );
+ });
+
+ await request.get(`/shared-layout/${id}/b`);
+ const hitTx = await hitTxPromise;
+
+ const putSpan = fillTx.spans?.find(span => span.op === 'cache.put');
+ expect(putSpan).toBeDefined();
+
+ const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true);
+ expect(hitGetSpan).toBeDefined();
+ expect(hitGetSpan?.description).toBe(putSpan!.description);
+ expect(hitGetSpan?.links).toEqual([
+ {
+ trace_id: fillTx.contexts?.trace?.trace_id,
+ span_id: putSpan!.span_id,
+ sampled: true,
+ attributes: { 'sentry.link.type': 'cache_origin' },
+ },
+ ]);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx
new file mode 100644
index 000000000000..a61f15ee7e3f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx
@@ -0,0 +1,25 @@
+import type { ReactNode } from 'react';
+import { cacheLife } from 'next/cache';
+
+// The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss
+// and the entry cannot come from a build-time fill. `children` passes through as an uncached hole.
+export default async function CachedMidLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ id: string }>;
+}) {
+ 'use cache';
+ cacheLife('hours');
+ const { id } = await params;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return (
+
+
+ {id}:{Date.now()}
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx
new file mode 100644
index 000000000000..94196e6e5ee8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx
@@ -0,0 +1,6 @@
+import { headers } from 'next/headers';
+
+export default async function Page() {
+ await headers();
+ return Dynamic leaf: {Date.now()}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx
new file mode 100644
index 000000000000..8e7de36d156a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx
@@ -0,0 +1,17 @@
+import { cacheLife } from 'next/cache';
+
+async function CachedLeaf({ id }: { id: string }) {
+ 'use cache';
+ cacheLife('hours');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return (
+
+ {id}:{Date.now()}
+
+ );
+}
+
+export default async function Page({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return ;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx
new file mode 100644
index 000000000000..412587fd8f33
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx
@@ -0,0 +1,7 @@
+import type { ReactNode } from 'react';
+import { headers } from 'next/headers';
+
+export default async function DynamicLayout({ children }: { children: ReactNode }) {
+ await headers();
+ return {children}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx
new file mode 100644
index 000000000000..84a6395b39ac
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx
@@ -0,0 +1,11 @@
+import { Suspense, type ReactNode } from 'react';
+
+// The Suspense boundary lets the nested layouts/pages below use request APIs (`headers()`,
+// runtime params) without tripping the Cache Components prerender guards.
+export default function Layout({ children }: { children: ReactNode }) {
+ return (
+
+ Loading...
}>{children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx
new file mode 100644
index 000000000000..2b0b77b6e3a9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+import { cacheLife } from 'next/cache';
+
+// Hard-expires after 2s while the page's cached component lives for hours, so one request can
+// refill the layout while hitting the component: two cached levels with different origin traces.
+export default async function ShortLivedLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ id: string }>;
+}) {
+ 'use cache';
+ cacheLife({ revalidate: 1, expire: 2 });
+ const { id } = await params;
+ return (
+
+
+ {id}:{Date.now()}
+
+ {children}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx
new file mode 100644
index 000000000000..18913cbe9790
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx
@@ -0,0 +1,19 @@
+import { headers } from 'next/headers';
+import { cacheLife } from 'next/cache';
+
+async function LongLivedComponent({ id }: { id: string }) {
+ 'use cache';
+ cacheLife('hours');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return (
+
+ {id}:{Date.now()}
+
+ );
+}
+
+export default async function Page({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ await headers();
+ return ;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx
new file mode 100644
index 000000000000..ec8d5e21d947
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx
@@ -0,0 +1,6 @@
+import { headers } from 'next/headers';
+
+export default async function Page() {
+ await headers();
+ return Route a: {Date.now()}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx
new file mode 100644
index 000000000000..9a1b84b2ca28
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx
@@ -0,0 +1,6 @@
+import { headers } from 'next/headers';
+
+export default async function Page() {
+ await headers();
+ return Route b: {Date.now()}
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx
new file mode 100644
index 000000000000..220fdcf92ca8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+import { cacheLife } from 'next/cache';
+
+// One cache entry (keyed by id) shared by the sibling routes `a` and `b` below.
+export default async function SharedLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ id: string }>;
+}) {
+ 'use cache';
+ cacheLife('hours');
+ const { id } = await params;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return (
+
+
+ {id}:{Date.now()}
+
+ {children}
+
+ );
+}
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
new file mode 100644
index 000000000000..d456686f24ec
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts
@@ -0,0 +1,226 @@
+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 (`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.
+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 => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /cached-mid-layout/[id]' && span.is_segment) &&
+ spansOfTrace.some(span => getSpanOp(span) === 'cache.put')
+ );
+ });
+
+ await request.get(`/cached-mid-layout/${id}`);
+ const missSpans = await missSpansPromise;
+
+ const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /cached-mid-layout/[id]' && span.is_segment) &&
+ spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true)
+ );
+ });
+
+ await request.get(`/cached-mid-layout/${id}`);
+ const hitSpans = await hitSpansPromise;
+
+ // The layout is the only cached entry on this route.
+ const putSpan = findCacheSpan(missSpans, 'cache.put');
+ expect(putSpan).toBeDefined();
+
+ const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true);
+ expect(hitGetSpan).toBeDefined();
+ expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']);
+ expect(hitGetSpan?.links).toEqual([
+ {
+ trace_id: putSpan!.trace_id,
+ span_id: putSpan!.span_id,
+ sampled: true,
+ attributes: CACHE_ORIGIN_LINK_ATTRIBUTES,
+ },
+ ]);
+});
+
+// Inverse nesting: all layouts above are dynamic, only the leaf component is cached — the leaf
+// entry is the only span that carries a link.
+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 => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && span.is_segment) &&
+ spansOfTrace.some(span => getSpanOp(span) === 'cache.put')
+ );
+ });
+
+ await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`);
+ const missSpans = await missSpansPromise;
+
+ const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && span.is_segment) &&
+ spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true)
+ );
+ });
+
+ await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`);
+ const hitSpans = await hitSpansPromise;
+
+ const putSpan = findCacheSpan(missSpans, 'cache.put');
+ expect(putSpan).toBeDefined();
+
+ const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true);
+ expect(hitGetSpan).toBeDefined();
+ expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']);
+ expect(hitGetSpan?.links).toEqual([
+ {
+ trace_id: putSpan!.trace_id,
+ span_id: putSpan!.span_id,
+ sampled: true,
+ attributes: CACHE_ORIGIN_LINK_ATTRIBUTES,
+ },
+ ]);
+});
+
+// Nested levels with different lifetimes: after the layout expired, request 2 refills the layout
+// while the component still hits. Request 3 then hits both entries, and its two `cache.get`
+// 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();
+
+ const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) &&
+ spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2
+ );
+ });
+
+ await request.get(`/mixed-lifetimes/${id}`);
+ const fillSpans = await fillSpansPromise;
+
+ // Layout + component entry.
+ const fillPutSpans = fillSpans.filter(span => getSpanOp(span) === 'cache.put');
+ expect(new Set(fillPutSpans.map(span => JSON.stringify(span.attributes['cache.key']?.value))).size).toBe(2);
+
+ // Sleep past the layout's `expire` (2s); the component entry stays valid for hours.
+ await new Promise(resolve => setTimeout(resolve, 3_000));
+
+ const refillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) &&
+ spansOfTrace.some(span => getSpanOp(span) === 'cache.put') &&
+ spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true)
+ );
+ });
+
+ await request.get(`/mixed-lifetimes/${id}`);
+ const refillSpans = await refillSpansPromise;
+
+ const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) &&
+ spansOfTrace.filter(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true)
+ .length >= 2
+ );
+ });
+
+ await request.get(`/mixed-lifetimes/${id}`);
+ const hitSpans = await hitSpansPromise;
+
+ const layoutPutSpan = findCacheSpan(refillSpans, 'cache.put');
+ expect(layoutPutSpan).toBeDefined();
+ const layoutKey = JSON.stringify(layoutPutSpan!.attributes['cache.key']?.value);
+ const componentPutSpan = fillPutSpans.find(span => JSON.stringify(span.attributes['cache.key']?.value) !== layoutKey);
+ expect(componentPutSpan).toBeDefined();
+ expect(layoutPutSpan!.trace_id).not.toBe(componentPutSpan!.trace_id);
+
+ const layoutHitSpan = hitSpans.find(
+ span => getSpanOp(span) === 'cache.get' && JSON.stringify(span.attributes['cache.key']?.value) === layoutKey,
+ );
+ expect(layoutHitSpan?.links).toEqual([
+ {
+ trace_id: layoutPutSpan!.trace_id,
+ span_id: layoutPutSpan!.span_id,
+ sampled: true,
+ attributes: CACHE_ORIGIN_LINK_ATTRIBUTES,
+ },
+ ]);
+
+ const componentHitSpan = hitSpans.find(
+ span =>
+ getSpanOp(span) === 'cache.get' &&
+ JSON.stringify(span.attributes['cache.key']?.value) ===
+ JSON.stringify(componentPutSpan!.attributes['cache.key']?.value),
+ );
+ expect(componentHitSpan?.links).toEqual([
+ {
+ trace_id: componentPutSpan!.trace_id,
+ span_id: componentPutSpan!.span_id,
+ sampled: true,
+ attributes: CACHE_ORIGIN_LINK_ATTRIBUTES,
+ },
+ ]);
+});
+
+// Routes `a` and `b` share one layout entry: a hit on `b` links to the fill trace of `a`, so the
+// origin is a different transaction than the serving one.
+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 => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /shared-layout/[id]/a' && span.is_segment) &&
+ spansOfTrace.some(span => getSpanOp(span) === 'cache.put')
+ );
+ });
+
+ await request.get(`/shared-layout/${id}/a`);
+ const fillSpans = await fillSpansPromise;
+
+ const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => {
+ return (
+ spansOfTrace.some(span => span.name === 'GET /shared-layout/[id]/b' && span.is_segment) &&
+ spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true)
+ );
+ });
+
+ await request.get(`/shared-layout/${id}/b`);
+ const hitSpans = await hitSpansPromise;
+
+ const putSpan = findCacheSpan(fillSpans, 'cache.put');
+ expect(putSpan).toBeDefined();
+
+ const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true);
+ expect(hitGetSpan).toBeDefined();
+ expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']);
+ expect(hitGetSpan?.links).toEqual([
+ {
+ trace_id: putSpan!.trace_id,
+ span_id: putSpan!.span_id,
+ sampled: true,
+ attributes: CACHE_ORIGIN_LINK_ATTRIBUTES,
+ },
+ ]);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts
new file mode 100644
index 000000000000..d43ce40324fb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts
@@ -0,0 +1,16 @@
+import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';
+import { getSpanOp } from '@sentry-internal/test-utils';
+
+export const CACHE_ORIGIN_LINK_ATTRIBUTES = {
+ 'sentry.link.type': { value: 'cache_origin', type: 'string' },
+};
+
+export function findCacheSpan(
+ spans: SerializedStreamedSpan[],
+ op: 'cache.get' | 'cache.put',
+ hit?: boolean,
+): SerializedStreamedSpan | undefined {
+ return spans.find(
+ span => getSpanOp(span) === op && (hit === undefined || span.attributes['cache.hit']?.value === hit),
+ );
+}