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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-docs/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions apps/cli-docs/src/fragments/commands/games.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
1 change: 1 addition & 0 deletions packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
2 changes: 2 additions & 0 deletions packages/cli/src/commands/games/index.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
136 changes: 136 additions & 0 deletions packages/cli/src/commands/games/leaderboard.ts
Original file line number Diff line number Diff line change
@@ -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<typeof EntrySchema>;

type LeaderboardData = {
period: string;
entries: LeaderboardEntry[];
};

async function fetchLeaderboard(): Promise<LeaderboardData> {
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<LeaderboardEntry>[] = [
{ 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()}.` };
},
});
3 changes: 2 additions & 1 deletion packages/cli/src/commands/games/snake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
},
});
6 changes: 6 additions & 0 deletions packages/cli/src/lib/env-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/lib/games/player.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading