Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 16 additions & 118 deletions packages/cli/src/lib/response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* @module
*/

import { createHmac, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import {
mkdir,
readdir,
Expand All @@ -26,6 +26,11 @@ import {
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import {
buildCacheKey as buildIdentityCacheKey,
classifyUrl,
FALLBACK_TTL_MS,
} from "@sentry/toolkit-core/response-cache";
import CachePolicy from "http-cache-semantics";
import pLimit from "p-limit";

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

// ---------------------------------------------------------------------------
// TTL tiers — used as fallback when the server sends no cache headers
// ---------------------------------------------------------------------------

/**
* TTL tier classification for URLs.
*
* - `stable`: data that changes infrequently (orgs, projects, teams,
* organization event details, trace-item details)
* - `volatile`: data that changes often (issues and their events, project
* event details, full traces, log and span queries)
* - `no-cache`: never cache (polling endpoints like autofix state)
*
* No tier treats a response as immutable. Event payloads embed mutable
* fields (`nextEventID`, user reports, release data), `latest`/`oldest`/
* `recommended` resolve to different events over time, and traces gain
* spans as they arrive (getsentry/sentry#128818).
*/
type TtlTier = "stable" | "volatile" | "no-cache";

/** Fallback TTL durations by tier (milliseconds). `no-cache` uses 0 as a sentinel. */
const FALLBACK_TTL_MS: Record<TtlTier, number> = {
stable: 5 * 60 * 1000, // 5 minutes
volatile: 60 * 1000, // 60 seconds
"no-cache": 0,
};

/**
* URL patterns grouped by TTL tier.
*
* Checked in tier priority order (no-cache → volatile).
* "stable" has no patterns — it is the default fallback when nothing else matches.
*
* TODO: Classify by generated operation metadata from `@sentry/api` instead
* of URL regexes, once it ships per-operation metadata
* (getsentry/sentry-api-schema#81) and a route-keyed operation map
* (getsentry/sentry-api-schema#102).
*/
const URL_TIER_REGEXPS: Readonly<Record<TtlTier, readonly RegExp[]>> = {
// Polling endpoints where state changes rapidly
"no-cache": [/\/(?:autofix|root-cause)\//],
volatile: [
// Issue endpoints: lists, detail views, and issue events such as
// `/issues/{id}/events/latest/`
/\/issues\//,
/[?&]dataset=(?:logs|spans)/,
/\/trace-logs\//,
// Full traces gain spans as they arrive
/\/trace\/[0-9a-f]{32}\//,
// Project event details embed `nextEventID`/`previousEventID` and user
// reports, and reprocessing can change them for the same ID
/\/projects\/[^/]+\/[^/]+\/events\/[^/?]+\/?(?:\?|$)/,
],
// Default fallback — no patterns needed
stable: [],
};

/** Tier check order — stable is the default and has no patterns to check. */
const TIER_CHECK_ORDER: readonly TtlTier[] = ["no-cache", "volatile"];

/**
* Classify a URL into a TTL tier for fallback caching.
*
* @param url - Full URL string (with query params)
* @returns The TTL tier
* @internal Exported for testing
*/
export function classifyUrl(url: string): TtlTier {
for (const tier of TIER_CHECK_ORDER) {
for (const pattern of URL_TIER_REGEXPS[tier]) {
if (pattern.test(url)) {
return tier;
}
}
}
return "stable";
}

// ---------------------------------------------------------------------------
// Cache key generation
// Cache keys — tiers, keys, and invalidation prefixes live in toolkit-core
// ---------------------------------------------------------------------------

/**
* Build a deterministic cache key from the active identity + method + URL.
*
* Query params are sorted alphabetically so `?a=1&b=2` and `?b=2&a=1`
* produce the same key. The identity fingerprint scopes entries per
* bearer token so switching accounts never serves another user's data.
* Build the cache key for the active identity + method + URL.
*
* @internal Exported for testing
*/
export function buildCacheKey(
export async function buildCacheKey(
method: string,
url: string,
identity = getIdentityFingerprint(),
): string {
const normalized = normalizeUrl(method, url);
return createHmac("sha256", identity).update(normalized).digest("hex");
}

/**
* Normalize method + URL into a stable string for cache key derivation.
* Sorts query params alphabetically for deterministic key generation.
*
* @throws {TypeError} If the URL cannot be parsed
* @internal Exported for testing
*/
export function normalizeUrl(method: string, url: string): string {
const parsed = new URL(url);
const sortedParams = new URLSearchParams(
[...parsed.searchParams.entries()].sort(([a], [b]) => {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}),
);
parsed.search = sortedParams.toString() ? `?${sortedParams.toString()}` : "";
return `${method.toUpperCase()}|${parsed.toString()}`;
): Promise<string> {
return await buildIdentityCacheKey(method, url, identity);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -451,10 +349,10 @@ export async function getCachedResponse(
let key: string;
// oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
try {
key = buildCacheKey(method, url, identity);
key = await buildCacheKey(method, url, identity);
} catch {
// Malformed URL — skip cache lookup. The request itself will surface
// any real URL error.
// Malformed URL or empty identity — skip cache lookup. The request
// itself will surface any real URL error.
return;
}

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

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/sentry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
import { setTimeout as sleepMs } from "node:timers/promises";
import type { SentryClient } from "@sentry/api";
import { getTraceData } from "@sentry/node-core/light";
import { computeInvalidationPrefixes } from "@sentry/toolkit-core/response-cache";
import {
formatAuthHeader,
normalizeAuthToken,
trimAuthToken,
} from "./auth-header.js";
import { maybeWarnEnvTokenIgnored } from "./auth-hint.js";
import { computeInvalidationPrefixes } from "./cache-keys.js";
import {
DEFAULT_SENTRY_URL,
getConfiguredSentryUrl,
Expand Down
19 changes: 9 additions & 10 deletions packages/cli/test/lib/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1884,16 +1884,15 @@ describe("getLogs", () => {

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

// Should have made 2 separate requests
expect(capturedUrls).toHaveLength(2);

// First batch: 100 IDs
const url1 = new URL(capturedUrls[0]);
expect(url1.searchParams.get("per_page")).toBe(String(API_MAX_PER_PAGE));

// Second batch: 50 IDs
const url2 = new URL(capturedUrls[1]);
expect(url2.searchParams.get("per_page")).toBe("50");
// One request of 100 IDs and one of 50. The batches run in parallel, so
// they can reach fetch in either order.
const perPage = capturedUrls.map((url) =>
new URL(url).searchParams.get("per_page"),
);
expect(perPage).toHaveLength(2);
expect(perPage).toEqual(
expect.arrayContaining([String(API_MAX_PER_PAGE), "50"]),
);

// Results from both batches should be flattened
expect(result).toHaveLength(2);
Expand Down
55 changes: 5 additions & 50 deletions packages/cli/test/lib/response-cache.test.ts
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
disableResponseCache,
getCachedResponse,
invalidateCachedResponsesMatching,
normalizeUrl,
resetCacheState,
storeCachedResponse,
} from "../../src/lib/response-cache.js";
Expand Down Expand Up @@ -337,62 +336,18 @@ describe("cache bypass", () => {
});

// ---------------------------------------------------------------------------
// normalizeUrl
// ---------------------------------------------------------------------------

describe("normalizeUrl", () => {
test("sorts query params alphabetically", () => {
const result = normalizeUrl(
"GET",
"https://sentry.io/api/0/issues/?b=2&a=1",
);
expect(result).toBe("GET|https://sentry.io/api/0/issues/?a=1&b=2");
});

test("uppercases the method", () => {
const result = normalizeUrl("get", "https://sentry.io/api/0/issues/");
expect(result).toMatch(/^GET\|/);
});

test("throws on invalid URLs", () => {
expect(() => normalizeUrl("GET", "not-a-valid-url")).toThrow();
});

test("handles self-hosted URLs with unusual schemes", () => {
const result = normalizeUrl(
"GET",
"https://sentry.mycompany.internal/api/0/issues/",
);
expect(result).toBe("GET|https://sentry.mycompany.internal/api/0/issues/");
});
});

// ---------------------------------------------------------------------------
// buildCacheKey
// buildCacheKey — key derivation is tested in toolkit-core; this covers the
// CLI wrapper's default identity, taken from the active login
// ---------------------------------------------------------------------------

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

test("is deterministic", () => {
expect(buildCacheKey("GET", TEST_URL)).toBe(buildCacheKey("GET", TEST_URL));
});

test("different methods produce different keys", () => {
expect(buildCacheKey("GET", TEST_URL)).not.toBe(
buildCacheKey("POST", TEST_URL),
);
});

test("different identities produce different keys for the same URL", () => {
test("different identities produce different keys for the same URL", async () => {
// Switching accounts must route reads/writes through a different
// namespace so users never see each other's cached data.
setAuthToken("alice_access", 3600, "alice_refresh");
const aliceKey = buildCacheKey("GET", TEST_URL);
const aliceKey = await buildCacheKey("GET", TEST_URL);
setAuthToken("bob_access", 3600, "bob_refresh");
const bobKey = buildCacheKey("GET", TEST_URL);
const bobKey = await buildCacheKey("GET", TEST_URL);
expect(aliceKey).not.toBe(bobKey);
});
});
Expand Down
19 changes: 19 additions & 0 deletions packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ the page cap, receives warnings, and turns failures into its own errors. The
CLI's `paginate` and `fetchAllPages` in
`packages/cli/src/lib/api/infrastructure.ts` are thin wrappers over it.

## Response cache

`response-cache` holds the cache policy for Sentry API GET responses.
`classifyUrl` puts a URL in a fallback TTL tier for responses without HTTP
caching headers: `volatile` (60 seconds) for issues, project event details,
full traces, and log and span queries; `no-cache` for autofix and root-cause
polling; and `stable` (5 minutes) for everything else. No tier keeps a
response longer than 5 minutes. `buildCacheKey` derives a key from the
requesting identity, the method, and the full URL with sorted query params,
as a WebCrypto HMAC-SHA256 that matches the CLI's earlier `node:crypto` keys.
`computeInvalidationPrefixes` lists the URL prefixes a successful mutation
makes stale.

The module has no storage. The host supplies an identity that is a stable
hash of the login (never a raw token), applies server caching headers ahead
of the tiers, stores entries, and deletes only the requesting identity's
entries under each invalidation prefix. The CLI's disk cache in
`packages/cli/src/lib/response-cache.ts` uses it.

## Resource identity

The `resource-identity` types describe the identifiers shared by organization,
Expand Down
5 changes: 5 additions & 0 deletions packages/toolkit-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
"types": "./src/resource-identity.ts",
"default": "./src/resource-identity.ts"
},
"./response-cache": {
"types": "./src/response-cache/index.ts",
"default": "./src/response-cache/index.ts"
},
"./search-query": {
"types": "./src/search-query.ts",
"default": "./src/search-query.ts"
Expand All @@ -78,6 +82,7 @@
"tsc": "tsc --noEmit"
},
"devDependencies": {
"fast-check": "^4.8.0",
"typescript": "catalog:",
"vitest": "catalog:"
}
Expand Down
9 changes: 9 additions & 0 deletions packages/toolkit-core/src/response-cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Response cache policy shared by the CLI and MCP: fallback TTL tiers,
* identity-scoped keys, and mutation invalidation prefixes. Pure logic with
* no storage or I/O; each host owns where entries live.
*/

export { computeInvalidationPrefixes } from "./invalidation.js";
export { buildCacheKey, normalizeUrl } from "./keys.js";
export { classifyUrl, FALLBACK_TTL_MS, type TtlTier } from "./tiers.js";
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { describe, expect, test } from "vitest";
import { computeInvalidationPrefixes as computeInvalidationPrefixesRaw } from "../../src/lib/cache-keys.js";
import { computeInvalidationPrefixes as computeInvalidationPrefixesRaw } from "./invalidation.js";

const BASE = "https://us.sentry.io/api/0/";
const API_BASE_URL = "https://sentry.io";
Expand Down
Loading
Loading