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..d2b5abac7 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-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. + +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/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 075fced92..36706f437 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 @@ -21,4 +31,4 @@ Play Snake in your terminal sentry games snake ``` -All commands also support `--help`, `--log-level`, and `--verbose` flags. +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. 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..92400ec67 --- /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://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."; + +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 cb050bac3..d363701d6 100644 --- a/packages/cli/src/commands/games/snake.ts +++ b/packages/cli/src/commands/games/snake.ts @@ -8,6 +8,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"; export const snakeCommand = buildCommand({ docs: { @@ -35,6 +36,6 @@ export const snakeCommand = buildCommand({ // A static import would load Ink and its WebAssembly layout engine // whenever the app starts, which breaks the library SDK build. const { runSnakeGame } = await import("../../lib/init/ui/ink-ui.js"); - await runSnakeGame(); + await runSnakeGame(reportSnakeScore); }, }); diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 96b5dc901..b81a336d3 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://games.sentry.new`. 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..b0e5f18a1 --- /dev/null +++ b/packages/cli/src/lib/games/player.ts @@ -0,0 +1,106 @@ +/** + * Anonymous player handle for game leaderboards. + * + * 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. + */ + +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{4}$/; + +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(10_000)).padStart(4, "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..9b1aa85b6 --- /dev/null +++ b/packages/cli/src/lib/games/score.ts @@ -0,0 +1,62 @@ +/** + * 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 { 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; +const SCORE_FLUSH_TIMEOUT_MS = 3000; + +/** + * 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() || + !Number.isInteger(score) || + score < 1 || + score > MAX_SNAKE_SCORE + ) { + return; + } + + 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 }, + }); + }); + }); + }); + // 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/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index 859d98d3d..bbc65d047 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, @@ -373,6 +374,7 @@ function AppBody({ store }: AppProps): React.ReactNode { muted={MUTED_DIM} onCancel={cancelFromSnake} onExit={closeSnake} + onGameOver={onGameOver} session={snakeSession} /> @@ -3123,20 +3125,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); @@ -3157,6 +3168,7 @@ function SnakeGameScreen(): React.ReactNode { muted={MUTED_DIM} onCancel={close} onExit={close} + onGameOver={onGameOver} session={session} /> @@ -3170,8 +3182,11 @@ function SnakeGameScreen(): React.ReactNode { * the alternate screen, so it also restores the primary screen on exit and on * SIGINT/SIGTERM. */ -export function mountSnakeGame(options: MountOptions): InkInstance { - return inkRender(createElement(SnakeGameApp), { +export function mountSnakeGame( + options: MountOptions, + onGameOver?: (score: number) => void, +): InkInstance { + return inkRender(createElement(SnakeGameApp, { onGameOver }), { ...options, alternateScreen: true, }); diff --git a/packages/cli/src/lib/init/ui/ink-snake.tsx b/packages/cli/src/lib/init/ui/ink-snake.tsx index bb3c2ec10..f6dcfe2cd 100644 --- a/packages/cli/src/lib/init/ui/ink-snake.tsx +++ b/packages/cli/src/lib/init/ui/ink-snake.tsx @@ -81,6 +81,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; }; @@ -114,6 +116,7 @@ function SnakeBoard({ muted, onCancel, onExit, + onGameOver, session, width, }: SnakeGameProps & { height: number; width: number }): React.ReactNode { @@ -122,18 +125,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 b435e2e2f..019f7cc04 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 { @@ -325,17 +326,24 @@ export async function loadInkSidecar(): Promise { /** * Run the standalone Snake game until the player quits. Ink handles the * alternate screen and restores it on exit and on SIGINT/SIGTERM. + * + * @param onGameOver - Called with the final score when a game ends */ -export async function runSnakeGame(): Promise { +export async function runSnakeGame( + onGameOver?: (score: number) => void, +): Promise { const app = await loadInkSidecar(); const freshStdin = openFreshTtyForInk(); try { - const instance = app.mountSnakeGame({ - // Ctrl+C is routed through the game's own shortcut so it exits cleanly. - exitOnCtrlC: false, - patchConsole: false, - ...(freshStdin ? { stdin: freshStdin } : {}), - }); + const instance = app.mountSnakeGame( + { + // Ctrl+C is routed through the game's own shortcut so it exits cleanly. + exitOnCtrlC: false, + patchConsole: false, + ...(freshStdin ? { stdin: freshStdin } : {}), + }, + onGameOver, + ); await instance.waitUntilExit(); } finally { if (freshStdin) { @@ -405,7 +413,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..4bf744c24 --- /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 = "/v1/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-4242", score: 57 }, + { rank: 2, handle, score: 40 }, + ], + }, + (s) => run(s, { json: false }), + ); + expect(out).toContain("brave-otter-4242"); + 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-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-0303"); + expect(out).not.toContain("\x1b[31m"); + expect(out).not.toContain("good-wolf-0101"); + expect(out).not.toContain("ok-fox-0202"); + }); + + 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-4242", 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-4242", 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..089ea8436 --- /dev/null +++ b/packages/cli/test/lib/games/player.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +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-"); + +describe("getPlayerHandle", () => { + test("matches the handle format", () => { + expect(getPlayerHandle()).toMatch(PLAYER_HANDLE_REGEX); + }); + + 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 new file mode 100644 index 000000000..f0b831331 --- /dev/null +++ b/packages/cli/test/lib/games/score.test.ts @@ -0,0 +1,131 @@ +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"; +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), +})); + +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", () => { + 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", () => { + reportSnakeScore(12); + expect(distribution).toHaveBeenCalledTimes(1); + expect(distribution).toHaveBeenCalledWith("snake.score", 12, { + attributes: { handle: getPlayerHandle() }, + }); + }); + + 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", () => { + 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) => { + 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-4242", + "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-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 7ba1e8e7b..872ce83ea 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -130,6 +130,7 @@ async function renderApp( instance.unmount(); // waitUntilExit() hangs in CI — race with a short unref'd timeout. await Promise.race([ + // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. instance.waitUntilExit().catch(() => { // Ink may reject on unmount — ignore. }), 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/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts index f37d30cec..5906afa5d 100644 --- a/packages/cli/test/script/generate-skill-markdown.test.ts +++ b/packages/cli/test/script/generate-skill-markdown.test.ts @@ -14,6 +14,7 @@ import { refreshCommand } from "../../src/commands/auth/refresh.js"; import { uploadCommand as dartUploadCommand } from "../../src/commands/dart-symbol-map/upload.js"; import { uploadCommand as debugUploadCommand } from "../../src/commands/debug-files/upload.js"; import { sendCommand } from "../../src/commands/event/send.js"; +import { leaderboardCommand } from "../../src/commands/games/leaderboard.js"; import { snakeCommand } from "../../src/commands/games/snake.js"; import { mergeCommand } from "../../src/commands/issue/merge.js"; import { createCommand } from "../../src/commands/project/create.js"; @@ -212,11 +213,24 @@ describe("games reference", () => { ).toBe(true); }); - test("does not advertise auth or JSON flags that snake lacks", async () => { + test("advertises only the auth and flags that games commands have", async () => { const content = await reference(); + const infos = [ + buildCommandInfo(snakeCommand as never, "sentry games snake"), + buildCommandInfo(leaderboardCommand as never, "sentry games leaderboard"), + ]; + const accepts = (name: string) => + infos.some((info) => info.flags.some((flag) => flag.name === name)); + expect(content).toContain("auth: false"); - expect(content).not.toContain("--json"); - expect(content).not.toContain("--fields"); + expect(accepts("json")).toBe(true); + expect(content.includes("`--json`")).toBe(accepts("json")); + expect(content.includes("`--fields`")).toBe(accepts("fields")); + expect( + infos + .find((info) => info.path === "sentry games snake") + ?.flags.some((flag) => flag.name === "json"), + ).toBe(false); }); }); 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/games-api/src/leaderboard.test.ts b/packages/games-api/src/leaderboard.test.ts new file mode 100644 index 000000000..02f56b060 --- /dev/null +++ b/packages/games-api/src/leaderboard.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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]"; + +const CACHE_URL = "https://games.sentry.new/__cache/snake/leaderboard/v1"; + +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, + ...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 = "/v1/snake/leaderboard"; +const REQ = { headers: { "CF-Connecting-IP": "192.0.2.1" } }; + +describe("games leaderboard route", () => { + const fetchMock = vi.fn(); + const cacheMatch = vi.fn(); + const cachePut = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + cacheMatch.mockReset().mockResolvedValue(undefined); + cachePut.mockReset().mockResolvedValue(undefined); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("caches", { default: { match: cacheMatch, put: cachePut } }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns cached data without calling upstream", async () => { + const cached = { + period: "30d", + entries: [{ rank: 1, handle: "brave-otter-4242", score: 57 }], + }; + 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"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("treats an invalid cached value as a miss", async () => { + fetchMock.mockResolvedValue( + jsonResponse(upstreamBody([["brave-otter-4242", 57]])), + ); + cacheMatch.mockResolvedValue(cacheResponse({ bogus: true })); + const res = await app.request(PATH, REQ, makeEnv()); + 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-4242", 57]])), + ); + const res = await app.request(PATH, REQ, makeEnv()); + + 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-4242", 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}`, + }); + + 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 () => { + const rows: Array<[unknown, unknown]> = [ + ["Bad-Handle-0101", 900], + ["no-digits-xx", 800], + ["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 = [ + "aa", + "bb", + "cc", + "dd", + "ee", + "ff", + "gg", + "hh", + "ii", + "jj", + "kk", + "ll", + ]; + 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()); + 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-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-0101"); + expect(body.entries.map((e) => e.handle)).not.toContain("old-format-42"); + }); + + 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 the cache throws", async () => { + fetchMock.mockResolvedValue( + jsonResponse(upstreamBody([["brave-otter-4242", 57]])), + ); + 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/games-api/src/leaderboard.ts b/packages/games-api/src/leaderboard.ts new file mode 100644 index 000000000..e9743ba8c --- /dev/null +++ b/packages/games-api/src/leaderboard.ts @@ -0,0 +1,201 @@ +import { Hono } from "hono"; +import { z } from "zod"; + +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; + +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_URL = "https://games.sentry.new/__cache/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) { + console.warn("Snake leaderboard upstream failed", { 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 body: unknown; + try { + 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.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(): Promise { + try { + 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 { + console.warn("Snake leaderboard cache read failed"); + return null; + } +} + +async function writeCache(value: Leaderboard) { + try { + await caches.default.put( + CACHE_URL, + new Response(JSON.stringify(value), { + headers: { + "Content-Type": "application/json", + "Cache-Control": `max-age=${CACHE_TTL_SECONDS}`, + }, + }), + ); + } catch { + console.warn("Snake leaderboard cache write failed"); + } +} + +const CACHE_CONTROL = "public, max-age=60"; + +export default new Hono<{ Bindings: Env }>().get( + "/snake/leaderboard", + async (c) => { + 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) { + 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 } = await c.env.GAMES_RATE_LIMITER.limit({ + key: `games:ip:${hashHex.substring(0, 16)}`, + }); + if (!success) { + return c.json({ error: "Too many requests" }, 429); + } + } catch { + console.warn("Snake leaderboard rate limiter failed"); + 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 = await readCache(); + 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); + } + + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 018447d54..01e57fdd8 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':