Skip to content
Open
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
60 changes: 53 additions & 7 deletions docs/cloudflare/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ const result = streamText({
- **Pages**: Static asset hosting for React app
- **KV Namespace**: OAuth token storage
- **AI Binding**: Access to Cloudflare AI models (AutoRAG for docs search)
- **R2**: File storage (future)
- **R2**: The response cache's tier for allowlisted detail endpoints
(`MCP_RESPONSE_CACHE_BUCKET`, see
[Sentry API Response Cache](#sentry-api-response-cache))

### Environment Variables

Expand All @@ -172,11 +174,11 @@ The MCP handler can cache Sentry API GET responses through the shared
response cache in `@sentry/toolkit-core/api-client/response-cache`. It is
behind `MCP_RESPONSE_CACHE`:

| Value | Behavior |
| ------------------- | -------------------------------------------------------------------------- |
| unset, `off`, other | No caching. The default in every deployed environment. |
| `memory` | Caches every tier in the isolate's memory. |
| `tiered` | Memory only for now; the R2 tier for allowlisted detail endpoints follows. |
| Value | Behavior |
| ------------------- | ---------------------------------------------------------------------------- |
| unset, `off`, other | No caching. The default in every deployed environment. |
| `memory` | Caches every tier in the isolate's memory. |
| `tiered` | `memory`, plus R2 for allowlisted detail endpoints (memory only without R2). |

How it works (`src/server/lib/response-cache.ts`):

Expand All @@ -197,9 +199,53 @@ How it works (`src/server/lib/response-cache.ts`):
has zstd; a deployed Worker is not confirmed yet. The
`app.response_cache.write` metric records the encoding each write used.

#### R2 tier

`tiered` shares a few detail responses across isolates through the
`MCP_RESPONSE_CACHE_BUCKET` R2 binding (`src/server/lib/r2-cache-store.ts`):

- **Allowlist.** Only organization event details
(`/api/0/organizations/{org}/events/{project}:{event_id}/`) without
`averageColumn`, and project trace-item details
(`/api/0/projects/{org}/{project}/trace-items/{id}/`) without `debug`.
Every other URL stays in memory.
- **Server opt-in.** An allowlisted response is written to R2 only when it
carries `Cache-Control: private` with a positive `max-age` (and no
`no-store` or `no-cache`). Until Sentry sends that header
(getsentry/sentry#128828), R2 stores nothing.
- **Expiry.** Each object records an expiry, the lesser of `max-age` and 5
minutes, and reads treat a later object as a miss. An R2 hit is copied
into memory for the time it has left.
- **Keys.** Objects are keyed `<identity>/<HMAC cache key>`, with the
identity hash above, and a read also checks the identity stored in the
entry. Authorization values the policy keeps for `Vary` are stored as
SHA-256 hashes, never as tokens.
- **Writes** run in `ctx.waitUntil()`. Mutation invalidation only touches
memory, so a write under the same path, such as deleting the project, does
not clear R2. The 5-minute expiry bounds how long a deleted item can still
be served.
- **Garbage collection.** A 1-day bucket lifecycle rule deletes old objects.
It only frees space; reads enforce the expiry.

The buckets are `sentry-mcp-response-cache` (production) and
`sentry-mcp-canary-response-cache` (canary). Deploys run with
`--experimental-auto-create=false`, so each bucket must exist before a
config that binds it deploys:

```bash
pnpm exec wrangler r2 bucket create sentry-mcp-response-cache
pnpm exec wrangler r2 bucket lifecycle add sentry-mcp-response-cache \
expire-1d --expire-days 1 --force
pnpm exec wrangler r2 bucket create sentry-mcp-canary-response-cache
pnpm exec wrangler r2 bucket lifecycle add sentry-mcp-canary-response-cache \
expire-1d --expire-days 1 --force
```

The binding is inert while `MCP_RESPONSE_CACHE` is not `tiered`.

Turning it on in production is a separate decision: revoked access or deleted
data can still be served from the cache until the entry expires (up to 5
minutes for stable entries). Leave `MCP_RESPONSE_CACHE` unset in production
minutes for stable entries, including from R2). Leave `MCP_RESPONSE_CACHE` unset in production
until that window has privacy and security sign-off. Setting it back to `off`
restores uncached behavior. Metrics are listed in
[Monitoring](../operations/monitoring.md#response-cache-metrics).
Expand Down
10 changes: 10 additions & 0 deletions docs/operations/monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,11 @@ off.
charges against its budget, after each write
- `app.response_cache.memory.entries` - Gauge of entries in the isolate's
store, after each write
- `app.response_cache.r2.lookup` - Count of R2 reads after a memory miss on an
allowlisted URL (`MCP_RESPONSE_CACHE=tiered` only), with
`app.response_cache.result`: `hit`, `miss`, or `expired` (past its recorded
expiry)
- `app.response_cache.r2.write` - Count of entries written to R2

The first eviction in an isolate also logs `Response cache reached its memory
budget` at info level, with the budget, bytes, and entry count.
Expand All @@ -365,6 +370,11 @@ Interpretation:
the unfiltered sum
- A steady eviction rate with `memory.bytes` at the budget means the budget is
too small for the traffic an isolate serves
- R2 hit rate: `app.response_cache.r2.lookup` filtered to `result=hit`, over
the unfiltered sum. No `r2.write` at all means Sentry is not sending
`Cache-Control: private, max-age` on the allowlisted endpoints.
- R2 read and write failures log `Response cache R2 read failed` and
`Response cache R2 write failed` at warning level; a failed read is a miss.

### Traces Configuration

Expand Down
1 change: 1 addition & 0 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ async function handleAuthenticatedMcpRequest(
auth.kind === "oauth" && auth.grantId
? await getResponseCache({
mode: resolveResponseCacheMode(env.MCP_RESPONSE_CACHE),
bucket: env.MCP_RESPONSE_CACHE_BUCKET,
userId: auth.userId,
grantId: auth.grantId,
sentryHost,
Expand Down
282 changes: 282 additions & 0 deletions packages/mcp-cloudflare/src/server/lib/r2-cache-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import { env } from "cloudflare:test";
import {
type CacheEntry,
createMemoryStore,
} from "@sentry/toolkit-core/api-client/response-cache";
import { createApiTransport } from "@sentry/toolkit-core/api-client/transport";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { sentryMetricsCount } = vi.hoisted(() => ({
sentryMetricsCount: vi.fn(),
}));

vi.mock("@sentry/cloudflare", async (importOriginal) => ({
...(await importOriginal<typeof import("@sentry/cloudflare")>()),
metrics: { count: sentryMetricsCount, gauge: vi.fn() },
}));

import type { Env } from "../types";
import {
createTieredStore,
isR2Allowlisted,
R2_MAX_TTL_MS,
r2TtlMs,
} from "./r2-cache-store";
import { createResponseCache } from "./response-cache";

const bucket = (env as unknown as Env).MCP_RESPONSE_CACHE_BUCKET as R2Bucket;

const API = "https://us.sentry.io/api/0";
const ORG_EVENT_URL = `${API}/organizations/org/events/backend:0123456789abcdef0123456789abcdef/`;
const TRACE_ITEM_URL = `${API}/projects/org/backend/trace-items/0123456789abcdef/`;
const ISSUES_URL = `${API}/organizations/org/issues/?query=is%3Aunresolved`;
const PRIVATE_5M = "private, max-age=300";
const TOKEN = "sntryu_secret-upstream-token";

function entryWithCacheControl(cacheControl?: string): CacheEntry {
return {
url: ORG_EVENT_URL,
identity: "identity-a",
tier: "stable",
status: 200,
headers: cacheControl ? { "cache-control": cacheControl } : {},
body: { bytes: new Uint8Array(), encoding: "identity", rawSize: 0 },
policy: {},
createdAt: Date.now(),
};
}

describe("isR2Allowlisted", () => {
it.each([
[ORG_EVENT_URL, true],
[TRACE_ITEM_URL, true],
[`${TRACE_ITEM_URL}?trace_id=abc&item_type=spans`, true],
[`${ORG_EVENT_URL}?averageColumn=span.duration`, false],
[`${TRACE_ITEM_URL}?debug=1`, false],
[`${API}/projects/org/backend/events/0123456789abcdef/`, false],
[`${API}/organizations/org/issues/1/events/latest/`, false],
[ISSUES_URL, false],
["not a url", false],
])("%s → %s", (url, allowed) => {
expect(isR2Allowlisted(url)).toBe(allowed);
});
});

describe("r2TtlMs", () => {
it.each([
[undefined, 0],
["max-age=300", 0],
["public, max-age=300", 0],
["private", 0],
["private, max-age=0", 0],
["private, no-cache, max-age=300", 0],
["private, max-age=60", 60_000],
[PRIVATE_5M, R2_MAX_TTL_MS],
["Private, Max-Age=3600", R2_MAX_TTL_MS],
])("%j → %d", (cacheControl, ttl) => {
expect(r2TtlMs(entryWithCacheControl(cacheControl))).toBe(ttl);
});
});

describe("createTieredStore", () => {
let upstream: ReturnType<typeof vi.fn<typeof fetch>>;
let pending: Promise<unknown>[] = [];
const waitUntil = (promise: Promise<unknown>) => {
pending.push(promise);
};

beforeEach(async () => {
vi.useFakeTimers({ toFake: ["Date"] });
sentryMetricsCount.mockReset();
pending = [];
for (const object of (await bucket.list()).objects) {
await bucket.delete(object.key);
}
upstream = vi.fn<typeof fetch>();
});

afterEach(() => {
vi.useRealTimers();
});

/** Answer every request with `body` and these response headers. */
function respondWith(body: unknown, headers: Record<string, string> = {}) {
upstream.mockImplementation(async () =>
Response.json(body, { headers: { ...headers } }),
);
}

/**
* One Worker isolate: its own memory store over the shared bucket, and a
* transport that caches for `identity`.
*/
function isolate(identity: string) {
const memory = createMemoryStore();
const { cache } = createResponseCache({
store: memory,
allowlistedStore: createTieredStore({
memory,
bucket,
identity,
waitUntil,
}),
identity,
apiBaseUrl: "https://sentry.io",
waitUntil,
});
const transport = createApiTransport({
retry: {
maxRetries: 0,
baseDelayMs: 0,
maxDelayMs: 0,
maxTotalDelayMs: 0,
},
credentials: { current: () => ({ token: TOKEN, identity }) },
cache,
fetch: upstream,
});
return {
memory,
async get(url: string): Promise<unknown> {
const response = await transport.request(url);
const body = await response.json();
// Writes finish in waitUntil, as on a Worker.
while (pending.length > 0) {
await Promise.all(pending.splice(0));
}
return body;
},
};
}

async function objectKeys(): Promise<string[]> {
return (await bucket.list()).objects.map((object) => object.key);
}

it("serves an allowlisted response from another isolate", async () => {
respondWith({ id: "event-1" }, { "Cache-Control": PRIVATE_5M });

expect(await isolate("identity-a").get(ORG_EVENT_URL)).toEqual({
id: "event-1",
});
expect(upstream).toHaveBeenCalledTimes(1);
const keys = await objectKeys();
expect(keys).toHaveLength(1);
expect(keys[0]).toMatch(/^identity-a\/[0-9a-f]{64}$/);

const second = isolate("identity-a");
expect(await second.get(ORG_EVENT_URL)).toEqual({ id: "event-1" });
expect(upstream).toHaveBeenCalledTimes(1);
// The R2 hit was copied into the second isolate's memory.
expect(second.memory.stats().entries).toBe(1);
expect(sentryMetricsCount).toHaveBeenCalledWith(
"app.response_cache.r2.lookup",
1,
{ attributes: { "app.response_cache.result": "hit" } },
);
});

it("never serves one identity's R2 entry to another", async () => {
respondWith({ id: "event-1" }, { "Cache-Control": PRIVATE_5M });
await isolate("identity-a").get(ORG_EVENT_URL);

await isolate("identity-b").get(ORG_EVENT_URL);
expect(upstream).toHaveBeenCalledTimes(2);
expect((await objectKeys()).sort()).toEqual([
expect.stringMatching(/^identity-a\//),
expect.stringMatching(/^identity-b\//),
]);
});

it.each([
["no server cache headers", {}],
["a public response", { "Cache-Control": "public, max-age=300" }],
["a zero max-age", { "Cache-Control": "private, max-age=0" }],
])("does not write to R2 for %s", async (_, headers) => {
respondWith({ id: "event-1" }, headers);
const first = isolate("identity-a");
await first.get(ORG_EVENT_URL);

expect(await objectKeys()).toEqual([]);
expect(upstream).toHaveBeenCalledTimes(1);
await isolate("identity-a").get(ORG_EVENT_URL);
expect(upstream).toHaveBeenCalledTimes(2);
});

it("keeps an allowlisted response without server headers in memory", async () => {
respondWith({ id: "event-1" });
const first = isolate("identity-a");
await first.get(ORG_EVENT_URL);
await first.get(ORG_EVENT_URL);
expect(upstream).toHaveBeenCalledTimes(1);
expect(await objectKeys()).toEqual([]);
});

it.each([
["an issue list", ISSUES_URL],
["an event with averageColumn", `${ORG_EVENT_URL}?averageColumn=x`],
["a trace item with debug", `${TRACE_ITEM_URL}?debug=1`],
])("keeps %s in memory only", async (_, url) => {
respondWith({ id: "1" }, { "Cache-Control": PRIVATE_5M });
const first = isolate("identity-a");
await first.get(url);
await first.get(url);

expect(upstream).toHaveBeenCalledTimes(1);
expect(await objectKeys()).toEqual([]);
await isolate("identity-a").get(url);
expect(upstream).toHaveBeenCalledTimes(2);
});

it("persists an allowlisted trace item", async () => {
respondWith({ itemId: "1" }, { "Cache-Control": PRIVATE_5M });
await isolate("identity-a").get(TRACE_ITEM_URL);
expect(await objectKeys()).toHaveLength(1);
});

it("enforces the 5-minute cap on read, whatever max-age says", async () => {
respondWith(
{ id: "event-1" },
{ "Cache-Control": "private, max-age=3600" },
);
await isolate("identity-a").get(ORG_EVENT_URL);

vi.setSystemTime(Date.now() + R2_MAX_TTL_MS - 1000);
await isolate("identity-a").get(ORG_EVENT_URL);
expect(upstream).toHaveBeenCalledTimes(1);

vi.setSystemTime(Date.now() + 2000);
await isolate("identity-a").get(ORG_EVENT_URL);
expect(upstream).toHaveBeenCalledTimes(2);
expect(sentryMetricsCount).toHaveBeenCalledWith(
"app.response_cache.r2.lookup",
1,
{ attributes: { "app.response_cache.result": "expired" } },
);
});

it("expires at a server max-age shorter than 5 minutes", async () => {
respondWith({ id: "event-1" }, { "Cache-Control": "private, max-age=60" });
await isolate("identity-a").get(ORG_EVENT_URL);

vi.setSystemTime(Date.now() + 61_000);
await isolate("identity-a").get(ORG_EVENT_URL);
expect(upstream).toHaveBeenCalledTimes(2);
});

it("never persists the raw upstream token", async () => {
respondWith(
{ id: "event-1" },
{ "Cache-Control": PRIVATE_5M, Vary: "Authorization" },
);
await isolate("identity-a").get(ORG_EVENT_URL);

const [key] = await objectKeys();
const object = await bucket.get(key ?? "");
const stored = new TextDecoder().decode(await object?.arrayBuffer());
expect(stored).not.toContain(TOKEN);
// The hashed Authorization still matches for the same token.
await isolate("identity-a").get(ORG_EVENT_URL);
expect(upstream).toHaveBeenCalledTimes(1);
});
});
Loading
Loading