services/playgroundPush

PURPOSE

Shared client helper for Ship Playground / dev-tool PUSH buttons. POSTs a balance change to /__dev/push-stats and routes every failure mode to Sentry with full pivot tags so Claude can query issues without asking Nate to read errors. Recognizes the three common prod failures distinctly: missing endpoint (Vercel SPA fallback returning index.html), HTTP error, and network error.

OWNS

  • PushResult interface — discriminated payload covering both the HTTP outcome (ok, status, reason) and the full dev-plugin pipeline trace (fileWriteOk, fileError, file, changed, gitAttempted, commitOk, commitSha, commitError, pushOk, pushError, timestampMs).
  • pushStats(body) — the single exported async helper. No internal state, no module-level mutables.

READS FROM

  • body.target (string) — used as the primary Sentry pivot tag (push_target).
  • body.{hull,id,archetype,weaponId,upgradeId,artifactId,planetId,modId} — promoted to push_<key> Sentry tags when present as strings.
  • Response.headers['content-type'] — gates the SPA-fallback detection (text/html → treat as missing endpoint).
  • Response.status, Response.url, Response.text(), Response.json() — diagnostic surfaces fed to Sentry breadcrumbs/extras and into the returned PushResult.

PUSHES TO

  • POST /__dev/push-stats — single fetch target. JSON body, Content-Type: application/json. Only exists in the dev Vite plugin; absent in production (SPA fallback returns index.html 200).
  • @sentry/browser — every failure path runs Sentry.withScope and emits either captureException (network, JSON parse) or captureMessage (missing endpoint, HTTP error, ok:false). All four error categories carry scope.level = 'error'.
  • console.error — local mirror of network / SPA-fallback / HTTP-error cases (truncated to 400 chars for bodies). Used for in-browser dev visibility only; user-facing diagnostics go through Sentry.

DOES NOT

  • Does not retry. One fetch, one outcome.
  • Does not validate the request body shape — caller (Ship Playground UI / dev tools) is trusted to send the right schema.
  • Does not surface toasts or UI — returns PushResult.reason and lets the caller decide.
  • Does not persist state. Sentry scope is per-call (withScope), tags do not leak across pushes.
  • Does not trigger git ops itself — those happen server-side in the dev plugin; this helper only reports their outcome via the gitAttempted / commitOk / pushOk mirrored fields.
  • Does not swallow the JSON-parse error silently — bad_response_shape is captured to Sentry.

Signals

Sentry tags emitted

TagSourceWhen
push_targetbody.targetevery call
push_hull / push_id / push_archetype / push_weaponId / push_upgradeId / push_artifactId / push_planetId / push_modIdcorresponding body[k] if stringconditional
push_failure_reasonconstant per branchnetwork_error | production_no_endpoint | http_error | bad_response_shape | server_ok_false
push_statusres.statusHTTP error branch only

Sentry extras

  • push_body — the full request body (every call).
  • push_content_type + push_url — SPA-fallback branch.
  • push_response_body — HTTP-error branch, truncated to 2000 chars.
  • push_server_errorok:false branch, sourced from parsed.error ?? parsed.fileError.

Capture kind

  • captureException(err) — network branch, bad-response-shape branch (attaches original err).
  • captureMessage(string, 'error') — SPA fallback, HTTP error, server ok:false.

Entry points

  • Ship Playground PUSH buttons (per-system Save panels) — the primary caller.
  • Any dev-only tool that wants to write a stats change back to the source .ts file: import pushStats and POST { target, ...fields }.

Pattern notes

  • Vercel SPA fallback detection is load-bearing. Without the text/html content-type guard, pushes in production return { res.ok: true, status: 200 } and silently vanish. This branch translates that into push_failure_reason=production_no_endpoint with reason: 'endpoint unavailable (deploy to dev)'.
  • Per-call Sentry scope. All tag/extra writes happen inside Sentry.withScope via the local withScope closure, so tags do not contaminate the global scope and one PUSH’s diagnostics cannot leak into an unrelated capture.
  • Pipeline trace passthrough. On success, every field the server reports (fileWriteOk, file, changed, gitAttempted, commitOk, commitSha, commitError, pushOk, pushError, timestampMs) is forwarded verbatim into PushResult so the SAVE status panel can render the full file-write → commit → push trace without a second request. Defaults: fileWriteOk defaults to true (server already vouched for the write by responding 200), boolean git fields coerce via !!, timestampMs falls back to Date.now().
  • ok:false semantic-failure branch. Even on HTTP 200, a server payload of { ok: false, error?, fileError? } is reported to Sentry as server_ok_false and translated into PushResult.ok=false with fileWriteOk: false so the caller treats it as a hard failure.
  • No internal abstraction. The Sentry context is built up at call time as a flat tags map plus extras; no helper module wraps Sentry beyond the local closure. Matches the global “no premature abstractions” code-style rule.

EXTRACT-CANDIDATE

  • Sentry-scoped capture helper. The withScope + setTag + setExtra + setLevel('error') pattern with a per-call tag map is identical in shape to anything else in the codebase that wants a “report this with pivots” surface. If another client-side service grows the same Sentry.withScope boilerplate, lift this into a shared lib/sentryReport.ts with a report(err|message, { tags, extras, level }) signature.
  • Vercel SPA-fallback HTML detector. Any other fetch('/__dev/...') or /__internal/... call in client code will hit the same production silent-failure mode. A shared assertDevEndpointResponse(res) (or a wrapper devFetch(url, init)) that throws / tags production_no_endpoint on content-type: text/html would consolidate it.