From 49149a9b6ea83b82cfd9ca43aadb1c43881c84d6 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 7 Oct 2026 10:33:59 -0400 Subject: [PATCH 1/5] feat(cli): Add an anonymous Snake leaderboard A finished Snake game sends one snake.score metric tagged only with a random player handle. It goes out in a new trace with the user cleared, and beforeSendMetric keeps only an attribute allowlist. Nothing is sent when telemetry is off. The sentry-mcp Worker serves GET /api/games/snake/leaderboard. It reads the top 10 scores of the last 30 days with a read-only token, caches the result in KV, rate-limits by hashed IP, and returns only generic errors. `sentry games leaderboard` validates every row again before it prints. Co-Authored-By: Claude Opus 5.5 --- .../cli-docs/src/content/docs/contributing.md | 2 +- apps/cli-docs/src/fragments/commands/games.md | 21 ++ docs/cloudflare/architecture.md | 22 +- docs/operations/github-actions.md | 20 +- docs/releases/cloudflare.md | 45 +++- .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/games.md | 10 + packages/cli/src/commands/games/index.ts | 2 + .../cli/src/commands/games/leaderboard.ts | 136 ++++++++++ packages/cli/src/commands/games/snake.ts | 16 +- packages/cli/src/lib/env-registry.ts | 6 + packages/cli/src/lib/games/player.ts | 106 ++++++++ packages/cli/src/lib/games/score.ts | 41 +++ packages/cli/src/lib/init/ui/ink-app.tsx | 33 ++- packages/cli/src/lib/init/ui/ink-snake.tsx | 13 +- packages/cli/src/lib/init/ui/ink-ui.ts | 3 +- packages/cli/src/lib/telemetry.ts | 29 ++ .../test/commands/games/leaderboard.test.ts | 131 +++++++++ packages/cli/test/lib/games/player.test.ts | 18 ++ packages/cli/test/lib/games/score.test.ts | 94 +++++++ .../test/lib/init/ui/ink-snake-app.test.tsx | 40 ++- packages/mcp-cloudflare/.env.example | 5 + packages/mcp-cloudflare/src/server/app.ts | 2 + .../src/server/routes/games.test.ts | 253 ++++++++++++++++++ .../mcp-cloudflare/src/server/routes/games.ts | 200 ++++++++++++++ packages/mcp-cloudflare/src/server/types.ts | 2 + packages/mcp-cloudflare/wrangler.jsonc | 55 ++-- 27 files changed, 1232 insertions(+), 74 deletions(-) create mode 100644 packages/cli/src/commands/games/leaderboard.ts create mode 100644 packages/cli/src/lib/games/player.ts create mode 100644 packages/cli/src/lib/games/score.ts create mode 100644 packages/cli/test/commands/games/leaderboard.test.ts create mode 100644 packages/cli/test/lib/games/player.test.ts create mode 100644 packages/cli/test/lib/games/score.test.ts create mode 100644 packages/mcp-cloudflare/src/server/routes/games.test.ts create mode 100644 packages/mcp-cloudflare/src/server/routes/games.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index d2d8046cd..7582c6198 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -68,7 +68,7 @@ toolkit/ │ │ │ ├── dsn/ # list │ │ │ ├── event/ # list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view -│ │ │ ├── games/ # snake +│ │ │ ├── games/ # leaderboard, snake │ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view │ │ │ ├── local/ # run, serve │ │ │ ├── log/ # list, view diff --git a/apps/cli-docs/src/fragments/commands/games.md b/apps/cli-docs/src/fragments/commands/games.md index 7e3f4bd05..3a74c8d61 100644 --- a/apps/cli-docs/src/fragments/commands/games.md +++ b/apps/cli-docs/src/fragments/commands/games.md @@ -9,3 +9,24 @@ sentry games snake Steer with the arrow keys, pause with `p`, retry with `r`, and quit with `esc` or `q`. The game needs an interactive terminal and is not available when an AI agent runs the CLI. + + +### Show the leaderboard + +```bash +sentry games leaderboard +``` + +Shows the top Snake scores from the last 30 days (a rolling window). Your own +row is marked `(you)`. Use `--json` for machine-readable output. + +## Anonymous scores + +When a Snake game ends, the CLI sends one score and a random player handle such +as `brave-otter-42`. The handle is generated on your machine and is not linked +to your account, name, email, organization, or installation. The score is sent +in its own trace, apart from the CLI's other telemetry. + +Scores are sent only when telemetry is on. To opt out, set +`SENTRY_CLI_NO_TELEMETRY=1` or `DO_NOT_TRACK=1`, or run +`sentry cli defaults telemetry off`. diff --git a/docs/cloudflare/architecture.md b/docs/cloudflare/architecture.md index 122e9ed12..71874b609 100644 --- a/docs/cloudflare/architecture.md +++ b/docs/cloudflare/architecture.md @@ -45,6 +45,7 @@ export default new Hono() ``` **Features:** + - OAuth 2.0 flow with Sentry - Token storage in Cloudflare KV - Automatic token refresh @@ -59,12 +60,13 @@ React-based chat UI with real-time streaming: export function Chat() { const { messages, handleSubmit } = useChat({ api: "/api/chat", - headers: { Authorization: `Bearer ${authToken}` } + headers: { Authorization: `Bearer ${authToken}` }, }); } ``` **Features:** + - Message streaming with Vercel AI SDK - Tool call visualization - Slash commands (/help, /prompts, /clear) @@ -82,12 +84,13 @@ const mcpClient = await experimental_createMCPClient({ transport: { type: "sse", url: sseUrl, - headers: { Authorization: `Bearer ${accessToken}` } - } + headers: { Authorization: `Bearer ${accessToken}` }, + }, }); ``` **Features:** + - Server-sent events (SSE) for MCP communication - Automatic tool discovery - Prompt metadata endpoint @@ -102,11 +105,12 @@ const result = streamText({ model: openai("gpt-4o"), messages: processedMessages, tools: mcpTools, - system: "You are an AI assistant for testing Sentry MCP..." + system: "You are an AI assistant for testing Sentry MCP...", }); ``` **Features:** + - Streaming responses - Tool execution - Prompt template processing @@ -115,11 +119,13 @@ const result = streamText({ ## Data Flow 1. **User Authentication**: + ``` User → OAuth Login → Sentry → OAuth Callback → KV Storage ``` 2. **Chat Message Flow**: + ``` User Input → Chat API → Process Prompts → AI Model → Stream Response ↓ @@ -151,13 +157,21 @@ COOKIE_SECRET = "..." # For session encryption OPENAI_API_KEY = "..." # For GPT-4 access SENTRY_CLIENT_ID = "..." # OAuth app ID SENTRY_CLIENT_SECRET = "..." # OAuth app secret +SENTRY_GAMES_READ_TOKEN = "..." # Optional: Snake leaderboard (set with `wrangler secret put`) ``` +`SENTRY_GAMES_READ_TOKEN` is the Sentry token used by +`GET /api/games/snake/leaderboard`. The token must belong to a bot account that +has only `org:read` and is a member only of the team that owns the CLI project. +Enable "Prevent storing IP addresses" on that project. +Without the token, the leaderboard route returns 503. + ### API Routes - `/api/auth/*` - Authentication endpoints - `/api/chat` - Main chat endpoint - `/api/metadata` - MCP metadata endpoint +- `/api/games/snake/leaderboard` - Public Snake leaderboard (rate limited by IP, cached in `MCP_CACHE` for 300 seconds) - `/sse` - Server-sent events for MCP ## Security Considerations diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index 06f18c129..2d34716e8 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -8,6 +8,7 @@ and docs when `SENTRY_CLIENT_ID` is available. ## Workflows ### test.yml + Runs on pushes to `main`, pull requests, and merge queue entries. Discovery reads pnpm workspace projects and their package scripts. Pull requests check changed projects, their workspace consumers, and semantic dependencies (the CLI @@ -19,6 +20,7 @@ Package-specific exceptions live in `package.json#sentryCi`; the standalone smoke-test suite remains in its own workflow. ### deploy.yml + Runs after a successful `Test` push run on `main`. Checks out the tested commit and requires that it is still the tip of `main`. Builds once, records the active production version, and uploads one new version of `sentry-mcp` from Vite's @@ -32,6 +34,7 @@ it restores the exact captured prior version only if the live deployments still belong to this run. ### recover-cloudflare-deployment.yml + Manual `workflow_dispatch` recovery accepts a deployment run ID and attempt. It runs trusted current-`main` code in the protected `production` environment, checks the completed source run, and derives the prior version from contiguous @@ -45,15 +48,18 @@ only its 404 response. When the route exists, the smoke test compares its version ID with the restored version. ### migrate-cloudflare-token.yml + Moves the Cloudflare API token from a repository secret into the protected `production` environment. Only `main` in the Toolkit repository can run it. Copy and removal are separate dispatches so a normal production deployment can prove that the environment copy works before the repository copy is deleted. ### eval.yml + Runs evaluation tests against the MCP server. ### pr-risk-jev.yml + Classifies PR risk with Jev and publishes one `risk: low`, `risk: medium`, or `risk: high` label. Runs when a non-draft PR is opened, updated with a push, reopened, marked ready for review, or edited. Manual dispatch accepts a PR number @@ -67,6 +73,7 @@ are cleared and the PR stays unclassified. Results are retained as workflow artifacts for 30 days. ### pr-risk-labels-test.yml + Runs the label publisher's regression tests when its workflow or tests change. Covers label replacement, stale revisions, failed classifications, and concurrent label creation. @@ -89,23 +96,26 @@ Other configuration: - **`SENTRY_CLIENT_SECRET`** - Sentry OAuth client secret - **`COOKIE_SECRET`** - Session cookie encryption secret - **`OPENAI_API_KEY`** - For AI-powered search features +- **`SENTRY_GAMES_READ_TOKEN`** - Not a GitHub secret. The deploy workflow does not pass Worker secrets; set it with `wrangler secret put SENTRY_GAMES_READ_TOKEN`. The token must belong to a bot account with only `org:read` and membership only in the team that owns the CLI project. Enable "Prevent storing IP addresses" on that project. - **`AI_GATEWAY_API_KEY`** - Vercel AI Gateway key for Jev PR risk classification ## Deployment Architecture ### Workers + - **`sentry-mcp`** - Production worker at `https://mcp.sentry.dev` - The candidate is tested on the production Worker at 0% traffic before promotion. ### Resource Isolation + The existing canary Worker has separate resources; exact-version rollout does not deploy it. The production candidate uses the production bindings: -| Resource | Production | Canary | -|----------|------------|---------| -| KV Namespace | `8dd5e9bafe1945298e2d5ca3b408a553` | `a3fe0d23b2d34416930e284362a88a3b` | -| Rate Limiter IDs | `1001`, `1002`, `1003`, `1004` | `2001`, `2002`, `2003`, `2004` | -| Wrangler Config | `wrangler.jsonc` | `wrangler.canary.jsonc` | +| Resource | Production | Canary | +| ---------------- | ---------------------------------- | ---------------------------------- | +| KV Namespace | `8dd5e9bafe1945298e2d5ca3b408a553` | `a3fe0d23b2d34416930e284362a88a3b` | +| Rate Limiter IDs | `1001`, `1002`, `1003`, `1004` | `2001`, `2002`, `2003`, `2004` | +| Wrangler Config | `wrangler.jsonc` | `wrangler.canary.jsonc` | ### Deployment Flow diff --git a/docs/releases/cloudflare.md b/docs/releases/cloudflare.md index dc02a5bf8..2f7544c71 100644 --- a/docs/releases/cloudflare.md +++ b/docs/releases/cloudflare.md @@ -5,6 +5,7 @@ Cloudflare Workers deployment configuration and release process. ## Architecture Overview The deployment consists of: + - **Worker**: Stateless HTTP server with OAuth flow and MCP handler - **KV Storage**: OAuth token storage - **Static Assets**: React UI for setup instructions @@ -20,26 +21,29 @@ The deployment consists of: "compatibility_date": "2025-03-21", "compatibility_flags": [ "nodejs_compat", - "nodejs_compat_populate_process_env" + "nodejs_compat_populate_process_env", ], "keep_vars": true, // Bindings - "kv_namespaces": [{ - "binding": "OAUTH_KV", - "id": "your-kv-namespace-id" - }], + "kv_namespaces": [ + { + "binding": "OAUTH_KV", + "id": "your-kv-namespace-id", + }, + ], // SPA configuration "site": { - "bucket": "./dist/client" - } + "bucket": "./dist/client", + }, } ``` ### Environment Variables Required in production: + ```bash SENTRY_CLIENT_ID=your_oauth_app_id SENTRY_CLIENT_SECRET=your_oauth_app_secret @@ -47,6 +51,7 @@ COOKIE_SECRET=32_char_random_string ``` Optional overrides for self-hosted deployments: + ```bash # Leave unset to target the SaaS host SENTRY_HOST=sentry.example.com # Hostname only (self-hosted only) @@ -56,7 +61,14 @@ Configure these overrides only when your Cloudflare deployment connects to a self-hosted Sentry instance; no additional host variables are required for the SaaS service. +Optional secret for the Snake leaderboard (`GET /api/games/snake/leaderboard`). +Set it with `wrangler secret put SENTRY_GAMES_READ_TOKEN`. Without it, the +route returns 503. The token must belong to a bot account with only `org:read` +and membership only in the team that owns the CLI project. Enable "Prevent +storing IP addresses" on that project. + Development (.dev.vars): + ```bash SENTRY_CLIENT_ID=dev_client_id SENTRY_CLIENT_SECRET=dev_secret @@ -72,7 +84,11 @@ import { experimental_createMcpHandler as createMcpHandler } from "agents/mcp"; import { buildServer } from "@sentry/mcp-server/server"; const mcpHandler: ExportedHandler = { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { // Extract auth props from ExecutionContext (set by OAuth provider) const oauthCtx = ctx as OAuthExecutionContext; @@ -81,7 +97,7 @@ const mcpHandler: ExportedHandler = { userId: oauthCtx.props.userId, clientId: oauthCtx.props.clientId, accessToken: oauthCtx.props.accessToken, - grantedSkills, // Primary authorization method + grantedSkills, // Primary authorization method constraints: verification.constraints, sentryHost, mcpUrl: oauthCtx.props.mcpUrl, @@ -179,6 +195,7 @@ For feature branches, GitHub Actions automatically uploads new versions without 4. Use Cloudflare dashboard to gradually roll out the version Manual version upload: + ```bash pnpm cf:versions:upload ``` @@ -186,6 +203,7 @@ pnpm cf:versions:upload ### Creating Resources First-time setup: + ```bash # Create KV namespace for OAuth token storage npx wrangler kv:namespace create OAUTH_KV @@ -196,6 +214,7 @@ npx wrangler kv:namespace create OAUTH_KV ## Multi-Region Considerations Cloudflare Workers run globally, but consider: + - KV is eventually consistent globally - Workers are stateless and edge-deployed - Use regional hints for performance @@ -205,10 +224,7 @@ Cloudflare Workers run globally, but consider: ### CORS Settings ```typescript -const ALLOWED_ORIGINS = [ - "https://sentry.io", - "https://*.sentry.io" -]; +const ALLOWED_ORIGINS = ["https://sentry.io", "https://*.sentry.io"]; // Apply to responses response.headers.set("Access-Control-Allow-Origin", origin); @@ -219,7 +235,7 @@ response.headers.set("Access-Control-Allow-Credentials", "true"); ```typescript // Secure cookie settings -"HttpOnly; Secure; SameSite=Lax; Max-Age=2592000" +"HttpOnly; Secure; SameSite=Lax; Max-Age=2592000"; ``` ## Monitoring @@ -245,6 +261,7 @@ export default { ### Worker Analytics Monitor via Cloudflare dashboard: + - Request rates - Error rates - CPU time and memory usage diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 257d25e03..5f53c4fb8 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -645,6 +645,7 @@ Manage User Feedback Terminal games +- `sentry games leaderboard` — Show the top Snake scores from the last 30 days - `sentry games snake` — Play Snake in your terminal → Full flags and examples: `references/games.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md index 306643e3e..4bcc6e9e7 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/games.md @@ -11,6 +11,16 @@ requires: Terminal games +### `sentry games leaderboard` + +Show the top Snake scores from the last 30 days + +**Examples:** + +```bash +sentry games leaderboard +``` + ### `sentry games snake` Play Snake in your terminal diff --git a/packages/cli/src/commands/games/index.ts b/packages/cli/src/commands/games/index.ts index cf1da1f9e..9a1ebc9d9 100644 --- a/packages/cli/src/commands/games/index.ts +++ b/packages/cli/src/commands/games/index.ts @@ -1,8 +1,10 @@ import { buildRouteMap } from "../../lib/route-map.js"; +import { leaderboardCommand } from "./leaderboard.js"; import { snakeCommand } from "./snake.js"; export const gamesRoute = buildRouteMap({ routes: { + leaderboard: leaderboardCommand, snake: snakeCommand, }, docs: { diff --git a/packages/cli/src/commands/games/leaderboard.ts b/packages/cli/src/commands/games/leaderboard.ts new file mode 100644 index 000000000..e44bd5312 --- /dev/null +++ b/packages/cli/src/commands/games/leaderboard.ts @@ -0,0 +1,136 @@ +/** + * sentry games leaderboard + * + * Show the top Snake scores from the last 30 days. Scores are anonymous: each + * entry is a random player handle and a score. + */ + +import { + array, + type InferOutput, + integer, + maxValue, + minValue, + number, + object, + pipe, + regex, + safeParse, + string, + unknown, +} from "valibot"; +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { customFetch } from "../../lib/custom-ca.js"; +import { getEnv } from "../../lib/env.js"; +import { CliError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { MAX_SNAKE_SCORE } from "../../lib/games/score.js"; +import { + getPlayerHandle, + PLAYER_HANDLE_REGEX, +} from "../../lib/games/player.js"; +import { logger } from "../../lib/logger.js"; +import type { Writer } from "../../types/index.js"; + +const log = logger.withTag("games.leaderboard"); + +const DEFAULT_GAMES_API_URL = "https://mcp.sentry.dev"; +const LEADERBOARD_PATH = "/api/games/snake/leaderboard"; +const REQUEST_TIMEOUT_MS = 5000; +const LOAD_FAILED_MESSAGE = "Could not load the leaderboard. Try again later."; + +const ResponseSchema = object({ + period: string(), + entries: array(unknown()), +}); + +const EntrySchema = object({ + rank: pipe(number(), integer(), minValue(1)), + handle: pipe(string(), regex(PLAYER_HANDLE_REGEX)), + score: pipe(number(), integer(), minValue(1), maxValue(MAX_SNAKE_SCORE)), +}); + +type LeaderboardEntry = InferOutput; + +type LeaderboardData = { + period: string; + entries: LeaderboardEntry[]; +}; + +async function fetchLeaderboard(): Promise { + const baseUrl = getEnv().SENTRY_GAMES_API_URL || DEFAULT_GAMES_API_URL; + let body: unknown; + try { + const response = await customFetch( + `${baseUrl.replace(/\/+$/, "")}${LEADERBOARD_PATH}`, + { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + body = await response.json(); + } catch (error) { + log.debug("Leaderboard request failed", error); + throw new CliError(LOAD_FAILED_MESSAGE); + } + + const parsed = safeParse(ResponseSchema, body); + if (!parsed.success) { + log.debug("Leaderboard response failed validation"); + throw new CliError(LOAD_FAILED_MESSAGE); + } + + // Drop rows that fail validation instead of printing server-controlled text. + const entries: LeaderboardEntry[] = []; + for (const raw of parsed.output.entries) { + const entry = safeParse(EntrySchema, raw); + if (entry.success) { + entries.push(entry.output); + } + } + return { period: parsed.output.period, entries }; +} + +function formatLeaderboardHuman(data: LeaderboardData): string { + if (data.entries.length === 0) { + return "No scores in the last 30 days."; + } + + const handle = getPlayerHandle(); + const columns: Column[] = [ + { header: "RANK", value: (e) => String(e.rank) }, + { + header: "PLAYER", + value: (e) => (e.handle === handle ? `${e.handle} (you)` : e.handle), + }, + { header: "SCORE", value: (e) => String(e.score) }, + ]; + + const parts: string[] = []; + const buffer: Writer = { write: (s: string) => parts.push(s) }; + writeTable(buffer, data.entries, columns); + return parts.join("").trimEnd(); +} + +export const leaderboardCommand = buildCommand({ + docs: { + brief: "Show the top Snake scores from the last 30 days", + fullDescription: + "Show the top Snake scores from the last 30 days.\n\n" + + "Scores are anonymous. Each entry is a random player handle and a score. " + + "Your own entry is marked `(you)`.\n\n" + + "Examples:\n" + + " sentry games leaderboard\n" + + " sentry games leaderboard --json", + }, + auth: false, + output: { human: formatLeaderboardHuman }, + parameters: {}, + async *func(this: SentryContext) { + const data = await fetchLeaderboard(); + yield new CommandOutput(data); + return { hint: `You play as ${getPlayerHandle()}.` }; + }, +}); diff --git a/packages/cli/src/commands/games/snake.ts b/packages/cli/src/commands/games/snake.ts index f577fb0cb..255bf84f0 100644 --- a/packages/cli/src/commands/games/snake.ts +++ b/packages/cli/src/commands/games/snake.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { detectAgent } from "../../lib/detect-agent.js"; import { ValidationError } from "../../lib/errors.js"; +import { reportSnakeScore } from "../../lib/games/score.js"; const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[2J\x1b[H"; const LEAVE_ALT_SCREEN = "\x1b[?1049l"; @@ -49,12 +50,15 @@ export const snakeCommand = buildCommand({ const quit = () => instance?.unmount(); process.on("SIGINT", quit); try { - instance = app.mountSnakeGame({ - // Ctrl+C is routed through the game's own shortcut so it exits cleanly. - exitOnCtrlC: false, - patchConsole: false, - ...(freshStdin ? { stdin: freshStdin } : {}), - }); + instance = app.mountSnakeGame( + { + // Ctrl+C is routed through the game's own shortcut so it exits cleanly. + exitOnCtrlC: false, + patchConsole: false, + ...(freshStdin ? { stdin: freshStdin } : {}), + }, + reportSnakeScore, + ); await instance.waitUntilExit(); } finally { process.removeListener("SIGINT", quit); diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 96b5dc901..2c207ea8a 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -189,6 +189,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Control the optional Snake game that `sentry init` offers while it waits for setup to finish. Set to `0` to hide it. The game is always hidden when an AI agent runs the CLI.", example: "0", }, + { + name: "SENTRY_GAMES_API_URL", + description: + "Base URL of the service that serves the `sentry games leaderboard` scores. Defaults to `https://mcp.sentry.dev`. Mainly useful for testing.", + example: "http://localhost:8787", + }, // -- TLS / Certificates -- { name: "NODE_EXTRA_CA_CERTS", diff --git a/packages/cli/src/lib/games/player.ts b/packages/cli/src/lib/games/player.ts new file mode 100644 index 000000000..a18c06cb3 --- /dev/null +++ b/packages/cli/src/lib/games/player.ts @@ -0,0 +1,106 @@ +/** + * Anonymous player handle for game leaderboards. + * + * A random `adjective-animal-NN` label generated once and stored in the + * metadata table. It is deliberately independent of the telemetry instance ID, + * user, and machine data, so a leaderboard entry cannot be linked to a person. + */ + +import { randomInt } from "node:crypto"; +import { getDatabase } from "../db/index.js"; +import { getMetadata, setMetadata } from "../db/utils.js"; + +const PLAYER_HANDLE_KEY = "games.handle"; + +/** Shape of a valid handle. Also used to vet handles received from the server. */ +export const PLAYER_HANDLE_REGEX = /^[a-z]{2,12}-[a-z]{2,12}-\d{2}$/; + +const ADJECTIVES = [ + "brave", + "calm", + "clever", + "cosmic", + "crisp", + "daring", + "eager", + "fancy", + "fuzzy", + "gentle", + "giant", + "happy", + "jolly", + "keen", + "lucky", + "merry", + "mighty", + "nimble", + "noble", + "plucky", + "proud", + "quick", + "quiet", + "rapid", + "shiny", + "silent", + "sleepy", + "sneaky", + "swift", + "tiny", + "witty", + "zesty", +] as const; + +const ANIMALS = [ + "badger", + "beaver", + "bison", + "cobra", + "crane", + "dingo", + "eagle", + "falcon", + "ferret", + "gecko", + "heron", + "ibis", + "jaguar", + "koala", + "lemur", + "lynx", + "marmot", + "newt", + "ocelot", + "otter", + "panda", + "parrot", + "puffin", + "quokka", + "raven", + "salmon", + "sloth", + "tapir", + "toucan", + "walrus", + "weasel", + "wombat", +] as const; + +function generateHandle(): string { + const adjective = ADJECTIVES[randomInt(ADJECTIVES.length)]; + const animal = ANIMALS[randomInt(ANIMALS.length)]; + const suffix = String(randomInt(100)).padStart(2, "0"); + return `${adjective}-${animal}-${suffix}`; +} + +/** Return the persisted player handle, creating it on first use. */ +export function getPlayerHandle(): string { + const db = getDatabase(); + const existing = getMetadata(db, [PLAYER_HANDLE_KEY]).get(PLAYER_HANDLE_KEY); + if (existing && PLAYER_HANDLE_REGEX.test(existing)) { + return existing; + } + + const handle = generateHandle(); + setMetadata(db, { [PLAYER_HANDLE_KEY]: handle }); + return handle; +} diff --git a/packages/cli/src/lib/games/score.ts b/packages/cli/src/lib/games/score.ts new file mode 100644 index 000000000..79556937d --- /dev/null +++ b/packages/cli/src/lib/games/score.ts @@ -0,0 +1,41 @@ +/** + * Anonymous game score reporting. + * + * A score is sent as a single metric tagged only with the random player + * handle. It is emitted in its own trace with the user cleared, so it cannot + * be joined to the CLI's other telemetry. `beforeSendMetric` in telemetry.ts + * enforces the attribute allowlist as a second layer. + */ + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import +import * as Sentry from "@sentry/node-core/light"; +import { isTelemetryEnabled } from "../telemetry.js"; +import { getPlayerHandle } from "./player.js"; + +export const SNAKE_SCORE_METRIC = "snake.score"; +export const MAX_SNAKE_SCORE = 10_000; + +/** Record a finished Snake game. No-op when telemetry is off or the score is out of range. */ +export function reportSnakeScore(score: number): void { + if ( + !isTelemetryEnabled() || + !Number.isInteger(score) || + score < 1 || + score > MAX_SNAKE_SCORE + ) { + return; + } + + const handle = getPlayerHandle(); + Sentry.withIsolationScope((isolationScope) => { + isolationScope.setUser(null); + Sentry.withScope((scope) => { + scope.setUser(null); + Sentry.startNewTrace(() => { + Sentry.metrics.distribution(SNAKE_SCORE_METRIC, score, { + attributes: { handle }, + }); + }); + }); + }); +} diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index c1338b0dc..356a29e59 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -183,17 +183,18 @@ export function formatFeedbackBanner( export type AppProps = { store: WizardStore; + onGameOver?: (score: number) => void; }; -export function App({ store }: AppProps): React.ReactNode { +export function App({ store, onGameOver }: AppProps): React.ReactNode { return ( - + ); } -function AppBody({ store }: AppProps): React.ReactNode { +function AppBody({ store, onGameOver }: AppProps): React.ReactNode { const snapshot = useSyncExternalStore( store.subscribe, store.getSnapshot, @@ -366,6 +367,7 @@ function AppBody({ store }: AppProps): React.ReactNode { muted={MUTED_DIM} onCancel={cancelFromSnake} onExit={closeSnake} + onGameOver={onGameOver} session={snakeSession} /> @@ -3116,20 +3118,29 @@ type InkInstance = { export function mountApp( store: WizardStore, options: MountOptions, + onGameOver?: (score: number) => void, ): InkInstance { - return inkRender(createElement(App, { store }), options); + return inkRender(createElement(App, { store, onGameOver }), options); } /** Full-screen Snake for `sentry games snake`; esc, q, and ctrl+c exit the app. */ -export function SnakeGameApp(): React.ReactNode { +export function SnakeGameApp({ + onGameOver, +}: { + onGameOver?: (score: number) => void; +}): React.ReactNode { return ( - + ); } -function SnakeGameScreen(): React.ReactNode { +function SnakeGameScreen({ + onGameOver, +}: { + onGameOver?: (score: number) => void; +}): React.ReactNode { const { exit } = useApp(); const { columns, rows } = useInkFrameSize(); const [session] = useState(createSnakeSession); @@ -3150,6 +3161,7 @@ function SnakeGameScreen(): React.ReactNode { muted={MUTED_DIM} onCancel={close} onExit={close} + onGameOver={onGameOver} session={session} /> @@ -3159,6 +3171,9 @@ function SnakeGameScreen(): React.ReactNode { } /** Mount the standalone Snake game; same sidecar rules as `mountApp`. */ -export function mountSnakeGame(options: MountOptions): InkInstance { - return inkRender(createElement(SnakeGameApp), options); +export function mountSnakeGame( + options: MountOptions, + onGameOver?: (score: number) => void, +): InkInstance { + return inkRender(createElement(SnakeGameApp, { onGameOver }), options); } diff --git a/packages/cli/src/lib/init/ui/ink-snake.tsx b/packages/cli/src/lib/init/ui/ink-snake.tsx index 663fd7529..1de30a854 100644 --- a/packages/cli/src/lib/init/ui/ink-snake.tsx +++ b/packages/cli/src/lib/init/ui/ink-snake.tsx @@ -73,6 +73,8 @@ type SnakeGameProps = { muted: string; onCancel: () => void; onExit: () => void; + /** Called once with the final score when a run ends. */ + onGameOver?: (score: number) => void; session: SnakeSession; }; @@ -105,6 +107,7 @@ function SnakeBoard({ muted, onCancel, onExit, + onGameOver, session, width, }: SnakeGameProps & { height: number; width: number }): React.ReactNode { @@ -113,18 +116,24 @@ function SnakeBoard({ ? resizeSnake(session.state, width, height) : createSnake(width, height); const state = session.state; + const onGameOverRef = useRef(onGameOver); + onGameOverRef.current = onGameOver; const apply = useCallback( (update: (current: SnakeState) => SnakeState) => { if (!session.state) { return; } - const next = update(session.state); - if (next === session.state) { + const current = session.state; + const next = update(current); + if (next === current) { return; } session.state = next; session.best = Math.max(session.best, next.score); + if (next.status === "over" && current.status !== "over") { + onGameOverRef.current?.(next.score); + } redraw(); }, [session], diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index e64ce9778..2f0ffcebf 100644 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ b/packages/cli/src/lib/init/ui/ink-ui.ts @@ -70,6 +70,7 @@ import { formatSuccessExitLine, formatSuccessReport, } from "./ink-report.js"; +import { reportSnakeScore } from "../../games/score.js"; import { LEARN_SEQUENCE } from "./learn-content.js"; import { SENTRY_TIPS } from "./sentry-tips.js"; import { @@ -376,7 +377,7 @@ export async function createInkUI( // startup never shows stale layout from a prior render. process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H"); try { - const instance = app.mountApp(store, renderOptions); + const instance = app.mountApp(store, renderOptions, reportSnakeScore); return new InkUI(instance, store, freshStdin, { initialWelcome, diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 1364b35a2..befb89253 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -11,6 +11,7 @@ import { chmodSync, statSync } from "node:fs"; import { createRequire } from "node:module"; +import type { Metric } from "@sentry/core"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; @@ -158,6 +159,32 @@ export function isTelemetryEnabled(): boolean { return computeTelemetryEffective().enabled; } +/** + * Attributes a `snake.score` metric may carry. Everything else the SDK adds + * (user.*, replay ids, ...) is dropped so a score cannot be linked to a user. + */ +const SNAKE_SCORE_ATTRIBUTE_ALLOWLIST = new Set([ + "handle", + "sentry.release", + "sentry.environment", + "sentry.sdk.name", + "sentry.sdk.version", +]); + +/** `beforeSendMetric` hook: allowlist attributes for anonymous game scores, pass other metrics through. */ +export function scrubAnonymousMetric(metric: Metric): Metric { + if (metric.name !== "snake.score") { + return metric; + } + const attributes: Record = {}; + for (const [key, value] of Object.entries(metric.attributes ?? {})) { + if (SNAKE_SCORE_ATTRIBUTE_ALLOWLIST.has(key)) { + attributes[key] = value; + } + } + return { ...metric, attributes }; +} + /** * Wrap CLI execution with telemetry tracking. * @@ -657,6 +684,8 @@ export function initSentry( // Propagate traces to Sentry API for distributed tracing tracePropagationTargets: getSentryTracePropagationTargets(), + beforeSendMetric: scrubAnonymousMetric, + beforeSendTransaction: (event) => { // Remove server_name which may contain hostname (PII) event.server_name = undefined; diff --git a/packages/cli/test/commands/games/leaderboard.test.ts b/packages/cli/test/commands/games/leaderboard.test.ts new file mode 100644 index 000000000..516140196 --- /dev/null +++ b/packages/cli/test/commands/games/leaderboard.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { leaderboardCommand } from "../../../src/commands/games/leaderboard.js"; +import { withEnv } from "../../../src/lib/env.js"; +import { CliError } from "../../../src/lib/errors.js"; +import { getPlayerHandle } from "../../../src/lib/games/player.js"; +import { useTestConfigDir } from "../../helpers.js"; +import { createMockServer, type MockServer } from "../../mocks/server.js"; + +useTestConfigDir("test-games-leaderboard-"); + +const PATH = "/api/games/snake/leaderboard"; + +async function run( + server: MockServer, + flags: { json: boolean }, +): Promise { + const chunks: string[] = []; + const context = { + stdout: { write: (s: string) => chunks.push(s) }, + stderr: { write: () => true }, + stdin: { isTTY: false }, + }; + const func = await leaderboardCommand.loader(); + await withEnv({ ...process.env, SENTRY_GAMES_API_URL: server.url }, () => + func.call(context as never, flags as never), + ); + return chunks.join(""); +} + +async function withServer( + status: number, + body: unknown, + fn: (server: MockServer) => Promise, +): Promise { + const server = createMockServer([ + { method: "GET", path: PATH, response: body, status }, + ]); + await server.start(); + try { + return await fn(server); + } finally { + server.stop(); + } +} + +describe("games leaderboard", () => { + const mockedFetch = globalThis.fetch; + beforeEach(() => { + // The preload blocks network access; the mock server listens on localhost. + globalThis.fetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = mockedFetch; + }); + + test("renders the table and marks the local player", async () => { + const handle = getPlayerHandle(); + const out = await withServer( + 200, + { + period: "30d", + entries: [ + { rank: 1, handle: "brave-otter-42", score: 57 }, + { rank: 2, handle, score: 40 }, + ], + }, + (s) => run(s, { json: false }), + ); + expect(out).toContain("brave-otter-42"); + expect(out).toContain(`${handle} (you)`); + expect(out).toContain(`You play as ${handle}.`); + }); + + test("drops invalid rows", async () => { + const out = await withServer( + 200, + { + period: "30d", + entries: [ + { rank: 1, handle: "\x1b[31m-evil-00", score: 5 }, + { rank: 2, handle: "good-wolf-01", score: 0 }, + { rank: 3, handle: "ok-fox-02", score: 10_001 }, + { rank: 4, handle: "fine-fox-03", score: 9 }, + ], + }, + (s) => run(s, { json: false }), + ); + expect(out).toContain("fine-fox-03"); + expect(out).not.toContain("\x1b[31m"); + expect(out).not.toContain("good-wolf-01"); + expect(out).not.toContain("ok-fox-02"); + }); + + test("shows a message when there are no scores", async () => { + const out = await withServer(200, { period: "30d", entries: [] }, (s) => + run(s, { json: false }), + ); + expect(out).toContain("No scores in the last 30 days."); + }); + + test("fails with a generic message on a server error", async () => { + const error = await withServer( + 500, + { error: "secret internal detail" }, + (s) => run(s, { json: false }).catch((e: unknown) => e), + ); + expect(error).toBeInstanceOf(CliError); + expect((error as Error).message).toBe( + "Could not load the leaderboard. Try again later.", + ); + }); + + test("--json returns the period and validated entries", async () => { + const out = await withServer( + 200, + { + period: "30d", + entries: [ + { rank: 1, handle: "brave-otter-42", score: 57 }, + { rank: 2, handle: "bad handle", score: 3 }, + ], + }, + (s) => run(s, { json: true }), + ); + expect(JSON.parse(out)).toEqual({ + period: "30d", + entries: [{ rank: 1, handle: "brave-otter-42", score: 57 }], + }); + }); +}); diff --git a/packages/cli/test/lib/games/player.test.ts b/packages/cli/test/lib/games/player.test.ts new file mode 100644 index 000000000..d81c1069b --- /dev/null +++ b/packages/cli/test/lib/games/player.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; +import { + getPlayerHandle, + PLAYER_HANDLE_REGEX, +} from "../../../src/lib/games/player.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-games-player-"); + +describe("getPlayerHandle", () => { + test("matches the handle format", () => { + expect(getPlayerHandle()).toMatch(PLAYER_HANDLE_REGEX); + }); + + test("persists the same handle across calls", () => { + expect(getPlayerHandle()).toBe(getPlayerHandle()); + }); +}); diff --git a/packages/cli/test/lib/games/score.test.ts b/packages/cli/test/lib/games/score.test.ts new file mode 100644 index 000000000..ea883ce72 --- /dev/null +++ b/packages/cli/test/lib/games/score.test.ts @@ -0,0 +1,94 @@ +import { metrics } from "@sentry/node-core/light"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { getPlayerHandle } from "../../../src/lib/games/player.js"; +import { reportSnakeScore } from "../../../src/lib/games/score.js"; +import { + isTelemetryEnabled, + scrubAnonymousMetric, +} from "../../../src/lib/telemetry.js"; +import { useTestConfigDir } from "../../helpers.js"; + +// Enabling real telemetry would also turn on traced DB access; stub only the gate. +vi.mock("../../../src/lib/telemetry.js", async (importOriginal) => ({ + ...(await importOriginal()), + isTelemetryEnabled: vi.fn(() => true), +})); + +useTestConfigDir("test-games-score-"); + +describe("reportSnakeScore", () => { + let distribution: ReturnType; + + beforeEach(() => { + distribution = vi + .spyOn(metrics, "distribution") + .mockImplementation(() => undefined); + }); + + afterEach(() => { + distribution.mockRestore(); + }); + + test("emits the score with only the handle attribute", () => { + reportSnakeScore(12); + expect(distribution).toHaveBeenCalledTimes(1); + expect(distribution).toHaveBeenCalledWith("snake.score", 12, { + attributes: { handle: getPlayerHandle() }, + }); + }); + + test("is skipped when telemetry is disabled", () => { + vi.mocked(isTelemetryEnabled).mockReturnValueOnce(false); + reportSnakeScore(12); + expect(distribution).not.toHaveBeenCalled(); + }); + + test.each([0, -1, 1.5, Number.NaN, 10_001])( + "ignores invalid score %s", + (score) => { + reportSnakeScore(score); + expect(distribution).not.toHaveBeenCalled(); + }, + ); +}); + +describe("scrubAnonymousMetric", () => { + test("keeps only allowlisted attributes for snake.score", () => { + const result = scrubAnonymousMetric({ + name: "snake.score", + type: "distribution", + value: 5, + attributes: { + handle: "brave-otter-42", + "user.id": "1", + "user.email": "a@b.c", + "user.name": "x", + "server.address": "host", + "sentry.replay_id": "abc", + trace_id: "t", + span_id: "s", + "sentry.release": "1.0.0", + "sentry.environment": "production", + "sentry.sdk.name": "sdk", + "sentry.sdk.version": "1", + }, + }); + expect(Object.keys(result.attributes ?? {}).sort()).toEqual([ + "handle", + "sentry.environment", + "sentry.release", + "sentry.sdk.name", + "sentry.sdk.version", + ]); + }); + + test("leaves other metrics unchanged", () => { + const metric = { + name: "other.metric", + type: "counter" as const, + value: 1, + attributes: { "user.id": "1", foo: "bar" }, + }; + expect(scrubAnonymousMetric(metric)).toBe(metric); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx b/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx index a2d81b305..5b22465bd 100644 --- a/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-snake-app.test.tsx @@ -42,17 +42,20 @@ function makeStdin(): Readable { }); } -function mount() { +function mount(onGameOver?: (score: number) => void) { const out = new CaptureStream(); const stdin = makeStdin(); - const instance = mountSnakeGame({ - exitOnCtrlC: false, - patchConsole: false, - stdin: stdin as unknown as import("node:tty").ReadStream, - stdout: out, - // Ink writes no live frames in CI unless forced interactive. - interactive: true, - } as Parameters[0]); + const instance = mountSnakeGame( + { + exitOnCtrlC: false, + patchConsole: false, + stdin: stdin as unknown as import("node:tty").ReadStream, + stdout: out, + // Ink writes no live frames in CI unless forced interactive. + interactive: true, + } as Parameters[0], + onGameOver, + ); return { instance, out, stdin }; } @@ -112,4 +115,23 @@ describe("mountSnakeGame", () => { stdin.destroy(); } }); + + test("reports the score once when the snake hits a wall", async () => { + const scores: number[] = []; + const { instance, stdin } = mount((score) => scores.push(score)); + try { + await sleep(SETTLE_MS); + // The snake starts heading right; steering up then left runs it into a wall. + stdin.push("\u001B[A"); + await sleep(SETTLE_MS); + for (let i = 0; i < 40 && scores.length === 0; i++) { + await sleep(100); + } + await sleep(500); + expect(scores).toHaveLength(1); + expect(scores[0]).toBeGreaterThanOrEqual(0); + } finally { + instance.unmount(); + } + }, 15_000); }); diff --git a/packages/mcp-cloudflare/.env.example b/packages/mcp-cloudflare/.env.example index 797dabe75..abdce327f 100644 --- a/packages/mcp-cloudflare/.env.example +++ b/packages/mcp-cloudflare/.env.example @@ -29,3 +29,8 @@ MCP_HOST=http://localhost:5173 # Enable Spotlight SENTRY_SPOTLIGHT=1 + +# Token used by GET /api/games/snake/leaderboard to read Snake scores from Sentry. +# Must belong to a bot account with only org:read, a member only of the team +# that owns the CLI project. Leave empty to make the endpoint return 503. +# SENTRY_GAMES_READ_TOKEN= diff --git a/packages/mcp-cloudflare/src/server/app.ts b/packages/mcp-cloudflare/src/server/app.ts index 8d6b0f244..b87d25eea 100644 --- a/packages/mcp-cloudflare/src/server/app.ts +++ b/packages/mcp-cloudflare/src/server/app.ts @@ -9,6 +9,7 @@ import sentryOauth from "./oauth"; import { createProtectedResourceMetadataResponse } from "./protected-resource-metadata"; import chat from "./routes/chat"; import chatOauth from "./routes/chat-oauth"; +import games from "./routes/games"; import mcpRoutes from "./routes/mcp"; import metadata from "./routes/metadata"; import search from "./routes/search"; @@ -120,6 +121,7 @@ const app = new Hono<{ .route("/api/auth", chatOauth) .route("/api/chat", chat) .route("/api/search", search) + .route("/api/games", games) .route("/api/metadata", metadata) .route("/.mcp", mcpRoutes) .get("/sse", (c) => { diff --git a/packages/mcp-cloudflare/src/server/routes/games.test.ts b/packages/mcp-cloudflare/src/server/routes/games.test.ts new file mode 100644 index 000000000..13057d757 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/routes/games.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import gamesRoute from "./games"; +import type { Env } from "../types"; + +const TOKEN = "sntrys_test_token_value"; +const SCORE_FIELD = "max(value,snake.score,distribution,-)"; +const HANDLE_FIELD = "tags[handle,string]"; + +function createTestApp() { + const app = new Hono<{ Bindings: Env }>(); + app.route("/api/games", gamesRoute); + return app; +} + +function createKv(initial?: unknown) { + return { + get: vi.fn().mockResolvedValue(initial ?? null), + put: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeEnv(overrides: Record = {}): Env { + return { + SENTRY_GAMES_READ_TOKEN: TOKEN, + MCP_CACHE: createKv(), + ...overrides, + } as unknown as Env; +} + +function upstreamBody(rows: Array<[unknown, unknown]>) { + return { + data: rows.map(([handle, score]) => ({ + [HANDLE_FIELD]: handle, + [SCORE_FIELD]: score, + })), + }; +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const PATH = "/api/games/snake/leaderboard"; +const REQ = { headers: { "CF-Connecting-IP": "192.0.2.1" } }; + +describe("games leaderboard route", () => { + const fetchMock = vi.fn(); + let app: ReturnType; + + beforeEach(() => { + app = createTestApp(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns cached data without calling upstream", async () => { + const cached = { + period: "30d", + entries: [{ rank: 1, handle: "brave-otter-42", score: 57 }], + }; + const res = await app.request( + PATH, + REQ, + makeEnv({ MCP_CACHE: createKv(cached) }), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(cached); + expect(res.headers.get("Cache-Control")).toBe("public, max-age=60"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("treats an invalid cached value as a miss", async () => { + fetchMock.mockResolvedValue( + jsonResponse(upstreamBody([["brave-otter-42", 57]])), + ); + const res = await app.request( + PATH, + REQ, + makeEnv({ MCP_CACHE: createKv({ bogus: true }) }), + ); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("fetches upstream on a miss and writes the cache", async () => { + fetchMock.mockResolvedValue( + jsonResponse(upstreamBody([["brave-otter-42", 57]])), + ); + const env = makeEnv(); + const res = await app.request(PATH, REQ, env); + + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("public, max-age=60"); + expect(await res.json()).toEqual({ + period: "30d", + entries: [{ rank: 1, handle: "brave-otter-42", score: 57 }], + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + const parsed = new URL(url as string); + expect(parsed.origin + parsed.pathname).toBe( + "https://us.sentry.io/api/0/organizations/sentry/events/", + ); + expect(parsed.searchParams.get("dataset")).toBe("tracemetrics"); + expect(parsed.searchParams.get("project")).toBe("4510776311808000"); + expect(parsed.searchParams.getAll("field")).toEqual([ + HANDLE_FIELD, + SCORE_FIELD, + ]); + expect(parsed.searchParams.get("query")).toBe( + "metric.name:snake.score metric.type:distribution value:<=10000", + ); + expect(parsed.searchParams.get("statsPeriod")).toBe("30d"); + expect(parsed.searchParams.get("sort")).toBe(`-${SCORE_FIELD}`); + expect(parsed.searchParams.get("per_page")).toBe("50"); + expect((init as RequestInit).headers).toEqual({ + Authorization: `Bearer ${TOKEN}`, + }); + + const kv = env.MCP_CACHE as unknown as ReturnType; + expect(kv.put).toHaveBeenCalledWith( + "games:snake:leaderboard:v1", + expect.any(String), + { expirationTtl: 300 }, + ); + }); + + it("filters invalid rows, dedupes, caps at 10 and assigns ranks", async () => { + const rows: Array<[unknown, unknown]> = [ + ["Bad-Handle-01", 900], + ["no-digits-xx", 800], + ["too-high-01", 10001], + ["zero-score-01", 0], + ["float-score-01", 700.9], + ["float-score-01", 600], + ["string-score-01", "500"], + [42, 400], + ]; + const names = [ + "aa", + "bb", + "cc", + "dd", + "ee", + "ff", + "gg", + "hh", + "ii", + "jj", + "kk", + "ll", + ]; + names.forEach((n, i) => rows.push([`${n}-${n}-1${i % 10}`, 300 - i])); + fetchMock.mockResolvedValue(jsonResponse(upstreamBody(rows))); + + const res = await app.request(PATH, REQ, makeEnv()); + const body = (await res.json()) as { + entries: Array<{ rank: number; handle: string; score: number }>; + }; + expect(body.entries).toHaveLength(10); + expect(body.entries[0]).toEqual({ + rank: 1, + handle: "float-score-01", + score: 700, + }); + expect(body.entries.map((e) => e.rank)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + ]); + expect(body.entries.map((e) => e.handle)).not.toContain("Bad-Handle-01"); + }); + + it("returns 502 without leaking upstream details or the token", async () => { + fetchMock.mockResolvedValue( + new Response(`secret-upstream-detail ${TOKEN}`, { status: 500 }), + ); + const res = await app.request(PATH, REQ, makeEnv()); + const text = await res.text(); + expect(res.status).toBe(502); + expect(JSON.parse(text)).toEqual({ error: "Leaderboard unavailable" }); + expect(text).not.toContain("secret-upstream-detail"); + expect(text).not.toContain(TOKEN); + expect(JSON.stringify([...res.headers])).not.toContain(TOKEN); + }); + + it("returns 502 for invalid JSON and schema mismatch", async () => { + fetchMock.mockResolvedValueOnce(new Response("not json", { status: 200 })); + expect((await app.request(PATH, REQ, makeEnv())).status).toBe(502); + fetchMock.mockResolvedValueOnce(jsonResponse({ nope: [] })); + expect((await app.request(PATH, REQ, makeEnv())).status).toBe(502); + }); + + it("returns 502 when fetch throws or times out", async () => { + fetchMock.mockRejectedValue( + new DOMException(`secret-upstream-detail ${TOKEN}`, "TimeoutError"), + ); + const res = await app.request(PATH, REQ, makeEnv()); + const text = await res.text(); + expect(res.status).toBe(502); + expect(text).not.toContain("secret-upstream-detail"); + expect(text).not.toContain(TOKEN); + }); + + it("returns 503 when the token is not configured", async () => { + const res = await app.request( + PATH, + REQ, + makeEnv({ SENTRY_GAMES_READ_TOKEN: undefined }), + ); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "Leaderboard unavailable" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns 429 when rate limited and skips upstream", async () => { + const limit = vi.fn().mockResolvedValue({ success: false }); + const res = await app.request( + PATH, + REQ, + makeEnv({ GAMES_RATE_LIMITER: { limit } }), + ); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: "Too many requests" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("uses a hashed IP rate limit key", async () => { + const limit = vi.fn().mockResolvedValue({ success: false }); + await app.request(PATH, REQ, makeEnv({ GAMES_RATE_LIMITER: { limit } })); + expect(limit.mock.calls[0][0].key).toMatch(/^games:ip:[a-f0-9]{16}$/); + expect(limit.mock.calls[0][0].key).not.toContain("192.0.2.1"); + }); + + it("fails open when KV throws", async () => { + fetchMock.mockResolvedValue( + jsonResponse(upstreamBody([["brave-otter-42", 57]])), + ); + const kv = { + get: vi.fn().mockRejectedValue(new Error("kv down")), + put: vi.fn().mockRejectedValue(new Error("kv down")), + }; + const res = await app.request(PATH, REQ, makeEnv({ MCP_CACHE: kv })); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/mcp-cloudflare/src/server/routes/games.ts b/packages/mcp-cloudflare/src/server/routes/games.ts new file mode 100644 index 000000000..b056c3038 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/routes/games.ts @@ -0,0 +1,200 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { logWarn } from "@sentry/mcp-core/telem/logging"; +import type { Env } from "../types"; +import type { RateLimitResult } from "../types/chat"; +import { getClientIp } from "../utils/client-ip"; +import { annotateResponseMetric } from "../metrics"; + +export const SNAKE_HANDLE_REGEX = /^[a-z]{2,12}-[a-z]{2,12}-\d{2}$/; +export const MAX_SNAKE_SCORE = 10000; + +const SENTRY_HOST = "https://us.sentry.io"; +const SENTRY_ORG_SLUG = "sentry"; +const SNAKE_PROJECT_ID = "4510776311808000"; +const SCORE_FIELD = "max(value,snake.score,distribution,-)"; +const HANDLE_FIELD = "tags[handle,string]"; +const LEADERBOARD_PERIOD = "30d"; +const LEADERBOARD_SIZE = 10; +const UPSTREAM_TIMEOUT_MS = 5000; +const CACHE_KEY = "games:snake:leaderboard:v1"; +const CACHE_TTL_SECONDS = 300; + +const LeaderboardSchema = z.object({ + period: z.literal(LEADERBOARD_PERIOD), + entries: z + .array( + z.object({ + rank: z.number().int().min(1), + handle: z.string().regex(SNAKE_HANDLE_REGEX), + score: z.number().int().min(1).max(MAX_SNAKE_SCORE), + }), + ) + .max(LEADERBOARD_SIZE), +}); + +type Leaderboard = z.infer; + +const UpstreamSchema = z.object({ + data: z.array(z.record(z.string(), z.unknown())), +}); + +class UpstreamError extends Error { + constructor(readonly status?: number) { + super("Snake leaderboard upstream failed"); + } +} + +// Failure details are deliberately dropped: errors may carry the upstream +// URL, headers or body. +function logUpstreamFailure(status?: number) { + logWarn("Snake leaderboard upstream failed", { + loggerScope: ["cloudflare", "games"], + extra: { status: status ?? null }, + }); +} + +function buildUpstreamUrl(): string { + const params = new URLSearchParams(); + params.set("dataset", "tracemetrics"); + params.set("project", SNAKE_PROJECT_ID); + params.append("field", HANDLE_FIELD); + params.append("field", SCORE_FIELD); + params.set( + "query", + `metric.name:snake.score metric.type:distribution value:<=${MAX_SNAKE_SCORE}`, + ); + params.set("statsPeriod", LEADERBOARD_PERIOD); + params.set("sort", `-${SCORE_FIELD}`); + params.set("per_page", "50"); + return `${SENTRY_HOST}/api/0/organizations/${SENTRY_ORG_SLUG}/events/?${params.toString()}`; +} + +async function fetchLeaderboard(token: string): Promise { + let response: Response; + try { + response = await fetch(buildUpstreamUrl(), { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + } catch { + throw new UpstreamError(); + } + if (!response.ok) { + throw new UpstreamError(response.status); + } + + let parsed: z.infer; + try { + const result = UpstreamSchema.safeParse(await response.json()); + if (!result.success) throw new UpstreamError(response.status); + parsed = result.data; + } catch { + throw new UpstreamError(response.status); + } + + const seen = new Set(); + const entries: Leaderboard["entries"] = []; + for (const row of parsed.data) { + const handle = row[HANDLE_FIELD]; + const rawScore = row[SCORE_FIELD]; + if (typeof handle !== "string" || !SNAKE_HANDLE_REGEX.test(handle)) { + continue; + } + if (typeof rawScore !== "number" || !Number.isFinite(rawScore)) continue; + const score = Math.floor(rawScore); + if (score < 1 || score > MAX_SNAKE_SCORE) continue; + if (seen.has(handle)) continue; + seen.add(handle); + entries.push({ rank: entries.length + 1, handle, score }); + if (entries.length === LEADERBOARD_SIZE) break; + } + + return { period: LEADERBOARD_PERIOD, entries }; +} + +async function readCache(kv: KVNamespace): Promise { + try { + const cached = await kv.get(CACHE_KEY, "json"); + if (cached === null) return null; + const result = LeaderboardSchema.safeParse(cached); + return result.success ? result.data : null; + } catch { + logWarn("Snake leaderboard cache read failed", { + loggerScope: ["cloudflare", "games"], + }); + return null; + } +} + +async function writeCache(kv: KVNamespace, value: Leaderboard) { + try { + await kv.put(CACHE_KEY, JSON.stringify(value), { + expirationTtl: CACHE_TTL_SECONDS, + }); + } catch { + logWarn("Snake leaderboard cache write failed", { + loggerScope: ["cloudflare", "games"], + }); + } +} + +const CACHE_CONTROL = "public, max-age=60"; + +export default new Hono<{ Bindings: Env }>().get( + "/snake/leaderboard", + async (c) => { + const clientIP = getClientIp(c.req.raw); + + // The rate limiter binding is optional; it is absent in local development. + if (c.env.GAMES_RATE_LIMITER && clientIP) { + try { + const hashBuffer = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(clientIP), + ); + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const { success }: RateLimitResult = + await c.env.GAMES_RATE_LIMITER.limit({ + key: `games:ip:${hashHex.substring(0, 16)}`, + }); + if (!success) { + return annotateResponseMetric( + c.json({ error: "Too many requests" }, 429), + { responseReason: "local_rate_limit", rateLimitScope: "ip" }, + ); + } + } catch { + logWarn("Snake leaderboard rate limiter failed", { + loggerScope: ["cloudflare", "games"], + }); + return c.json({ error: "Leaderboard unavailable" }, 503); + } + } + + const token = c.env.SENTRY_GAMES_READ_TOKEN; + if (!token) { + return c.json({ error: "Leaderboard unavailable" }, 503); + } + + const cached = c.env.MCP_CACHE ? await readCache(c.env.MCP_CACHE) : null; + if (cached) { + return c.json(cached, 200, { "Cache-Control": CACHE_CONTROL }); + } + + let leaderboard: Leaderboard; + try { + leaderboard = await fetchLeaderboard(token); + } catch (error) { + logUpstreamFailure( + error instanceof UpstreamError ? error.status : undefined, + ); + return c.json({ error: "Leaderboard unavailable" }, 502); + } + + if (c.env.MCP_CACHE) await writeCache(c.env.MCP_CACHE, leaderboard); + return c.json(leaderboard, 200, { "Cache-Control": CACHE_CONTROL }); + }, +); diff --git a/packages/mcp-cloudflare/src/server/types.ts b/packages/mcp-cloudflare/src/server/types.ts index 2a91d6816..a27a6c42f 100644 --- a/packages/mcp-cloudflare/src/server/types.ts +++ b/packages/mcp-cloudflare/src/server/types.ts @@ -63,6 +63,7 @@ export interface Env { /** @deprecated Prefer OPENROUTER_API_KEY for hosted MCP AI features. */ OPENAI_API_KEY?: string; OPENROUTER_API_KEY?: string; + SENTRY_GAMES_READ_TOKEN?: string; OPENROUTER_MODEL?: string; OPENROUTER_REASONING_EFFORT?: string; EMBEDDED_AGENT_PROVIDER?: string; @@ -72,6 +73,7 @@ export interface Env { CF_VERSION_METADATA?: WorkerVersionMetadata; CHAT_RATE_LIMITER?: RateLimit; SEARCH_RATE_LIMITER?: RateLimit; + GAMES_RATE_LIMITER?: RateLimit; MCP_IP_RATE_LIMITER?: RateLimit; MCP_USER_RATE_LIMITER?: RateLimit; // Backward-compatible fallback while deployments roll out dedicated MCP limiters. diff --git a/packages/mcp-cloudflare/wrangler.jsonc b/packages/mcp-cloudflare/wrangler.jsonc index a2ae5d657..a2c861fef 100644 --- a/packages/mcp-cloudflare/wrangler.jsonc +++ b/packages/mcp-cloudflare/wrangler.jsonc @@ -10,7 +10,7 @@ "compatibility_flags": [ "nodejs_compat", "nodejs_compat_populate_process_env", - "global_fetch_strictly_public" + "global_fetch_strictly_public", ], // we ask people to configure environment variables in prod // Available environment variables: @@ -19,10 +19,10 @@ "assets": { "directory": "./public", "binding": "ASSETS", - "not_found_handling": "single-page-application" + "not_found_handling": "single-page-application", }, "version_metadata": { - "binding": "CF_VERSION_METADATA" + "binding": "CF_VERSION_METADATA", }, "vars": {}, "unsafe": { @@ -33,8 +33,8 @@ "namespace_id": "1001", "simple": { "limit": 10, - "period": 60 - } + "period": 60, + }, }, { "name": "SEARCH_RATE_LIMITER", @@ -42,8 +42,17 @@ "namespace_id": "1002", "simple": { "limit": 20, - "period": 60 - } + "period": 60, + }, + }, + { + "name": "GAMES_RATE_LIMITER", + "type": "ratelimit", + "namespace_id": "1005", + "simple": { + "limit": 30, + "period": 60, + }, }, { "name": "MCP_IP_RATE_LIMITER", @@ -51,8 +60,8 @@ "namespace_id": "1003", "simple": { "limit": 300, - "period": 60 - } + "period": 60, + }, }, { "name": "MCP_USER_RATE_LIMITER", @@ -60,41 +69,41 @@ "namespace_id": "1004", "simple": { "limit": 60, - "period": 60 - } - } - ] + "period": 60, + }, + }, + ], }, "kv_namespaces": [ { "binding": "OAUTH_KV", - "id": "8dd5e9bafe1945298e2d5ca3b408a553" + "id": "8dd5e9bafe1945298e2d5ca3b408a553", }, { "binding": "MCP_CACHE", - "id": "01b5b45e9f3c4edaa9ef357b4f9949db" - } + "id": "01b5b45e9f3c4edaa9ef357b4f9949db", + }, ], "ai": { - "binding": "AI" + "binding": "AI", }, "migrations": [ { - "tag": "v1" + "tag": "v1", }, { - "tag": "v2" - } + "tag": "v2", + }, ], "observability": { "enabled": true, - "head_sampling_rate": 1 + "head_sampling_rate": 1, }, "tail_consumers": [ // super noisy - disable until it can be improve // { "service": "sentry-mcp-tail" } ], "dev": { - "port": 8788 - } + "port": 8788, + }, } From 1e95436c81b8cd1f67d7c884a15b37183bb864c6 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 7 Oct 2026 21:07:46 -0400 Subject: [PATCH 2/5] fix(cli): Keep Snake score errors out of setup and widen player handles Score reporting runs from the game timer, so a database or transport error could crash sentry init. It now logs the error and skips the score. Handles had only 102,400 values, so two players were likely to share one after a few hundred players. Four-digit suffixes give about 10 million. An old two-digit handle is replaced the next time it is read. Co-authored-by: Claude --- apps/cli-docs/src/fragments/commands/games.md | 2 +- packages/cli/src/lib/games/player.ts | 6 ++-- packages/cli/src/lib/games/score.ts | 31 +++++++++++++------ .../test/commands/games/leaderboard.test.ts | 22 ++++++------- packages/cli/test/lib/games/player.test.ts | 9 ++++++ packages/cli/test/lib/games/score.test.ts | 23 +++++++++++++- .../src/server/routes/games.test.ts | 30 +++++++++--------- .../mcp-cloudflare/src/server/routes/games.ts | 2 +- 8 files changed, 84 insertions(+), 41 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/games.md b/apps/cli-docs/src/fragments/commands/games.md index 3a74c8d61..d2b5abac7 100644 --- a/apps/cli-docs/src/fragments/commands/games.md +++ b/apps/cli-docs/src/fragments/commands/games.md @@ -23,7 +23,7 @@ row is marked `(you)`. Use `--json` for machine-readable output. ## Anonymous scores When a Snake game ends, the CLI sends one score and a random player handle such -as `brave-otter-42`. The handle is generated on your machine and is not linked +as `brave-otter-4242`. The handle is generated on your machine and is not linked to your account, name, email, organization, or installation. The score is sent in its own trace, apart from the CLI's other telemetry. diff --git a/packages/cli/src/lib/games/player.ts b/packages/cli/src/lib/games/player.ts index a18c06cb3..b0e5f18a1 100644 --- a/packages/cli/src/lib/games/player.ts +++ b/packages/cli/src/lib/games/player.ts @@ -1,7 +1,7 @@ /** * Anonymous player handle for game leaderboards. * - * A random `adjective-animal-NN` label generated once and stored in the + * A random `adjective-animal-NNNN` label generated once and stored in the * metadata table. It is deliberately independent of the telemetry instance ID, * user, and machine data, so a leaderboard entry cannot be linked to a person. */ @@ -13,7 +13,7 @@ import { getMetadata, setMetadata } from "../db/utils.js"; const PLAYER_HANDLE_KEY = "games.handle"; /** Shape of a valid handle. Also used to vet handles received from the server. */ -export const PLAYER_HANDLE_REGEX = /^[a-z]{2,12}-[a-z]{2,12}-\d{2}$/; +export const PLAYER_HANDLE_REGEX = /^[a-z]{2,12}-[a-z]{2,12}-\d{4}$/; const ADJECTIVES = [ "brave", @@ -88,7 +88,7 @@ const ANIMALS = [ function generateHandle(): string { const adjective = ADJECTIVES[randomInt(ADJECTIVES.length)]; const animal = ANIMALS[randomInt(ANIMALS.length)]; - const suffix = String(randomInt(100)).padStart(2, "0"); + const suffix = String(randomInt(10_000)).padStart(4, "0"); return `${adjective}-${animal}-${suffix}`; } diff --git a/packages/cli/src/lib/games/score.ts b/packages/cli/src/lib/games/score.ts index 79556937d..94958a721 100644 --- a/packages/cli/src/lib/games/score.ts +++ b/packages/cli/src/lib/games/score.ts @@ -9,13 +9,20 @@ // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; +import { logger } from "../logger.js"; import { isTelemetryEnabled } from "../telemetry.js"; import { getPlayerHandle } from "./player.js"; +const log = logger.withTag("games"); + export const SNAKE_SCORE_METRIC = "snake.score"; export const MAX_SNAKE_SCORE = 10_000; -/** Record a finished Snake game. No-op when telemetry is off or the score is out of range. */ +/** + * Record a finished Snake game. No-op when telemetry is off or the score is + * out of range. Never throws: it runs from the game's timer, where an error + * would crash `sentry init`. + */ export function reportSnakeScore(score: number): void { if ( !isTelemetryEnabled() || @@ -26,16 +33,20 @@ export function reportSnakeScore(score: number): void { return; } - const handle = getPlayerHandle(); - Sentry.withIsolationScope((isolationScope) => { - isolationScope.setUser(null); - Sentry.withScope((scope) => { - scope.setUser(null); - Sentry.startNewTrace(() => { - Sentry.metrics.distribution(SNAKE_SCORE_METRIC, score, { - attributes: { handle }, + try { + const handle = getPlayerHandle(); + Sentry.withIsolationScope((isolationScope) => { + isolationScope.setUser(null); + Sentry.withScope((scope) => { + scope.setUser(null); + Sentry.startNewTrace(() => { + Sentry.metrics.distribution(SNAKE_SCORE_METRIC, score, { + attributes: { handle }, + }); }); }); }); - }); + } catch (error) { + log.debug("Could not report the Snake score", error); + } } diff --git a/packages/cli/test/commands/games/leaderboard.test.ts b/packages/cli/test/commands/games/leaderboard.test.ts index 516140196..78cad859b 100644 --- a/packages/cli/test/commands/games/leaderboard.test.ts +++ b/packages/cli/test/commands/games/leaderboard.test.ts @@ -61,13 +61,13 @@ describe("games leaderboard", () => { { period: "30d", entries: [ - { rank: 1, handle: "brave-otter-42", score: 57 }, + { rank: 1, handle: "brave-otter-4242", score: 57 }, { rank: 2, handle, score: 40 }, ], }, (s) => run(s, { json: false }), ); - expect(out).toContain("brave-otter-42"); + expect(out).toContain("brave-otter-4242"); expect(out).toContain(`${handle} (you)`); expect(out).toContain(`You play as ${handle}.`); }); @@ -78,18 +78,18 @@ describe("games leaderboard", () => { { period: "30d", entries: [ - { rank: 1, handle: "\x1b[31m-evil-00", score: 5 }, - { rank: 2, handle: "good-wolf-01", score: 0 }, - { rank: 3, handle: "ok-fox-02", score: 10_001 }, - { rank: 4, handle: "fine-fox-03", score: 9 }, + { rank: 1, handle: "\x1b[31m-evil-0000", score: 5 }, + { rank: 2, handle: "good-wolf-0101", score: 0 }, + { rank: 3, handle: "ok-fox-0202", score: 10_001 }, + { rank: 4, handle: "fine-fox-0303", score: 9 }, ], }, (s) => run(s, { json: false }), ); - expect(out).toContain("fine-fox-03"); + expect(out).toContain("fine-fox-0303"); expect(out).not.toContain("\x1b[31m"); - expect(out).not.toContain("good-wolf-01"); - expect(out).not.toContain("ok-fox-02"); + expect(out).not.toContain("good-wolf-0101"); + expect(out).not.toContain("ok-fox-0202"); }); test("shows a message when there are no scores", async () => { @@ -117,7 +117,7 @@ describe("games leaderboard", () => { { period: "30d", entries: [ - { rank: 1, handle: "brave-otter-42", score: 57 }, + { rank: 1, handle: "brave-otter-4242", score: 57 }, { rank: 2, handle: "bad handle", score: 3 }, ], }, @@ -125,7 +125,7 @@ describe("games leaderboard", () => { ); expect(JSON.parse(out)).toEqual({ period: "30d", - entries: [{ rank: 1, handle: "brave-otter-42", score: 57 }], + entries: [{ rank: 1, handle: "brave-otter-4242", score: 57 }], }); }); }); diff --git a/packages/cli/test/lib/games/player.test.ts b/packages/cli/test/lib/games/player.test.ts index d81c1069b..089ea8436 100644 --- a/packages/cli/test/lib/games/player.test.ts +++ b/packages/cli/test/lib/games/player.test.ts @@ -3,6 +3,8 @@ import { getPlayerHandle, PLAYER_HANDLE_REGEX, } from "../../../src/lib/games/player.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { setMetadata } from "../../../src/lib/db/utils.js"; import { useTestConfigDir } from "../../helpers.js"; useTestConfigDir("test-games-player-"); @@ -15,4 +17,11 @@ describe("getPlayerHandle", () => { test("persists the same handle across calls", () => { expect(getPlayerHandle()).toBe(getPlayerHandle()); }); + + test("replaces a stored handle in the old two-digit format", () => { + setMetadata(getDatabase(), { "games.handle": "brave-otter-42" }); + const handle = getPlayerHandle(); + expect(handle).not.toBe("brave-otter-42"); + expect(handle).toMatch(PLAYER_HANDLE_REGEX); + }); }); diff --git a/packages/cli/test/lib/games/score.test.ts b/packages/cli/test/lib/games/score.test.ts index ea883ce72..85e43d8cf 100644 --- a/packages/cli/test/lib/games/score.test.ts +++ b/packages/cli/test/lib/games/score.test.ts @@ -14,6 +14,12 @@ vi.mock("../../../src/lib/telemetry.js", async (importOriginal) => ({ isTelemetryEnabled: vi.fn(() => true), })); +vi.mock("../../../src/lib/games/player.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, getPlayerHandle: vi.fn(actual.getPlayerHandle) }; +}); + useTestConfigDir("test-games-score-"); describe("reportSnakeScore", () => { @@ -43,6 +49,21 @@ describe("reportSnakeScore", () => { expect(distribution).not.toHaveBeenCalled(); }); + test("does not throw when the handle cannot be stored", () => { + vi.mocked(getPlayerHandle).mockImplementationOnce(() => { + throw new Error("ENOTDIR: not a directory"); + }); + expect(() => reportSnakeScore(12)).not.toThrow(); + expect(distribution).not.toHaveBeenCalled(); + }); + + test("does not throw when the metric cannot be sent", () => { + distribution.mockImplementationOnce(() => { + throw new Error("transport failed"); + }); + expect(() => reportSnakeScore(12)).not.toThrow(); + }); + test.each([0, -1, 1.5, Number.NaN, 10_001])( "ignores invalid score %s", (score) => { @@ -59,7 +80,7 @@ describe("scrubAnonymousMetric", () => { type: "distribution", value: 5, attributes: { - handle: "brave-otter-42", + handle: "brave-otter-4242", "user.id": "1", "user.email": "a@b.c", "user.name": "x", diff --git a/packages/mcp-cloudflare/src/server/routes/games.test.ts b/packages/mcp-cloudflare/src/server/routes/games.test.ts index 13057d757..578da40ec 100644 --- a/packages/mcp-cloudflare/src/server/routes/games.test.ts +++ b/packages/mcp-cloudflare/src/server/routes/games.test.ts @@ -64,7 +64,7 @@ describe("games leaderboard route", () => { it("returns cached data without calling upstream", async () => { const cached = { period: "30d", - entries: [{ rank: 1, handle: "brave-otter-42", score: 57 }], + entries: [{ rank: 1, handle: "brave-otter-4242", score: 57 }], }; const res = await app.request( PATH, @@ -79,7 +79,7 @@ describe("games leaderboard route", () => { it("treats an invalid cached value as a miss", async () => { fetchMock.mockResolvedValue( - jsonResponse(upstreamBody([["brave-otter-42", 57]])), + jsonResponse(upstreamBody([["brave-otter-4242", 57]])), ); const res = await app.request( PATH, @@ -92,7 +92,7 @@ describe("games leaderboard route", () => { it("fetches upstream on a miss and writes the cache", async () => { fetchMock.mockResolvedValue( - jsonResponse(upstreamBody([["brave-otter-42", 57]])), + jsonResponse(upstreamBody([["brave-otter-4242", 57]])), ); const env = makeEnv(); const res = await app.request(PATH, REQ, env); @@ -101,7 +101,7 @@ describe("games leaderboard route", () => { expect(res.headers.get("Cache-Control")).toBe("public, max-age=60"); expect(await res.json()).toEqual({ period: "30d", - entries: [{ rank: 1, handle: "brave-otter-42", score: 57 }], + entries: [{ rank: 1, handle: "brave-otter-4242", score: 57 }], }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -136,13 +136,14 @@ describe("games leaderboard route", () => { it("filters invalid rows, dedupes, caps at 10 and assigns ranks", async () => { const rows: Array<[unknown, unknown]> = [ - ["Bad-Handle-01", 900], + ["Bad-Handle-0101", 900], ["no-digits-xx", 800], - ["too-high-01", 10001], - ["zero-score-01", 0], - ["float-score-01", 700.9], - ["float-score-01", 600], - ["string-score-01", "500"], + ["old-format-42", 850], + ["too-high-0101", 10001], + ["zero-score-0101", 0], + ["float-score-0101", 700.9], + ["float-score-0101", 600], + ["string-score-0101", "500"], [42, 400], ]; const names = [ @@ -159,7 +160,7 @@ describe("games leaderboard route", () => { "kk", "ll", ]; - names.forEach((n, i) => rows.push([`${n}-${n}-1${i % 10}`, 300 - i])); + names.forEach((n, i) => rows.push([`${n}-${n}-100${i % 10}`, 300 - i])); fetchMock.mockResolvedValue(jsonResponse(upstreamBody(rows))); const res = await app.request(PATH, REQ, makeEnv()); @@ -169,13 +170,14 @@ describe("games leaderboard route", () => { expect(body.entries).toHaveLength(10); expect(body.entries[0]).toEqual({ rank: 1, - handle: "float-score-01", + handle: "float-score-0101", score: 700, }); expect(body.entries.map((e) => e.rank)).toEqual([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ]); - expect(body.entries.map((e) => e.handle)).not.toContain("Bad-Handle-01"); + expect(body.entries.map((e) => e.handle)).not.toContain("Bad-Handle-0101"); + expect(body.entries.map((e) => e.handle)).not.toContain("old-format-42"); }); it("returns 502 without leaking upstream details or the token", async () => { @@ -241,7 +243,7 @@ describe("games leaderboard route", () => { it("fails open when KV throws", async () => { fetchMock.mockResolvedValue( - jsonResponse(upstreamBody([["brave-otter-42", 57]])), + jsonResponse(upstreamBody([["brave-otter-4242", 57]])), ); const kv = { get: vi.fn().mockRejectedValue(new Error("kv down")), diff --git a/packages/mcp-cloudflare/src/server/routes/games.ts b/packages/mcp-cloudflare/src/server/routes/games.ts index b056c3038..f4385eea4 100644 --- a/packages/mcp-cloudflare/src/server/routes/games.ts +++ b/packages/mcp-cloudflare/src/server/routes/games.ts @@ -6,7 +6,7 @@ import type { RateLimitResult } from "../types/chat"; import { getClientIp } from "../utils/client-ip"; import { annotateResponseMetric } from "../metrics"; -export const SNAKE_HANDLE_REGEX = /^[a-z]{2,12}-[a-z]{2,12}-\d{2}$/; +export const SNAKE_HANDLE_REGEX = /^[a-z]{2,12}-[a-z]{2,12}-\d{4}$/; export const MAX_SNAKE_SCORE = 10000; const SENTRY_HOST = "https://us.sentry.io"; From b1f2002cac2bbf7e81fb2624bb99360667d7c8b8 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 8 Oct 2026 09:34:05 -0400 Subject: [PATCH 3/5] ref(games): Serve the Snake leaderboard from its own Worker Move the leaderboard endpoint out of the sentry-mcp Worker into a new packages/games-api Hono Worker in the Cloudflare "Sentry Internal" account, on games.sentry.new. The Sentry read token no longer shares a Worker with the MCP server's OAuth secrets, and the MCP server has no games changes. The Workers Cache API replaces the KV cache, so the new Worker needs no account resources besides the rate limiter. The CLI now reads https://games.sentry.new/v1/snake/leaderboard. Co-authored-by: Claude --- docs/cloudflare/architecture.md | 8 -- docs/operations/github-actions.md | 1 - docs/releases/cloudflare.md | 6 -- .../cli/src/commands/games/leaderboard.ts | 4 +- packages/cli/src/lib/env-registry.ts | 2 +- .../test/commands/games/leaderboard.test.ts | 2 +- packages/games-api/README.md | 28 ++++++ packages/games-api/package.json | 26 ++++++ packages/games-api/src/index.ts | 9 ++ .../src/leaderboard.test.ts} | 78 ++++++++--------- .../games.ts => games-api/src/leaderboard.ts} | 87 ++++++++++--------- packages/games-api/tsconfig.json | 7 ++ packages/games-api/vitest.config.ts | 8 ++ packages/games-api/wrangler.jsonc | 29 +++++++ packages/mcp-cloudflare/.env.example | 5 -- packages/mcp-cloudflare/src/server/app.ts | 2 - packages/mcp-cloudflare/src/server/types.ts | 2 - packages/mcp-cloudflare/wrangler.jsonc | 9 -- pnpm-lock.yaml | 42 +++++++-- 19 files changed, 227 insertions(+), 128 deletions(-) create mode 100644 packages/games-api/README.md create mode 100644 packages/games-api/package.json create mode 100644 packages/games-api/src/index.ts rename packages/{mcp-cloudflare/src/server/routes/games.test.ts => games-api/src/leaderboard.test.ts} (79%) rename packages/{mcp-cloudflare/src/server/routes/games.ts => games-api/src/leaderboard.ts} (69%) create mode 100644 packages/games-api/tsconfig.json create mode 100644 packages/games-api/vitest.config.ts create mode 100644 packages/games-api/wrangler.jsonc diff --git a/docs/cloudflare/architecture.md b/docs/cloudflare/architecture.md index 71874b609..51b28889e 100644 --- a/docs/cloudflare/architecture.md +++ b/docs/cloudflare/architecture.md @@ -157,21 +157,13 @@ COOKIE_SECRET = "..." # For session encryption OPENAI_API_KEY = "..." # For GPT-4 access SENTRY_CLIENT_ID = "..." # OAuth app ID SENTRY_CLIENT_SECRET = "..." # OAuth app secret -SENTRY_GAMES_READ_TOKEN = "..." # Optional: Snake leaderboard (set with `wrangler secret put`) ``` -`SENTRY_GAMES_READ_TOKEN` is the Sentry token used by -`GET /api/games/snake/leaderboard`. The token must belong to a bot account that -has only `org:read` and is a member only of the team that owns the CLI project. -Enable "Prevent storing IP addresses" on that project. -Without the token, the leaderboard route returns 503. - ### API Routes - `/api/auth/*` - Authentication endpoints - `/api/chat` - Main chat endpoint - `/api/metadata` - MCP metadata endpoint -- `/api/games/snake/leaderboard` - Public Snake leaderboard (rate limited by IP, cached in `MCP_CACHE` for 300 seconds) - `/sse` - Server-sent events for MCP ## Security Considerations diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index 2d34716e8..08d7777ce 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -96,7 +96,6 @@ Other configuration: - **`SENTRY_CLIENT_SECRET`** - Sentry OAuth client secret - **`COOKIE_SECRET`** - Session cookie encryption secret - **`OPENAI_API_KEY`** - For AI-powered search features -- **`SENTRY_GAMES_READ_TOKEN`** - Not a GitHub secret. The deploy workflow does not pass Worker secrets; set it with `wrangler secret put SENTRY_GAMES_READ_TOKEN`. The token must belong to a bot account with only `org:read` and membership only in the team that owns the CLI project. Enable "Prevent storing IP addresses" on that project. - **`AI_GATEWAY_API_KEY`** - Vercel AI Gateway key for Jev PR risk classification ## Deployment Architecture diff --git a/docs/releases/cloudflare.md b/docs/releases/cloudflare.md index 2f7544c71..7178ac797 100644 --- a/docs/releases/cloudflare.md +++ b/docs/releases/cloudflare.md @@ -61,12 +61,6 @@ Configure these overrides only when your Cloudflare deployment connects to a self-hosted Sentry instance; no additional host variables are required for the SaaS service. -Optional secret for the Snake leaderboard (`GET /api/games/snake/leaderboard`). -Set it with `wrangler secret put SENTRY_GAMES_READ_TOKEN`. Without it, the -route returns 503. The token must belong to a bot account with only `org:read` -and membership only in the team that owns the CLI project. Enable "Prevent -storing IP addresses" on that project. - Development (.dev.vars): ```bash diff --git a/packages/cli/src/commands/games/leaderboard.ts b/packages/cli/src/commands/games/leaderboard.ts index e44bd5312..92400ec67 100644 --- a/packages/cli/src/commands/games/leaderboard.ts +++ b/packages/cli/src/commands/games/leaderboard.ts @@ -36,8 +36,8 @@ import type { Writer } from "../../types/index.js"; const log = logger.withTag("games.leaderboard"); -const DEFAULT_GAMES_API_URL = "https://mcp.sentry.dev"; -const LEADERBOARD_PATH = "/api/games/snake/leaderboard"; +const DEFAULT_GAMES_API_URL = "https://games.sentry.new"; +const LEADERBOARD_PATH = "/v1/snake/leaderboard"; const REQUEST_TIMEOUT_MS = 5000; const LOAD_FAILED_MESSAGE = "Could not load the leaderboard. Try again later."; diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 2c207ea8a..b81a336d3 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -192,7 +192,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_GAMES_API_URL", description: - "Base URL of the service that serves the `sentry games leaderboard` scores. Defaults to `https://mcp.sentry.dev`. Mainly useful for testing.", + "Base URL of the service that serves the `sentry games leaderboard` scores. Defaults to `https://games.sentry.new`. Mainly useful for testing.", example: "http://localhost:8787", }, // -- TLS / Certificates -- diff --git a/packages/cli/test/commands/games/leaderboard.test.ts b/packages/cli/test/commands/games/leaderboard.test.ts index 78cad859b..4bf744c24 100644 --- a/packages/cli/test/commands/games/leaderboard.test.ts +++ b/packages/cli/test/commands/games/leaderboard.test.ts @@ -8,7 +8,7 @@ import { createMockServer, type MockServer } from "../../mocks/server.js"; useTestConfigDir("test-games-leaderboard-"); -const PATH = "/api/games/snake/leaderboard"; +const PATH = "/v1/snake/leaderboard"; async function run( server: MockServer, diff --git a/packages/games-api/README.md b/packages/games-api/README.md new file mode 100644 index 000000000..ff6885324 --- /dev/null +++ b/packages/games-api/README.md @@ -0,0 +1,28 @@ +# @sentry/games-api + +Standalone Cloudflare Worker (Hono) that serves the public Snake leaderboard +for `sentry games leaderboard`. It reads scores from Sentry with a read-only +token and caches the result with the Workers Cache API. + +## Endpoint + +`GET https://games.sentry.new/v1/snake/leaderboard` + +Everything else returns `404 {"error":"Not found"}`. + +## Deploy + +```bash +pnpm --filter @sentry/games-api run deploy +cd packages/games-api && npx wrangler secret put SENTRY_GAMES_READ_TOKEN +``` + +The Worker runs on a custom domain because Cloudflare's Cache API is a no-op +on workers.dev. + +## Token requirements + +- A Sentry token with only the `org:read` scope. +- The token's user must be a member only of the team that owns the CLI + project. +- Enable "Prevent storing IP addresses" on that project. diff --git a/packages/games-api/package.json b/packages/games-api/package.json new file mode 100644 index 000000000..f30334449 --- /dev/null +++ b/packages/games-api/package.json @@ -0,0 +1,26 @@ +{ + "name": "@sentry/games-api", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "FSL-1.1-ALv2", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "vitest run", + "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", + "test:watch": "vitest", + "tsc": "tsc --noEmit" + }, + "dependencies": { + "hono": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@sentry/mcp-server-tsconfig": "workspace:*", + "typescript": "catalog:", + "vitest": "catalog:", + "wrangler": "4.80.0" + } +} diff --git a/packages/games-api/src/index.ts b/packages/games-api/src/index.ts new file mode 100644 index 000000000..ccf846319 --- /dev/null +++ b/packages/games-api/src/index.ts @@ -0,0 +1,9 @@ +import { Hono } from "hono"; +import leaderboard, { type Env } from "./leaderboard"; + +const app = new Hono<{ Bindings: Env }>(); + +app.route("/v1", leaderboard); +app.notFound((c) => c.json({ error: "Not found" }, 404)); + +export default app; diff --git a/packages/mcp-cloudflare/src/server/routes/games.test.ts b/packages/games-api/src/leaderboard.test.ts similarity index 79% rename from packages/mcp-cloudflare/src/server/routes/games.test.ts rename to packages/games-api/src/leaderboard.test.ts index 578da40ec..02f56b060 100644 --- a/packages/mcp-cloudflare/src/server/routes/games.test.ts +++ b/packages/games-api/src/leaderboard.test.ts @@ -1,29 +1,22 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { Hono } from "hono"; -import gamesRoute from "./games"; -import type { Env } from "../types"; +import app from "./index"; +import type { Env } from "./leaderboard"; const TOKEN = "sntrys_test_token_value"; const SCORE_FIELD = "max(value,snake.score,distribution,-)"; const HANDLE_FIELD = "tags[handle,string]"; -function createTestApp() { - const app = new Hono<{ Bindings: Env }>(); - app.route("/api/games", gamesRoute); - return app; -} +const CACHE_URL = "https://games.sentry.new/__cache/snake/leaderboard/v1"; -function createKv(initial?: unknown) { - return { - get: vi.fn().mockResolvedValue(initial ?? null), - put: vi.fn().mockResolvedValue(undefined), - }; +function cacheResponse(value: unknown) { + return new Response(JSON.stringify(value), { + headers: { "Content-Type": "application/json" }, + }); } function makeEnv(overrides: Record = {}): Env { return { SENTRY_GAMES_READ_TOKEN: TOKEN, - MCP_CACHE: createKv(), ...overrides, } as unknown as Env; } @@ -44,17 +37,20 @@ function jsonResponse(body: unknown, status = 200) { }); } -const PATH = "/api/games/snake/leaderboard"; +const PATH = "/v1/snake/leaderboard"; const REQ = { headers: { "CF-Connecting-IP": "192.0.2.1" } }; describe("games leaderboard route", () => { const fetchMock = vi.fn(); - let app: ReturnType; + const cacheMatch = vi.fn(); + const cachePut = vi.fn(); beforeEach(() => { - app = createTestApp(); fetchMock.mockReset(); + cacheMatch.mockReset().mockResolvedValue(undefined); + cachePut.mockReset().mockResolvedValue(undefined); vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("caches", { default: { match: cacheMatch, put: cachePut } }); }); afterEach(() => { @@ -66,11 +62,9 @@ describe("games leaderboard route", () => { period: "30d", entries: [{ rank: 1, handle: "brave-otter-4242", score: 57 }], }; - const res = await app.request( - PATH, - REQ, - makeEnv({ MCP_CACHE: createKv(cached) }), - ); + cacheMatch.mockResolvedValue(cacheResponse(cached)); + const res = await app.request(PATH, REQ, makeEnv()); + expect(cacheMatch).toHaveBeenCalledWith(CACHE_URL); expect(res.status).toBe(200); expect(await res.json()).toEqual(cached); expect(res.headers.get("Cache-Control")).toBe("public, max-age=60"); @@ -81,11 +75,8 @@ describe("games leaderboard route", () => { fetchMock.mockResolvedValue( jsonResponse(upstreamBody([["brave-otter-4242", 57]])), ); - const res = await app.request( - PATH, - REQ, - makeEnv({ MCP_CACHE: createKv({ bogus: true }) }), - ); + cacheMatch.mockResolvedValue(cacheResponse({ bogus: true })); + const res = await app.request(PATH, REQ, makeEnv()); expect(res.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -94,8 +85,7 @@ describe("games leaderboard route", () => { fetchMock.mockResolvedValue( jsonResponse(upstreamBody([["brave-otter-4242", 57]])), ); - const env = makeEnv(); - const res = await app.request(PATH, REQ, env); + const res = await app.request(PATH, REQ, makeEnv()); expect(res.status).toBe(200); expect(res.headers.get("Cache-Control")).toBe("public, max-age=60"); @@ -126,12 +116,16 @@ describe("games leaderboard route", () => { Authorization: `Bearer ${TOKEN}`, }); - const kv = env.MCP_CACHE as unknown as ReturnType; - expect(kv.put).toHaveBeenCalledWith( - "games:snake:leaderboard:v1", - expect.any(String), - { expirationTtl: 300 }, + expect(cachePut).toHaveBeenCalledTimes(1); + const [key, cachedResponse] = cachePut.mock.calls[0]; + expect(key).toBe(CACHE_URL); + expect((cachedResponse as Response).headers.get("Cache-Control")).toBe( + "max-age=300", ); + expect(await (cachedResponse as Response).json()).toEqual({ + period: "30d", + entries: [{ rank: 1, handle: "brave-otter-4242", score: 57 }], + }); }); it("filters invalid rows, dedupes, caps at 10 and assigns ranks", async () => { @@ -241,15 +235,19 @@ describe("games leaderboard route", () => { expect(limit.mock.calls[0][0].key).not.toContain("192.0.2.1"); }); - it("fails open when KV throws", async () => { + it("fails open when the cache throws", async () => { fetchMock.mockResolvedValue( jsonResponse(upstreamBody([["brave-otter-4242", 57]])), ); - const kv = { - get: vi.fn().mockRejectedValue(new Error("kv down")), - put: vi.fn().mockRejectedValue(new Error("kv down")), - }; - const res = await app.request(PATH, REQ, makeEnv({ MCP_CACHE: kv })); + cacheMatch.mockRejectedValue(new Error("cache down")); + cachePut.mockRejectedValue(new Error("cache down")); + const res = await app.request(PATH, REQ, makeEnv()); expect(res.status).toBe(200); }); + + it("returns 404 for unknown paths", async () => { + const res = await app.request("/nope", REQ, makeEnv()); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "Not found" }); + }); }); diff --git a/packages/mcp-cloudflare/src/server/routes/games.ts b/packages/games-api/src/leaderboard.ts similarity index 69% rename from packages/mcp-cloudflare/src/server/routes/games.ts rename to packages/games-api/src/leaderboard.ts index f4385eea4..e9743ba8c 100644 --- a/packages/mcp-cloudflare/src/server/routes/games.ts +++ b/packages/games-api/src/leaderboard.ts @@ -1,10 +1,10 @@ import { Hono } from "hono"; import { z } from "zod"; -import { logWarn } from "@sentry/mcp-core/telem/logging"; -import type { Env } from "../types"; -import type { RateLimitResult } from "../types/chat"; -import { getClientIp } from "../utils/client-ip"; -import { annotateResponseMetric } from "../metrics"; + +export type Env = { + SENTRY_GAMES_READ_TOKEN?: string; + GAMES_RATE_LIMITER?: RateLimit; +}; export const SNAKE_HANDLE_REGEX = /^[a-z]{2,12}-[a-z]{2,12}-\d{4}$/; export const MAX_SNAKE_SCORE = 10000; @@ -17,7 +17,7 @@ const HANDLE_FIELD = "tags[handle,string]"; const LEADERBOARD_PERIOD = "30d"; const LEADERBOARD_SIZE = 10; const UPSTREAM_TIMEOUT_MS = 5000; -const CACHE_KEY = "games:snake:leaderboard:v1"; +const CACHE_URL = "https://games.sentry.new/__cache/snake/leaderboard/v1"; const CACHE_TTL_SECONDS = 300; const LeaderboardSchema = z.object({ @@ -48,10 +48,7 @@ class UpstreamError extends Error { // Failure details are deliberately dropped: errors may carry the upstream // URL, headers or body. function logUpstreamFailure(status?: number) { - logWarn("Snake leaderboard upstream failed", { - loggerScope: ["cloudflare", "games"], - extra: { status: status ?? null }, - }); + console.warn("Snake leaderboard upstream failed", { status: status ?? null }); } function buildUpstreamUrl(): string { @@ -84,18 +81,18 @@ async function fetchLeaderboard(token: string): Promise { throw new UpstreamError(response.status); } - let parsed: z.infer; + let body: unknown; try { - const result = UpstreamSchema.safeParse(await response.json()); - if (!result.success) throw new UpstreamError(response.status); - parsed = result.data; + body = await response.json(); } catch { throw new UpstreamError(response.status); } + const parsed = UpstreamSchema.safeParse(body); + if (!parsed.success) throw new UpstreamError(response.status); const seen = new Set(); const entries: Leaderboard["entries"] = []; - for (const row of parsed.data) { + for (const row of parsed.data.data) { const handle = row[HANDLE_FIELD]; const rawScore = row[SCORE_FIELD]; if (typeof handle !== "string" || !SNAKE_HANDLE_REGEX.test(handle)) { @@ -113,29 +110,31 @@ async function fetchLeaderboard(token: string): Promise { return { period: LEADERBOARD_PERIOD, entries }; } -async function readCache(kv: KVNamespace): Promise { +async function readCache(): Promise { try { - const cached = await kv.get(CACHE_KEY, "json"); - if (cached === null) return null; - const result = LeaderboardSchema.safeParse(cached); + const response = await caches.default.match(CACHE_URL); + if (!response) return null; + const result = LeaderboardSchema.safeParse(await response.json()); return result.success ? result.data : null; } catch { - logWarn("Snake leaderboard cache read failed", { - loggerScope: ["cloudflare", "games"], - }); + console.warn("Snake leaderboard cache read failed"); return null; } } -async function writeCache(kv: KVNamespace, value: Leaderboard) { +async function writeCache(value: Leaderboard) { try { - await kv.put(CACHE_KEY, JSON.stringify(value), { - expirationTtl: CACHE_TTL_SECONDS, - }); + await caches.default.put( + CACHE_URL, + new Response(JSON.stringify(value), { + headers: { + "Content-Type": "application/json", + "Cache-Control": `max-age=${CACHE_TTL_SECONDS}`, + }, + }), + ); } catch { - logWarn("Snake leaderboard cache write failed", { - loggerScope: ["cloudflare", "games"], - }); + console.warn("Snake leaderboard cache write failed"); } } @@ -144,7 +143,7 @@ const CACHE_CONTROL = "public, max-age=60"; export default new Hono<{ Bindings: Env }>().get( "/snake/leaderboard", async (c) => { - const clientIP = getClientIp(c.req.raw); + const clientIP = c.req.header("CF-Connecting-IP"); // The rate limiter binding is optional; it is absent in local development. if (c.env.GAMES_RATE_LIMITER && clientIP) { @@ -156,20 +155,14 @@ export default new Hono<{ Bindings: Env }>().get( const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); - const { success }: RateLimitResult = - await c.env.GAMES_RATE_LIMITER.limit({ - key: `games:ip:${hashHex.substring(0, 16)}`, - }); + const { success } = await c.env.GAMES_RATE_LIMITER.limit({ + key: `games:ip:${hashHex.substring(0, 16)}`, + }); if (!success) { - return annotateResponseMetric( - c.json({ error: "Too many requests" }, 429), - { responseReason: "local_rate_limit", rateLimitScope: "ip" }, - ); + return c.json({ error: "Too many requests" }, 429); } } catch { - logWarn("Snake leaderboard rate limiter failed", { - loggerScope: ["cloudflare", "games"], - }); + console.warn("Snake leaderboard rate limiter failed"); return c.json({ error: "Leaderboard unavailable" }, 503); } } @@ -179,7 +172,7 @@ export default new Hono<{ Bindings: Env }>().get( return c.json({ error: "Leaderboard unavailable" }, 503); } - const cached = c.env.MCP_CACHE ? await readCache(c.env.MCP_CACHE) : null; + const cached = await readCache(); if (cached) { return c.json(cached, 200, { "Cache-Control": CACHE_CONTROL }); } @@ -194,7 +187,15 @@ export default new Hono<{ Bindings: Env }>().get( return c.json({ error: "Leaderboard unavailable" }, 502); } - if (c.env.MCP_CACHE) await writeCache(c.env.MCP_CACHE, leaderboard); + const write = writeCache(leaderboard); + let ctx: ExecutionContext | undefined; + try { + ctx = c.executionCtx; + } catch { + // Hono throws when no execution context exists (e.g. in tests). + } + if (ctx) ctx.waitUntil(write); + else await write; return c.json(leaderboard, 200, { "Cache-Control": CACHE_CONTROL }); }, ); diff --git a/packages/games-api/tsconfig.json b/packages/games-api/tsconfig.json new file mode 100644 index 000000000..4e0793b59 --- /dev/null +++ b/packages/games-api/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@sentry/mcp-server-tsconfig/tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/packages/games-api/vitest.config.ts b/packages/games-api/vitest.config.ts new file mode 100644 index 000000000..c1433e6ef --- /dev/null +++ b/packages/games-api/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/games-api/wrangler.jsonc b/packages/games-api/wrangler.jsonc new file mode 100644 index 000000000..18e2daf6d --- /dev/null +++ b/packages/games-api/wrangler.jsonc @@ -0,0 +1,29 @@ +/** + * For more details on how to configure Wrangler, refer to: + * https://developers.cloudflare.com/workers/wrangler/configuration/ + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "sentry-games-api", + "main": "src/index.ts", + "compatibility_date": "2025-03-21", + "account_id": "20d94f53c7cab0b469521b703ff1923c", + // The Cache API is a no-op on workers.dev, so serve from a custom domain. + "routes": [{ "pattern": "games.sentry.new", "custom_domain": true }], + "workers_dev": false, + "observability": { "enabled": true }, + // SENTRY_GAMES_READ_TOKEN is a secret set with `wrangler secret put`. + "unsafe": { + "bindings": [ + { + "name": "GAMES_RATE_LIMITER", + "type": "ratelimit", + "namespace_id": "7301", + "simple": { + "limit": 30, + "period": 60, + }, + }, + ], + }, +} diff --git a/packages/mcp-cloudflare/.env.example b/packages/mcp-cloudflare/.env.example index abdce327f..797dabe75 100644 --- a/packages/mcp-cloudflare/.env.example +++ b/packages/mcp-cloudflare/.env.example @@ -29,8 +29,3 @@ MCP_HOST=http://localhost:5173 # Enable Spotlight SENTRY_SPOTLIGHT=1 - -# Token used by GET /api/games/snake/leaderboard to read Snake scores from Sentry. -# Must belong to a bot account with only org:read, a member only of the team -# that owns the CLI project. Leave empty to make the endpoint return 503. -# SENTRY_GAMES_READ_TOKEN= diff --git a/packages/mcp-cloudflare/src/server/app.ts b/packages/mcp-cloudflare/src/server/app.ts index b87d25eea..8d6b0f244 100644 --- a/packages/mcp-cloudflare/src/server/app.ts +++ b/packages/mcp-cloudflare/src/server/app.ts @@ -9,7 +9,6 @@ import sentryOauth from "./oauth"; import { createProtectedResourceMetadataResponse } from "./protected-resource-metadata"; import chat from "./routes/chat"; import chatOauth from "./routes/chat-oauth"; -import games from "./routes/games"; import mcpRoutes from "./routes/mcp"; import metadata from "./routes/metadata"; import search from "./routes/search"; @@ -121,7 +120,6 @@ const app = new Hono<{ .route("/api/auth", chatOauth) .route("/api/chat", chat) .route("/api/search", search) - .route("/api/games", games) .route("/api/metadata", metadata) .route("/.mcp", mcpRoutes) .get("/sse", (c) => { diff --git a/packages/mcp-cloudflare/src/server/types.ts b/packages/mcp-cloudflare/src/server/types.ts index a27a6c42f..2a91d6816 100644 --- a/packages/mcp-cloudflare/src/server/types.ts +++ b/packages/mcp-cloudflare/src/server/types.ts @@ -63,7 +63,6 @@ export interface Env { /** @deprecated Prefer OPENROUTER_API_KEY for hosted MCP AI features. */ OPENAI_API_KEY?: string; OPENROUTER_API_KEY?: string; - SENTRY_GAMES_READ_TOKEN?: string; OPENROUTER_MODEL?: string; OPENROUTER_REASONING_EFFORT?: string; EMBEDDED_AGENT_PROVIDER?: string; @@ -73,7 +72,6 @@ export interface Env { CF_VERSION_METADATA?: WorkerVersionMetadata; CHAT_RATE_LIMITER?: RateLimit; SEARCH_RATE_LIMITER?: RateLimit; - GAMES_RATE_LIMITER?: RateLimit; MCP_IP_RATE_LIMITER?: RateLimit; MCP_USER_RATE_LIMITER?: RateLimit; // Backward-compatible fallback while deployments roll out dedicated MCP limiters. diff --git a/packages/mcp-cloudflare/wrangler.jsonc b/packages/mcp-cloudflare/wrangler.jsonc index a2c861fef..68e5026b7 100644 --- a/packages/mcp-cloudflare/wrangler.jsonc +++ b/packages/mcp-cloudflare/wrangler.jsonc @@ -45,15 +45,6 @@ "period": 60, }, }, - { - "name": "GAMES_RATE_LIMITER", - "type": "ratelimit", - "namespace_id": "1005", - "simple": { - "limit": 30, - "period": 60, - }, - }, { "name": "MCP_IP_RATE_LIMITER", "type": "ratelimit", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 018447d54..f2cd84c24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -443,6 +443,31 @@ importers: specifier: ^10.0.0 version: 10.0.1 + packages/games-api: + dependencies: + hono: + specifier: 'catalog:' + version: 4.13.8 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 4.20260405.1 + '@sentry/mcp-server-tsconfig': + specifier: workspace:* + version: link:../mcp-server-tsconfig + typescript: + specifier: 'catalog:' + version: 5.8.3 + vitest: + specifier: 'catalog:' + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + wrangler: + specifier: 4.80.0 + version: 4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5) + packages/mcp-cloudflare: dependencies: '@ai-sdk/mcp': @@ -529,7 +554,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.13.15 - version: 1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) + version: 1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260916.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) '@cloudflare/vitest-pool-workers': specifier: 'catalog:' version: 0.14.1(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11) @@ -1509,6 +1534,7 @@ packages: '@cloudflare/vitest-pool-workers@0.14.1': resolution: {integrity: sha512-AuIvoGXi9bMjgD1636JtP84yGsjhdtsk23rYMVFLO9KB84rvwhVPkvwgBv74RbhbURXYDKWV5koPwPucFSFgUg==} + deprecated: '@cloudflare/vitest-pool-workers has been renamed to @cloudflare/vitest-plugin. This package will not receive future updates.' peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 @@ -8964,7 +8990,7 @@ snapshots: '@astrojs/sitemap@3.7.4': dependencies: sitemap: 9.0.1 - zod: 4.4.3 + zod: 4.6.5 '@astrojs/starlight@0.41.11(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.5)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(typescript@5.9.3)': dependencies: @@ -9374,15 +9400,15 @@ snapshots: optionalDependencies: workerd: 1.20260916.1 - '@cloudflare/unenv-preset@2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260401.1)': + '@cloudflare/unenv-preset@2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260916.1)': dependencies: unenv: 2.0.0-rc.21 optionalDependencies: - workerd: 1.20260401.1 + workerd: 1.20260916.1 - '@cloudflare/vite-plugin@1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': + '@cloudflare/vite-plugin@1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260916.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': dependencies: - '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260401.1) + '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260916.1) '@remix-run/node-fetch-server': 0.8.1 get-port: 7.1.0 miniflare: 4.20251011.1(@types/node@24.13.5) @@ -10329,7 +10355,7 @@ snapshots: '@modelcontextprotocol/server@2.0.0': dependencies: '@modelcontextprotocol/core': 2.0.0 - zod: 4.4.3 + zod: 4.6.5 '@mswjs/interceptors@0.39.8': dependencies: @@ -16900,7 +16926,7 @@ snapshots: zod-from-json-schema@0.5.6: dependencies: - zod: 4.4.3 + zod: 4.6.5 zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: From 9db0309b5ae2206319847eb2d76f93fd657e9850 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 8 Oct 2026 09:34:08 -0400 Subject: [PATCH 4/5] fix(cli): Flush the Snake score as soon as the game ends Metrics wait up to 5s in the SDK buffer. On macOS the CLI force-exits about 100ms after a command ends, which cuts off the exit flush, so a player who quit right after game over lost the score. Co-authored-by: Claude --- packages/cli/src/lib/games/score.ts | 10 ++++++++++ packages/cli/test/lib/games/score.test.ts | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/games/score.ts b/packages/cli/src/lib/games/score.ts index 94958a721..9b1aa85b6 100644 --- a/packages/cli/src/lib/games/score.ts +++ b/packages/cli/src/lib/games/score.ts @@ -17,6 +17,7 @@ const log = logger.withTag("games"); export const SNAKE_SCORE_METRIC = "snake.score"; export const MAX_SNAKE_SCORE = 10_000; +const SCORE_FLUSH_TIMEOUT_MS = 3000; /** * Record a finished Snake game. No-op when telemetry is off or the score is @@ -46,6 +47,15 @@ export function reportSnakeScore(score: number): void { }); }); }); + // Metrics otherwise wait up to 5s in the SDK buffer, and on macOS the CLI + // force-exits ~100ms after the command ends (force-exit.ts), cutting off + // the exit flush. Without this, quitting right after game over drops the + // score. + Sentry.getClient() + ?.flush(SCORE_FLUSH_TIMEOUT_MS) + .then(undefined, (error: unknown) => { + log.debug("Could not flush the Snake score", error); + }); } catch (error) { log.debug("Could not report the Snake score", error); } diff --git a/packages/cli/test/lib/games/score.test.ts b/packages/cli/test/lib/games/score.test.ts index 85e43d8cf..f0b831331 100644 --- a/packages/cli/test/lib/games/score.test.ts +++ b/packages/cli/test/lib/games/score.test.ts @@ -1,4 +1,4 @@ -import { metrics } from "@sentry/node-core/light"; +import { type Client, getCurrentScope, metrics } from "@sentry/node-core/light"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { getPlayerHandle } from "../../../src/lib/games/player.js"; import { reportSnakeScore } from "../../../src/lib/games/score.js"; @@ -24,15 +24,19 @@ useTestConfigDir("test-games-score-"); describe("reportSnakeScore", () => { let distribution: ReturnType; + let flush: ReturnType; beforeEach(() => { distribution = vi .spyOn(metrics, "distribution") .mockImplementation(() => undefined); + flush = vi.fn().mockResolvedValue(true); + getCurrentScope().setClient({ flush } as unknown as Client); }); afterEach(() => { distribution.mockRestore(); + getCurrentScope().setClient(undefined); }); test("emits the score with only the handle attribute", () => { @@ -43,10 +47,22 @@ describe("reportSnakeScore", () => { }); }); + test("flushes right away so a quick quit does not drop the score", () => { + reportSnakeScore(12); + expect(flush).toHaveBeenCalledTimes(1); + }); + + test("does not throw when the flush fails", async () => { + flush.mockRejectedValueOnce(new Error("network down")); + expect(() => reportSnakeScore(12)).not.toThrow(); + await Promise.resolve(); + }); + test("is skipped when telemetry is disabled", () => { vi.mocked(isTelemetryEnabled).mockReturnValueOnce(false); reportSnakeScore(12); expect(distribution).not.toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); }); test("does not throw when the handle cannot be stored", () => { From 3b2a8af02754ae1dd735b7b3f8e7e358c4efd182 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 8 Oct 2026 09:41:47 -0400 Subject: [PATCH 5/5] fix(games): Keep lockfile resolutions and MCP files unchanged The games-api install re-resolved zod to 4.6.5 and workerd to a newer build for unrelated packages, which broke the mcp-core typecheck. The lockfile now adds only the games-api importer. Also restore the MCP server config and docs to the base branch. The pre-commit formatter had reformatted them during the earlier revert. Co-authored-by: Claude --- docs/cloudflare/architecture.md | 14 +++----- docs/operations/github-actions.md | 19 +++-------- docs/releases/cloudflare.md | 39 ++++++++-------------- packages/mcp-cloudflare/wrangler.jsonc | 46 +++++++++++++------------- pnpm-lock.yaml | 17 +++++----- 5 files changed, 54 insertions(+), 81 deletions(-) diff --git a/docs/cloudflare/architecture.md b/docs/cloudflare/architecture.md index 51b28889e..122e9ed12 100644 --- a/docs/cloudflare/architecture.md +++ b/docs/cloudflare/architecture.md @@ -45,7 +45,6 @@ export default new Hono() ``` **Features:** - - OAuth 2.0 flow with Sentry - Token storage in Cloudflare KV - Automatic token refresh @@ -60,13 +59,12 @@ React-based chat UI with real-time streaming: export function Chat() { const { messages, handleSubmit } = useChat({ api: "/api/chat", - headers: { Authorization: `Bearer ${authToken}` }, + headers: { Authorization: `Bearer ${authToken}` } }); } ``` **Features:** - - Message streaming with Vercel AI SDK - Tool call visualization - Slash commands (/help, /prompts, /clear) @@ -84,13 +82,12 @@ const mcpClient = await experimental_createMCPClient({ transport: { type: "sse", url: sseUrl, - headers: { Authorization: `Bearer ${accessToken}` }, - }, + headers: { Authorization: `Bearer ${accessToken}` } + } }); ``` **Features:** - - Server-sent events (SSE) for MCP communication - Automatic tool discovery - Prompt metadata endpoint @@ -105,12 +102,11 @@ const result = streamText({ model: openai("gpt-4o"), messages: processedMessages, tools: mcpTools, - system: "You are an AI assistant for testing Sentry MCP...", + system: "You are an AI assistant for testing Sentry MCP..." }); ``` **Features:** - - Streaming responses - Tool execution - Prompt template processing @@ -119,13 +115,11 @@ const result = streamText({ ## Data Flow 1. **User Authentication**: - ``` User → OAuth Login → Sentry → OAuth Callback → KV Storage ``` 2. **Chat Message Flow**: - ``` User Input → Chat API → Process Prompts → AI Model → Stream Response ↓ diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index 08d7777ce..06f18c129 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -8,7 +8,6 @@ and docs when `SENTRY_CLIENT_ID` is available. ## Workflows ### test.yml - Runs on pushes to `main`, pull requests, and merge queue entries. Discovery reads pnpm workspace projects and their package scripts. Pull requests check changed projects, their workspace consumers, and semantic dependencies (the CLI @@ -20,7 +19,6 @@ Package-specific exceptions live in `package.json#sentryCi`; the standalone smoke-test suite remains in its own workflow. ### deploy.yml - Runs after a successful `Test` push run on `main`. Checks out the tested commit and requires that it is still the tip of `main`. Builds once, records the active production version, and uploads one new version of `sentry-mcp` from Vite's @@ -34,7 +32,6 @@ it restores the exact captured prior version only if the live deployments still belong to this run. ### recover-cloudflare-deployment.yml - Manual `workflow_dispatch` recovery accepts a deployment run ID and attempt. It runs trusted current-`main` code in the protected `production` environment, checks the completed source run, and derives the prior version from contiguous @@ -48,18 +45,15 @@ only its 404 response. When the route exists, the smoke test compares its version ID with the restored version. ### migrate-cloudflare-token.yml - Moves the Cloudflare API token from a repository secret into the protected `production` environment. Only `main` in the Toolkit repository can run it. Copy and removal are separate dispatches so a normal production deployment can prove that the environment copy works before the repository copy is deleted. ### eval.yml - Runs evaluation tests against the MCP server. ### pr-risk-jev.yml - Classifies PR risk with Jev and publishes one `risk: low`, `risk: medium`, or `risk: high` label. Runs when a non-draft PR is opened, updated with a push, reopened, marked ready for review, or edited. Manual dispatch accepts a PR number @@ -73,7 +67,6 @@ are cleared and the PR stays unclassified. Results are retained as workflow artifacts for 30 days. ### pr-risk-labels-test.yml - Runs the label publisher's regression tests when its workflow or tests change. Covers label replacement, stale revisions, failed classifications, and concurrent label creation. @@ -101,20 +94,18 @@ Other configuration: ## Deployment Architecture ### Workers - - **`sentry-mcp`** - Production worker at `https://mcp.sentry.dev` - The candidate is tested on the production Worker at 0% traffic before promotion. ### Resource Isolation - The existing canary Worker has separate resources; exact-version rollout does not deploy it. The production candidate uses the production bindings: -| Resource | Production | Canary | -| ---------------- | ---------------------------------- | ---------------------------------- | -| KV Namespace | `8dd5e9bafe1945298e2d5ca3b408a553` | `a3fe0d23b2d34416930e284362a88a3b` | -| Rate Limiter IDs | `1001`, `1002`, `1003`, `1004` | `2001`, `2002`, `2003`, `2004` | -| Wrangler Config | `wrangler.jsonc` | `wrangler.canary.jsonc` | +| Resource | Production | Canary | +|----------|------------|---------| +| KV Namespace | `8dd5e9bafe1945298e2d5ca3b408a553` | `a3fe0d23b2d34416930e284362a88a3b` | +| Rate Limiter IDs | `1001`, `1002`, `1003`, `1004` | `2001`, `2002`, `2003`, `2004` | +| Wrangler Config | `wrangler.jsonc` | `wrangler.canary.jsonc` | ### Deployment Flow diff --git a/docs/releases/cloudflare.md b/docs/releases/cloudflare.md index 7178ac797..dc02a5bf8 100644 --- a/docs/releases/cloudflare.md +++ b/docs/releases/cloudflare.md @@ -5,7 +5,6 @@ Cloudflare Workers deployment configuration and release process. ## Architecture Overview The deployment consists of: - - **Worker**: Stateless HTTP server with OAuth flow and MCP handler - **KV Storage**: OAuth token storage - **Static Assets**: React UI for setup instructions @@ -21,29 +20,26 @@ The deployment consists of: "compatibility_date": "2025-03-21", "compatibility_flags": [ "nodejs_compat", - "nodejs_compat_populate_process_env", + "nodejs_compat_populate_process_env" ], "keep_vars": true, // Bindings - "kv_namespaces": [ - { - "binding": "OAUTH_KV", - "id": "your-kv-namespace-id", - }, - ], + "kv_namespaces": [{ + "binding": "OAUTH_KV", + "id": "your-kv-namespace-id" + }], // SPA configuration "site": { - "bucket": "./dist/client", - }, + "bucket": "./dist/client" + } } ``` ### Environment Variables Required in production: - ```bash SENTRY_CLIENT_ID=your_oauth_app_id SENTRY_CLIENT_SECRET=your_oauth_app_secret @@ -51,7 +47,6 @@ COOKIE_SECRET=32_char_random_string ``` Optional overrides for self-hosted deployments: - ```bash # Leave unset to target the SaaS host SENTRY_HOST=sentry.example.com # Hostname only (self-hosted only) @@ -62,7 +57,6 @@ self-hosted Sentry instance; no additional host variables are required for the SaaS service. Development (.dev.vars): - ```bash SENTRY_CLIENT_ID=dev_client_id SENTRY_CLIENT_SECRET=dev_secret @@ -78,11 +72,7 @@ import { experimental_createMcpHandler as createMcpHandler } from "agents/mcp"; import { buildServer } from "@sentry/mcp-server/server"; const mcpHandler: ExportedHandler = { - async fetch( - request: Request, - env: Env, - ctx: ExecutionContext, - ): Promise { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { // Extract auth props from ExecutionContext (set by OAuth provider) const oauthCtx = ctx as OAuthExecutionContext; @@ -91,7 +81,7 @@ const mcpHandler: ExportedHandler = { userId: oauthCtx.props.userId, clientId: oauthCtx.props.clientId, accessToken: oauthCtx.props.accessToken, - grantedSkills, // Primary authorization method + grantedSkills, // Primary authorization method constraints: verification.constraints, sentryHost, mcpUrl: oauthCtx.props.mcpUrl, @@ -189,7 +179,6 @@ For feature branches, GitHub Actions automatically uploads new versions without 4. Use Cloudflare dashboard to gradually roll out the version Manual version upload: - ```bash pnpm cf:versions:upload ``` @@ -197,7 +186,6 @@ pnpm cf:versions:upload ### Creating Resources First-time setup: - ```bash # Create KV namespace for OAuth token storage npx wrangler kv:namespace create OAUTH_KV @@ -208,7 +196,6 @@ npx wrangler kv:namespace create OAUTH_KV ## Multi-Region Considerations Cloudflare Workers run globally, but consider: - - KV is eventually consistent globally - Workers are stateless and edge-deployed - Use regional hints for performance @@ -218,7 +205,10 @@ Cloudflare Workers run globally, but consider: ### CORS Settings ```typescript -const ALLOWED_ORIGINS = ["https://sentry.io", "https://*.sentry.io"]; +const ALLOWED_ORIGINS = [ + "https://sentry.io", + "https://*.sentry.io" +]; // Apply to responses response.headers.set("Access-Control-Allow-Origin", origin); @@ -229,7 +219,7 @@ response.headers.set("Access-Control-Allow-Credentials", "true"); ```typescript // Secure cookie settings -"HttpOnly; Secure; SameSite=Lax; Max-Age=2592000"; +"HttpOnly; Secure; SameSite=Lax; Max-Age=2592000" ``` ## Monitoring @@ -255,7 +245,6 @@ export default { ### Worker Analytics Monitor via Cloudflare dashboard: - - Request rates - Error rates - CPU time and memory usage diff --git a/packages/mcp-cloudflare/wrangler.jsonc b/packages/mcp-cloudflare/wrangler.jsonc index 68e5026b7..a2ae5d657 100644 --- a/packages/mcp-cloudflare/wrangler.jsonc +++ b/packages/mcp-cloudflare/wrangler.jsonc @@ -10,7 +10,7 @@ "compatibility_flags": [ "nodejs_compat", "nodejs_compat_populate_process_env", - "global_fetch_strictly_public", + "global_fetch_strictly_public" ], // we ask people to configure environment variables in prod // Available environment variables: @@ -19,10 +19,10 @@ "assets": { "directory": "./public", "binding": "ASSETS", - "not_found_handling": "single-page-application", + "not_found_handling": "single-page-application" }, "version_metadata": { - "binding": "CF_VERSION_METADATA", + "binding": "CF_VERSION_METADATA" }, "vars": {}, "unsafe": { @@ -33,8 +33,8 @@ "namespace_id": "1001", "simple": { "limit": 10, - "period": 60, - }, + "period": 60 + } }, { "name": "SEARCH_RATE_LIMITER", @@ -42,8 +42,8 @@ "namespace_id": "1002", "simple": { "limit": 20, - "period": 60, - }, + "period": 60 + } }, { "name": "MCP_IP_RATE_LIMITER", @@ -51,8 +51,8 @@ "namespace_id": "1003", "simple": { "limit": 300, - "period": 60, - }, + "period": 60 + } }, { "name": "MCP_USER_RATE_LIMITER", @@ -60,41 +60,41 @@ "namespace_id": "1004", "simple": { "limit": 60, - "period": 60, - }, - }, - ], + "period": 60 + } + } + ] }, "kv_namespaces": [ { "binding": "OAUTH_KV", - "id": "8dd5e9bafe1945298e2d5ca3b408a553", + "id": "8dd5e9bafe1945298e2d5ca3b408a553" }, { "binding": "MCP_CACHE", - "id": "01b5b45e9f3c4edaa9ef357b4f9949db", - }, + "id": "01b5b45e9f3c4edaa9ef357b4f9949db" + } ], "ai": { - "binding": "AI", + "binding": "AI" }, "migrations": [ { - "tag": "v1", + "tag": "v1" }, { - "tag": "v2", - }, + "tag": "v2" + } ], "observability": { "enabled": true, - "head_sampling_rate": 1, + "head_sampling_rate": 1 }, "tail_consumers": [ // super noisy - disable until it can be improve // { "service": "sentry-mcp-tail" } ], "dev": { - "port": 8788, - }, + "port": 8788 + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2cd84c24..01e57fdd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -554,7 +554,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.13.15 - version: 1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260916.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) + version: 1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) '@cloudflare/vitest-pool-workers': specifier: 'catalog:' version: 0.14.1(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11) @@ -1534,7 +1534,6 @@ packages: '@cloudflare/vitest-pool-workers@0.14.1': resolution: {integrity: sha512-AuIvoGXi9bMjgD1636JtP84yGsjhdtsk23rYMVFLO9KB84rvwhVPkvwgBv74RbhbURXYDKWV5koPwPucFSFgUg==} - deprecated: '@cloudflare/vitest-pool-workers has been renamed to @cloudflare/vitest-plugin. This package will not receive future updates.' peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 @@ -8990,7 +8989,7 @@ snapshots: '@astrojs/sitemap@3.7.4': dependencies: sitemap: 9.0.1 - zod: 4.6.5 + zod: 4.4.3 '@astrojs/starlight@0.41.11(astro@7.3.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.5)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(typescript@5.9.3)': dependencies: @@ -9400,15 +9399,15 @@ snapshots: optionalDependencies: workerd: 1.20260916.1 - '@cloudflare/unenv-preset@2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260916.1)': + '@cloudflare/unenv-preset@2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260401.1)': dependencies: unenv: 2.0.0-rc.21 optionalDependencies: - workerd: 1.20260916.1 + workerd: 1.20260401.1 - '@cloudflare/vite-plugin@1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260916.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': + '@cloudflare/vite-plugin@1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': dependencies: - '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260916.1) + '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260401.1) '@remix-run/node-fetch-server': 0.8.1 get-port: 7.1.0 miniflare: 4.20251011.1(@types/node@24.13.5) @@ -10355,7 +10354,7 @@ snapshots: '@modelcontextprotocol/server@2.0.0': dependencies: '@modelcontextprotocol/core': 2.0.0 - zod: 4.6.5 + zod: 4.4.3 '@mswjs/interceptors@0.39.8': dependencies: @@ -16926,7 +16925,7 @@ snapshots: zod-from-json-schema@0.5.6: dependencies: - zod: 4.6.5 + zod: 4.4.3 zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: