Skip to content

Commit dbeae30

Browse files
authored
feat(telemetry): add cache hit rate metric across all cache systems (#638)
## Summary - Add `recordCacheHit()` helper that emits `cache.hit_rate` distribution metric (0=miss, 100=hit) with `cache_name` attribute - Instrument all 5 cache systems: dsn, dsn-detection, project, region, project-root, http - Update dashboard "Cache Hit Rate (%)" widget to use tracemetrics avg grouped by cache_name ## Why Sentry dashboards don't support division/formula widgets, so computing `hits / total` isn't possible. By emitting 0 for miss and 100 for hit as a distribution metric, `avg(value,cache.hit_rate,distribution,none)` directly gives the hit rate percentage. ## Caches instrumented | Cache name | Function | What it caches | |---|---|---| | `dsn` | `getCachedDsn()` | Single-DSN detection result per directory | | `dsn-detection` | `getCachedDetection()` | Full detection with mtime validation | | `project` | `getByKey()` | Project info by orgId:projectId or DSN key | | `region` | `getOrgRegion()` | Org-to-region URL mapping | | `project-root` | `getCachedProjectRoot()` | cwd-to-project-root mapping | | `http` | `getCachedResponse()` | HTTP response cache (also has existing cache.hit span attribute) |
1 parent ffdfe27 commit dbeae30

File tree

6 files changed

+43
-2
lines changed

6 files changed

+43
-2
lines changed

src/lib/db/dsn-cache.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
DetectedDsn,
1414
ResolvedProjectInfo,
1515
} from "../dsn/types.js";
16+
import { recordCacheHit } from "../telemetry.js";
1617
import { getDatabase, maybeCleanupCaches } from "./index.js";
1718
import { runUpsert, touchCacheEntry } from "./utils.js";
1819

