Skip to content

Commit 4dccadd

Browse files
committed
feat: implement Sentry Cache Module instrumentation for response cache
Align cache spans with the Sentry Cache Module spec for the Caches Insights dashboard: - Change span ops from generic 'cache' to 'cache.get' and 'cache.put' - Set 'cache.key' attribute (string array) with the cache entry key - Set 'cache.hit' attribute (boolean) dynamically after lookup - Set 'cache.item_size' attribute with serialized body length on hit/store - Set 'network.peer.address' to the cache directory path - Use the URL as span name instead of generic 'cache.lookup'/'cache.store' - Pass span to callback in withCacheSpan for dynamic attribute setting Ref: https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/caches-module/
1 parent 15d70b9 commit 4dccadd

File tree

2 files changed

+77
-25
lines changed

2 files changed

+77
-25
lines changed

src/lib/response-cache.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -367,36 +367,48 @@ export async function getCachedResponse(
367367
return;
368368
}
369369

370+
const key = buildCacheKey(method, url);
371+
370372
return await withCacheSpan(
371-
"cache.lookup",
372-
async () => {
373-
const key = buildCacheKey(method, url);
373+
url,
374+
"cache.get",
375+
async (span) => {
374376
const entry = await readCacheEntry(key);
375377
if (!entry) {
378+
span.setAttribute("cache.hit", false);
376379
return;
377380
}
378381

379382
try {
380383
const policy = CachePolicy.fromObject(entry.policy);
381384
if (!isEntryFresh(policy, entry, requestHeaders, url)) {
385+
span.setAttribute("cache.hit", false);
382386
return;
383387
}
384388

389+
const body = JSON.stringify(entry.body);
390+
span.setAttribute("cache.hit", true);
391+
span.setAttribute("cache.item_size", body.length);
392+
385393
const responseHeaders = buildResponseHeaders(policy, entry);
386-
return new Response(JSON.stringify(entry.body), {
394+
return new Response(body, {
387395
status: entry.status,
388396
headers: responseHeaders,
389397
});
390398
} catch {
391399
// Corrupted or version-incompatible policy object — treat as cache miss.
392400
// Best-effort cleanup of the broken entry.
401+
span.setAttribute("cache.hit", false);
393402
unlink(cacheFilePath(key)).catch(() => {
394403
// Ignored — fire-and-forget
395404
});
396405
return;
397406
}
398407
},
399-
{ "cache.url": url }
408+
{
409+
"cache.key": [key],
410+
"network.peer.address": getCacheDir(),
411+
}
400412
);
401413
}
402414

@@ -455,38 +467,60 @@ export async function storeCachedResponse(
455467
return;
456468
}
457469

470+
const key = buildCacheKey(method, url);
471+
458472
try {
459473
await withCacheSpan(
460-
"cache.store",
461-
() => writeResponseToCache(method, url, requestHeaders, response),
462-
{ "cache.url": url }
474+
url,
475+
"cache.put",
476+
async (span) => {
477+
const size = await writeResponseToCache(
478+
key,
479+
url,
480+
requestHeaders,
481+
response
482+
);
483+
if (size > 0) {
484+
span.setAttribute("cache.item_size", size);
485+
}
486+
},
487+
{
488+
"cache.key": [key],
489+
"network.peer.address": getCacheDir(),
490+
}
463491
);
464492
} catch {
465493
// Cache write failures are non-fatal — silently ignore
466494
}
467495
}
468496

469-
/** Core cache write logic, separated for complexity management */
497+
/**
498+
* Core cache write logic, separated for complexity management.
499+
*
500+
* Always called for GET requests (caller checks method), so "GET" is hardcoded
501+
* for the CachePolicy constructor.
502+
*
503+
* @returns The serialized body size in bytes (0 if not storable).
504+
*/
470505
async function writeResponseToCache(
471-
method: string,
506+
key: string,
472507
url: string,
473508
requestHeaders: Record<string, string>,
474509
response: Response
475-
): Promise<void> {
510+
): Promise<number> {
476511
const responseHeadersObj = headersToObject(response.headers);
477512

478513
const policy = new CachePolicy(
479-
{ url, method, headers: requestHeaders },
514+
{ url, method: "GET", headers: requestHeaders },
480515
{ status: response.status, headers: responseHeadersObj },
481516
POLICY_OPTIONS
482517
);
483518

484519
if (!policy.storable()) {
485-
return;
520+
return 0;
486521
}
487522

488523
const body: unknown = await response.json();
489-
const key = buildCacheKey(method, url);
490524
const now = Date.now();
491525

492526
// Pre-compute expiry for cheap cleanup checks (avoids CachePolicy deserialization).
@@ -507,15 +541,18 @@ async function writeResponseToCache(
507541
expiresAt: now + ttl,
508542
};
509543

544+
const serialized = JSON.stringify(entry);
510545
await mkdir(getCacheDir(), { recursive: true, mode: 0o700 });
511-
await writeFile(cacheFilePath(key), JSON.stringify(entry), "utf-8");
546+
await writeFile(cacheFilePath(key), serialized, "utf-8");
512547

513548
// Probabilistic cleanup to avoid unbounded cache growth
514549
if (Math.random() < CLEANUP_PROBABILITY) {
515550
cleanupCache().catch(() => {
516551
// Non-fatal: cleanup failure doesn't affect cache correctness
517552
});
518553
}
554+
555+
return serialized.length;
519556
}
520557

521558
/**

src/lib/telemetry.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -952,20 +952,35 @@ export function withFsSpan<T>(
952952
}
953953

954954
/**
955-
* Wrap a cache operation with a span for tracing.
955+
* Wrap a cache operation with a Sentry Cache Module span.
956956
*
957-
* Creates a child span under the current active span to track
958-
* response cache hit/miss/store operations.
957+
* Implements the [Sentry Cache Module spec](https://develop.sentry.dev/sdk/performance/modules/caches/)
958+
* for the Caches Insights dashboard. The span is passed to the callback so
959+
* callers can set `cache.hit`, `cache.item_size`, etc. after the lookup.
959960
*
960-
* @param operation - Name of the operation (e.g., "cache.lookup", "cache.store")
961-
* @param fn - The function that performs the cache operation
962-
* @param attributes - Optional span attributes (e.g., url, cache.hit)
961+
* @param name - Span name (typically the cache key or a descriptive label)
962+
* @param op - Cache operation: `"cache.get"` for reads, `"cache.put"` for writes
963+
* @param fn - Function to execute, receives the span for dynamic attribute setting
964+
* @param attributes - Initial span attributes (e.g., `cache.key`, `network.peer.address`)
963965
* @returns The result of the function
964966
*/
965967
export function withCacheSpan<T>(
966-
operation: string,
967-
fn: () => T | Promise<T>,
968-
attributes?: Record<string, string | number | boolean>
968+
name: string,
969+
op: "cache.get" | "cache.put",
970+
fn: (span: Span) => T | Promise<T>,
971+
attributes?: Record<string, string | number | boolean | string[]>
969972
): Promise<T> {
970-
return withTracing(operation, "cache", fn, attributes);
973+
return Sentry.startSpan(
974+
{ name, op, attributes, onlyIfParent: true },
975+
async (span) => {
976+
try {
977+
const result = await fn(span);
978+
span.setStatus({ code: 1 }); // OK
979+
return result;
980+
} catch (error) {
981+
span.setStatus({ code: 2 }); // Error
982+
throw error;
983+
}
984+
}
985+
);
971986
}

0 commit comments

Comments
 (0)