Skip to content

Commit 62db407

Browse files
committed
integration improvements and only enabled with spans
1 parent 0a7a61b commit 62db407

4 files changed

Lines changed: 366 additions & 174 deletions

File tree

packages/nextjs/src/server/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getRootSpan,
1212
getVercelEnv,
1313
GLOBAL_OBJ,
14+
hasSpansEnabled,
1415
} from '@sentry/core';
1516
import type { NodeClient, NodeOptions } from '@sentry/node';
1617
import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node';
@@ -28,7 +29,7 @@ import { createLiveRootSpanAdapter } from '../common/utils/liveRootSpanAdapter';
2829
import { enhanceHandleRequestRootSpan } from './enhanceHandleRequestRootSpan';
2930
import { handleOnSpanStart } from './handleOnSpanStart';
3031
import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext';
31-
import { instrumentUseCacheHandlers } from './useCacheInstrumentation';
32+
import { nextjsUseCacheIntegration } from './useCacheInstrumentation';
3233
import { maybeCompleteCronCheckIn } from './vercelCronsMonitoring';
3334
import { maybeCleanupQueueSpan } from './vercelQueuesMonitoring';
3435

@@ -142,6 +143,10 @@ export function init(options: NodeOptions): NodeClient | undefined {
142143
customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName }));
143144
}
144145

146+
if (hasSpansEnabled(options)) {
147+
customDefaultIntegrations.push(nextjsUseCacheIntegration());
148+
}
149+
145150
// Detect if running on OpenNext/Cloudflare and get runtime config
146151
const cloudflareConfig = getCloudflareRuntimeConfig();
147152

