Skip to content

Commit 32c29d1

Browse files
ref(toolkit-core): Share the response cache policy with the MCP
Moves the CLI's cache policy into `@sentry/toolkit-core/response-cache` so the MCP can use the same rules (task 4.1 of the unify-sentry-api-layer proposal, #1669): - `classifyUrl` and `FALLBACK_TTL_MS`: the volatile/stable/no-cache tiers for responses without server cache headers. - `normalizeUrl` and `buildCacheKey`: identity-scoped keys over the method and full URL. Key derivation now uses WebCrypto HMAC-SHA256 instead of `node:crypto`, so it runs in Workers and browsers. The output is byte-identical, so existing CLI cache files still hit, and an empty identity is rejected instead of producing an unscoped key. - `computeInvalidationPrefixes`: moved unchanged from `cli/src/lib/cache-keys.ts`, with its tests. The module has no storage or I/O. The CLI's disk cache imports it, and its `buildCacheKey` wrapper (which defaults to the active identity) is now async, as are the tests that call it. WebCrypto HMAC runs on the libuv threadpool, so parallel requests can finish their cache lookups in either order. The `getLogs` batching test no longer assumes which batch reaches fetch first. Co-Authored-By: BYK <burak.kaya@sentry.io>
1 parent 616fea1 commit 32c29d1

14 files changed

Lines changed: 389 additions & 178 deletions

‎packages/cli/src/lib/response-cache.ts‎

Lines changed: 16 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* @module
1515
*/
1616