@@ -141,9 +142,11 @@ export function getCachedDsn(directory: string): CachedDsnEntry | undefined {
141142
.get(directory) as DsnCacheRow | undefined;
142143

143144
if (!row) {
145+
recordCacheHit("dsn", false);
144146
return;
145147
}
146148

149+
recordCacheHit("dsn", true);
147150
touchCacheEntry("dsn_cache", "directory", directory);
148151
return rowToCachedDsnEntry(row);
149152
}
@@ -330,6 +333,7 @@ export async function getCachedDetection(
330333
.get(projectRoot) as DsnCacheRow | undefined;
331334

332335
if (!row) {
336+
recordCacheHit("dsn-detection", false);
333337
return;
334338
}
335339

@@ -340,18 +344,21 @@ export async function getCachedDetection(
340344
row.ttl_expires_at === null
341345
) {
342346
// Old cache entry without full detection data
347+
recordCacheHit("dsn-detection", false);
343348
return;
344349
}
345350

346351
const now = Date.now();
347352

348353
// Check TTL expiration
349354
if (now > row.ttl_expires_at) {
355+
recordCacheHit("dsn-detection", false);
350356
return;
351357
}
352358

353359
// Check project root directory mtime
354360
if (!(await validateDirMtime(projectRoot, row.root_dir_mtime))) {
361+
recordCacheHit("dsn-detection", false);
355362
return;
356363
}
357364

@@ -361,6 +368,7 @@ export async function getCachedDetection(
361368
number
362369
>;
363370
if (!(await validateSourceMtimes(projectRoot, sourceMtimes))) {
371+
recordCacheHit("dsn-detection", false);
364372
return;
365373
}
366374

@@ -369,9 +377,11 @@ export async function getCachedDetection(
369377
? (JSON.parse(row.dir_mtimes_json) as Record<string, number>)
370378
: {};
371379
if (!(await validateDirMtimes(projectRoot, dirMtimes))) {
380+
recordCacheHit("dsn-detection", false);
372381
return;
373382
}
374383

384+
recordCacheHit("dsn-detection", true);
375385
// Cache is valid - update last access time
376386
touchCacheEntry("dsn_cache", "directory", projectRoot);
377387

src/lib/db/project-cache.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import type { CachedProject } from "../../types/index.js";
6+
import { recordCacheHit } from "../telemetry.js";
67
import { getDatabase, maybeCleanupCaches } from "./index.js";
78
import { runUpsert, touchCacheEntry } from "./utils.js";
89

@@ -44,9 +45,11 @@ function getByKey(key: string): CachedProject | undefined {
4445
.get(key) as ProjectCacheRow | undefined;
4546

4647
if (!row) {
48+
recordCacheHit("project", false);
4749
return;
4850
}
4951

52+
recordCacheHit("project", true);
5053
touchCacheEntry("project_cache", "cache_key", key);
5154
return rowToCachedProject(row);
5255
}

src/lib/db/project-root-cache.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import { stat } from "node:fs/promises";
1212
import type { ProjectRootReason } from "../dsn/project-root.js";
13+
import { recordCacheHit } from "../telemetry.js";
1314
import { getDatabase, maybeCleanupCaches } from "./index.js";
1415
import { runUpsert } from "./utils.js";
1516

@@ -58,6 +59,7 @@ export async function getCachedProjectRoot(
5859
.get(cwd) as ProjectRootCacheRow | undefined;
5960

6061
if (!row) {
62+
recordCacheHit("project-root", false);
6163
return;
6264
}
6365

@@ -67,6 +69,7 @@ export async function getCachedProjectRoot(
6769
if (now > row.ttl_expires_at) {
6870
// Cache expired, delete it
6971
db.query("DELETE FROM project_root_cache WHERE cwd = ?").run(cwd);
72+
recordCacheHit("project-root", false);
7073
return;
7174
}
7275

@@ -78,15 +81,17 @@ export async function getCachedProjectRoot(
7881
if (currentMtime !== row.cwd_mtime) {
7982
// Directory structure changed, invalidate cache
8083
db.query("DELETE FROM project_root_cache WHERE cwd = ?").run(cwd);
84+
recordCacheHit("project-root", false);
8185
return;
8286
}
8387
} catch {
8488
// Directory doesn't exist or can't stat - invalidate cache
8589
db.query("DELETE FROM project_root_cache WHERE cwd = ?").run(cwd);
90+
recordCacheHit("project-root", false);
8691
return;
8792
}
8893

89-
// Cache is valid - update last access time would be here if we tracked it
94+
recordCacheHit("project-root", true);
9095
return {
9196
projectRoot: row.project_root,
9297
reason: row.reason as ProjectRootReason,

src/lib/db/regions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* look up by `org_id = '1081365'` → get the slug).
1111
*/
1212

13+
import { recordCacheHit } from "../telemetry.js";
1314
import { getDatabase } from "./index.js";
1415
import { runUpsert } from "./utils.js";
1516

@@ -59,6 +60,7 @@ export function getOrgRegion(orgSlug: string): string | undefined {
5960
.query(`SELECT region_url FROM ${TABLE} WHERE org_slug = ?`)
6061
.get(orgSlug) as Pick<OrgRegionRow, "region_url"> | undefined;
6162

63+
recordCacheHit("region", !!row);
6264
return row?.region_url;
6365
}
6466

src/lib/response-cache.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import pLimit from "p-limit";
2929

3030
import { getConfigDir } from "./db/index.js";
3131
import { getEnv } from "./env.js";
32-
import { withCacheSpan } from "./telemetry.js";
32+
import { recordCacheHit, withCacheSpan } from "./telemetry.js";
3333

3434
// ---------------------------------------------------------------------------
3535
// TTL tiers — used as fallback when the server sends no cache headers
@@ -386,18 +386,21 @@ export async function getCachedResponse(
386386
const entry = await readCacheEntry(key);
387387
if (!entry) {
388388
span.setAttribute("cache.hit", false);
389+
recordCacheHit("http", false);
389390
return;
390391
}
391392

392393
try {
393394
const policy = CachePolicy.fromObject(entry.policy);
394395
if (!isEntryFresh(policy, entry, requestHeaders, url)) {
395396
span.setAttribute("cache.hit", false);
397+
recordCacheHit("http", false);
396398
return;
397399
}
398400

399401
const body = JSON.stringify(entry.body);
400402
span.setAttribute("cache.hit", true);
403+
recordCacheHit("http", true);
401404
span.setAttribute("cache.item_size", body.length);
402405

403406
const responseHeaders = buildResponseHeaders(policy, entry);
@@ -409,6 +412,7 @@ export async function getCachedResponse(
409412
// Corrupted or version-incompatible policy object — treat as cache miss.
410413
// Best-effort cleanup of the broken entry.
411414
span.setAttribute("cache.hit", false);
415+
recordCacheHit("http", false);
412416
unlink(cacheFilePath(key)).catch(() => {
413417
// Ignored — fire-and-forget
414418
});

src/lib/telemetry.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,23 @@ export function setArgsContext(args: readonly unknown[]): void {
620620
});
621621
}
622622

623+
/**
624+
* Record a cache hit or miss as a distribution metric (0 or 100).
625+
*
626+
* Emitting 0 for miss and 100 for hit allows computing hit rate as
627+
* `avg(value,cache.hit_rate,distribution,none)` on the tracemetrics
628+
* dashboard — Sentry doesn't support division in widgets, so this
629+
* pre-computed approach gives us percentages directly.
630+
*
631+
* @param cacheName - Identifier for the cache (e.g., "dsn", "project", "region", "http")
632+
* @param hit - Whether the cache lookup was a hit
633+
*/
634+
export function recordCacheHit(cacheName: string, hit: boolean): void {
635+
Sentry.metrics.distribution("cache.hit_rate", hit ? 100 : 0, {
636+
attributes: { cache_name: cacheName },
637+
});
638+
}
639+
623640
/**
624641
* Wrap an operation with a Sentry span for tracing.
625642
*

0 commit comments

Comments
 (0)