@@ -190,8 +195,6 @@ export function init(options: NodeOptions): NodeClient | undefined {
190195

191196
const client = nodeInit(opts);
192197

193-
instrumentUseCacheHandlers();
194-
195198
client?.on('beforeSampling', ({ spanAttributes }, samplingDecision) => {
196199
// There are situations where the Next.js Node.js server forwards requests for the Edge Runtime server (e.g. in
197200
// middleware) and this causes spans for Sentry ingest requests to be created. These are not exempt from our tracing

packages/nextjs/src/server/useCacheInstrumentation.ts

Lines changed: 121 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,45 @@ import { CACHE_ITEM_AGE, CACHE_OPERATION, CACHE_TAGS, CACHE_TTL } from '@sentry/
33
import { CACHE_GET, CACHE_PUT } from '@sentry/conventions/op';
44
import type { Span } from '@sentry/core';
55
import {
6-
_INTERNAL_safeDateNow,
76
CACHE_OPERATION_NAMES,
87
debug,
8+
defineIntegration,
99
fill,
10+
getActiveSpan,
1011
getClient,
1112
hasSpanStreamingEnabled,
1213
SEMANTIC_ATTRIBUTE_CACHE_HIT,
1314
SEMANTIC_ATTRIBUTE_CACHE_KEY,
1415
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
16+
spanIsSampled,
1517
startSpan,
18+
timestampInSeconds,
1619
} from '@sentry/core';
1720
import { DEBUG_BUILD } from '../common/debug-build';
1821

1922
// Next.js shares its `use cache` handlers across bundles via `globalThis`
2023
// (`next/src/server/use-cache/handlers.ts`). This module can load once per bundle, so all
21-
// double-wrap guards must also live on `globalThis` or on the handler objects.
24+
// double-wrap guards must also live on `globalThis`.
2225
const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map');
2326
const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private');
2427
const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented');
25-
const SENTRY_HANDLER_WRAPPED = Symbol.for('sentry.nextjs.wrappedCacheHandler');
28+
const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers');
2629

30+
const INTEGRATION_NAME = 'NextjsUseCache';
2731
const CACHE_SPAN_ORIGIN = 'auto.cache.nextjs';
2832

2933
// Next.js' `INFINITE_CACHE` sentinel. An `expire` at or above it means "never expires", which carries no signal as a TTL attribute.
3034
// https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/lib/constants.ts#L43-L46
3135
const NEXT_INFINITE_CACHE = 0xfffffffe;
3236

37+
// Next.js' `MIN_PRERENDERABLE_EXPIRE` (seconds). The dev server keeps entries at least this long,
38+
// even when their `expire` is shorter.
39+
const NEXT_DEV_MIN_EXPIRE = 300;
40+
3341
// Next.js vendored types below: https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/server/lib/cache-handlers/types.ts
3442

3543
// Subset of Next.js' `CacheHandler`
3644
interface UseCacheHandler {
37-
[SENTRY_HANDLER_WRAPPED]?: boolean;
3845
get(cacheKey: string, softTags?: string[]): Promise<unknown>;
3946
set(cacheKey: string, pendingEntry: Promise<unknown>): Promise<void>;
4047
}
@@ -46,13 +53,14 @@ interface UseCacheEntry {
4653
/** seconds; hard limit after which the entry is discarded on read */
4754
expire?: number;
4855
/** `cacheTag()` tags, excluding implicit soft tags */
49-
tags?: string[];
56+
tags?: unknown[];
5057
}
5158

5259
type GlobalWithCacheHandlers = typeof globalThis & {
5360
[NEXT_CACHE_HANDLERS_MAP]?: Map<string, UseCacheHandler>;
5461
[NEXT_PRIVATE_CACHE_HANDLER]?: UseCacheHandler;
5562
[SENTRY_CACHE_INSTRUMENTED]?: boolean;
63+
[SENTRY_WRAPPED_HANDLERS]?: WeakSet<object>;
5664
};
5765

5866
/**
@@ -63,19 +71,25 @@ function keyDigest(cacheKey: string): string {
6371
return createHash('sha1').update(cacheKey).digest('hex').slice(0, 12);
6472
}
6573

74+
/**
75+
* Cache reads can be hot, so all span work (including key hashing) is skipped without a sampled
76+
* parent span. Background revalidations still pass: they parent to the serving request's
77+
* (possibly already finished) root span.
78+
*/
79+
function shouldRecordCacheSpan(): boolean {
80+
const activeSpan = getActiveSpan();
81+
return !!activeSpan && spanIsSampled(activeSpan);
82+
}
83+
6684
function startCacheSpan<T>(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: string, callback: (span: Span) => T): T {
6785
const client = getClient();
6886
const digest = keyDigest(cacheKey);
6987

7088
return startSpan(
7189
{
72-
// low cardinality name for span streaming
90+
// low cardinality name for span streaming, so we can't fall back to the cache key
7391
name: client && hasSpanStreamingEnabled(client) ? op : digest,
7492
op,
75-
// Without an active parent span (e.g. a detached worker context), a cache span would become
76-
// its own orphan transaction. This does not filter background revalidations; those still
77-
// parent to the serving request's (possibly already finished) root span.
78-
onlyIfParent: true,
7993
attributes: {
8094
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN,
8195
[SEMANTIC_ATTRIBUTE_CACHE_KEY]: [digest],
@@ -87,9 +101,20 @@ function startCacheSpan<T>(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: st
87101
}
88102

89103
/**
90-
* `cache.hit` mirrors Next.js' use-cache wrapper: it discards entries past their hard `expire`
91-
* limit and re-runs the function, so those count as misses here. Tag-revalidation discards are
92-
* not detectable at the handler level and still count as hits.
104+
* Safety net for custom handlers that return entries past `expire`: Next.js discards those and
105+
* re-runs the function. Its default handler already returns no entry in that case.
106+
*/
107+
function isExpired(ageMs: number | undefined, expire: number | undefined): boolean {
108+
if (ageMs === undefined || typeof expire !== 'number') {
109+
return false;
110+
}
111+
const effectiveExpire = process.env.NODE_ENV === 'development' ? Math.max(expire, NEXT_DEV_MIN_EXPIRE) : expire;
112+
return ageMs > effectiveExpire * 1000;
113+
}
114+
115+
/**
116+
* A missing entry is a miss. Next.js' default handler also returns no entry for expired, evicted,
117+
* or tag-invalidated entries, so those count as misses too.
93118
*/
94119
function setEntryAttributes(span: Span, entry: unknown): void {
95120
if (entry === undefined) {
@@ -98,54 +123,83 @@ function setEntryAttributes(span: Span, entry: unknown): void {
98123
}
99124

100125
const { timestamp, expire, tags } = (entry ?? {}) as UseCacheEntry;
101-
const ageMs = typeof timestamp === 'number' ? _INTERNAL_safeDateNow() - timestamp : undefined;
102-
const isExpired = ageMs !== undefined && typeof expire === 'number' && ageMs > expire * 1000;
126+
const ageMs = typeof timestamp === 'number' ? timestampInSeconds() * 1000 - timestamp : undefined;
103127

104-
span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isExpired);
128+
span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isExpired(ageMs, expire));
105129

106130
if (ageMs !== undefined) {
107131
// Clamped: with a remote handler, the filling and the reading machine's clocks can drift.
108132
span.setAttribute(CACHE_ITEM_AGE, Math.max(0, Math.round(ageMs / 1000)));
109133
}
110-
if (typeof expire === 'number' && expire < NEXT_INFINITE_CACHE) {
134+
// A negative `expire` is Next.js' eviction sentinel, not a TTL.
135+
if (typeof expire === 'number' && expire >= 0 && expire < NEXT_INFINITE_CACHE) {
111136
span.setAttribute(CACHE_TTL, expire);
112137
}
113-
if (Array.isArray(tags) && tags.length > 0) {
114-
span.setAttribute(CACHE_TAGS, tags);
138+
const stringTags = Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === 'string') : [];
139+
if (stringTags.length > 0) {
140+
span.setAttribute(CACHE_TAGS, stringTags);
115141
}
116142
}
117143

118-
function instrumentHandler(handler: unknown): void {
119-
if (
120-
!handler ||
121-
typeof handler !== 'object' ||
122-
typeof (handler as UseCacheHandler).get !== 'function' ||
123-
typeof (handler as UseCacheHandler).set !== 'function' ||
124-
(handler as UseCacheHandler)[SENTRY_HANDLER_WRAPPED]
125-
) {
126-
return;
144+
function isCacheHandler(value: unknown): value is UseCacheHandler {
145+
return (
146+
typeof value === 'object' &&
147+
value !== null &&
148+
typeof (value as UseCacheHandler).get === 'function' &&
149+
typeof (value as UseCacheHandler).set === 'function'
150+
);
151+
}
152+
153+
// A WeakSet instead of a marker property, because frozen or proxied handlers reject new properties.
154+
function getWrappedHandlers(): WeakSet<object> {
155+
const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers;
156+
if (!globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS]) {
157+
globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS] = new WeakSet();
127158
}
159+
return globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS];
160+
}
128161

129-
const cacheHandler = handler as UseCacheHandler;
130-
cacheHandler[SENTRY_HANDLER_WRAPPED] = true;
162+
function instrumentHandler(handler: unknown): void {
163+
// Runs inside Next.js' handler registration, which must never fail because of Sentry.
164+
try {
165+
const wrappedHandlers = getWrappedHandlers();
166+
if (!isCacheHandler(handler) || wrappedHandlers.has(handler)) {
167+
return;
168+
}
169+
wrappedHandlers.add(handler);
131170

132-
fill(cacheHandler, 'get', (originalGet: UseCacheHandler['get']) => {
133-
return function (this: UseCacheHandler, cacheKey: string, softTags?: string[]): Promise<unknown> {
134-
return startCacheSpan(CACHE_GET, cacheKey, async span => {
135-
const entry = await originalGet.call(this, cacheKey, softTags);
136-
setEntryAttributes(span, entry);
137-
return entry;
138-
});
139-
};
140-
});
171+
fill(handler, 'get', (originalGet: UseCacheHandler['get']) => {
172+
return function (this: UseCacheHandler, cacheKey: string, softTags?: string[]): Promise<unknown> {
173+
if (!shouldRecordCacheSpan()) {
174+
return originalGet.call(this, cacheKey, softTags);
175+
}
176+
return startCacheSpan(CACHE_GET, cacheKey, span =>
177+
// `Promise.resolve` because custom handlers may return the entry synchronously.
178+
Promise.resolve(originalGet.call(this, cacheKey, softTags)).then(entry => {
179+
try {
180+
setEntryAttributes(span, entry);
181+
} catch (error) {
182+
DEBUG_BUILD && debug.warn('Failed to read Next.js cache entry metadata', error);
183+
}
184+
return entry;
185+
}),
186+
);
187+
};
188+
});
141189

142-
fill(cacheHandler, 'set', (originalSet: UseCacheHandler['set']) => {
143-
return function (this: UseCacheHandler, cacheKey: string, pendingEntry: Promise<unknown>): Promise<void> {
144-
// The handler drains `pendingEntry` (the still-streaming entry) before storing, so this
145-
// span covers producing and storing the entry, not just the write.
146-
return startCacheSpan(CACHE_PUT, cacheKey, () => originalSet.call(this, cacheKey, pendingEntry));
147-
};
148-
});
190+
fill(handler, 'set', (originalSet: UseCacheHandler['set']) => {
191+
return function (this: UseCacheHandler, cacheKey: string, pendingEntry: Promise<unknown>): Promise<void> {
192+
if (!shouldRecordCacheSpan()) {
193+
return originalSet.call(this, cacheKey, pendingEntry);
194+
}
195+
// The handler drains `pendingEntry` (the still-streaming entry) before storing, so this
196+
// span covers producing and storing the entry, not just the write.
197+
return startCacheSpan(CACHE_PUT, cacheKey, () => originalSet.call(this, cacheKey, pendingEntry));
198+
};
199+
});
200+
} catch (error) {
201+
DEBUG_BUILD && debug.warn('Failed to instrument a Next.js cache handler', error);
202+
}
149203
}
150204

151205
function instrumentHandlersMap(handlersMap: Map<string, UseCacheHandler>): void {
@@ -156,8 +210,9 @@ function instrumentHandlersMap(handlersMap: Map<string, UseCacheHandler>): void
156210
// Custom handlers can be registered later (`setCacheHandler`), so wrap new entries as they arrive.
157211
fill(handlersMap, 'set', (originalSet: Map<string, UseCacheHandler>['set']) => {
158212
return function (this: Map<string, UseCacheHandler>, kind: string, handler: UseCacheHandler) {
213+
const result = originalSet.call(this, kind, handler);
159214
instrumentHandler(handler);
160-
return originalSet.call(this, kind, handler);
215+
return result;
161216
};
162217
});
163218
}
@@ -189,12 +244,13 @@ function instrumentWhenAssigned(symbol: symbol, onValue: (value: unknown) => voi
189244
});
190245
}
191246

192-
/**
193-
* Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function
194-
* reads and fills show up in traces with hit/miss information. The registry only exists with
195-
* `cacheComponents`/`useCache` enabled; otherwise this only installs inert interceptors.
247+
/** Installs the `use cache` handler instrumentation once per process.
248+
*
249+
* Only exported for testing.
250+
*
251+
* @internal
196252
*/
197-
export function instrumentUseCacheHandlers(): void {
253+
export function _instrumentUseCacheHandlers(): void {
198254
try {
199255
const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers;
200256

@@ -215,3 +271,16 @@ export function instrumentUseCacheHandlers(): void {
215271
DEBUG_BUILD && debug.warn('Failed to instrument Next.js cache handlers', error);
216272
}
217273
}
274+
275+
/**
276+
* Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function
277+
* reads and fills show up in traces with hit/miss information.
278+
*/
279+
export const nextjsUseCacheIntegration = defineIntegration(() => {
280+
return {
281+
name: INTEGRATION_NAME,
282+
setupOnce() {
283+
_instrumentUseCacheHandlers();
284+
},
285+
};
286+
});

0 commit comments

Comments
 (0)