diff --git a/challenges/glitch-invaders/README.md b/challenges/glitch-invaders/README.md index a7ba279..40e5f99 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,62 @@ 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. Once filled in, a malformed value + fails setup naming it. An optional `apiUrl` replaces the Sentry API origin, + `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 `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. + +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 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 -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..d7ad883 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, { @@ -138,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; @@ -173,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() }), ); } @@ -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..0b0f60c 100644 --- a/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs +++ b/challenges/glitch-invaders/game/src/diagnostics/evidence-builders.mjs @@ -29,10 +29,20 @@ 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 }, ]; +/** 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 }, @@ -155,27 +165,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..fca8e5b --- /dev/null +++ b/challenges/glitch-invaders/game/src/diagnostics/live-evidence.mjs @@ -0,0 +1,56 @@ +/** + * 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. + */ + +/** 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.', + 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', + signal: AbortSignal.timeout(EVIDENCE_TIMEOUT_MS), + }); + 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..9dbed4f --- /dev/null +++ b/challenges/glitch-invaders/game/src/sentry/reporter.mjs @@ -0,0 +1,230 @@ +/** + * 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 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. + */ +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; + +/** 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`; +} + +/** 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 { + /** + * @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: 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: toSentryFrames(SHIELD_STACK_FRAMES), + }; + 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 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 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, + timestamp: startSeconds + (span.startMs + span.durationMs) / 1_000, + status: 'ok', + })); + + return envelope( + { + event_id: eventId, + 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: 'transaction' }, + { + 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, + }, + ); + } + + /** 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..9388cd7 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,30 @@ 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-caption { + margin-bottom: 6px; + font-size: 12px; +} + .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..d49b333 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, ); } @@ -404,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/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..66f4bcb --- /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. 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 new file mode 100644 index 0000000..e7ffda8 --- /dev/null +++ b/challenges/glitch-invaders/server/live-evidence.mjs @@ -0,0 +1,173 @@ +/** + * 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' }; + +/** 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' }; + +/** `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, 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), signal); + 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, 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) => ({ + 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(), 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. Every request and wait stops once `signal` aborts. + * + * @throws {SentryUnavailableError} when Sentry is down, slow to index, past the deadline, or answers + * with nonsense. + */ +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 f12d7dc..cddb16f 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,57 @@ 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 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; + +/** + * 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), + ); + inFlight.set(key, lookup); + + return lookup; + }; +} + +/** 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; @@ -67,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; @@ -236,6 +289,49 @@ 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, 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, lookupEvidence) { + if (lookupEvidence === undefined) { + sendStatus(response, 404); + + return; + } + if (request.method !== 'GET') { + sendStatus(response, 405); + + 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'); + 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 lookupEvidence(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 +370,20 @@ 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, 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); void (async () => { if (!hostIsLoopback(request)) { sendStatus(response, 403, 'this server answers to loopback names only'); @@ -293,6 +400,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, 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 new file mode 100644 index 0000000..a7b71ce --- /dev/null +++ b/challenges/glitch-invaders/server/sentry-api.mjs @@ -0,0 +1,177 @@ +/** + * 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. + */ +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 caller's deadline signal caps the whole lookup. + */ +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 { + constructor(message) { + super(message); + this.name = 'SentryUnavailableError'; + } +} + +/** 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. */ +export class SentryApi { + #token; + + /** + * @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; + 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, 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(resource, signal), signal); + } + + /** The issue an event was grouped into. */ + 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}`); + } + + return issue; + } + + /** The boss-load spans of trace `traceId`, oldest first; waits while Sentry is indexing them. */ + traceSpans(traceId, signal) { + const query = new URLSearchParams({ + dataset: 'spans', + query: `trace:${traceId} span.op:${LOAD_SPAN_OP}`, + 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, 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, and only until `signal` aborts. + */ + async untilIndexed(lookup, signal) { + for (const delayMs of [...this.retryDelaysMs, null]) { + const found = await lookup(); + if (found !== null) { + return found; + } + if (delayMs === null) { + break; + } + try { + await this.sleep(delayMs, signal); + } catch { + throw pastDeadline(); + } + if (signal.aborted) { + throw pastDeadline(); + } + } + + 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, a passed deadline, or any + * other non-200 status. + */ + 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.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) { + 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..2a29da2 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, signal) => fetchLiveEvidence(api, bug, id, signal); +} + /** 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..4234908 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,86 @@ 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 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.port === '' && + (url.hostname === 'sentry.io' || url.hostname.endsWith('.sentry.io')) + ); +} + +/** + * 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' || !isSentryApiOrigin(apiUrl)) { + throw new InvalidRuntimeConfigError('sentry.apiUrl must be https://sentry.io or a subdomain'); + } + + 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 +107,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..d6fabc6 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'; @@ -29,7 +32,14 @@ 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, +} 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,11 +51,15 @@ 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 { 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)); @@ -54,6 +68,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,16 +358,242 @@ function putProgressTo(url, body, headers = {}) { }); } -test('weapon stack trace frames point at the real lines of the calls they name', () => { +/** 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. 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 = { + '/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(JSON.stringify({ detail: incoming.headers.authorization })); + + 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, packageDir, env }; +} + +/** 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 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) { @@ -948,3 +1193,388 @@ 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('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); + + 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 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`); +}); + +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 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( + [ + ...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}`, + ].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')), + ); + + 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\)/); +}); + +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, new AbortController().signal); + + 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, new AbortController().signal), { + 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'); +}); + +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], + ], + ); +}); + +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], + ], + ); +});