17-
import { createHmac, randomUUID } from "node:crypto";
17+
import { randomUUID } from "node:crypto";
1818
import {
1919
mkdir,
2020
readdir,
@@ -26,6 +26,11 @@ import {
2626
writeFile,
2727
} from "node:fs/promises";
2828
import { join } from "node:path";
29+
import {
30+
buildCacheKey as buildIdentityCacheKey,
31+
classifyUrl,
32+
FALLBACK_TTL_MS,
33+
} from "@sentry/toolkit-core/response-cache";
2934
import CachePolicy from "http-cache-semantics";
3035
import pLimit from "p-limit";
3136

@@ -39,127 +44,20 @@ import { recordCacheHit, withCacheSpan } from "./telemetry.js";
3944
const log = logger.withTag("response-cache");
4045

4146
// ---------------------------------------------------------------------------
42-
// TTL tiers — used as fallback when the server sends no cache headers
43-
// ---------------------------------------------------------------------------
44-
45-
/**
46-
* TTL tier classification for URLs.
47-
*
48-
* - `stable`: data that changes infrequently (orgs, projects, teams,
49-
* organization event details, trace-item details)
50-
* - `volatile`: data that changes often (issues and their events, project
51-
* event details, full traces, log and span queries)
52-
* - `no-cache`: never cache (polling endpoints like autofix state)
53-
*
54-
* No tier treats a response as immutable. Event payloads embed mutable
55-
* fields (`nextEventID`, user reports, release data), `latest`/`oldest`/
56-
* `recommended` resolve to different events over time, and traces gain
57-
* spans as they arrive (getsentry/sentry#128818).
58-
*/
59-
type TtlTier = "stable" | "volatile" | "no-cache";
60-
61-
/** Fallback TTL durations by tier (milliseconds). `no-cache` uses 0 as a sentinel. */
62-
const FALLBACK_TTL_MS: Record<TtlTier, number> = {
63-
stable: 5 * 60 * 1000, // 5 minutes
64-
volatile: 60 * 1000, // 60 seconds
65-
"no-cache": 0,
66-
};
67-
68-
/**
69-
* URL patterns grouped by TTL tier.
70-
*
71-
* Checked in tier priority order (no-cache → volatile).
72-
* "stable" has no patterns — it is the default fallback when nothing else matches.
73-
*
74-
* TODO: Classify by generated operation metadata from `@sentry/api` instead
75-
* of URL regexes, once it ships per-operation metadata
76-
* (getsentry/sentry-api-schema#81) and a route-keyed operation map
77-
* (getsentry/sentry-api-schema#102).
78-
*/
79-
const URL_TIER_REGEXPS: Readonly<Record<TtlTier, readonly RegExp[]>> = {
80-
// Polling endpoints where state changes rapidly
81-
"no-cache": [/\/(?:autofix|root-cause)\//],
82-
volatile: [
83-
// Issue endpoints: lists, detail views, and issue events such as
84-
// `/issues/{id}/events/latest/`
85-
/\/issues\//,
86-
/[?&]dataset=(?:logs|spans)/,
87-
/\/trace-logs\//,
88-
// Full traces gain spans as they arrive
89-
/\/trace\/[0-9a-f]{32}\//,
90-
// Project event details embed `nextEventID`/`previousEventID` and user
91-
// reports, and reprocessing can change them for the same ID
92-
/\/projects\/[^/]+\/[^/]+\/events\/[^/?]+\/?(?:\?|$)/,
93-
],
94-
// Default fallback — no patterns needed
95-
stable: [],
96-
};
97-
98-
/** Tier check order — stable is the default and has no patterns to check. */
99-
const TIER_CHECK_ORDER: readonly TtlTier[] = ["no-cache", "volatile"];
100-
101-
/**
102-
* Classify a URL into a TTL tier for fallback caching.
103-
*
104-
* @param url - Full URL string (with query params)
105-
* @returns The TTL tier
106-
* @internal Exported for testing
107-
*/
108-
export function classifyUrl(url: string): TtlTier {
109-
for (const tier of TIER_CHECK_ORDER) {
110-
for (const pattern of URL_TIER_REGEXPS[tier]) {
111-
if (pattern.test(url)) {
112-
return tier;
113-
}
114-
}
115-
}
116-
return "stable";
117-
}
118-
119-
// ---------------------------------------------------------------------------
120-
// Cache key generation
47+
// Cache keys — tiers, keys, and invalidation prefixes live in toolkit-core
12148
// ---------------------------------------------------------------------------
12249

12350
/**
124-
* Build a deterministic cache key from the active identity + method + URL.
125-
*
126-
* Query params are sorted alphabetically so `?a=1&b=2` and `?b=2&a=1`
127-
* produce the same key. The identity fingerprint scopes entries per
128-
* bearer token so switching accounts never serves another user's data.
51+
* Build the cache key for the active identity + method + URL.
12952
*
13053
* @internal Exported for testing
13154
*/
132-
export function buildCacheKey(
55+
export async function buildCacheKey(
13356
method: string,
13457
url: string,
13558
identity = getIdentityFingerprint(),
136-
): string {
137-
const normalized = normalizeUrl(method, url);
138-
return createHmac("sha256", identity).update(normalized).digest("hex");
139-
}
140-
141-
/**
142-
* Normalize method + URL into a stable string for cache key derivation.
143-
* Sorts query params alphabetically for deterministic key generation.
144-
*
145-
* @throws {TypeError} If the URL cannot be parsed
146-
* @internal Exported for testing
147-
*/
148-
export function normalizeUrl(method: string, url: string): string {
149-
const parsed = new URL(url);
150-
const sortedParams = new URLSearchParams(
151-
[...parsed.searchParams.entries()].sort(([a], [b]) => {
152-
if (a < b) {
153-
return -1;
154-
}
155-
if (a > b) {
156-
return 1;
157-
}
158-
return 0;
159-
}),
160-
);
161-
parsed.search = sortedParams.toString() ? `?${sortedParams.toString()}` : "";
162-
return `${method.toUpperCase()}|${parsed.toString()}`;
59+
): Promise<string> {
60+
return await buildIdentityCacheKey(method, url, identity);
16361
}
16462

16563
// ---------------------------------------------------------------------------
@@ -451,10 +349,10 @@ export async function getCachedResponse(
451349
let key: string;
452350
// oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
453351
try {
454-
key = buildCacheKey(method, url, identity);
352+
key = await buildCacheKey(method, url, identity);
455353
} catch {
456-
// Malformed URL — skip cache lookup. The request itself will surface
457-
// any real URL error.
354+
// Malformed URL or empty identity — skip cache lookup. The request
355+
// itself will surface any real URL error.
458356
return;
459357
}
460358

@@ -570,9 +468,9 @@ export async function storeCachedResponse(
570468
let key: string;
571469
// oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
572470
try {
573-
key = buildCacheKey(method, url, identity);
471+
key = await buildCacheKey(method, url, identity);
574472
} catch {
575-
// Malformed URL — skip caching this response
473+
// Malformed URL or empty identity — skip caching this response
576474
return;
577475
}
578476

‎packages/cli/src/lib/sentry-client.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
import { setTimeout as sleepMs } from "node:timers/promises";
1212
import type { SentryClient } from "@sentry/api";
1313
import { getTraceData } from "@sentry/node-core/light";
14+
import { computeInvalidationPrefixes } from "@sentry/toolkit-core/response-cache";
1415
import {
1516
formatAuthHeader,
1617
normalizeAuthToken,
1718
trimAuthToken,
1819
} from "./auth-header.js";
1920
import { maybeWarnEnvTokenIgnored } from "./auth-hint.js";
20-
import { computeInvalidationPrefixes } from "./cache-keys.js";
2121
import {
2222
DEFAULT_SENTRY_URL,
2323
getConfiguredSentryUrl,

‎packages/cli/test/lib/api-client.test.ts‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,16 +1884,15 @@ describe("getLogs", () => {
18841884

18851885
const result = await getLogs("my-org", "my-project", ids);
18861886

1887-
// Should have made 2 separate requests
1888-
expect(capturedUrls).toHaveLength(2);
1889-
1890-
// First batch: 100 IDs
1891-
const url1 = new URL(capturedUrls[0]);
1892-
expect(url1.searchParams.get("per_page")).toBe(String(API_MAX_PER_PAGE));
1893-
1894-
// Second batch: 50 IDs
1895-
const url2 = new URL(capturedUrls[1]);
1896-
expect(url2.searchParams.get("per_page")).toBe("50");
1887+
// One request of 100 IDs and one of 50. The batches run in parallel, so
1888+
// they can reach fetch in either order.
1889+
const perPage = capturedUrls.map((url) =>
1890+
new URL(url).searchParams.get("per_page"),
1891+
);
1892+
expect(perPage).toHaveLength(2);
1893+
expect(perPage).toEqual(
1894+
expect.arrayContaining([String(API_MAX_PER_PAGE), "50"]),
1895+
);
18971896

18981897
// Results from both batches should be flattened
18991898
expect(result).toHaveLength(2);

‎packages/cli/test/lib/response-cache.property.test.ts‎

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,16 @@
77

88
import {
99
array,
10+
asyncProperty,
1011
constantFrom,
1112
assert as fcAssert,
1213
property,
1314
string,
1415
tuple,
1516
} from "fast-check";
17+
import { classifyUrl, normalizeUrl } from "@sentry/toolkit-core/response-cache";
1618
import { describe, expect, test } from "vitest";
17-
import {
18-
buildCacheKey,
19-
classifyUrl,
20-
normalizeUrl,
21-
} from "../../src/lib/response-cache.js";
19+
import { buildCacheKey } from "../../src/lib/response-cache.js";
2220
import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js";
2321

2422
// ---------------------------------------------------------------------------
@@ -66,52 +64,60 @@ const sentryUrlArb = tuple(
6664
// ---------------------------------------------------------------------------
6765

6866
describe("property: buildCacheKey", () => {
69-
test("produces a 64-char hex string (SHA-256)", () => {
70-
fcAssert(
71-
property(methodArb, sentryUrlArb, (method, url) => {
72-
expect(buildCacheKey(method, url)).toMatch(/^[0-9a-f]{64}$/);
67+
test("produces a 64-char hex string (SHA-256)", async () => {
68+
await fcAssert(
69+
asyncProperty(methodArb, sentryUrlArb, async (method, url) => {
70+
expect(await buildCacheKey(method, url)).toMatch(/^[0-9a-f]{64}$/);
7371
}),
7472
{ numRuns: DEFAULT_NUM_RUNS },
7573
);
7674
});
7775

78-
test("is deterministic — same inputs produce same key", () => {
79-
fcAssert(
80-
property(methodArb, sentryUrlArb, (method, url) => {
81-
expect(buildCacheKey(method, url)).toBe(buildCacheKey(method, url));
76+
test("is deterministic — same inputs produce same key", async () => {
77+
await fcAssert(
78+
asyncProperty(methodArb, sentryUrlArb, async (method, url) => {
79+
expect(await buildCacheKey(method, url)).toBe(
80+
await buildCacheKey(method, url),
81+
);
8282
}),
8383
{ numRuns: DEFAULT_NUM_RUNS },
8484
);
8585
});
8686

87-
test("different methods produce different keys for same URL", () => {
88-
fcAssert(
89-
property(sentryUrlArb, (url) => {
90-
expect(buildCacheKey("GET", url)).not.toBe(buildCacheKey("POST", url));
87+
test("different methods produce different keys for same URL", async () => {
88+
await fcAssert(
89+
asyncProperty(sentryUrlArb, async (url) => {
90+
expect(await buildCacheKey("GET", url)).not.toBe(
91+
await buildCacheKey("POST", url),
92+
);
9193
}),
9294
{ numRuns: DEFAULT_NUM_RUNS },
9395
);
9496
});
9597

96-
test("query param order does not affect the key", () => {
97-
fcAssert(
98-
property(
98+
test("query param order does not affect the key", async () => {
99+
await fcAssert(
100+
asyncProperty(
99101
constantFrom("https://us.sentry.io", "https://de.sentry.io"),
100102
pathSegmentArb,
101-
(base, path) => {
103+
async (base, path) => {
102104
const url1 = `${base}/api/0/${path}?a=1&b=2&c=3`;
103105
const url2 = `${base}/api/0/${path}?c=3&a=1&b=2`;
104-
expect(buildCacheKey("GET", url1)).toBe(buildCacheKey("GET", url2));
106+
expect(await buildCacheKey("GET", url1)).toBe(
107+
await buildCacheKey("GET", url2),
108+
);
105109
},
106110
),
107111
{ numRuns: DEFAULT_NUM_RUNS },
108112
);
109113
});
110114

111-
test("method comparison is case-insensitive", () => {
112-
fcAssert(
113-
property(sentryUrlArb, (url) => {
114-
expect(buildCacheKey("get", url)).toBe(buildCacheKey("GET", url));
115+
test("method comparison is case-insensitive", async () => {
116+
await fcAssert(
117+
asyncProperty(sentryUrlArb, async (url) => {
118+
expect(await buildCacheKey("get", url)).toBe(
119+
await buildCacheKey("GET", url),
120+
);
115121
}),
116122
{ numRuns: DEFAULT_NUM_RUNS },
117123
);

‎packages/cli/test/lib/response-cache.test.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { readdir } from "node:fs/promises";
99
import { join } from "node:path";
10+
import { normalizeUrl } from "@sentry/toolkit-core/response-cache";
1011
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
1112
import { setAuthToken } from "../../src/lib/db/auth.js";
1213
import {
@@ -15,7 +16,6 @@ import {
1516
disableResponseCache,
1617
getCachedResponse,
1718
invalidateCachedResponsesMatching,
18-
normalizeUrl,
1919
resetCacheState,
2020
storeCachedResponse,
2121
} from "../../src/lib/response-cache.js";
@@ -372,27 +372,29 @@ describe("normalizeUrl", () => {
372372
// ---------------------------------------------------------------------------
373373

374374
describe("buildCacheKey", () => {
375-
test("produces a 64-char hex string", () => {
376-
expect(buildCacheKey("GET", TEST_URL)).toMatch(/^[0-9a-f]{64}$/);
375+
test("produces a 64-char hex string", async () => {
376+
expect(await buildCacheKey("GET", TEST_URL)).toMatch(/^[0-9a-f]{64}$/);
377377
});
378378

379-
test("is deterministic", () => {
380-
expect(buildCacheKey("GET", TEST_URL)).toBe(buildCacheKey("GET", TEST_URL));
379+
test("is deterministic", async () => {
380+
expect(await buildCacheKey("GET", TEST_URL)).toBe(
381+
await buildCacheKey("GET", TEST_URL),
382+
);
381383
});
382384

383-
test("different methods produce different keys", () => {
384-
expect(buildCacheKey("GET", TEST_URL)).not.toBe(
385-
buildCacheKey("POST", TEST_URL),
385+
test("different methods produce different keys", async () => {
386+
expect(await buildCacheKey("GET", TEST_URL)).not.toBe(
387+
await buildCacheKey("POST", TEST_URL),
386388
);
387389
});
388390

389-
test("different identities produce different keys for the same URL", () => {
391+
test("different identities produce different keys for the same URL", async () => {
390392
// Switching accounts must route reads/writes through a different
391393
// namespace so users never see each other's cached data.
392394
setAuthToken("alice_access", 3600, "alice_refresh");
393-
const aliceKey = buildCacheKey("GET", TEST_URL);
395+
const aliceKey = await buildCacheKey("GET", TEST_URL);
394396
setAuthToken("bob_access", 3600, "bob_refresh");
395-
const bobKey = buildCacheKey("GET", TEST_URL);
397+
const bobKey = await buildCacheKey("GET", TEST_URL);
396398
expect(aliceKey).not.toBe(bobKey);
397399
});
398400
});

0 commit comments

Comments
 (0)