From 9857b1674bb11f5476d33c6f17c2909807af2e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:28:28 +0000 Subject: [PATCH 1/3] feat: bring live Sentry mode back to Glitch Invaders through vault_services The challenge now names the sentry vault service. With SENTRY_AUTH_TOKEN in the environment and real values in the package's sentry.json, the page reports the three failures to the DSN and the page server reads Sentry's record of each back from the Sentry API, so the token never reaches the page. Without both, the game runs in demo mode as before. A Sentry outage falls back to simulated evidence for that incident with a note. --- challenges/glitch-invaders/README.md | 53 +- challenges/glitch-invaders/challenge.json | 3 +- .../glitch-invaders/game/src/app/app.mjs | 42 +- .../src/diagnostics/evidence-builders.mjs | 45 +- .../game/src/diagnostics/live-evidence.mjs | 52 ++ challenges/glitch-invaders/game/src/main.mjs | 13 +- .../game/src/sentry/breadcrumbs.mjs | 111 ++++ .../game/src/sentry/reporter.mjs | 250 +++++++++ .../glitch-invaders/game/src/styles.css | 34 ++ .../glitch-invaders/game/src/ui/hud.mjs | 8 + .../game/src/ui/incident-console.mjs | 42 +- challenges/glitch-invaders/intro.md | 6 +- challenges/glitch-invaders/sentry.json | 6 + .../glitch-invaders/server/live-evidence.mjs | 169 ++++++ .../glitch-invaders/server/page-server.mjs | 80 ++- .../glitch-invaders/server/sentry-api.mjs | 142 +++++ .../glitch-invaders/server/sentry-config.mjs | 73 +++ challenges/glitch-invaders/server/serve.mjs | 29 +- challenges/glitch-invaders/server/setup.mjs | 31 +- .../glitch-invaders/shared/progress.mjs | 19 +- .../glitch-invaders/shared/runtime-config.mjs | 82 ++- .../skills/start-challenge/SKILL.md | 11 +- test/glitch-invaders.test.mjs | 495 +++++++++++++++++- 23 files changed, 1718 insertions(+), 78 deletions(-) create mode 100644 challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs create mode 100644 challenges/glitch-invaders/game/src/sentry/breadcrumbs.mjs create mode 100644 challenges/glitch-invaders/game/src/sentry/reporter.mjs create mode 100644 challenges/glitch-invaders/sentry.json create mode 100644 challenges/glitch-invaders/server/live-evidence.mjs create mode 100644 challenges/glitch-invaders/server/sentry-api.mjs create mode 100644 challenges/glitch-invaders/server/sentry-config.mjs diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index a7ba279..9a8ec73 100644 --- a/challenges/glitch-invaders/README.md +++ b/challenges/glitch-invaders/README.md @@ -6,16 +6,16 @@ Incident Console with a stack trace, breadcrumbs, or a performance trace, asks for the root cause, and applies your chosen patch so you can watch the fix work in play. -The game always runs in demo mode: the Incident Console shows simulated Sentry -evidence and no telemetry leaves the machine. Live Sentry mode returns once -boothstrap provides sponsor settings; see -[boothstrap.sh issue 519](https://github.com/MLH/boothstrap.sh/issues/519). +The game runs in demo mode unless [live Sentry mode](#live-sentry-mode) is +set up: the Incident Console shows simulated Sentry evidence and no telemetry +leaves the machine. ## Files | File | Purpose | | --- | --- | | `challenge.json`, `plugin.json` | Manifest and Agent Plugin metadata | +| `sentry.json` | Public Sentry settings for live mode: DSN, org slug, project slug | | `intro.md` | Intro screen text | | `skills/start-challenge/SKILL.md` | The guide Pi follows | | `monitor/SKILL.md` | Headless browser monitor skill | @@ -40,11 +40,50 @@ which clue helped most and reports `debrief`. The server listens on port 4373. Set `GLITCH_PORT` to use another port, though the challenge manifest expects 4373. +## Live Sentry mode + +Live mode needs both halves: + +- **`sentry.json` filled in.** The DSN, org slug, and project slug are public + values, so they live in the package. While any value is still a + ``, the game runs in demo mode. +- **`SENTRY_AUTH_TOKEN` in the environment.** On a station, the event vault's + `sentry` service sets it to a placeholder that the vault proxy swaps for the + real token. `boothstrap dev` and a standalone run read the real token from + your shell. + +In live mode the page reports each failure to the DSN: an error event for the +weapon and the shield, and a span trace for the boss spawn. The page server then +reads Sentry's record of it from the Sentry API (the issue, its stack trace or +breadcrumbs, or the trace's spans) and the Incident Console shows that, with a +link to it in Sentry. The token stays in the page server: it is never served to +the page or written anywhere. The page's CSP lets it contact only this server +and the DSN's ingest origin. If Sentry doesn't accept the report or can't return +it within about 15 seconds, that incident shows simulated evidence with a note +saying so. Setup prints `(live Sentry mode)` after the URL when live mode is on, +and a note when `sentry.json` is filled in but the token is missing. + +Because `vault_services` names `sentry`, the event vault must hold a `sentry` +service, or the station lists the challenge as unavailable, on every boothstrap +version: + +| Field | Value | +| --- | --- | +| Name | `sentry` | +| Auth | `bearer`, token `SENTRY_AUTH_TOKEN` | +| Host | `sentry.io` | + +The token needs the `event:read`, `project:read`, and `org:read` scopes. The +station also needs a boothstrap that gives challenges their vault credentials: +the `next` prerelease after 2026-10-18 09:23 UTC, or any release after `0.1.0`. +On `0.1.0` the challenge gets no token and runs in demo mode. + ## Run without boothstrap -Needs Node 26 and a `GEMINI_API_KEY`. Follow the standalone steps in -boothstrap.sh's `docs/authoring-challenges.md` to install Pi and load the -package as an Agent Plugin, then: +Needs Node 26 and a `GEMINI_API_KEY`; export `SENTRY_AUTH_TOKEN` too for live +mode. Follow the standalone steps in boothstrap.sh's +`docs/authoring-challenges.md` to install Pi and load the package as an Agent +Plugin, then: ```sh challenge=/absolute/path/to/glitch-invaders diff --git a/challenges/glitch-invaders/challenge.json b/challenges/glitch-invaders/challenge.json index 9aa2b42..e466345 100644 --- a/challenges/glitch-invaders/challenge.json +++ b/challenges/glitch-invaders/challenge.json @@ -7,7 +7,8 @@ "agent": "pi", "model": "google/gemini-3.8-flash", "vault_services": [ - "gemini" + "gemini", + "sentry" ], "points": 150, "estimated_minutes": 9, diff --git a/challenges/glitch-invaders/game/src/app/app.mjs b/challenges/glitch-invaders/game/src/app/app.mjs index a519cc6..fbde26b 100644 --- a/challenges/glitch-invaders/game/src/app/app.mjs +++ b/challenges/glitch-invaders/game/src/app/app.mjs @@ -4,10 +4,12 @@ import { loadBossResources } from '../boss/boss-loader.mjs'; import { BUG_DEFINITIONS } from '../bugs/bug-definitions.mjs'; import { tryRepair } from '../bugs/code-state.mjs'; import { IncidentTracker } from '../bugs/incident-tracker.mjs'; +import { LiveEvidenceProvider } from '../diagnostics/live-evidence.mjs'; import { SimulatedEvidenceProvider } from '../diagnostics/simulated-evidence.mjs'; import { GameEngine } from '../engine/game-engine.mjs'; import { createRandom } from '../engine/random.mjs'; import { CanvasRenderer } from '../render/canvas-renderer.mjs'; +import { attachBreadcrumbs } from '../sentry/breadcrumbs.mjs'; import { publishState } from '../session/monitor-state.mjs'; import { BossLoadingView } from '../ui/boss-loading.mjs'; @@ -49,7 +51,8 @@ export class GameApp { overlays = new OverlayHost(requireElement('overlay-root')); bossView = new BossLoadingView(requireElement('boss-loading')); engine; - evidence = new SimulatedEvidenceProvider(); + simulated = new SimulatedEvidenceProvider(); + live = null; incidentConsole; input; loop; @@ -62,10 +65,16 @@ export class GameApp { pauseOpen = false; pendingStart = null; + /** + * @param {object} options the page's config, store, and hooks; `reporter` is given only in live + * mode and sends the failures to Sentry. + * @param {object | null} saved the run to resume, if any. + */ constructor(options, saved) { this.options = options; this.now = options.now ?? (() => performance.now()); - this.progress = saved ?? createProgress(options.config.attemptId); + const mode = options.reporter === undefined ? 'demo' : 'live'; + this.progress = saved ?? createProgress(options.config.attemptId, mode); this.tracker = new IncidentTracker(this.progress); this.code = codeForProgress(this.progress); @@ -76,6 +85,14 @@ export class GameApp { now: this.now, particleScale: options.reducedMotion ? 0.25 : 1, }); + if (options.reporter !== undefined) { + attachBreadcrumbs(this.engine, options.reporter); + this.live = new LiveEvidenceProvider({ + reporter: options.reporter, + simulated: this.simulated, + fetchImpl: (input, init) => fetch(input, init), + }); + } this.incidentConsole = new IncidentConsole({ progressOf: (bug) => this.progress.bugs[bug], markEvidenceViewed: (bug) => this.tracker.markEvidenceViewed(bug), @@ -102,6 +119,9 @@ export class GameApp { begin() { this.tracker.subscribe((progress) => this.onProgress(progress)); this.engine.on((event) => this.onEvent(event)); + if (this.live !== null) { + this.hud.showLiveMode(); + } publishState(document, this.progress); this.input.attach(document, { @@ -314,10 +334,15 @@ export class GameApp { `${definition.incidentLabel}: ${definition.title}. The Incident Console is open.`, ); - void this.evidence.evidenceFor(encounter).then((evidence) => this.deliverEvidence(evidence)); + void (this.live ?? this.simulated) + .evidenceFor(encounter) + .then((evidence) => this.deliverEvidence(evidence)); } - /** A failure was restored from a saved run: reopen the console on its evidence. */ + /** + * A failure was restored from a saved run: reopen the console on its evidence. The live capture is + * gone and a second report would be a duplicate, so live mode falls back to simulated evidence. + */ openRestoredIncident(bug) { this.input.releaseAll(); this.incidentConsole.open(bug); @@ -328,9 +353,12 @@ export class GameApp { return; } - void this.evidence - .evidenceFor(canonicalEncounter(bug)) - .then((evidence) => this.deliverEvidence(evidence)); + const encounter = canonicalEncounter(bug); + const restored = + this.live === null + ? this.simulated.evidenceFor(encounter) + : this.live.fallback(encounter, 'restored'); + void restored.then((evidence) => this.deliverEvidence(evidence)); } /** Remember the evidence and show it if its console is still open. */ diff --git a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs index b60612d..c4be8ab 100644 --- a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs +++ b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs @@ -29,7 +29,7 @@ export const WEAPON_STACK_FRAMES = [ line: 160, inApp: true, }, - { functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 94, inApp: true }, + { functionName: 'GameLoop.frame', file: 'game/src/app/app.mjs', line: 111, inApp: true }, { functionName: 'requestAnimationFrame', file: '', line: 0, inApp: false }, ]; @@ -155,27 +155,34 @@ export function evidenceSections(encounter, breadcrumbs, spans) { }, ]; case 'boss': - return [ - { - kind: 'waterfall', - title: 'Trace: boss-spawn', - totalMs: spans.reduce((end, span) => Math.max(end, span.startMs + span.durationMs), 0), - spans: [...spans], - }, - { - kind: 'context', - title: 'boot_state', - contextName: 'boot_state', - entries: { - animationsLoaded: 'at game start', - spawnBudget: seconds(BOSS_SPAWN_BUDGET_MS), - strategy: 'sequential', - }, - }, - ]; + return [waterfallSection(spans), bootStateSection()]; } } +/** The boss-spawn trace as a waterfall section, its total the end of the last span. */ +export function waterfallSection(spans) { + return { + kind: 'waterfall', + title: 'Trace: boss-spawn', + totalMs: spans.reduce((end, span) => Math.max(end, span.startMs + span.durationMs), 0), + spans: [...spans], + }; +} + +/** The boss failure's context: what the shipped boot code already did and the spawn budget. */ +export function bootStateSection() { + return { + kind: 'context', + title: 'boot_state', + contextName: 'boot_state', + entries: { + animationsLoaded: 'at game start', + spawnBudget: seconds(BOSS_SPAWN_BUDGET_MS), + strategy: 'sequential', + }, + }; +} + /** Format an issue reference for the simulated provider, such as `SIM-WEAPON-1`. */ export function simulatedReference(bug) { return `SIM-${bug.toUpperCase()}-1`; diff --git a/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs new file mode 100644 index 0000000..cae03b9 --- /dev/null +++ b/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs @@ -0,0 +1,52 @@ +/** + * Live-mode evidence: report the failure to Sentry, then ask the page server for what Sentry + * recorded. When either step fails, the console still gets the simulated evidence, labelled with + * why, so it is never left blank. + */ + +/** The notes a fallback carries, by what went wrong. */ +export const FALLBACK_NOTES = { + unsent: 'Sentry did not accept the report, so this is simulated evidence.', + unavailable: 'Sentry could not return the report in time, so this is simulated evidence.', + restored: 'The page reloaded after this report, so this is simulated evidence.', +}; + +/** Builds evidence from Sentry's own record of each report. */ +export class LiveEvidenceProvider { + /** + * @param {{ reporter: { report: Function }, simulated: { evidenceFor: Function }, + * fetchImpl: typeof fetch }} options `fetchImpl` reaches the page server. + */ + constructor(options) { + this.options = options; + } + + /** Report `encounter` and fetch Sentry's evidence for it. Never rejects. */ + async evidenceFor(encounter) { + const outcome = await this.options.reporter.report(encounter); + if (!outcome.delivered) { + return this.fallback(encounter, 'unsent'); + } + + try { + const query = new URLSearchParams({ bug: encounter.bug, id: outcome.id }); + const response = await this.options.fetchImpl(`/api/sentry-evidence?${query}`, { + cache: 'no-store', + }); + if (!response.ok) { + return this.fallback(encounter, 'unavailable'); + } + + return await response.json(); + } catch { + return this.fallback(encounter, 'unavailable'); + } + } + + /** The simulated evidence for `encounter`, labelled as a fallback with the note for `reason`. */ + async fallback(encounter, reason) { + const simulated = await this.options.simulated.evidenceFor(encounter); + + return { ...simulated, source: 'fallback', note: FALLBACK_NOTES[reason] }; + } +} diff --git a/challenges/glitch-invaders/game/src/main.mjs b/challenges/glitch-invaders/game/src/main.mjs index 32e19eb..3091742 100644 --- a/challenges/glitch-invaders/game/src/main.mjs +++ b/challenges/glitch-invaders/game/src/main.mjs @@ -1,6 +1,10 @@ -/** The page entry: read this attempt's configuration and saved progress, and start the game. */ +/** + * The page entry: read this attempt's configuration and saved progress, and start the game, in live + * Sentry mode when the config carries Sentry settings. + */ import { parseRuntimeConfig } from '../../shared/runtime-config.mjs'; import { GameApp } from './app/app.mjs'; +import { SentryReporter } from './sentry/reporter.mjs'; import { HttpProgressStore } from './session/progress-store.mjs'; /** Read `/runtime-config.json`, which the page server writes for every attempt. */ @@ -56,6 +60,13 @@ async function main() { store, reload: () => window.location.reload(), reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ...(config.sentry === undefined + ? {} + : { + reporter: new SentryReporter(config.sentry, config.attemptId, (input, init) => + fetch(input, init), + ), + }), ...(Number.isInteger(seed) && query.has('seed') ? { seed } : {}), }, saved, diff --git a/challenges/glitch-invaders/game/src/sentry/breadcrumbs.mjs b/challenges/glitch-invaders/game/src/sentry/breadcrumbs.mjs new file mode 100644 index 0000000..36d9cc6 --- /dev/null +++ b/challenges/glitch-invaders/game/src/sentry/breadcrumbs.mjs @@ -0,0 +1,111 @@ +/** + * The breadcrumbs a real service would log for the game's events, so a live Sentry event carries the + * trail that led to it. + */ + +/** Format a number of seconds for breadcrumb text, such as `4.5 s`. */ +function secondsText(milliseconds) { + return `${(milliseconds / 1_000).toFixed(1)} s`; +} + +/** Subscribe `sink` to the engine's events, turning each into the breadcrumb a real service would log. */ +export function attachBreadcrumbs(engine, sink) { + return engine.on((event) => { + switch (event.type) { + case 'wave-started': + sink.add({ category: 'game.wave', message: `Wave ${event.wave} started` }); + break; + case 'pickup-collected': + if (event.kind === 'fanout') { + sink.add({ + category: 'game.weapon', + message: 'Weapon selected: fanout', + data: { weaponId: 'fanout' }, + }); + } else { + sink.add({ + category: 'game.shield', + message: 'Shield collected', + data: { type: 'aegis' }, + }); + sink.add({ + category: 'game.shield', + message: 'Shield enabled', + data: { shieldActive: 'true' }, + }); + } + break; + case 'volley-fired': + if (event.weapon !== 'single') { + sink.add({ + category: 'game.weapon', + message: `Fired ${event.weapon}`, + data: { projectiles: String(event.projectiles) }, + }); + } + break; + case 'weapon-jammed': + sink.add({ + category: 'game.weapon', + message: `Firing weapon: ${event.error.weaponId}`, + data: { weaponId: event.error.weaponId }, + }); + sink.add({ + category: 'game.weapon', + message: 'Weapon fire failed: configuration missing', + level: 'error', + data: { availableWeapons: event.availableWeapons.join(', ') }, + }); + break; + case 'shield-hit': + sink.add({ + category: 'game.combat', + message: `Incoming damage: ${event.incomingDamage}`, + data: { damage: String(event.incomingDamage) }, + }); + sink.add({ + category: 'game.shield', + message: 'Shield active: true', + data: { shieldActive: 'true' }, + }); + sink.add({ + category: 'game.combat', + message: `Actual damage applied: ${event.outcome.actualDamage}`, + level: event.outcome.actualDamage === 0 ? 'info' : 'warning', + data: { actualDamage: String(event.outcome.actualDamage), expected: '0' }, + }); + break; + case 'ship-down': + if (event.cause === 'shield-failure') { + sink.add({ + category: 'game.combat', + message: 'Ship destroyed: shield failed to protect', + level: 'error', + }); + } + break; + case 'boss-loading': + sink.add({ + category: 'game.boss', + message: 'Boss spawn initiated', + data: { strategy: event.strategy }, + }); + break; + case 'boss-online': + sink.add({ + category: 'game.boss', + message: `Boss ready after ${secondsText(event.result.totalMs)}`, + level: event.stalled ? 'warning' : 'info', + }); + break; + case 'verification-passed': + sink.add({ category: 'game.repair', message: `Repair verified: ${event.bug}` }); + break; + case 'enemy-destroyed': + case 'pickup-spawned': + case 'ship-restored': + case 'run-finished': + break; + } + }); +} diff --git a/challenges/glitch-invaders/game/src/sentry/reporter.mjs b/challenges/glitch-invaders/game/src/sentry/reporter.mjs new file mode 100644 index 0000000..2eb24a5 --- /dev/null +++ b/challenges/glitch-invaders/game/src/sentry/reporter.mjs @@ -0,0 +1,250 @@ +/** + * Live reporting through Sentry's envelope endpoint for the DSN: one real error event for the weapon + * jam, one for the shield failure, and a span trace for the boss spawn, each tagged with the run and + * the bug. It needs only the public DSN, so no token is ever on the page; the page server reads + * back what Sentry recorded. No SDK is loaded: the page's CSP allows scripts from itself alone. + */ +import { WEAPON_STACK_FRAMES } from '../diagnostics/evidence-builders.mjs'; + +/** How long to wait for Sentry to accept a report. */ +export const DELIVERY_TIMEOUT_MS = 2_500; + +/** The release every report carries, so Sentry groups them under this challenge. */ +const RELEASE = 'glitch-invaders-on-call@1'; + +/** The SDK name and version reports identify as; Sentry's ingest expects a browser SDK shape. */ +const SDK = { name: 'sentry.javascript.browser', version: '11.7.0' }; + +/** The most breadcrumbs an event carries. */ +const MAX_BREADCRUMBS = 40; + +/** A lowercase hex string of `length` characters, such as an event or trace id. */ +function randomHex(length) { + const bytes = crypto.getRandomValues(new Uint8Array(length / 2)); + + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +/** The envelope endpoint and public key of `dsn`. */ +export function parseDsn(dsn) { + const url = new URL(dsn); + const projectId = url.pathname.slice(1); + const query = new URLSearchParams({ + sentry_version: '7', + sentry_key: url.username, + sentry_client: `${SDK.name}/${SDK.version}`, + }); + + return { + publicKey: url.username, + envelopeUrl: `${url.origin}/api/${projectId}/envelope/?${query}`, + }; +} + +/** One envelope: a header line, then an item header and payload line per item. */ +function envelope(header, itemHeader, payload) { + return `${JSON.stringify(header)}\n${JSON.stringify(itemHeader)}\n${JSON.stringify(payload)}\n`; +} + +/** A typed attribute of a v2 span item. */ +function attribute(value, type = 'string') { + return { value, type }; +} + +/** Sends the game's intentional failures to Sentry, once per bug. */ +export class SentryReporter { + /** + * @param {{ dsn: string }} settings the public Sentry settings. + * @param {string} attemptId the run every report is tagged with. + * @param {typeof fetch} fetchImpl + */ + constructor(settings, attemptId, fetchImpl) { + this.dsn = parseDsn(settings.dsn); + this.attemptId = attemptId; + this.fetchImpl = fetchImpl; + this.breadcrumbs = []; + this.reports = new Map(); + } + + /** Record a breadcrumb so the next error event carries the trail. */ + add(breadcrumb) { + this.breadcrumbs.push({ + timestamp: Date.now() / 1_000, + category: breadcrumb.category, + message: breadcrumb.message, + level: breadcrumb.level ?? 'info', + ...(breadcrumb.data === undefined ? {} : { data: breadcrumb.data }), + }); + if (this.breadcrumbs.length > MAX_BREADCRUMBS) { + this.breadcrumbs.shift(); + } + } + + /** + * Report `encounter` once; a repeat resolves with the first outcome and sends nothing. + * + * @returns {Promise<{ id: string, delivered: boolean }>} the event id, or the trace id for the + * boss, and whether Sentry accepted it. Never rejects. + */ + report(encounter) { + const existing = this.reports.get(encounter.bug); + if (existing !== undefined) { + return existing; + } + + const id = randomHex(32); + const body = + encounter.bug === 'boss' + ? this.traceEnvelope(id, encounter.stall) + : this.eventEnvelope(id, encounter); + const outcome = this.post(body).then((delivered) => ({ id, delivered })); + this.reports.set(encounter.bug, outcome); + + return outcome; + } + + /** The error event envelope for the weapon or shield `encounter`, with id `eventId`. */ + eventEnvelope(eventId, encounter) { + const isWeapon = encounter.bug === 'weapon'; + const exception = isWeapon + ? { + type: 'WeaponConfigError', + value: encounter.jam.error.message, + frames: [...WEAPON_STACK_FRAMES].reverse().map((frame) => ({ + filename: frame.file, + function: frame.functionName, + lineno: frame.line, + in_app: frame.inApp, + })), + } + : { + type: 'GameLogicError', + value: `Shield was active but ${encounter.failure.actualDamage} damage was applied (expected 0). Ship destroyed despite an active shield.`, + frames: [ + { + filename: 'game/src/shield/shield.mjs', + function: 'resolveShieldHit', + lineno: 14, + in_app: true, + }, + ], + }; + const contexts = isWeapon + ? { + weapon_state: { + attemptedWeapon: encounter.jam.weaponId, + availableWeapons: encounter.jam.availableWeapons, + }, + } + : { shield_state: { shieldActive: true, expectedDamage: 0, ...encounter.failure } }; + + return envelope( + { event_id: eventId, sent_at: new Date().toISOString(), sdk: SDK }, + { type: 'event' }, + { + event_id: eventId, + timestamp: Date.now() / 1_000, + platform: 'javascript', + level: 'error', + release: RELEASE, + sdk: SDK, + tags: { run_id: this.attemptId, bug: encounter.bug }, + fingerprint: ['glitch-invaders', encounter.bug], + contexts, + breadcrumbs: [...this.breadcrumbs], + exception: { + values: [ + { + type: exception.type, + value: exception.value, + stacktrace: { frames: exception.frames }, + mechanism: { type: 'generic', handled: true }, + }, + ], + }, + }, + ); + } + + /** + * The span envelope for the boss `stall`: a `boss-spawn` root span and one child per load, timed + * as the run measured them and ending now. + */ + traceEnvelope(traceId, stall) { + const rootSpanId = randomHex(16); + const endSeconds = Date.now() / 1_000; + const startSeconds = endSeconds - stall.totalMs / 1_000; + const common = (name, spanId) => ({ + name, + span_id: spanId, + trace_id: traceId, + status: 'ok', + }); + const commonAttributes = { + 'sentry.origin': attribute('manual'), + 'sentry.segment.name': attribute('boss-spawn'), + 'sentry.segment.id': attribute(rootSpanId), + 'sentry.release': attribute(RELEASE), + 'sentry.sdk.name': attribute(SDK.name), + 'sentry.sdk.version': attribute(SDK.version), + }; + const children = stall.spans.map((span) => ({ + ...common(span.name, randomHex(16)), + parent_span_id: rootSpanId, + start_timestamp: startSeconds + span.startMs / 1_000, + end_timestamp: startSeconds + (span.startMs + span.durationMs) / 1_000, + is_segment: false, + attributes: { ...commonAttributes, 'sentry.op': attribute(span.operation) }, + })); + const root = { + ...common('boss-spawn', rootSpanId), + start_timestamp: startSeconds, + end_timestamp: endSeconds, + is_segment: true, + attributes: { + ...commonAttributes, + 'sentry.op': attribute('game.boss'), + 'sentry.sample_rate': attribute(1, 'integer'), + run_id: attribute(this.attemptId), + bug: attribute('boss'), + }, + }; + const items = [...children, root]; + + return envelope( + { + sent_at: new Date().toISOString(), + sdk: SDK, + trace: { + public_key: this.dsn.publicKey, + trace_id: traceId, + release: RELEASE, + transaction: 'boss-spawn', + sampled: 'true', + sample_rate: '1', + }, + }, + { + type: 'span', + item_count: items.length, + content_type: 'application/vnd.sentry.items.span.v2+json', + }, + { version: 2, items }, + ); + } + + /** POST `body` to the envelope endpoint, resolving whether Sentry accepted it in time. */ + async post(body) { + try { + const response = await this.fetchImpl(this.dsn.envelopeUrl, { + method: 'POST', + body, + signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS), + }); + + return response.ok; + } catch { + return false; + } + } +} diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css index c4572fa..d4675a1 100644 --- a/challenges/glitch-invaders/game/src/styles.css +++ b/challenges/glitch-invaders/game/src/styles.css @@ -151,6 +151,11 @@ body { white-space: nowrap; } +.badge[data-mode="live"] { + border-color: var(--success); + color: var(--success); +} + .topbar-reset { padding: 3px 10px; border: 1px solid var(--border); @@ -668,6 +673,16 @@ kbd { color: var(--mlh-yellow); } +.source-badge[data-source="live"] { + border-color: var(--success); + color: var(--success); +} + +.source-badge[data-source="fallback"] { + border-color: var(--warning); + color: var(--warning); +} + .sentry-callout { display: flex; flex-direction: column; @@ -697,6 +712,25 @@ kbd { font: 13px / 1.35 var(--sans); } +.button-sentry { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-color: var(--mlh-yellow); + border-radius: 6px; + background: var(--mlh-blue); + color: #ffffff; + font: 700 13px var(--sans); + text-decoration: none; + white-space: nowrap; + text-align: center; +} + +.button-sentry:hover { + border-color: #ffffff; +} + .context-pills { display: flex; flex-wrap: wrap; diff --git a/challenges/glitch-invaders/game/src/ui/hud.mjs b/challenges/glitch-invaders/game/src/ui/hud.mjs index 0acc831..b26605e 100644 --- a/challenges/glitch-invaders/game/src/ui/hud.mjs +++ b/challenges/glitch-invaders/game/src/ui/hud.mjs @@ -27,11 +27,19 @@ export class Hud { shield = requireElement('stat-shield'); kills = requireElement('stat-kills'); score = requireElement('stat-score'); + badge = requireElement('mode-badge'); objectiveText = requireElement('objective-text'); objectiveDetail = requireElement('objective-detail'); incidents = requireElement('incidents'); chipSignature = ''; + /** Swap the page's demo badge for the live one; it never changes during a run. */ + showLiveMode() { + this.badge.dataset.mode = 'live'; + setText(this.badge, 'LIVE · Sentry'); + this.badge.title = 'Live mode: the failures are reported to Sentry and the evidence is its record.'; + } + /** Refresh every stat from the latest world and progress. */ update(progress, world) { const hullText = world.player.restoreAt === null ? '♥'.repeat(world.player.hull) : '✖'; diff --git a/challenges/glitch-invaders/game/src/ui/incident-console.mjs b/challenges/glitch-invaders/game/src/ui/incident-console.mjs index 437750f..5d53ef6 100644 --- a/challenges/glitch-invaders/game/src/ui/incident-console.mjs +++ b/challenges/glitch-invaders/game/src/ui/incident-console.mjs @@ -10,6 +10,18 @@ const SOURCE_BADGES = { label: 'DEMO · SIMULATED', note: 'Demo mode: realistic diagnostics, no Sentry account needed.', }, + live: { label: 'LIVE SENTRY', note: 'Reported by the game and read back from Sentry.' }, + fallback: { + label: 'SIMULATED · SENTRY UNAVAILABLE', + note: 'Sentry could not provide this report, so the console shows simulated evidence.', + }, +}; + +/** How the evidence banner tells the participant to read each kind of evidence. */ +const HERO_COPY = { + simulated: + 'Demo mode (Sentry not connected): inspect the simulated telemetry snapshot to identify the root cause.', + live: 'Live from Sentry: this is what Sentry recorded for the failure you just hit. Find the root cause.', }; /** The glyphs for breadcrumb levels; level is never color alone. */ @@ -179,7 +191,7 @@ export class IncidentConsole { } } - /** Evidence step: the simulated Sentry capture of the failure. */ + /** Evidence step: Sentry's record of the failure, or the simulated capture of it. */ evidencePane() { const evidence = this.evidence; if (evidence === null) { @@ -225,8 +237,26 @@ export class IncidentConsole { ); } - /** Banner with the exception or trace headline and how to read the capture. */ + /** + * Banner with the exception or trace headline and how to read the capture: a fallback says why + * Sentry's record is missing, and live evidence links to it in Sentry. + */ sentryHero(evidence) { + const copy = evidence.source === 'fallback' ? evidence.note : HERO_COPY[evidence.source]; + const link = evidence.link?.url.startsWith('https://') + ? h( + 'a', + { + class: 'button button-sentry', + href: evidence.link.url, + target: '_blank', + rel: 'noopener noreferrer', + 'data-key': 'open-sentry', + }, + `${evidence.link.label} ↗`, + ) + : null; + return h( 'div', { class: 'sentry-callout' }, @@ -236,10 +266,14 @@ export class IncidentConsole { h('p', { class: 'evidence-head' }, evidence.title), h( 'p', - { class: 'sentry-callout-copy' }, - 'Demo mode (Sentry not connected): inspect the simulated telemetry snapshot to identify the root cause.', + { + class: 'sentry-callout-copy', + role: evidence.source === 'fallback' ? 'status' : undefined, + }, + copy, ), ), + link, ); } diff --git a/challenges/glitch-invaders/intro.md b/challenges/glitch-invaders/intro.md index e4694d2..331a773 100644 --- a/challenges/glitch-invaders/intro.md +++ b/challenges/glitch-invaders/intro.md @@ -8,8 +8,10 @@ Three things will fail while you play: - a boss that takes forever to spawn Each failure opens an Incident Console that shows the Sentry evidence: a -stack trace, breadcrumbs, or a performance trace. Read it, find the root -cause, choose the right patch, and watch the fix work in the game. +stack trace, breadcrumbs, or a performance trace. When this booth is +connected to Sentry, it is Sentry's live record of your run; otherwise it is +a realistic simulation. Read it, find the root cause, choose the right patch, +and watch the fix work in the game. Your guide, Pi, is on the right. Ask it for a nudge any time. It won't hand you the answers. diff --git a/challenges/glitch-invaders/sentry.json b/challenges/glitch-invaders/sentry.json new file mode 100644 index 0000000..53f1198 --- /dev/null +++ b/challenges/glitch-invaders/sentry.json @@ -0,0 +1,6 @@ +{ + "$comment": "Public settings for live Sentry mode; none is a secret. MLH fills these in from its Sentry project: dsn from Project Settings > Client Keys (DSN), org and project from the slugs in the project's URL. While any value is still a , the game runs in demo mode. Live mode also needs SENTRY_AUTH_TOKEN, which the station's vault provides; never put a token here.", + "dsn": "", + "org": "", + "project": "" +} diff --git a/challenges/glitch-invaders/server/live-evidence.mjs b/challenges/glitch-invaders/server/live-evidence.mjs new file mode 100644 index 0000000..4271d39 --- /dev/null +++ b/challenges/glitch-invaders/server/live-evidence.mjs @@ -0,0 +1,169 @@ +/** + * Live evidence: what Sentry itself recorded for one of the page's reports, fetched through + * {@link SentryApi} and reshaped into the sections the Incident Console renders. Everything Sentry + * returns is untrusted, so each field is checked or turned into plain text here. + */ +import { + bootStateSection, + evidenceTitle, + waterfallSection, +} from '../game/src/diagnostics/evidence-builders.mjs'; +import { SentryUnavailableError } from './sentry-api.mjs'; + +/** How many of the most recent breadcrumbs the console shows. */ +const SHOWN_BREADCRUMBS = 8; + +/** The context each error event carries, which the console shows as pills. */ +const CONTEXT_NAMES = { weapon: 'weapon_state', shield: 'shield_state' }; + +/** The breadcrumb levels the console has glyphs for; Sentry's others fold into these. */ +const CONSOLE_LEVELS = { info: 'info', warning: 'warning', error: 'error', fatal: 'error' }; + +/** `value` as display text, with lists joined the way the simulated evidence shows them. */ +function text(value) { + if (Array.isArray(value)) { + return value.map(String).join(', '); + } + + return typeof value === 'string' ? value : JSON.stringify(value ?? null); +} + +/** `value` as a finite number, or an outage when Sentry sent something else. */ +function finite(value, field) { + const number = Number(value); + if (!Number.isFinite(number)) { + throw new SentryUnavailableError(`Sentry sent a ${field} that is not a number`); + } + + return number; +} + +/** The `data` of the event entry of `type`, such as `exception` or `breadcrumbs`. */ +function entryData(event, type) { + const entries = Array.isArray(event.entries) ? event.entries : []; + + return entries.find((entry) => entry?.type === type)?.data ?? null; +} + +/** `url` when it is an HTTPS link the console may open, else `null`. */ +function httpsLink(url) { + return typeof url === 'string' && URL.parse(url)?.protocol === 'https:' ? url : null; +} + +/** Format how long before `lastMs` something happened, such as `-1.2 s`. */ +function offsetLabel(lastMs, atMs) { + const seconds = Math.max(0, lastMs - atMs) / 1_000; + + return seconds < 0.05 ? '0.0 s' : `-${seconds.toFixed(1)} s`; +} + +/** The last breadcrumbs of `event`, as console entries timed from the last one. */ +function breadcrumbEntries(event) { + const values = entryData(event, 'breadcrumbs')?.values; + const crumbs = (Array.isArray(values) ? values : []).slice(-SHOWN_BREADCRUMBS); + const times = crumbs.map((crumb) => Date.parse(crumb?.timestamp) || 0); + const lastMs = Math.max(0, ...times); + + return crumbs.map((crumb, index) => { + const data = Object.entries(crumb?.data ?? {}).map(([key, value]) => [key, text(value)]); + + return { + offsetLabel: offsetLabel(lastMs, times[index]), + category: text(crumb?.category ?? 'default'), + message: text(crumb?.message ?? ''), + level: CONSOLE_LEVELS[crumb?.level] ?? 'info', + data: data.length === 0 ? null : Object.fromEntries(data), + }; + }); +} + +/** The stack frames of `exception`, innermost first as the console lists them. */ +function stackFrames(exception) { + const frames = Array.isArray(exception.stacktrace?.frames) ? exception.stacktrace.frames : []; + + return frames.toReversed().map((frame) => ({ + functionName: text(frame?.function ?? '?'), + file: text(frame?.filename ?? '?'), + line: Number.isInteger(frame?.lineNo) ? frame.lineNo : 0, + inApp: frame?.inApp === true, + })); +} + +/** The context section `name` of `event`, leaving out the `type` Sentry adds to every context. */ +function contextSection(event, name) { + const { type: _type, ...entries } = event.contexts?.[name] ?? {}; + + return { + kind: 'context', + title: name, + contextName: name, + entries: Object.fromEntries(Object.entries(entries).map(([key, value]) => [key, text(value)])), + }; +} + +/** Evidence for the weapon or shield failure: its event and the issue Sentry grouped it into. */ +async function errorEvidence(api, bug, eventId) { + const event = await api.event(eventId); + const exception = entryData(event, 'exception')?.values?.at(-1); + if (exception === undefined || exception === null) { + throw new SentryUnavailableError(`Sentry's event ${eventId} has no exception`); + } + const issue = await api.issue(text(event.groupID)); + const permalink = httpsLink(issue.permalink); + const primary = + bug === 'weapon' + ? { kind: 'stacktrace', title: 'Stack trace', frames: stackFrames(exception) } + : { kind: 'breadcrumbs', title: 'Breadcrumbs', entries: breadcrumbEntries(event) }; + + return { + bug, + source: 'live', + title: text(issue.title ?? event.title), + referenceId: text(issue.shortId ?? eventId), + sections: [ + { + kind: 'exception', + title: 'Exception', + errorType: text(exception.type ?? 'Error'), + message: text(exception.value ?? ''), + }, + primary, + contextSection(event, CONTEXT_NAMES[bug]), + ], + link: permalink === null ? null : { label: 'Open the issue in Sentry', url: permalink }, + }; +} + +/** Evidence for the boss stall: the boss-spawn trace's load spans, as Sentry indexed them. */ +async function bossEvidence(api, traceId) { + const rows = await api.traceSpans(traceId); + const starts = rows.map((row) => finite(row['precise.start_ts'], 'span start')); + const firstStart = Math.min(...starts); + const spans = rows.map((row, index) => ({ + name: text(row['span.description']), + operation: text(row['span.op']), + startMs: Math.round((starts[index] - firstStart) * 1_000), + durationMs: Math.round(finite(row['span.duration'], 'span duration')), + })); + const waterfall = waterfallSection(spans); + const traceUrl = `https://${encodeURIComponent(api.org)}.sentry.io/explore/traces/trace/${traceId}/`; + + return { + bug: 'boss', + source: 'live', + title: evidenceTitle({ bug: 'boss', stall: { totalMs: waterfall.totalMs } }), + referenceId: `trace ${traceId.slice(0, 8)}`, + sections: [waterfall, bootStateSection()], + link: { label: 'Open the trace in Sentry', url: traceUrl }, + }; +} + +/** + * Fetch the live evidence for the page's report `id` of `bug`: an event id for the weapon and + * shield, a trace id for the boss. + * + * @throws {SentryUnavailableError} when Sentry is down, slow to index, or answers with nonsense. + */ +export function fetchLiveEvidence(api, bug, id) { + return bug === 'boss' ? bossEvidence(api, id) : errorEvidence(api, bug, id); +} diff --git a/challenges/glitch-invaders/server/page-server.mjs b/challenges/glitch-invaders/server/page-server.mjs index f12d7dc..bf3ede3 100644 --- a/challenges/glitch-invaders/server/page-server.mjs +++ b/challenges/glitch-invaders/server/page-server.mjs @@ -1,12 +1,15 @@ /** * The page server: serves the game, this attempt's runtime config, and the progress file - * that lets a refresh or a restarted guide pick up the run. Node built-ins only, because a + * that lets a refresh or a restarted guide pick up the run. In live mode it also fetches Sentry's + * record of the page's reports, so the token never reaches the page. Node built-ins only, because a * Boothstrap package runs wherever it is unpacked and cannot import the repository's dependencies. */ import { readFile, stat } from 'node:fs/promises'; import { createServer } from 'node:http'; import path from 'node:path'; -import { MAX_PROGRESS_BYTES, parseProgress } from '../shared/progress.mjs'; +import { BUG_IDS, MAX_PROGRESS_BYTES, parseProgress } from '../shared/progress.mjs'; +import { ingestOrigin } from '../shared/runtime-config.mjs'; +import { SentryUnavailableError } from './sentry-api.mjs'; import { readWorkspaceFile, RefusedFileError, @@ -27,17 +30,28 @@ const CONTENT_TYPES = { '.txt': 'text/plain; charset=utf-8', }; -/** - * Headers every response carries: nothing is cached, because a replay must never see old state, and - * the page may load and contact nothing but this server. - */ +/** Headers every response carries: nothing is cached, because a replay must never see old state. */ const BASE_HEADERS = { 'cache-control': 'no-store', - 'content-security-policy': - "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'", 'x-content-type-options': 'nosniff', }; +/** + * The page may load and contact nothing but this server, and in live mode Sentry's ingest origin + * for the DSN, where it sends its reports. + */ +function contentSecurityPolicy(runtimeConfig) { + const connect = + runtimeConfig.sentry === undefined + ? "'self'" + : `'self' ${ingestOrigin(runtimeConfig.sentry.dsn)}`; + + return `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ${connect}`; +} + +/** The ids the page gives its Sentry reports: 32 lowercase hex digits for events and traces. */ +const REPORT_ID_PATTERN = /^[0-9a-f]{32}$/; + /** How much of a refused upload the server reads and discards before it drops the connection. */ const MAX_DISCARDED_BYTES = 4 * MAX_PROGRESS_BYTES; @@ -236,6 +250,43 @@ async function handleProgress(request, response, options) { await saveProgress(request, response, options); } +/** + * Handle `/api/sentry-evidence?bug=&id=`: 200 with live evidence, 404 in demo + * mode, 400 for a bad query, and 502 naming the problem when Sentry cannot provide it. + */ +async function handleSentryEvidence(request, response, options) { + if (options.fetchEvidence === undefined) { + sendStatus(response, 404); + + return; + } + if (request.method !== 'GET') { + sendStatus(response, 405); + + return; + } + + const query = new URL(request.url ?? '/', 'http://localhost').searchParams; + const bug = query.get('bug'); + const id = query.get('id'); + if (!BUG_IDS.includes(bug) || id === null || !REPORT_ID_PATTERN.test(id)) { + sendStatus(response, 400, 'bug must be weapon, shield, or boss, and id 32 hex digits'); + + return; + } + + try { + sendJson(response, 200, await options.fetchEvidence(bug, id)); + } catch (error) { + if (error instanceof SentryUnavailableError) { + sendStatus(response, 502, error.message); + + return; + } + throw error; + } +} + /** Serve a file from the game directory (or `/shared/`), with `/` meaning `index.html`. */ async function handleStatic(pathname, response, gameDir) { let decoded; @@ -274,9 +325,18 @@ async function handleStatic(pathname, response, gameDir) { } } -/** Create the page server. The caller decides when and where it listens. */ +/** + * Create the page server. The caller decides when and where it listens. + * + * @param {{ gameDir: string, progressFile: string, runtimeConfig: object, + * fetchEvidence?: (bug: string, id: string) => Promise }} options `fetchEvidence` is + * given only in live mode. + */ export function createPageServer(options) { + const policy = contentSecurityPolicy(options.runtimeConfig); + return createServer((request, response) => { + response.setHeader('content-security-policy', policy); void (async () => { if (!hostIsLoopback(request)) { sendStatus(response, 403, 'this server answers to loopback names only'); @@ -293,6 +353,8 @@ export function createPageServer(options) { sendJson(response, 200, options.runtimeConfig); } else if (pathname === '/api/progress') { await handleProgress(request, response, options); + } else if (pathname === '/api/sentry-evidence') { + await handleSentryEvidence(request, response, options); } else if (request.method === 'GET' || request.method === 'HEAD') { await handleStatic(pathname, response, options.gameDir); } else { diff --git a/challenges/glitch-invaders/server/sentry-api.mjs b/challenges/glitch-invaders/server/sentry-api.mjs new file mode 100644 index 0000000..334cedd --- /dev/null +++ b/challenges/glitch-invaders/server/sentry-api.mjs @@ -0,0 +1,142 @@ +/** + * The Sentry API calls the page server makes for live evidence. The token stays inside this client: + * it goes only into the Authorization header of requests to the configured API, and no error or + * result ever carries it. On a station the request leaves through the vault proxy, which swaps the + * placeholder token for the real one. + */ + +/** How long one API request may take before it counts as an outage. */ +const REQUEST_TIMEOUT_MS = 5_000; + +/** + * Waits between lookups of something Sentry has accepted but not yet indexed, about 15 seconds in + * all; an event or trace usually appears within a few. + */ +export const INDEXING_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 4_000, 5_000]; + +/** Sentry did not answer usefully. The message names the request, never the token. */ +export class SentryUnavailableError extends Error { + constructor(message) { + super(message); + this.name = 'SentryUnavailableError'; + } +} + +/** Pause for `milliseconds`. */ +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/** Reads one Sentry organization and project with a bearer token. */ +export class SentryApi { + #token; + + /** + * @param {{ apiUrl: string, org: string, project: string, token: string, sleep?: Function, + * retryDelaysMs?: number[] }} options `sleep` and `retryDelaysMs` let tests skip the waits. + */ + constructor(options) { + this.apiUrl = options.apiUrl; + this.org = options.org; + this.project = options.project; + this.#token = options.token; + this.sleep = options.sleep ?? sleep; + this.retryDelaysMs = options.retryDelaysMs ?? INDEXING_RETRY_DELAYS_MS; + } + + /** One error event, by the id the page gave it; waits while Sentry is still indexing it. */ + event(eventId) { + const org = encodeURIComponent(this.org); + const project = encodeURIComponent(this.project); + + return this.untilIndexed(() => this.get(`/api/0/projects/${org}/${project}/events/${eventId}/`)); + } + + /** The issue an event was grouped into. */ + async issue(groupId) { + const org = encodeURIComponent(this.org); + const issue = await this.get( + `/api/0/organizations/${org}/issues/${encodeURIComponent(groupId)}/`, + ); + if (issue === null) { + throw new SentryUnavailableError(`Sentry has no issue ${groupId}`); + } + + return issue; + } + + /** The boss-load spans of trace `traceId`, oldest first; waits while Sentry is indexing them. */ + traceSpans(traceId) { + const query = new URLSearchParams({ + dataset: 'spans', + query: `trace:${traceId} span.op:game.resource.load`, + sort: 'precise.start_ts', + statsPeriod: '24h', + per_page: '20', + }); + for (const field of ['span.description', 'span.op', 'span.duration', 'precise.start_ts']) { + query.append('field', field); + } + const resource = `/api/0/organizations/${encodeURIComponent(this.org)}/events/?${query}`; + + return this.untilIndexed(async () => { + const result = await this.get(resource); + const rows = Array.isArray(result?.data) ? result.data : []; + + return rows.length === 0 ? null : rows; + }); + } + + /** + * Run `lookup` until it finds something, waiting {@link INDEXING_RETRY_DELAYS_MS} between tries. + * An outage fails at once; only "not there yet" is retried. + */ + async untilIndexed(lookup) { + for (const delayMs of [...this.retryDelaysMs, null]) { + const found = await lookup(); + if (found !== null) { + return found; + } + if (delayMs === null) { + break; + } + await this.sleep(delayMs); + } + + throw new SentryUnavailableError('Sentry has not indexed the report yet'); + } + + /** + * GET `resource` from the API as JSON. + * + * @returns {Promise} the body, or `null` for a 404. + * @throws {SentryUnavailableError} for a network failure, a timeout, or any other non-200 status. + */ + async get(resource) { + const pathname = resource.split('?')[0]; + let response; + try { + response = await fetch(`${this.apiUrl}${resource}`, { + headers: { authorization: `Bearer ${this.#token}`, accept: 'application/json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new SentryUnavailableError(`Sentry did not answer ${pathname}`); + } + if (response.status === 404) { + await response.body?.cancel(); + + return null; + } + if (!response.ok) { + await response.body?.cancel(); + throw new SentryUnavailableError(`Sentry answered ${pathname} with ${response.status}`); + } + + try { + return await response.json(); + } catch { + throw new SentryUnavailableError(`Sentry answered ${pathname} with a body that is not JSON`); + } + } +} diff --git a/challenges/glitch-invaders/server/sentry-config.mjs b/challenges/glitch-invaders/server/sentry-config.mjs new file mode 100644 index 0000000..2d3975e --- /dev/null +++ b/challenges/glitch-invaders/server/sentry-config.mjs @@ -0,0 +1,73 @@ +/** + * Live or demo: live Sentry mode needs both halves, the public settings in the package's + * `sentry.json` and `SENTRY_AUTH_TOKEN` in the environment, where the station's vault puts a + * placeholder it swaps for the real token on the way out. Anything less is demo mode. + */ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { parseSentrySettings } from '../shared/runtime-config.mjs'; + +/** The package file holding the public Sentry settings. */ +export const SENTRY_CONFIG_FILE_NAME = 'sentry.json'; + +/** The variable the vault's `sentry` service sets, and the one Sentry's own tools read. */ +export const SENTRY_TOKEN_VARIABLE = 'SENTRY_AUTH_TOKEN'; + +/** Whether `value` is still a `` waiting for MLH's real setting. */ +function isPlaceholder(value) { + return typeof value === 'string' && value.startsWith('<') && value.endsWith('>'); +} + +/** + * Read the package's Sentry settings. + * + * @returns {Promise | null>} the settings, or `null` when + * the file is absent or any value is still a placeholder. + * @throws {Error} naming the file when it is not JSON or a filled-in value is malformed. + */ +export async function readSentryConfig(packageDir) { + const file = path.join(packageDir, SENTRY_CONFIG_FILE_NAME); + let text; + try { + text = await readFile(file, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + return null; + } + throw error; + } + + let record; + try { + record = JSON.parse(text); + } catch { + throw new Error(`${SENTRY_CONFIG_FILE_NAME} is not valid JSON`); + } + if (Object.values(record ?? {}).some(isPlaceholder)) { + return null; + } + try { + return parseSentrySettings(record); + } catch (error) { + throw new Error(`${SENTRY_CONFIG_FILE_NAME}: ${error.message}`); + } +} + +/** + * Decide the attempt's mode from the package config and `env`. + * + * @returns {Promise<{ sentry: ReturnType | null, note: string | null }>} + * `sentry` is `null` for demo mode; `note` says why live mode is off when the package is ready + * for it, and is `null` otherwise. + */ +export async function decideMode(env, packageDir) { + const sentry = await readSentryConfig(packageDir); + if (sentry === null) { + return { sentry: null, note: null }; + } + if ((env[SENTRY_TOKEN_VARIABLE] ?? '') === '') { + return { sentry: null, note: `live Sentry mode is off: ${SENTRY_TOKEN_VARIABLE} is not set` }; + } + + return { sentry, note: null }; +} diff --git a/challenges/glitch-invaders/server/serve.mjs b/challenges/glitch-invaders/server/serve.mjs index 38fd5dc..ba871ae 100644 --- a/challenges/glitch-invaders/server/serve.mjs +++ b/challenges/glitch-invaders/server/serve.mjs @@ -1,11 +1,15 @@ /** * The page server process: `setup` starts it in the background. It listens on loopback, tells - * setup once it is ready (or why it could not be), and stops on SIGTERM. + * setup once it is ready (or why it could not be), and stops on SIGTERM. In live mode it reads + * `SENTRY_AUTH_TOKEN` from its environment and hands it only to the Sentry API client. */ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { parseRuntimeConfig } from '../shared/runtime-config.mjs'; +import { fetchLiveEvidence } from './live-evidence.mjs'; import { createPageServer } from './page-server.mjs'; +import { SentryApi } from './sentry-api.mjs'; +import { SENTRY_TOKEN_VARIABLE } from './sentry-config.mjs'; /** Tell the parent, if there is one, then drop the channel so the parent can exit. */ function report(message) { @@ -25,11 +29,32 @@ function readArguments(argv) { return { port: Number(portText), runtimeConfigFile, progressFile }; } +/** The live-evidence fetcher for a live config, or `undefined` in demo mode. */ +function liveEvidenceFetcher(runtimeConfig, env) { + if (runtimeConfig.sentry === undefined) { + return undefined; + } + const token = env[SENTRY_TOKEN_VARIABLE] ?? ''; + if (token === '') { + throw new Error(`live Sentry mode needs ${SENTRY_TOKEN_VARIABLE}`); + } + + const api = new SentryApi({ ...runtimeConfig.sentry, token }); + + return (bug, id) => fetchLiveEvidence(api, bug, id); +} + /** Start serving. */ function main() { const { port, runtimeConfigFile, progressFile } = readArguments(process.argv.slice(2)); const runtimeConfig = parseRuntimeConfig(JSON.parse(readFileSync(runtimeConfigFile, 'utf8'))); - const server = createPageServer({ gameDir: GAME_DIR, progressFile, runtimeConfig }); + const fetchEvidence = liveEvidenceFetcher(runtimeConfig, process.env); + const server = createPageServer({ + gameDir: GAME_DIR, + progressFile, + runtimeConfig, + ...(fetchEvidence === undefined ? {} : { fetchEvidence }), + }); server.once('error', (error) => { report({ listening: false, error: error.message }); diff --git a/challenges/glitch-invaders/server/setup.mjs b/challenges/glitch-invaders/server/setup.mjs index 56457b3..3502fef 100644 --- a/challenges/glitch-invaders/server/setup.mjs +++ b/challenges/glitch-invaders/server/setup.mjs @@ -1,7 +1,7 @@ /** - * Setup: prepare a clean attempt and start the page server. Deterministic and fast: it writes a - * fresh runtime config, serves the game in the background, and exits 0 only once the server - * answers, so Chrome never opens an unready page. + * Setup: prepare a clean attempt and start the page server. Deterministic and fast: it decides live + * or demo mode, writes a fresh runtime config, serves the game in the background, and exits 0 only + * once the server answers, so Chrome never opens an unready page. */ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { stopServer } from './cleanup.mjs'; import { resolveStatePaths } from './paths.mjs'; +import { decideMode } from './sentry-config.mjs'; import { clearWorkspacePath, ensureWorkspaceDirectory, @@ -31,8 +32,11 @@ function resolvePort(env) { return Number(new URL(manifest.browser.start_url).port); } -/** Start the page server in the background and wait for it to listen. */ -function startServer(paths, port) { +/** + * Start the page server in the background and wait for it to listen. It inherits `env`, the hook's + * environment, which in live mode holds the token it alone reads. + */ +function startServer(paths, port, env) { return new Promise((resolve, reject) => { const serveArguments = [ path.join(PACKAGE_DIR, 'server', 'serve.mjs'), @@ -41,6 +45,7 @@ function startServer(paths, port) { paths.progressFile, ]; const child = spawn(process.execPath, serveArguments, { + env, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], }); const timer = setTimeout(() => { @@ -85,11 +90,15 @@ export async function runSetup(env, cwd) { await clearWorkspacePath(paths.progressFile); await clearWorkspacePath(paths.pidFile); - const config = { attemptId: randomUUID() }; + const mode = await decideMode(env, PACKAGE_DIR); + const config = { + attemptId: randomUUID(), + ...(mode.sentry === null ? {} : { sentry: mode.sentry }), + }; await writeWorkspaceFile(paths.runtimeConfigFile, `${JSON.stringify(config, null, 2)}\n`); const port = resolvePort(env); - const serverPid = await startServer(paths, port); + const serverPid = await startServer(paths, port, env); try { await writeWorkspaceFile(paths.pidFile, `${serverPid}\n`); } catch (error) { @@ -100,6 +109,8 @@ export async function runSetup(env, cwd) { return { url: `http://127.0.0.1:${port}/`, attemptId: config.attemptId, + live: mode.sentry !== null, + note: mode.note, serverPid, }; } @@ -107,7 +118,11 @@ export async function runSetup(env, cwd) { if (import.meta.main) { runSetup(process.env, process.cwd()).then( (result) => { - process.stdout.write(`Glitch Invaders is ready at ${result.url}\n`); + const live = result.live ? ' (live Sentry mode)' : ''; + process.stdout.write(`Glitch Invaders is ready at ${result.url}${live}\n`); + if (result.note !== null) { + process.stdout.write(`note: ${result.note}\n`); + } process.exit(0); }, (error) => { diff --git a/challenges/glitch-invaders/shared/progress.mjs b/challenges/glitch-invaders/shared/progress.mjs index 8472920..3241cb3 100644 --- a/challenges/glitch-invaders/shared/progress.mjs +++ b/challenges/glitch-invaders/shared/progress.mjs @@ -30,15 +30,18 @@ export class InvalidProgressError extends Error { /** The largest request body the server accepts for a progress write. */ export const MAX_PROGRESS_BYTES = 16 * 1024; -/** The only mode the game runs in: the Incident Console shows simulated Sentry evidence. */ -export const MODE = 'demo'; +/** + * How the Incident Console gets its evidence: `demo` shows simulated Sentry evidence, and `live` + * shows what Sentry recorded for the page's own reports. + */ +export const MODES = ['demo', 'live']; -/** A fresh, unplayed run. */ -export function createProgress(attemptId) { +/** A fresh, unplayed run in `mode`. */ +export function createProgress(attemptId, mode = 'demo') { return { version: 1, attemptId, - mode: MODE, + mode, launched: false, completed: false, score: 0, @@ -151,8 +154,8 @@ export function parseProgress(value) { 'attemptId must be 4 to 64 lowercase letters, digits, or hyphens', ); } - if (record.mode !== MODE) { - throw new InvalidProgressError(`mode must be ${MODE}`); + if (!isOneOf(MODES, record.mode)) { + throw new InvalidProgressError(`mode must be one of ${MODES.join(', ')}`); } const bugs = readRecord(record.bugs, 'bugs'); if (!Array.isArray(record.milestones) || record.milestones.length > 200) { @@ -162,7 +165,7 @@ export function parseProgress(value) { return { version: 1, attemptId: record.attemptId, - mode: MODE, + mode: record.mode, launched: readFlag(record, 'launched', 'progress'), completed: readFlag(record, 'completed', 'progress'), score: readCount(record, 'score', 'progress'), diff --git a/challenges/glitch-invaders/shared/runtime-config.mjs b/challenges/glitch-invaders/shared/runtime-config.mjs index 14a1add..329dc53 100644 --- a/challenges/glitch-invaders/shared/runtime-config.mjs +++ b/challenges/glitch-invaders/shared/runtime-config.mjs @@ -1,8 +1,12 @@ /** * The attempt's runtime config: setup writes it, the page server serves it as - * `/runtime-config.json`, and the page reads it to tie saved progress to this attempt. + * `/runtime-config.json`, and the page reads it to tie saved progress to this attempt. A live + * attempt also carries the public Sentry settings; demo has none. No secret is ever in it. */ +/** The Sentry API the vault injects the token for, unless the package config names another. */ +export const DEFAULT_SENTRY_API_URL = 'https://sentry.io'; + /** Thrown when a served or stored runtime config is malformed. */ export class InvalidRuntimeConfigError extends Error { constructor(reason) { @@ -11,11 +15,78 @@ export class InvalidRuntimeConfigError extends Error { } } +/** Whether `dsn` looks like `https://@/`. */ +function isDsn(dsn) { + const url = URL.parse(dsn); + + return ( + url !== null && + url.protocol === 'https:' && + url.username !== '' && + url.password === '' && + /^\/\d+$/.test(url.pathname) + ); +} + +/** Whether `value` is a Sentry organization or project slug. */ +function isSlug(value) { + return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(value); +} + +/** Whether `apiUrl` is an HTTPS origin, or a plain-HTTP loopback one such as a local stand-in. */ +function isApiOrigin(apiUrl) { + const url = URL.parse(apiUrl); + if (url === null || url.origin !== apiUrl) { + return false; + } + + return ( + url.protocol === 'https:' || + (url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname)) + ); +} + +/** + * Validate untrusted JSON as the public Sentry settings, with `apiUrl` defaulting to + * {@link DEFAULT_SENTRY_API_URL}. + * + * @returns {{ dsn: string, org: string, project: string, apiUrl: string }} + * @throws {InvalidRuntimeConfigError} naming the first field that is missing or malformed. + */ +export function parseSentrySettings(value) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new InvalidRuntimeConfigError('sentry must be an object'); + } + + const { dsn, org, project, apiUrl = DEFAULT_SENTRY_API_URL } = value; + if (typeof dsn !== 'string' || !isDsn(dsn)) { + throw new InvalidRuntimeConfigError('sentry.dsn must be a Sentry DSN'); + } + if (typeof org !== 'string' || !isSlug(org)) { + throw new InvalidRuntimeConfigError('sentry.org must be a Sentry slug'); + } + if (typeof project !== 'string' || !isSlug(project)) { + throw new InvalidRuntimeConfigError('sentry.project must be a Sentry slug'); + } + if (typeof apiUrl !== 'string' || !isApiOrigin(apiUrl)) { + throw new InvalidRuntimeConfigError('sentry.apiUrl must be an HTTPS origin'); + } + + return { dsn, org, project, apiUrl }; +} + +/** The origin the page sends Sentry envelopes to, which the page's CSP must allow. */ +export function ingestOrigin(dsn) { + return new URL(dsn).origin; +} + /** * Validate untrusted JSON as a runtime config. * - * @returns {{ attemptId: string }} - * @throws {InvalidRuntimeConfigError} when it is not an object or `attemptId` is malformed. + * @returns {{ attemptId: string, sentry?: ReturnType }} with `sentry` + * only in live mode. + * @throws {InvalidRuntimeConfigError} when it is not an object, `attemptId` is malformed, or + * `sentry` is present but malformed. */ export function parseRuntimeConfig(value) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { @@ -28,6 +99,9 @@ export function parseRuntimeConfig(value) { 'attemptId must be 4 to 64 lowercase letters, digits, or hyphens', ); } + if (value.sentry === undefined) { + return { attemptId }; + } - return { attemptId }; + return { attemptId, sentry: parseSentrySettings(value.sentry) }; } diff --git a/challenges/glitch-invaders/skills/start-challenge/SKILL.md b/challenges/glitch-invaders/skills/start-challenge/SKILL.md index d6fa595..7a63cef 100644 --- a/challenges/glitch-invaders/skills/start-challenge/SKILL.md +++ b/challenges/glitch-invaders/skills/start-challenge/SKILL.md @@ -57,8 +57,12 @@ Match your help to the `stage` of the bug they are on. Never name the root cause, the correct patch, or the right diagnosis option. Offer the game's own hint button before giving a bigger nudge. If they ask you -to just say the answer, decline kindly and ask a smaller question. The -evidence is labeled `SIMULATED`; that is expected. Do not call it a problem. +to just say the answer, decline kindly and ask a smaller question. When +`mode` is `demo`, the evidence is labeled `SIMULATED`. When it is `live`, the +evidence is Sentry's own record of their run, labeled `LIVE SENTRY`, with a +link that opens it in Sentry. A `SENTRY UNAVAILABLE` label means Sentry did not +answer, so the console shows simulated evidence instead. All of these are +expected; do not call any of them a problem. ## Steps @@ -81,7 +85,8 @@ bugs are repaired. Each time you read `glitch-progress.json`, look at `milestones`. Forward each one you have not yet forwarded with `emit_event`, in order. Name it exactly as the milestone's `name` (kebab-case) and set `props` to -`{"bug": "", "mode": "demo"}`, leaving out `bug` for +`{"bug": "", "mode": ""}`, with `mode` copied +from the file and leaving out `bug` for `run-started` and `challenge-completed`; add `"correct": true|false` for `repair-attempted`. Never add names, emails, or free text. Remember the highest `seq` you forwarded by writing it to `.glitch/forwarded-seq.txt`, and read that diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index 7aa19df..a8a21db 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -2,13 +2,16 @@ // through gameplay and verify their repairs in play; stack frames match the // real source lines; the IncidentTracker enforces stage progression; the page // server serves native .mjs modules under a strict CSP, enforces origin, host, -// size, and path checks, and refuses a planted progress file; and the setup and -// cleanup hooks start and stop the server cleanly, including after failures. +// size, and path checks, and refuses a planted progress file; the setup and +// cleanup hooks start and stop the server cleanly, including after failures; and +// live Sentry mode turns on only with a token and filled-in settings, reads +// evidence from a local stand-in for the Sentry API without ever exposing the +// token, and falls back to labelled simulated evidence when Sentry fails. import assert from 'node:assert/strict'; import { execFile, spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; -import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { createServer as createHttpServer, request } from 'node:http'; import { createServer } from 'node:net'; import os from 'node:os'; @@ -30,6 +33,10 @@ import { IncidentTracker, } from '../challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs'; import { WEAPON_STACK_FRAMES } from '../challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs'; +import { + FALLBACK_NOTES, + LiveEvidenceProvider, +} from '../challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs'; import { SimulatedEvidenceProvider } from '../challenges/glitch-invaders/game/src/diagnostics/simulated-evidence.mjs'; import { BOSS_SPAWN_BUDGET_MS, @@ -41,9 +48,11 @@ import { import { GameEngine } from '../challenges/glitch-invaders/game/src/engine/game-engine.mjs'; import { createRandom } from '../challenges/glitch-invaders/game/src/engine/random.mjs'; import { idleInput } from '../challenges/glitch-invaders/game/src/engine/world.mjs'; +import { SentryReporter } from '../challenges/glitch-invaders/game/src/sentry/reporter.mjs'; import { WeaponConfigError } from '../challenges/glitch-invaders/game/src/weapons/weapons.mjs'; import { runCleanup } from '../challenges/glitch-invaders/server/cleanup.mjs'; import { resolveStatePaths } from '../challenges/glitch-invaders/server/paths.mjs'; +import { SentryApi } from '../challenges/glitch-invaders/server/sentry-api.mjs'; import { runSetup } from '../challenges/glitch-invaders/server/setup.mjs'; import { BUG_IDS, createProgress, monitorStateLine } from '../challenges/glitch-invaders/shared/progress.mjs'; @@ -54,6 +63,11 @@ const FRAME_MS = 16; const SERVER_EXIT_TIMEOUT_MS = 5_000; const CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'"; +const SENTRY_TOKEN = 'sntrys_stand-in-token-that-must-never-show'; +const LIVE_DSN = 'https://publickey@o1.ingest.us.sentry.io/42'; +const WEAPON_EVENT_ID = 'a'.repeat(32); +const SHIELD_EVENT_ID = 'b'.repeat(32); +const BOSS_TRACE_ID = 'c'.repeat(32); /** Fake time for the boss loader, which needs `now` and `sleep`. */ class VirtualClock { @@ -339,6 +353,230 @@ function putProgressTo(url, body, headers = {}) { }); } +/** A Sentry API error event as the events endpoint returns it; `overrides` replace fields. */ +function sentryEventFactory(overrides = {}) { + return { + eventID: WEAPON_EVENT_ID, + groupID: '7001', + title: 'WeaponConfigError: Configuration missing for weapon: fanout', + contexts: { + weapon_state: { attemptedWeapon: 'fanout', availableWeapons: ['single'], type: 'default' }, + }, + entries: [ + { + type: 'exception', + data: { + values: [ + { + type: 'WeaponConfigError', + value: 'Configuration missing for weapon: fanout', + stacktrace: { + frames: [ + { function: 'GameEngine.tryFire', filename: 'game/src/engine/game-engine.mjs', lineNo: 320, inApp: true }, + { function: 'fireWeapon', filename: 'game/src/weapons/weapons.mjs', lineNo: 46, inApp: true }, + ], + }, + }, + ], + }, + }, + ], + ...overrides, + }; +} + +/** A Sentry API issue; `overrides` replace fields. */ +function sentryIssueFactory(overrides = {}) { + return { + id: '7001', + shortId: 'GLITCH-INVADERS-1', + title: 'WeaponConfigError: Configuration missing for weapon: fanout', + permalink: 'https://mlh.sentry.io/issues/7001/', + ...overrides, + }; +} + +/** The shield event: an exception, its breadcrumb trail, and the shield context. */ +function shieldEventFactory() { + return sentryEventFactory({ + eventID: SHIELD_EVENT_ID, + groupID: '7002', + contexts: { shield_state: { shieldActive: true, actualDamage: 3, type: 'default' } }, + entries: [ + { + type: 'exception', + data: { values: [{ type: 'GameLogicError', value: 'Shield was active but 3 damage was applied' }] }, + }, + { + type: 'breadcrumbs', + data: { + values: [ + { timestamp: '2026-10-17T12:00:00.000Z', category: 'game.shield', message: 'Shield enabled', level: 'info', data: { shieldActive: 'true' } }, + { timestamp: '2026-10-17T12:00:05.600Z', category: 'game.combat', message: 'Actual damage applied: 3', level: 'warning', data: { actualDamage: '3' } }, + { timestamp: '2026-10-17T12:00:06.800Z', category: 'game.combat', message: 'Ship destroyed: shield failed to protect', level: 'fatal' }, + ], + }, + }, + ], + }); +} + +/** The spans endpoint rows for the boss trace: two sequential loads of 1.2 s and 1.0 s. */ +function bossSpanRows() { + return [ + { 'span.description': 'loadTextures', 'span.op': 'game.resource.load', 'span.duration': 1200, 'precise.start_ts': 1_000 }, + { 'span.description': 'loadSounds', 'span.op': 'game.resource.load', 'span.duration': 1000, 'precise.start_ts': 1_001.2 }, + ]; +} + +/** + * Start a local stand-in for the Sentry API that answers the routes the page server calls from + * `routes` (pathname to body, or a status number), records each request, and closes when the test + * ends. `outage` makes every route answer 503, and `unindexedRequests` makes that many first + * requests answer 404, as Sentry does before it has indexed a report. + */ +function startSentryStandIn(testContext, options = {}) { + const routes = { + '/api/0/projects/mlh/glitch-invaders/events/': { data: [] }, + [`/api/0/projects/mlh/glitch-invaders/events/${WEAPON_EVENT_ID}/`]: sentryEventFactory(), + [`/api/0/projects/mlh/glitch-invaders/events/${SHIELD_EVENT_ID}/`]: shieldEventFactory(), + '/api/0/organizations/mlh/issues/7001/': sentryIssueFactory(), + '/api/0/organizations/mlh/issues/7002/': sentryIssueFactory({ shortId: 'GLITCH-INVADERS-2', title: 'GameLogicError: Shield was active' }), + '/api/0/organizations/mlh/events/': { data: bossSpanRows() }, + ...options.routes, + }; + const requests = []; + + return new Promise((resolve, reject) => { + const server = createHttpServer((incoming, response) => { + const url = new URL(incoming.url ?? '/', 'http://localhost'); + requests.push({ method: incoming.method, url, authorization: incoming.headers.authorization }); + const unindexed = requests.length <= (options.unindexedRequests ?? 0); + const route = options.outage ? 503 : unindexed ? 404 : (routes[url.pathname] ?? 404); + if (typeof route === 'number') { + response.writeHead(route).end('{"detail":"stand-in refusal"}'); + + return; + } + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(route)); + }); + testContext.after( + () => + new Promise((done) => { + server.close(done); + server.closeAllConnections(); + }), + ); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + resolve({ url: `http://127.0.0.1:${server.address().port}`, requests }); + }); + }); +} + +/** Run `hook` from the package at `packageDir` and resolve with its exit code and output. */ +function runPackageHook(packageDir, hook, env) { + return new Promise((resolve, reject) => { + const child = spawn(path.join(packageDir, hook), [], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk; + }); + child.stderr.on('data', (chunk) => { + output += chunk; + }); + child.once('error', reject); + child.once('close', (code) => resolve({ code, output })); + }); +} + +/** + * Copy the package and fill in its `sentry.json` with live settings pointing at `apiUrl`, so the + * shipped hooks run against the stand-in. Resolves with the copy's directory, which + * {@link startLivePackage} removes once its cleanup hook has run. + */ +async function makeLivePackage(apiUrl) { + const copyRoot = await mkdtemp(path.join(os.tmpdir(), 'glitch-package-')); + const packageDir = path.join(copyRoot, 'glitch-invaders'); + await cp(packageDirectory, packageDir, { recursive: true }); + const settings = { dsn: LIVE_DSN, org: 'mlh', project: 'glitch-invaders', apiUrl }; + await writeFile(path.join(packageDir, 'sentry.json'), `${JSON.stringify(settings)}\n`); + + return packageDir; +} + +/** + * Run the live package's setup hook in a fresh workspace with `extraEnv`, registering its cleanup + * hook. Resolves with the server's base URL, the hook's output, and the workspace. + */ +async function startLivePackage(testContext, packageDir, extraEnv) { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'glitch-live-')); + const port = await freePort(); + const env = { ...hookEnvironment(workspace, port), ...extraEnv }; + testContext.after(async () => { + await runPackageHook(packageDir, 'cleanup', env); + await rm(workspace, { recursive: true, force: true }); + await rm(path.dirname(packageDir), { recursive: true, force: true }); + }); + + const setup = await runPackageHook(packageDir, 'setup', env); + assert.equal(setup.code, 0, setup.output); + + return { base: `http://127.0.0.1:${port}`, output: setup.output, workspace }; +} + +/** Start a live stack: a Sentry stand-in, a live package copy, and its server with the token. */ +async function startLiveStack(testContext, standInOptions = {}) { + const standIn = await startSentryStandIn(testContext, standInOptions); + const packageDir = await makeLivePackage(standIn.url); + const stack = await startLivePackage(testContext, packageDir, { SENTRY_AUTH_TOKEN: SENTRY_TOKEN }); + + return { ...stack, standIn }; +} + +/** Ask the page server at `base` for the live evidence of report `id` of `bug`. */ +function fetchEvidence(base, bug, id) { + return fetch(`${base}/api/sentry-evidence?bug=${bug}&id=${id}`); +} + +/** A reporter fake for the page's live provider: it records reports and answers with `outcome`. */ +function fakeReporter(outcome) { + const reported = []; + + return { + reported, + report: async (encounter) => { + reported.push(encounter); + + return outcome; + }, + }; +} + +/** A fetch fake that answers every request with `response`, recording the URLs asked for. */ +function fakeFetch(response) { + const urls = []; + const fetchImpl = async (url) => { + urls.push(String(url)); + + return response; + }; + + return { urls, fetchImpl }; +} + +/** The weapon encounter the engine reports when the fan-out upgrade jams. */ +function weaponEncounterFactory() { + return { + bug: 'weapon', + jam: { + weaponId: 'fanout', + availableWeapons: ['single'], + error: new Error('Configuration missing for weapon: fanout'), + }, + }; +} + test('weapon stack trace frames point at the real lines of the calls they name', () => { const callAtLine = { fireWeapon: 'throw new WeaponConfigError', @@ -948,3 +1186,254 @@ test('setup and cleanup run standalone from the current directory with no BOOTHS assert.equal(await isRefused(port), true); assert.equal(existsSync(path.join(workspace, '.glitch')), false); }); + +test('the package names the gemini and sentry vault services and ships placeholder Sentry settings', async () => { + const manifest = JSON.parse(await readFile(path.join(packageDirectory, 'challenge.json'), 'utf8')); + const settings = JSON.parse(await readFile(path.join(packageDirectory, 'sentry.json'), 'utf8')); + + assert.deepEqual(manifest.vault_services, ['gemini', 'sentry']); + for (const key of ['dsn', 'org', 'project']) { + assert.match(settings[key], /^<.+>$/, `${key} is not a placeholder`); + } +}); + +test('setup stays in demo mode with SENTRY_AUTH_TOKEN set while sentry.json holds placeholders', async (testContext) => { + const stack = await startStack(testContext, { SENTRY_AUTH_TOKEN: SENTRY_TOKEN }); + + const config = await (await fetch(`${stack.url}runtime-config.json`)).json(); + const page = await getRaw(stack.url, '/'); + const evidence = await fetchEvidence(stack.url.replace(/\/$/, ''), 'weapon', WEAPON_EVENT_ID); + + assert.deepEqual(config, { attemptId: stack.attemptId }); + assert.equal(stack.live, false); + assert.equal(stack.note, null); + assert.equal(page.headers['content-security-policy'], CONTENT_SECURITY_POLICY); + assert.equal(evidence.status, 404); +}); + +test('setup notes that live mode is off when sentry.json is filled in but SENTRY_AUTH_TOKEN is unset', async (testContext) => { + const standIn = await startSentryStandIn(testContext); + const packageDir = await makeLivePackage(standIn.url); + + const stack = await startLivePackage(testContext, packageDir, {}); + + const config = await (await fetch(`${stack.base}/runtime-config.json`)).json(); + assert.match(stack.output, /^Glitch Invaders is ready at http:\/\/127\.0\.0\.1:\d+\/\n/); + assert.match(stack.output, /note: live Sentry mode is off: SENTRY_AUTH_TOKEN is not set/); + assert.deepEqual(Object.keys(config), ['attemptId']); + assert.equal(standIn.requests.length, 0); +}); + +test('live mode fetches the weapon issue from the Sentry API with the token and returns its stack trace', async (testContext) => { + const stack = await startLiveStack(testContext); + + const response = await fetchEvidence(stack.base, 'weapon', WEAPON_EVENT_ID); + + assert.equal(response.status, 200); + const evidence = await response.json(); + assert.equal(evidence.source, 'live'); + assert.equal(evidence.referenceId, 'GLITCH-INVADERS-1'); + assert.deepEqual(evidence.link, { label: 'Open the issue in Sentry', url: 'https://mlh.sentry.io/issues/7001/' }); + const frames = evidence.sections.find((section) => section.kind === 'stacktrace').frames; + assert.deepEqual( + frames.map((frame) => `${frame.functionName} ${frame.file}:${frame.line}`), + ['fireWeapon game/src/weapons/weapons.mjs:46', 'GameEngine.tryFire game/src/engine/game-engine.mjs:320'], + ); + const context = evidence.sections.find((section) => section.kind === 'context'); + assert.deepEqual(context.entries, { attemptedWeapon: 'fanout', availableWeapons: 'single' }); + assert.deepEqual( + stack.standIn.requests.map((request) => [request.url.pathname, request.authorization]), + [ + [`/api/0/projects/mlh/glitch-invaders/events/${WEAPON_EVENT_ID}/`, `Bearer ${SENTRY_TOKEN}`], + ['/api/0/organizations/mlh/issues/7001/', `Bearer ${SENTRY_TOKEN}`], + ], + ); +}); + +test('live mode returns the shield breadcrumbs timed from the last one', async (testContext) => { + const stack = await startLiveStack(testContext); + + const evidence = await (await fetchEvidence(stack.base, 'shield', SHIELD_EVENT_ID)).json(); + + const crumbs = evidence.sections.find((section) => section.kind === 'breadcrumbs').entries; + assert.deepEqual( + crumbs.map((crumb) => [crumb.offsetLabel, crumb.level, crumb.message]), + [ + ['-6.8 s', 'info', 'Shield enabled'], + ['-1.2 s', 'warning', 'Actual damage applied: 3'], + ['0.0 s', 'error', 'Ship destroyed: shield failed to protect'], + ], + ); + assert.equal(evidence.referenceId, 'GLITCH-INVADERS-2'); +}); + +test('live mode returns the boss trace as a waterfall from the spans Sentry indexed', async (testContext) => { + const stack = await startLiveStack(testContext); + + const evidence = await (await fetchEvidence(stack.base, 'boss', BOSS_TRACE_ID)).json(); + + const waterfall = evidence.sections.find((section) => section.kind === 'waterfall'); + assert.deepEqual( + waterfall.spans.map((span) => [span.name, span.startMs, span.durationMs]), + [ + ['loadTextures', 0, 1200], + ['loadSounds', 1200, 1000], + ], + ); + assert.equal(waterfall.totalMs, 2200); + assert.equal(evidence.link.url, `https://mlh.sentry.io/explore/traces/trace/${BOSS_TRACE_ID}/`); + const query = stack.standIn.requests[0].url.searchParams; + assert.equal(query.get('dataset'), 'spans'); + assert.equal(query.get('query'), `trace:${BOSS_TRACE_ID} span.op:game.resource.load`); +}); + +test('live mode lets the page reach the DSN ingest origin and no other', async (testContext) => { + const stack = await startLiveStack(testContext); + + const page = await getRaw(`${stack.base}/`, '/'); + + assert.equal( + page.headers['content-security-policy'], + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://o1.ingest.us.sentry.io", + ); +}); + +test('the token never appears in a response, a page asset, the workspace, or hook output', async (testContext) => { + const stack = await startLiveStack(testContext); + const outage = await startLiveStack(testContext, { outage: true }); + + const bodies = await Promise.all( + [ + `${stack.base}/`, + `${stack.base}/src/main.mjs`, + `${stack.base}/src/sentry/reporter.mjs`, + `${stack.base}/runtime-config.json`, + `${stack.base}/api/sentry-evidence?bug=weapon&id=${WEAPON_EVENT_ID}`, + `${stack.base}/api/sentry-evidence?bug=boss&id=not-an-id`, + `${outage.base}/api/sentry-evidence?bug=shield&id=${SHIELD_EVENT_ID}`, + ].map(async (url) => { + const response = await fetch(url); + + return `${JSON.stringify([...response.headers])}${await response.text()}`; + }), + ); + const workspaceFiles = await readdir(stack.workspace, { recursive: true, withFileTypes: true }); + const stored = await Promise.all( + workspaceFiles + .filter((entry) => entry.isFile()) + .map((entry) => readFile(path.join(entry.parentPath, entry.name), 'utf8')), + ); + + for (const text of [...bodies, ...stored, stack.output, outage.output]) { + assert.ok(!text.includes(SENTRY_TOKEN), `the token leaked into: ${text.slice(0, 200)}`); + } + assert.match(stack.output, /\(live Sentry mode\)/); +}); + +test('a Sentry outage answers 502 naming the failed request, without retrying', async (testContext) => { + const stack = await startLiveStack(testContext, { outage: true }); + + const response = await fetchEvidence(stack.base, 'weapon', WEAPON_EVENT_ID); + + assert.equal(response.status, 502); + assert.equal( + await response.text(), + `Sentry answered /api/0/projects/mlh/glitch-invaders/events/${WEAPON_EVENT_ID}/ with 503`, + ); + assert.equal(stack.standIn.requests.length, 1); +}); + +test('SentryApi waits while Sentry is still indexing an event, then returns it', async (testContext) => { + const standIn = await startSentryStandIn(testContext, { unindexedRequests: 2 }); + const waits = []; + const api = new SentryApi({ + apiUrl: standIn.url, + org: 'mlh', + project: 'glitch-invaders', + token: SENTRY_TOKEN, + retryDelaysMs: [1_000, 2_000, 3_000], + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + }); + + const event = await api.event(WEAPON_EVENT_ID); + + assert.equal(event.eventID, WEAPON_EVENT_ID); + assert.deepEqual(waits, [1_000, 2_000]); + assert.equal(standIn.requests.length, 3); +}); + +test('SentryApi gives up as unavailable once Sentry never indexes the report', async (testContext) => { + const standIn = await startSentryStandIn(testContext, { unindexedRequests: 10 }); + const api = new SentryApi({ + apiUrl: standIn.url, + org: 'mlh', + project: 'glitch-invaders', + token: SENTRY_TOKEN, + retryDelaysMs: [1_000, 2_000], + sleep: async () => {}, + }); + + await assert.rejects(api.traceSpans(BOSS_TRACE_ID), { + name: 'SentryUnavailableError', + message: 'Sentry has not indexed the report yet', + }); + assert.equal(standIn.requests.length, 3); +}); + +test('the live provider falls back to simulated evidence with a visible note when Sentry fails', async () => { + const reporter = fakeReporter({ id: WEAPON_EVENT_ID, delivered: true }); + const server = fakeFetch(new Response('Sentry answered with 503', { status: 502 })); + const provider = new LiveEvidenceProvider({ + reporter, + simulated: new SimulatedEvidenceProvider(), + fetchImpl: server.fetchImpl, + }); + + const evidence = await provider.evidenceFor(weaponEncounterFactory()); + + assert.equal(evidence.source, 'fallback'); + assert.equal(evidence.note, FALLBACK_NOTES.unavailable); + assert.ok(evidence.sections.some((section) => section.kind === 'stacktrace')); + assert.deepEqual(server.urls, [`/api/sentry-evidence?bug=weapon&id=${WEAPON_EVENT_ID}`]); +}); + +test('the live provider falls back without asking the server when Sentry refused the report', async () => { + const server = fakeFetch(new Response('{}')); + const provider = new LiveEvidenceProvider({ + reporter: fakeReporter({ id: WEAPON_EVENT_ID, delivered: false }), + simulated: new SimulatedEvidenceProvider(), + fetchImpl: server.fetchImpl, + }); + + const evidence = await provider.evidenceFor(weaponEncounterFactory()); + + assert.equal(evidence.source, 'fallback'); + assert.equal(evidence.note, FALLBACK_NOTES.unsent); + assert.deepEqual(server.urls, []); +}); + +test('SentryReporter posts one tagged error event per bug to the DSN envelope endpoint', async () => { + const ingest = []; + const reporter = new SentryReporter({ dsn: LIVE_DSN }, 'attempt-1', async (url, init) => { + ingest.push({ url, body: init.body }); + + return new Response('{}'); + }); + reporter.add({ category: 'game.weapon', message: 'Firing weapon: fanout' }); + + const first = await reporter.report(weaponEncounterFactory()); + const repeat = await reporter.report(weaponEncounterFactory()); + + assert.deepEqual(repeat, first); + assert.equal(first.delivered, true); + assert.equal(ingest.length, 1); + assert.match(ingest[0].url, /^https:\/\/o1\.ingest\.us\.sentry\.io\/api\/42\/envelope\/\?.*sentry_key=publickey/); + const [header, item, payload] = ingest[0].body.trim().split('\n').map((line) => JSON.parse(line)); + assert.equal(header.event_id, first.id); + assert.equal(item.type, 'event'); + assert.deepEqual(payload.tags, { run_id: 'attempt-1', bug: 'weapon' }); + assert.equal(payload.exception.values[0].stacktrace.frames.at(-1).function, 'fireWeapon'); + assert.equal(payload.breadcrumbs[0].message, 'Firing weapon: fanout'); +}); From f80cdd9b2776dce529c5f932a33b6ae91d7c6867 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:33:02 +0000 Subject: [PATCH 2/3] fix: send the Glitch Invaders boss trace as a transaction and cap the live wait --- challenges/glitch-invaders/README.md | 10 +-- .../game/src/diagnostics/live-evidence.mjs | 4 ++ .../game/src/sentry/reporter.mjs | 72 +++++++------------ challenges/glitch-invaders/sentry.json | 2 +- .../glitch-invaders/server/page-server.mjs | 25 ++++++- .../glitch-invaders/server/sentry-api.mjs | 6 +- test/glitch-invaders.test.mjs | 44 ++++++++++++ 7 files changed, 109 insertions(+), 54 deletions(-) diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index 9a8ec73..aac6eb4 100644 --- a/challenges/glitch-invaders/README.md +++ b/challenges/glitch-invaders/README.md @@ -46,7 +46,9 @@ Live mode needs both halves: - **`sentry.json` filled in.** The DSN, org slug, and project slug are public values, so they live in the package. While any value is still a - ``, the game runs in demo mode. + ``, the game runs in demo mode. Once filled in, a malformed value + fails setup naming it. An optional `apiUrl` replaces the Sentry API origin, + `https://sentry.io`; the tests point it at a local stand-in. - **`SENTRY_AUTH_TOKEN` in the environment.** On a station, the event vault's `sentry` service sets it to a placeholder that the vault proxy swaps for the real token. `boothstrap dev` and a standalone run read the real token from @@ -74,9 +76,9 @@ version: | Host | `sentry.io` | The token needs the `event:read`, `project:read`, and `org:read` scopes. The -station also needs a boothstrap that gives challenges their vault credentials: -the `next` prerelease after 2026-10-18 09:23 UTC, or any release after `0.1.0`. -On `0.1.0` the challenge gets no token and runs in demo mode. +station also needs a boothstrap that sets each `vault_services` credential in +the challenge's hooks. Release `0.1.0` predates that, so on it the challenge gets +no token and runs in demo mode. ## Run without boothstrap diff --git a/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs b/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs index cae03b9..fca8e5b 100644 --- a/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs +++ b/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs @@ -4,6 +4,9 @@ * why, so it is never left blank. */ +/** How long the page waits for the server, which gives up on Sentry sooner than this. */ +const EVIDENCE_TIMEOUT_MS = 20_000; + /** The notes a fallback carries, by what went wrong. */ export const FALLBACK_NOTES = { unsent: 'Sentry did not accept the report, so this is simulated evidence.', @@ -32,6 +35,7 @@ export class LiveEvidenceProvider { const query = new URLSearchParams({ bug: encounter.bug, id: outcome.id }); const response = await this.options.fetchImpl(`/api/sentry-evidence?${query}`, { cache: 'no-store', + signal: AbortSignal.timeout(EVIDENCE_TIMEOUT_MS), }); if (!response.ok) { return this.fallback(encounter, 'unavailable'); diff --git a/challenges/glitch-invaders/game/src/sentry/reporter.mjs b/challenges/glitch-invaders/game/src/sentry/reporter.mjs index 2eb24a5..2b1b5d4 100644 --- a/challenges/glitch-invaders/game/src/sentry/reporter.mjs +++ b/challenges/glitch-invaders/game/src/sentry/reporter.mjs @@ -1,6 +1,6 @@ /** * Live reporting through Sentry's envelope endpoint for the DSN: one real error event for the weapon - * jam, one for the shield failure, and a span trace for the boss spawn, each tagged with the run and + * jam, one for the shield failure, and a transaction trace for the boss spawn, each tagged with the run and * the bug. It needs only the public DSN, so no token is ever on the page; the page server reads * back what Sentry recorded. No SDK is loaded: the page's CSP allows scripts from itself alone. */ @@ -46,11 +46,6 @@ function envelope(header, itemHeader, payload) { return `${JSON.stringify(header)}\n${JSON.stringify(itemHeader)}\n${JSON.stringify(payload)}\n`; } -/** A typed attribute of a v2 span item. */ -function attribute(value, type = 'string') { - return { value, type }; -} - /** Sends the game's intentional failures to Sentry, once per bug. */ export class SentryReporter { /** @@ -167,52 +162,28 @@ export class SentryReporter { } /** - * The span envelope for the boss `stall`: a `boss-spawn` root span and one child per load, timed - * as the run measured them and ending now. + * The transaction envelope for the boss `stall`: a `boss-spawn` transaction with one child span + * per load, timed as the run measured them and ending now. */ traceEnvelope(traceId, stall) { + const eventId = randomHex(32); const rootSpanId = randomHex(16); const endSeconds = Date.now() / 1_000; const startSeconds = endSeconds - stall.totalMs / 1_000; - const common = (name, spanId) => ({ - name, - span_id: spanId, - trace_id: traceId, - status: 'ok', - }); - const commonAttributes = { - 'sentry.origin': attribute('manual'), - 'sentry.segment.name': attribute('boss-spawn'), - 'sentry.segment.id': attribute(rootSpanId), - 'sentry.release': attribute(RELEASE), - 'sentry.sdk.name': attribute(SDK.name), - 'sentry.sdk.version': attribute(SDK.version), - }; - const children = stall.spans.map((span) => ({ - ...common(span.name, randomHex(16)), + const spans = stall.spans.map((span) => ({ + span_id: randomHex(16), parent_span_id: rootSpanId, + trace_id: traceId, + op: span.operation, + description: span.name, start_timestamp: startSeconds + span.startMs / 1_000, - end_timestamp: startSeconds + (span.startMs + span.durationMs) / 1_000, - is_segment: false, - attributes: { ...commonAttributes, 'sentry.op': attribute(span.operation) }, + timestamp: startSeconds + (span.startMs + span.durationMs) / 1_000, + status: 'ok', })); - const root = { - ...common('boss-spawn', rootSpanId), - start_timestamp: startSeconds, - end_timestamp: endSeconds, - is_segment: true, - attributes: { - ...commonAttributes, - 'sentry.op': attribute('game.boss'), - 'sentry.sample_rate': attribute(1, 'integer'), - run_id: attribute(this.attemptId), - bug: attribute('boss'), - }, - }; - const items = [...children, root]; return envelope( { + event_id: eventId, sent_at: new Date().toISOString(), sdk: SDK, trace: { @@ -224,12 +195,23 @@ export class SentryReporter { sample_rate: '1', }, }, + { type: 'transaction' }, { - type: 'span', - item_count: items.length, - content_type: 'application/vnd.sentry.items.span.v2+json', + type: 'transaction', + event_id: eventId, + transaction: 'boss-spawn', + transaction_info: { source: 'custom' }, + start_timestamp: startSeconds, + timestamp: endSeconds, + platform: 'javascript', + release: RELEASE, + sdk: SDK, + tags: { run_id: this.attemptId, bug: 'boss' }, + contexts: { + trace: { trace_id: traceId, span_id: rootSpanId, op: 'game.boss', status: 'ok' }, + }, + spans, }, - { version: 2, items }, ); } diff --git a/challenges/glitch-invaders/sentry.json b/challenges/glitch-invaders/sentry.json index 53f1198..78fbb7e 100644 --- a/challenges/glitch-invaders/sentry.json +++ b/challenges/glitch-invaders/sentry.json @@ -1,5 +1,5 @@ { - "$comment": "Public settings for live Sentry mode; none is a secret. MLH fills these in from its Sentry project: dsn from Project Settings > Client Keys (DSN), org and project from the slugs in the project's URL. While any value is still a , the game runs in demo mode. Live mode also needs SENTRY_AUTH_TOKEN, which the station's vault provides; never put a token here.", + "$comment": "Public settings for live Sentry mode; none is a secret. MLH fills these in from its Sentry project: dsn from Project Settings > Client Keys (DSN), org and project from the slugs in the project's URL. While any value is still a , the game runs in demo mode. Live mode also needs SENTRY_AUTH_TOKEN, which the station's vault provides; never put a token here. An optional apiUrl replaces the Sentry API origin, https://sentry.io; tests point it at a local stand-in.", "dsn": "", "org": "", "project": "" diff --git a/challenges/glitch-invaders/server/page-server.mjs b/challenges/glitch-invaders/server/page-server.mjs index bf3ede3..fe02e5d 100644 --- a/challenges/glitch-invaders/server/page-server.mjs +++ b/challenges/glitch-invaders/server/page-server.mjs @@ -49,6 +49,28 @@ function contentSecurityPolicy(runtimeConfig) { return `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ${connect}`; } +/** + * The longest a live-evidence request may take, however Sentry's retries and timeouts add up, so + * the page falls back to simulated evidence promptly. + */ +const EVIDENCE_DEADLINE_MS = 15_000; + +/** Settle with `work`, or reject with a {@link SentryUnavailableError} once `milliseconds` pass. */ +async function withDeadline(work, milliseconds) { + let timer; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new SentryUnavailableError('Sentry took too long to return the report')), + milliseconds, + ); + }); + try { + return await Promise.race([work, deadline]); + } finally { + clearTimeout(timer); + } +} + /** The ids the page gives its Sentry reports: 32 lowercase hex digits for events and traces. */ const REPORT_ID_PATTERN = /^[0-9a-f]{32}$/; @@ -276,7 +298,8 @@ async function handleSentryEvidence(request, response, options) { } try { - sendJson(response, 200, await options.fetchEvidence(bug, id)); + const evidence = await withDeadline(options.fetchEvidence(bug, id), EVIDENCE_DEADLINE_MS); + sendJson(response, 200, evidence); } catch (error) { if (error instanceof SentryUnavailableError) { sendStatus(response, 502, error.message); diff --git a/challenges/glitch-invaders/server/sentry-api.mjs b/challenges/glitch-invaders/server/sentry-api.mjs index 334cedd..d51ccbd 100644 --- a/challenges/glitch-invaders/server/sentry-api.mjs +++ b/challenges/glitch-invaders/server/sentry-api.mjs @@ -9,10 +9,10 @@ const REQUEST_TIMEOUT_MS = 5_000; /** - * Waits between lookups of something Sentry has accepted but not yet indexed, about 15 seconds in - * all; an event or trace usually appears within a few. + * Waits between lookups of something Sentry has accepted but not yet indexed, 10 seconds in all; an + * event or trace usually appears within a few. The page server's deadline caps the whole lookup. */ -export const INDEXING_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 4_000, 5_000]; +export const INDEXING_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 4_000]; /** Sentry did not answer usefully. The message names the request, never the token. */ export class SentryUnavailableError extends Error { diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index a8a21db..f9560a5 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -1224,6 +1224,20 @@ test('setup notes that live mode is off when sentry.json is filled in but SENTRY assert.equal(standIn.requests.length, 0); }); +test('setup fails naming sentry.json when a filled-in value is malformed', async (testContext) => { + const standIn = await startSentryStandIn(testContext); + const packageDir = await makeLivePackage(standIn.url); + const settings = { dsn: 'http://publickey@o1.ingest.us.sentry.io/42', org: 'mlh', project: 'glitch-invaders' }; + await writeFile(path.join(packageDir, 'sentry.json'), JSON.stringify(settings)); + const workspace = await makeTemporaryDirectory(testContext); + testContext.after(() => rm(path.dirname(packageDir), { recursive: true, force: true })); + + const setup = await runPackageHook(packageDir, 'setup', hookEnvironment(workspace, await freePort())); + + assert.equal(setup.code, 1); + assert.match(setup.output, /setup: sentry\.json: invalid runtime config: sentry\.dsn must be a Sentry DSN/); +}); + test('live mode fetches the weapon issue from the Sentry API with the token and returns its stack trace', async (testContext) => { const stack = await startLiveStack(testContext); @@ -1437,3 +1451,33 @@ test('SentryReporter posts one tagged error event per bug to the DSN envelope en assert.equal(payload.exception.values[0].stacktrace.frames.at(-1).function, 'fireWeapon'); assert.equal(payload.breadcrumbs[0].message, 'Firing weapon: fanout'); }); + +test('SentryReporter sends the boss stall as a boss-spawn transaction with one span per load', async () => { + const ingest = []; + const reporter = new SentryReporter({ dsn: LIVE_DSN }, 'attempt-1', async (_url, init) => { + ingest.push(init.body); + + return new Response('{}'); + }); + const stall = { + totalMs: 2_200, + spans: [ + { name: 'loadTextures', operation: 'game.resource.load', startMs: 0, durationMs: 1_200 }, + { name: 'loadSounds', operation: 'game.resource.load', startMs: 1_200, durationMs: 1_000 }, + ], + }; + + const outcome = await reporter.report({ bug: 'boss', stall }); + + const [, item, payload] = ingest[0].trim().split('\n').map((line) => JSON.parse(line)); + assert.equal(item.type, 'transaction'); + assert.equal(payload.transaction, 'boss-spawn'); + assert.equal(payload.contexts.trace.trace_id, outcome.id); + assert.deepEqual( + payload.spans.map((span) => [span.description, span.op, Math.round((span.timestamp - span.start_timestamp) * 1_000)]), + [ + ['loadTextures', 'game.resource.load', 1200], + ['loadSounds', 'game.resource.load', 1000], + ], + ); +}); From 698345fa8686b552cbcab6e361b9947939c941d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:47:57 +0000 Subject: [PATCH 3/3] fix: tighten Glitch Invaders live Sentry mode after review Limit apiUrl to sentry.io hosts or loopback, refuse cross-site evidence requests and share one lookup per report, abort Sentry work at the deadline, label the boss context as local state, fix the shield frame line, show live copy on the start card, and widen the token test. --- challenges/glitch-invaders/README.md | 26 ++-- .../glitch-invaders/game/src/app/app.mjs | 4 +- .../src/diagnostics/evidence-builders.mjs | 10 ++ .../game/src/sentry/reporter.mjs | 28 ++-- .../glitch-invaders/game/src/styles.css | 5 + .../game/src/ui/incident-console.mjs | 9 +- .../glitch-invaders/game/src/ui/overlays.mjs | 13 +- challenges/glitch-invaders/sentry.json | 2 +- .../glitch-invaders/server/live-evidence.mjs | 24 ++-- .../glitch-invaders/server/page-server.mjs | 70 ++++++---- .../glitch-invaders/server/sentry-api.mjs | 71 +++++++--- challenges/glitch-invaders/server/serve.mjs | 2 +- .../glitch-invaders/shared/runtime-config.mjs | 20 ++- test/glitch-invaders.test.mjs | 121 ++++++++++++++++-- 14 files changed, 305 insertions(+), 100 deletions(-) diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index aac6eb4..40e5f99 100644 --- a/challenges/glitch-invaders/README.md +++ b/challenges/glitch-invaders/README.md @@ -48,20 +48,30 @@ Live mode needs both halves: values, so they live in the package. While any value is still a ``, the game runs in demo mode. Once filled in, a malformed value fails setup naming it. An optional `apiUrl` replaces the Sentry API origin, - `https://sentry.io`; the tests point it at a local stand-in. + `https://sentry.io`, with a subdomain of it or, for the tests' local + stand-in, a loopback address; the token goes to no other host. - **`SENTRY_AUTH_TOKEN` in the environment.** On a station, the event vault's `sentry` service sets it to a placeholder that the vault proxy swaps for the real token. `boothstrap dev` and a standalone run read the real token from your shell. +`boothstrap dev` refuses to play the package unless `SENTRY_AUTH_TOKEN` or +`SENTRY_API_KEY` is set, because `vault_services` names `sentry`. Any value +keeps demo mode while `sentry.json` holds placeholders, and `SENTRY_API_KEY` +satisfies dev but leaves the game in demo mode, since the package reads only +`SENTRY_AUTH_TOKEN`. + In live mode the page reports each failure to the DSN: an error event for the -weapon and the shield, and a span trace for the boss spawn. The page server then -reads Sentry's record of it from the Sentry API (the issue, its stack trace or -breadcrumbs, or the trace's spans) and the Incident Console shows that, with a -link to it in Sentry. The token stays in the page server: it is never served to -the page or written anywhere. The page's CSP lets it contact only this server -and the DSN's ingest origin. If Sentry doesn't accept the report or can't return -it within about 15 seconds, that incident shows simulated evidence with a note +weapon and the shield, and a `boss-spawn` transaction with one span per load for +the boss. The page server then reads Sentry's record of it from the Sentry API +(the issue, its stack trace or breadcrumbs, or the trace's spans) and the +Incident Console shows that, with a link to it in Sentry. The boss's +`boot_state` pills are the game's own state, labelled as such. Only requests +from the page itself are answered, and repeated requests for one report share a +single lookup. The token stays in the page server: it is never served to the +page or written anywhere. The page's CSP lets it contact only this server and +the DSN's ingest origin. If Sentry doesn't accept the report or can't return it +within about 15 seconds, that incident shows simulated evidence with a note saying so. Setup prints `(live Sentry mode)` after the URL when live mode is on, and a note when `sentry.json` is filled in but the token is missing. diff --git a/challenges/glitch-invaders/game/src/app/app.mjs b/challenges/glitch-invaders/game/src/app/app.mjs index fbde26b..d7ad883 100644 --- a/challenges/glitch-invaders/game/src/app/app.mjs +++ b/challenges/glitch-invaders/game/src/app/app.mjs @@ -158,7 +158,7 @@ export class GameApp { showOpening() { if (!this.progress.launched) { this.overlays.show( - introCard({ returning: false, onStart: () => this.startRun() }), + introCard({ live: this.live !== null, returning: false, onStart: () => this.startRun() }), ); return; @@ -193,7 +193,7 @@ export class GameApp { this.openRestoredIncident(bug); }; this.overlays.show( - introCard({ returning: true, onStart: () => this.resumeRun() }), + introCard({ live: this.live !== null, returning: true, onStart: () => this.resumeRun() }), ); } diff --git a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs index c4be8ab..0b0f60c 100644 --- a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs +++ b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs @@ -33,6 +33,16 @@ export const WEAPON_STACK_FRAMES = [ { functionName: 'requestAnimationFrame', file: '', line: 0, inApp: false }, ]; +/** Where the shield failure's live report says it happened; the same unit test checks this line. */ +export const SHIELD_STACK_FRAMES = [ + { + functionName: 'resolveShieldHit', + file: 'game/src/shield/shield.mjs', + line: 17, + inApp: true, + }, +]; + /** The canonical boss waterfall: the four loads, back to back. */ export const SEQUENTIAL_BOSS_SPANS = [ { name: 'loadTextures', operation: 'game.resource.load', startMs: 0, durationMs: 1_200 }, diff --git a/challenges/glitch-invaders/game/src/sentry/reporter.mjs b/challenges/glitch-invaders/game/src/sentry/reporter.mjs index 2b1b5d4..9dbed4f 100644 --- a/challenges/glitch-invaders/game/src/sentry/reporter.mjs +++ b/challenges/glitch-invaders/game/src/sentry/reporter.mjs @@ -4,7 +4,7 @@ * the bug. It needs only the public DSN, so no token is ever on the page; the page server reads * back what Sentry recorded. No SDK is loaded: the page's CSP allows scripts from itself alone. */ -import { WEAPON_STACK_FRAMES } from '../diagnostics/evidence-builders.mjs'; +import { SHIELD_STACK_FRAMES, WEAPON_STACK_FRAMES } from '../diagnostics/evidence-builders.mjs'; /** How long to wait for Sentry to accept a report. */ export const DELIVERY_TIMEOUT_MS = 2_500; @@ -46,6 +46,16 @@ function envelope(header, itemHeader, payload) { return `${JSON.stringify(header)}\n${JSON.stringify(itemHeader)}\n${JSON.stringify(payload)}\n`; } +/** Curated frames, innermost first, as Sentry's oldest-first stacktrace frames. */ +function toSentryFrames(frames) { + return frames.toReversed().map((frame) => ({ + filename: frame.file, + function: frame.functionName, + lineno: frame.line, + in_app: frame.inApp, + })); +} + /** Sends the game's intentional failures to Sentry, once per bug. */ export class SentryReporter { /** @@ -105,24 +115,12 @@ export class SentryReporter { ? { type: 'WeaponConfigError', value: encounter.jam.error.message, - frames: [...WEAPON_STACK_FRAMES].reverse().map((frame) => ({ - filename: frame.file, - function: frame.functionName, - lineno: frame.line, - in_app: frame.inApp, - })), + frames: toSentryFrames(WEAPON_STACK_FRAMES), } : { type: 'GameLogicError', value: `Shield was active but ${encounter.failure.actualDamage} damage was applied (expected 0). Ship destroyed despite an active shield.`, - frames: [ - { - filename: 'game/src/shield/shield.mjs', - function: 'resolveShieldHit', - lineno: 14, - in_app: true, - }, - ], + frames: toSentryFrames(SHIELD_STACK_FRAMES), }; const contexts = isWeapon ? { diff --git a/challenges/glitch-invaders/game/src/styles.css b/challenges/glitch-invaders/game/src/styles.css index d4675a1..9388cd7 100644 --- a/challenges/glitch-invaders/game/src/styles.css +++ b/challenges/glitch-invaders/game/src/styles.css @@ -731,6 +731,11 @@ kbd { border-color: #ffffff; } +.context-caption { + margin-bottom: 6px; + font-size: 12px; +} + .context-pills { display: flex; flex-wrap: wrap; diff --git a/challenges/glitch-invaders/game/src/ui/incident-console.mjs b/challenges/glitch-invaders/game/src/ui/incident-console.mjs index 5d53ef6..d49b333 100644 --- a/challenges/glitch-invaders/game/src/ui/incident-console.mjs +++ b/challenges/glitch-invaders/game/src/ui/incident-console.mjs @@ -438,15 +438,20 @@ export class IncidentConsole { } } -/** Render context tags as scannable pill badges. */ +/** Render context tags as scannable pill badges, under the section's caption when it has one. */ function renderContextPills(section) { - return h( + const pills = h( 'dl', { class: 'context-pills', 'aria-label': section.title }, ...Object.entries(section.entries).map(([key, value]) => h('div', { class: 'context-pill' }, h('dt', {}, `${key}:`), h('dd', {}, value)), ), ); + if (section.caption === undefined) { + return pills; + } + + return h('div', {}, h('p', { class: 'muted context-caption' }, section.caption), pills); } /** Render the primary diagnostic section on screen. */ diff --git a/challenges/glitch-invaders/game/src/ui/overlays.mjs b/challenges/glitch-invaders/game/src/ui/overlays.mjs index 6d765b1..7119690 100644 --- a/challenges/glitch-invaders/game/src/ui/overlays.mjs +++ b/challenges/glitch-invaders/game/src/ui/overlays.mjs @@ -63,7 +63,10 @@ function card(title, label, ...children) { ); } -/** The briefing shown before play, or the welcome-back card after a refresh. */ +/** + * The briefing shown before play, or the welcome-back card after a refresh; `options.live` says + * whether the evidence comes from Sentry. + */ export function introCard(options) { return card( options.returning ? 'Welcome back' : 'Incident briefing', @@ -94,7 +97,13 @@ export function introCard(options) { h('li', {}, h('kbd', {}, 'Space'), ' fires'), h('li', {}, h('kbd', {}, 'P'), ' pauses'), ), - h('p', { class: 'muted' }, 'Demo mode: the diagnostics are realistic simulations.'), + h( + 'p', + { class: 'muted' }, + options.live + ? 'Live mode: the failures are reported to Sentry, and the console shows its record.' + : 'Demo mode: the diagnostics are realistic simulations.', + ), onClick( h( 'button', diff --git a/challenges/glitch-invaders/sentry.json b/challenges/glitch-invaders/sentry.json index 78fbb7e..66f4bcb 100644 --- a/challenges/glitch-invaders/sentry.json +++ b/challenges/glitch-invaders/sentry.json @@ -1,5 +1,5 @@ { - "$comment": "Public settings for live Sentry mode; none is a secret. MLH fills these in from its Sentry project: dsn from Project Settings > Client Keys (DSN), org and project from the slugs in the project's URL. While any value is still a , the game runs in demo mode. Live mode also needs SENTRY_AUTH_TOKEN, which the station's vault provides; never put a token here. An optional apiUrl replaces the Sentry API origin, https://sentry.io; tests point it at a local stand-in.", + "$comment": "Public settings for live Sentry mode; none is a secret. MLH fills these in from its Sentry project: dsn from Project Settings > Client Keys (DSN), org and project from the slugs in the project's URL. While any value is still a , the game runs in demo mode. Live mode also needs SENTRY_AUTH_TOKEN, which the station's vault provides; never put a token here. An optional apiUrl replaces the Sentry API origin, https://sentry.io, with a subdomain of it or a loopback stand-in for tests; the token goes to no other host.", "dsn": "", "org": "", "project": "" diff --git a/challenges/glitch-invaders/server/live-evidence.mjs b/challenges/glitch-invaders/server/live-evidence.mjs index 4271d39..e7ffda8 100644 --- a/challenges/glitch-invaders/server/live-evidence.mjs +++ b/challenges/glitch-invaders/server/live-evidence.mjs @@ -16,6 +16,9 @@ const SHOWN_BREADCRUMBS = 8; /** The context each error event carries, which the console shows as pills. */ const CONTEXT_NAMES = { weapon: 'weapon_state', shield: 'shield_state' }; +/** Marks the boss context as the game's own state, since the trace Sentry returns carries none. */ +const LOCAL_STATE_CAPTION = 'Local game state, not from Sentry'; + /** The breadcrumb levels the console has glyphs for; Sentry's others fold into these. */ const CONSOLE_LEVELS = { info: 'info', warning: 'warning', error: 'error', fatal: 'error' }; @@ -102,13 +105,13 @@ function contextSection(event, name) { } /** Evidence for the weapon or shield failure: its event and the issue Sentry grouped it into. */ -async function errorEvidence(api, bug, eventId) { - const event = await api.event(eventId); +async function errorEvidence(api, bug, eventId, signal) { + const event = await api.event(eventId, signal); const exception = entryData(event, 'exception')?.values?.at(-1); if (exception === undefined || exception === null) { throw new SentryUnavailableError(`Sentry's event ${eventId} has no exception`); } - const issue = await api.issue(text(event.groupID)); + const issue = await api.issue(text(event.groupID), signal); const permalink = httpsLink(issue.permalink); const primary = bug === 'weapon' @@ -135,8 +138,8 @@ async function errorEvidence(api, bug, eventId) { } /** Evidence for the boss stall: the boss-spawn trace's load spans, as Sentry indexed them. */ -async function bossEvidence(api, traceId) { - const rows = await api.traceSpans(traceId); +async function bossEvidence(api, traceId, signal) { + const rows = await api.traceSpans(traceId, signal); const starts = rows.map((row) => finite(row['precise.start_ts'], 'span start')); const firstStart = Math.min(...starts); const spans = rows.map((row, index) => ({ @@ -153,17 +156,18 @@ async function bossEvidence(api, traceId) { source: 'live', title: evidenceTitle({ bug: 'boss', stall: { totalMs: waterfall.totalMs } }), referenceId: `trace ${traceId.slice(0, 8)}`, - sections: [waterfall, bootStateSection()], + sections: [waterfall, { ...bootStateSection(), caption: LOCAL_STATE_CAPTION }], link: { label: 'Open the trace in Sentry', url: traceUrl }, }; } /** * Fetch the live evidence for the page's report `id` of `bug`: an event id for the weapon and - * shield, a trace id for the boss. + * shield, a trace id for the boss. Every request and wait stops once `signal` aborts. * - * @throws {SentryUnavailableError} when Sentry is down, slow to index, or answers with nonsense. + * @throws {SentryUnavailableError} when Sentry is down, slow to index, past the deadline, or answers + * with nonsense. */ -export function fetchLiveEvidence(api, bug, id) { - return bug === 'boss' ? bossEvidence(api, id) : errorEvidence(api, bug, id); +export function fetchLiveEvidence(api, bug, id, signal) { + return bug === 'boss' ? bossEvidence(api, id, signal) : errorEvidence(api, bug, id, signal); } diff --git a/challenges/glitch-invaders/server/page-server.mjs b/challenges/glitch-invaders/server/page-server.mjs index fe02e5d..cddb16f 100644 --- a/challenges/glitch-invaders/server/page-server.mjs +++ b/challenges/glitch-invaders/server/page-server.mjs @@ -50,25 +50,32 @@ function contentSecurityPolicy(runtimeConfig) { } /** - * The longest a live-evidence request may take, however Sentry's retries and timeouts add up, so - * the page falls back to simulated evidence promptly. + * The longest a live-evidence lookup may take, however Sentry's retries and timeouts add up, so + * the page falls back to simulated evidence promptly. Its requests and waits stop then too. */ const EVIDENCE_DEADLINE_MS = 15_000; -/** Settle with `work`, or reject with a {@link SentryUnavailableError} once `milliseconds` pass. */ -async function withDeadline(work, milliseconds) { - let timer; - const deadline = new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new SentryUnavailableError('Sentry took too long to return the report')), - milliseconds, +/** + * Share one Sentry lookup per report among the requests asking for it at once, each lookup under + * {@link EVIDENCE_DEADLINE_MS}, so repeated requests cannot multiply the calls on the token. + */ +export function createEvidenceLookup(fetchEvidence) { + const inFlight = new Map(); + + return (bug, id) => { + const key = `${bug}:${id}`; + const running = inFlight.get(key); + if (running !== undefined) { + return running; + } + + const lookup = fetchEvidence(bug, id, AbortSignal.timeout(EVIDENCE_DEADLINE_MS)).finally(() => + inFlight.delete(key), ); - }); - try { - return await Promise.race([work, deadline]); - } finally { - clearTimeout(timer); - } + inFlight.set(key, lookup); + + return lookup; + }; } /** The ids the page gives its Sentry reports: 32 lowercase hex digits for events and traces. */ @@ -103,6 +110,16 @@ function hostIsLoopback(request) { return allowedOrigins(request).some((origin) => origin === `http://${host}`); } +/** + * Whether a request came from this page by Fetch Metadata as well as by Origin, for a read that + * spends something: a cross-site GET from an image or a link sends no Origin. + */ +function isSameOrigin(request) { + const site = request.headers['sec-fetch-site']; + + return originIsOwn(request) && (site === undefined || site === 'same-origin'); +} + /** Whether a state-changing request came from this page rather than another site. */ function originIsOwn(request) { const origin = request.headers.origin; @@ -274,10 +291,11 @@ async function handleProgress(request, response, options) { /** * Handle `/api/sentry-evidence?bug=&id=`: 200 with live evidence, 404 in demo - * mode, 400 for a bad query, and 502 naming the problem when Sentry cannot provide it. + * mode, 403 from another site, 400 for a bad query, and 502 naming the problem when Sentry cannot + * provide it. `lookupEvidence` is `undefined` in demo mode. */ -async function handleSentryEvidence(request, response, options) { - if (options.fetchEvidence === undefined) { +async function handleSentryEvidence(request, response, lookupEvidence) { + if (lookupEvidence === undefined) { sendStatus(response, 404); return; @@ -287,6 +305,11 @@ async function handleSentryEvidence(request, response, options) { return; } + if (!isSameOrigin(request)) { + sendStatus(response, 403, 'cross-site evidence requests are not allowed'); + + return; + } const query = new URL(request.url ?? '/', 'http://localhost').searchParams; const bug = query.get('bug'); @@ -298,8 +321,7 @@ async function handleSentryEvidence(request, response, options) { } try { - const evidence = await withDeadline(options.fetchEvidence(bug, id), EVIDENCE_DEADLINE_MS); - sendJson(response, 200, evidence); + sendJson(response, 200, await lookupEvidence(bug, id)); } catch (error) { if (error instanceof SentryUnavailableError) { sendStatus(response, 502, error.message); @@ -352,11 +374,13 @@ async function handleStatic(pathname, response, gameDir) { * Create the page server. The caller decides when and where it listens. * * @param {{ gameDir: string, progressFile: string, runtimeConfig: object, - * fetchEvidence?: (bug: string, id: string) => Promise }} options `fetchEvidence` is - * given only in live mode. + * fetchEvidence?: (bug: string, id: string, signal: AbortSignal) => Promise }} options + * `fetchEvidence` is given only in live mode, and stops its work once `signal` aborts. */ export function createPageServer(options) { const policy = contentSecurityPolicy(options.runtimeConfig); + const lookupEvidence = + options.fetchEvidence === undefined ? undefined : createEvidenceLookup(options.fetchEvidence); return createServer((request, response) => { response.setHeader('content-security-policy', policy); @@ -377,7 +401,7 @@ export function createPageServer(options) { } else if (pathname === '/api/progress') { await handleProgress(request, response, options); } else if (pathname === '/api/sentry-evidence') { - await handleSentryEvidence(request, response, options); + await handleSentryEvidence(request, response, lookupEvidence); } else if (request.method === 'GET' || request.method === 'HEAD') { await handleStatic(pathname, response, options.gameDir); } else { diff --git a/challenges/glitch-invaders/server/sentry-api.mjs b/challenges/glitch-invaders/server/sentry-api.mjs index d51ccbd..a7b71ce 100644 --- a/challenges/glitch-invaders/server/sentry-api.mjs +++ b/challenges/glitch-invaders/server/sentry-api.mjs @@ -4,13 +4,14 @@ * result ever carries it. On a station the request leaves through the vault proxy, which swaps the * placeholder token for the real one. */ +import { LOAD_SPAN_OP } from '../game/src/boss/boss-loader.mjs'; /** How long one API request may take before it counts as an outage. */ const REQUEST_TIMEOUT_MS = 5_000; /** * Waits between lookups of something Sentry has accepted but not yet indexed, 10 seconds in all; an - * event or trace usually appears within a few. The page server's deadline caps the whole lookup. + * event or trace usually appears within a few. The caller's deadline signal caps the whole lookup. */ export const INDEXING_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 4_000]; @@ -22,9 +23,24 @@ export class SentryUnavailableError extends Error { } } -/** Pause for `milliseconds`. */ -function sleep(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); +/** Pause for `milliseconds`, or reject as soon as `signal` aborts. */ +function sleep(milliseconds, signal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, milliseconds); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); + }); +} + +/** The failure a lookup ends with once its deadline passes. */ +function pastDeadline() { + return new SentryUnavailableError('Sentry took too long to return the report'); } /** Reads one Sentry organization and project with a bearer token. */ @@ -32,8 +48,11 @@ export class SentryApi { #token; /** - * @param {{ apiUrl: string, org: string, project: string, token: string, sleep?: Function, + * @param {{ apiUrl: string, org: string, project: string, token: string, + * sleep?: (milliseconds: number, signal: AbortSignal) => Promise, * retryDelaysMs?: number[] }} options `sleep` and `retryDelaysMs` let tests skip the waits. + * Every lookup takes a `signal`, its deadline: once it aborts, the lookup stops its request or + * wait and fails as a {@link SentryUnavailableError}. */ constructor(options) { this.apiUrl = options.apiUrl; @@ -45,18 +64,20 @@ export class SentryApi { } /** One error event, by the id the page gave it; waits while Sentry is still indexing it. */ - event(eventId) { + event(eventId, signal) { const org = encodeURIComponent(this.org); const project = encodeURIComponent(this.project); + const resource = `/api/0/projects/${org}/${project}/events/${eventId}/`; - return this.untilIndexed(() => this.get(`/api/0/projects/${org}/${project}/events/${eventId}/`)); + return this.untilIndexed(() => this.get(resource, signal), signal); } /** The issue an event was grouped into. */ - async issue(groupId) { + async issue(groupId, signal) { const org = encodeURIComponent(this.org); const issue = await this.get( `/api/0/organizations/${org}/issues/${encodeURIComponent(groupId)}/`, + signal, ); if (issue === null) { throw new SentryUnavailableError(`Sentry has no issue ${groupId}`); @@ -66,10 +87,10 @@ export class SentryApi { } /** The boss-load spans of trace `traceId`, oldest first; waits while Sentry is indexing them. */ - traceSpans(traceId) { + traceSpans(traceId, signal) { const query = new URLSearchParams({ dataset: 'spans', - query: `trace:${traceId} span.op:game.resource.load`, + query: `trace:${traceId} span.op:${LOAD_SPAN_OP}`, sort: 'precise.start_ts', statsPeriod: '24h', per_page: '20', @@ -80,18 +101,18 @@ export class SentryApi { const resource = `/api/0/organizations/${encodeURIComponent(this.org)}/events/?${query}`; return this.untilIndexed(async () => { - const result = await this.get(resource); + const result = await this.get(resource, signal); const rows = Array.isArray(result?.data) ? result.data : []; return rows.length === 0 ? null : rows; - }); + }, signal); } /** * Run `lookup` until it finds something, waiting {@link INDEXING_RETRY_DELAYS_MS} between tries. - * An outage fails at once; only "not there yet" is retried. + * An outage fails at once; only "not there yet" is retried, and only until `signal` aborts. */ - async untilIndexed(lookup) { + async untilIndexed(lookup, signal) { for (const delayMs of [...this.retryDelaysMs, null]) { const found = await lookup(); if (found !== null) { @@ -100,7 +121,14 @@ export class SentryApi { if (delayMs === null) { break; } - await this.sleep(delayMs); + try { + await this.sleep(delayMs, signal); + } catch { + throw pastDeadline(); + } + if (signal.aborted) { + throw pastDeadline(); + } } throw new SentryUnavailableError('Sentry has not indexed the report yet'); @@ -110,17 +138,24 @@ export class SentryApi { * GET `resource` from the API as JSON. * * @returns {Promise} the body, or `null` for a 404. - * @throws {SentryUnavailableError} for a network failure, a timeout, or any other non-200 status. + * @throws {SentryUnavailableError} for a network failure, a timeout, a passed deadline, or any + * other non-200 status. */ - async get(resource) { + async get(resource, signal) { + if (signal.aborted) { + throw pastDeadline(); + } const pathname = resource.split('?')[0]; let response; try { response = await fetch(`${this.apiUrl}${resource}`, { headers: { authorization: `Bearer ${this.#token}`, accept: 'application/json' }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]), }); } catch { + if (signal.aborted) { + throw pastDeadline(); + } throw new SentryUnavailableError(`Sentry did not answer ${pathname}`); } if (response.status === 404) { diff --git a/challenges/glitch-invaders/server/serve.mjs b/challenges/glitch-invaders/server/serve.mjs index ba871ae..2a29da2 100644 --- a/challenges/glitch-invaders/server/serve.mjs +++ b/challenges/glitch-invaders/server/serve.mjs @@ -41,7 +41,7 @@ function liveEvidenceFetcher(runtimeConfig, env) { const api = new SentryApi({ ...runtimeConfig.sentry, token }); - return (bug, id) => fetchLiveEvidence(api, bug, id); + return (bug, id, signal) => fetchLiveEvidence(api, bug, id, signal); } /** Start serving. */ diff --git a/challenges/glitch-invaders/shared/runtime-config.mjs b/challenges/glitch-invaders/shared/runtime-config.mjs index 329dc53..4234908 100644 --- a/challenges/glitch-invaders/shared/runtime-config.mjs +++ b/challenges/glitch-invaders/shared/runtime-config.mjs @@ -33,16 +33,24 @@ function isSlug(value) { return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(value); } -/** Whether `apiUrl` is an HTTPS origin, or a plain-HTTP loopback one such as a local stand-in. */ -function isApiOrigin(apiUrl) { +/** + * Whether `apiUrl` is a Sentry API origin the token may go to: `https://sentry.io` or a subdomain of + * it, or a plain-HTTP loopback origin such as a local stand-in. Any other host would receive the + * real token in `boothstrap dev` or a standalone run. + */ +export function isSentryApiOrigin(apiUrl) { const url = URL.parse(apiUrl); if (url === null || url.origin !== apiUrl) { return false; } + if (url.protocol === 'http:') { + return ['127.0.0.1', 'localhost'].includes(url.hostname); + } return ( - url.protocol === 'https:' || - (url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname)) + url.protocol === 'https:' && + url.port === '' && + (url.hostname === 'sentry.io' || url.hostname.endsWith('.sentry.io')) ); } @@ -68,8 +76,8 @@ export function parseSentrySettings(value) { if (typeof project !== 'string' || !isSlug(project)) { throw new InvalidRuntimeConfigError('sentry.project must be a Sentry slug'); } - if (typeof apiUrl !== 'string' || !isApiOrigin(apiUrl)) { - throw new InvalidRuntimeConfigError('sentry.apiUrl must be an HTTPS origin'); + if (typeof apiUrl !== 'string' || !isSentryApiOrigin(apiUrl)) { + throw new InvalidRuntimeConfigError('sentry.apiUrl must be https://sentry.io or a subdomain'); } return { dsn, org, project, apiUrl }; diff --git a/test/glitch-invaders.test.mjs b/test/glitch-invaders.test.mjs index f9560a5..d6fabc6 100644 --- a/test/glitch-invaders.test.mjs +++ b/test/glitch-invaders.test.mjs @@ -32,7 +32,10 @@ import { IllegalBugTransitionError, IncidentTracker, } from '../challenges/glitch-invaders/game/src/bugs/incident-tracker.mjs'; -import { WEAPON_STACK_FRAMES } from '../challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs'; +import { + SHIELD_STACK_FRAMES, + WEAPON_STACK_FRAMES, +} from '../challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs'; import { FALLBACK_NOTES, LiveEvidenceProvider, @@ -51,10 +54,12 @@ import { idleInput } from '../challenges/glitch-invaders/game/src/engine/world.m import { SentryReporter } from '../challenges/glitch-invaders/game/src/sentry/reporter.mjs'; import { WeaponConfigError } from '../challenges/glitch-invaders/game/src/weapons/weapons.mjs'; import { runCleanup } from '../challenges/glitch-invaders/server/cleanup.mjs'; +import { createEvidenceLookup } from '../challenges/glitch-invaders/server/page-server.mjs'; import { resolveStatePaths } from '../challenges/glitch-invaders/server/paths.mjs'; import { SentryApi } from '../challenges/glitch-invaders/server/sentry-api.mjs'; import { runSetup } from '../challenges/glitch-invaders/server/setup.mjs'; import { BUG_IDS, createProgress, monitorStateLine } from '../challenges/glitch-invaders/shared/progress.mjs'; +import { isSentryApiOrigin } from '../challenges/glitch-invaders/shared/runtime-config.mjs'; const runFile = promisify(execFile); const testDirectory = path.dirname(fileURLToPath(import.meta.url)); @@ -433,7 +438,8 @@ function bossSpanRows() { * Start a local stand-in for the Sentry API that answers the routes the page server calls from * `routes` (pathname to body, or a status number), records each request, and closes when the test * ends. `outage` makes every route answer 503, and `unindexedRequests` makes that many first - * requests answer 404, as Sentry does before it has indexed a report. + * requests answer 404, as Sentry does before it has indexed a report. Every refusal echoes the + * request's Authorization header, so a server that forwarded Sentry's error body would leak it. */ function startSentryStandIn(testContext, options = {}) { const routes = { @@ -454,7 +460,7 @@ function startSentryStandIn(testContext, options = {}) { const unindexed = requests.length <= (options.unindexedRequests ?? 0); const route = options.outage ? 503 : unindexed ? 404 : (routes[url.pathname] ?? 404); if (typeof route === 'number') { - response.writeHead(route).end('{"detail":"stand-in refusal"}'); + response.writeHead(route).end(JSON.stringify({ detail: incoming.headers.authorization })); return; } @@ -522,7 +528,7 @@ async function startLivePackage(testContext, packageDir, extraEnv) { const setup = await runPackageHook(packageDir, 'setup', env); assert.equal(setup.code, 0, setup.output); - return { base: `http://127.0.0.1:${port}`, output: setup.output, workspace }; + return { base: `http://127.0.0.1:${port}`, output: setup.output, workspace, packageDir, env }; } /** Start a live stack: a Sentry stand-in, a live package copy, and its server with the token. */ @@ -577,16 +583,17 @@ function weaponEncounterFactory() { }; } -test('weapon stack trace frames point at the real lines of the calls they name', () => { +test('weapon and shield stack trace frames point at the real lines of the calls they name', () => { const callAtLine = { fireWeapon: 'throw new WeaponConfigError', 'GameEngine.tryFire': 'fireWeapon(', 'GameEngine.updatePlayer': 'this.tryFire()', 'GameEngine.update': 'this.updatePlayer(', 'GameLoop.frame': 'this.engine.update(', + resolveShieldHit: 'incomingDamage * SHIELD_BUG_MULTIPLIER', }; - const inApp = WEAPON_STACK_FRAMES.filter((frame) => frame.inApp); + const inApp = [...WEAPON_STACK_FRAMES, ...SHIELD_STACK_FRAMES].filter((frame) => frame.inApp); assert.equal(inApp.length, Object.keys(callAtLine).length); for (const frame of inApp) { @@ -1296,6 +1303,8 @@ test('live mode returns the boss trace as a waterfall from the spans Sentry inde ); assert.equal(waterfall.totalMs, 2200); assert.equal(evidence.link.url, `https://mlh.sentry.io/explore/traces/trace/${BOSS_TRACE_ID}/`); + const context = evidence.sections.find((section) => section.kind === 'context'); + assert.equal(context.caption, 'Local game state, not from Sentry'); const query = stack.standIn.requests[0].url.searchParams; assert.equal(query.get('dataset'), 'spans'); assert.equal(query.get('query'), `trace:${BOSS_TRACE_ID} span.op:game.resource.load`); @@ -1316,12 +1325,18 @@ test('the token never appears in a response, a page asset, the workspace, or hoo const stack = await startLiveStack(testContext); const outage = await startLiveStack(testContext, { outage: true }); + const gameAssets = (await readdir(path.join(packageDirectory, 'game'), { recursive: true })) + .filter((file) => path.extname(file) !== '') + .map((file) => `${stack.base}/${file.split(path.sep).join('/')}`); + const sharedAssets = (await readdir(path.join(packageDirectory, 'shared'))).map( + (file) => `${stack.base}/shared/${file}`, + ); const bodies = await Promise.all( [ - `${stack.base}/`, - `${stack.base}/src/main.mjs`, - `${stack.base}/src/sentry/reporter.mjs`, + ...gameAssets, + ...sharedAssets, `${stack.base}/runtime-config.json`, + `${stack.base}/api/progress`, `${stack.base}/api/sentry-evidence?bug=weapon&id=${WEAPON_EVENT_ID}`, `${stack.base}/api/sentry-evidence?bug=boss&id=not-an-id`, `${outage.base}/api/sentry-evidence?bug=shield&id=${SHIELD_EVENT_ID}`, @@ -1338,7 +1353,9 @@ test('the token never appears in a response, a page asset, the workspace, or hoo .map((entry) => readFile(path.join(entry.parentPath, entry.name), 'utf8')), ); - for (const text of [...bodies, ...stored, stack.output, outage.output]) { + const cleanup = await runPackageHook(stack.packageDir, 'cleanup', stack.env); + + for (const text of [...bodies, ...stored, stack.output, outage.output, cleanup.output]) { assert.ok(!text.includes(SENTRY_TOKEN), `the token leaked into: ${text.slice(0, 200)}`); } assert.match(stack.output, /\(live Sentry mode\)/); @@ -1371,7 +1388,7 @@ test('SentryApi waits while Sentry is still indexing an event, then returns it', }, }); - const event = await api.event(WEAPON_EVENT_ID); + const event = await api.event(WEAPON_EVENT_ID, new AbortController().signal); assert.equal(event.eventID, WEAPON_EVENT_ID); assert.deepEqual(waits, [1_000, 2_000]); @@ -1389,7 +1406,7 @@ test('SentryApi gives up as unavailable once Sentry never indexes the report', a sleep: async () => {}, }); - await assert.rejects(api.traceSpans(BOSS_TRACE_ID), { + await assert.rejects(api.traceSpans(BOSS_TRACE_ID, new AbortController().signal), { name: 'SentryUnavailableError', message: 'Sentry has not indexed the report yet', }); @@ -1481,3 +1498,83 @@ test('SentryReporter sends the boss stall as a boss-spawn transaction with one s ], ); }); + +for (const [apiUrl, allowed] of [ + ['https://sentry.io', true], + ['https://de.sentry.io', true], + ['http://127.0.0.1:8123', true], + ['http://localhost:8123', true], + ['https://sentry.io.evil.example', false], + ['https://evilsentry.io', false], + ['https://sentry.io:8443', false], + ['http://sentry.io', false], + ['https://sentry.io/api', false], + ['http://10.0.0.5:8123', false], +]) { + test(`the token may go to ${apiUrl}: ${allowed}`, () => { + const verdict = isSentryApiOrigin(apiUrl); + + assert.equal(verdict, allowed); + }); +} + +test('SentryApi stops waiting and fails as unavailable once its deadline passes', async (testContext) => { + const standIn = await startSentryStandIn(testContext, { unindexedRequests: 10 }); + const deadline = new AbortController(); + const api = new SentryApi({ + apiUrl: standIn.url, + org: 'mlh', + project: 'glitch-invaders', + token: SENTRY_TOKEN, + retryDelaysMs: [1_000, 2_000, 3_000], + sleep: async () => deadline.abort(), + }); + + await assert.rejects(api.event(WEAPON_EVENT_ID, deadline.signal), { + name: 'SentryUnavailableError', + message: 'Sentry took too long to return the report', + }); + assert.equal(standIn.requests.length, 1); +}); + +test('live mode refuses a cross-site evidence request without calling Sentry', async (testContext) => { + const stack = await startLiveStack(testContext); + + const crossSite = await getRaw(`${stack.base}/`, `/api/sentry-evidence?bug=weapon&id=${WEAPON_EVENT_ID}`, { + 'sec-fetch-site': 'cross-site', + }); + const otherOrigin = await getRaw(`${stack.base}/`, `/api/sentry-evidence?bug=weapon&id=${WEAPON_EVENT_ID}`, { + origin: 'https://evil.example', + }); + + assert.equal(crossSite.status, 403); + assert.equal(otherOrigin.status, 403); + assert.equal(stack.standIn.requests.length, 0); +}); + +test('the evidence lookup shares one Sentry lookup among concurrent requests for a report', async () => { + const calls = []; + let finish; + const lookup = createEvidenceLookup((bug, id, signal) => { + calls.push({ bug, id, signal }); + + return new Promise((resolve) => { + finish = resolve; + }); + }); + + const first = lookup('weapon', WEAPON_EVENT_ID); + const second = lookup('weapon', WEAPON_EVENT_ID); + const other = lookup('shield', SHIELD_EVENT_ID); + finish({ source: 'live' }); + + assert.equal(second, first); + assert.notEqual(other, first); + assert.deepEqual( + calls.map((call) => [call.bug, call.id, call.signal instanceof AbortSignal]), + [ + ['weapon', WEAPON_EVENT_ID, true], + ['shield', SHIELD_EVENT_ID, true], + ], + ); +});