Skip to content

feat!: rename the declared-screenshot feature to "captures" - #6307

Merged
lukemelia merged 25 commits into
mainfrom
cs-13193-rename-captures
Sep 30, 2026
Merged

lukemelia merged 25 commits into
mainfrom
cs-13193-rename-captures

Conversation

@lukemelia

@lukemelia lukemelia commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Background and Goal

CS-13193. static screenshots was named before the feature could emit PDFs. Now that it can, the name is wrong rather than merely imprecise — PdfScreenshotSpec reads as an oxymoron, and the wrong noun had leaked into author-facing errors ("a pdf screenshot's format must be 'isolated' or 'embedded'"). A 20-page document laid out under @page and @media print is not a shot of a screen.

The codebase had already half-migrated on its own. In runtime-common + base + host/app, bare "capture" outnumbered "screenshot" roughly 2.5:1; the modules were already capture-spec.ts and capture-url-token.ts, the column already capture_spec_hash, the path constant already CAPTURE_SERVING_PREFIX — pointing at '_screenshot/'. QUERY /_sign-capture-urls already signed _screenshot/ URLs. "Screenshot" survived concentrated in the author-facing declaration and the URL prefix.

No card author has adopted the feature and no durable _screenshot/ URLs exist in user data, so this is the cheapest this rename will ever be.

One word in the platform vocabulary: capture. boxel screenshot stays on as a CLI verb alias, where concreteness pays and a second noun costs nothing.

Beyond the word

The larger defect was that distinct concepts were told apart only by which synonym they picked, so a reader had to read the comment above each one. Three format rosters and two output-type rosters now state their axis instead:

Was Now
SCREENSHOT_FORMATS ON_DEMAND_CAPTURE_FORMATS
CAPTURE_FORMATS CANONICAL_CAPTURE_FORMATS
DECLARED_SCREENSHOT_FORMATS DECLARED_CAPTURE_FORMATS
SCREENSHOT_IMAGE_TYPES RASTER_OUTPUT_TYPES

Three spec types likewise collided on the good name; each now says what it is. CaptureSpec is what an author declares, CaptureRequestSpec is the wire spec behind the POST body and GET DSL, and CaptureIdentity is the canonicalized subset that keys the MediaCache ledger.

Where to start

  • packages/runtime-common/capture-spec.ts — the roster and spec-type renames above.
  • packages/base/card-api.gts — static captures, CaptureSpec, captureURLs, data-capture-pending.
  • packages/postgres/migrations/1790289919732_* and migrations-removal/1790289920732_* — prerendered_html.screenshots → .captures across the production table and both twins, expand/contract: additive add plus backfill pre-deploy, drop post-deploy.
  • packages/realm-server/prerender/utils.ts — see the timer note below.

Two things worth a reviewer's attention

screenshotMs did not become captureMs. captureMs already existed in prerender/utils.ts as a different metric (whole-step elapsed), while screenshotMs timed only the CDP captureScreenshot/printToPDF call. Merging them would have silently corrupted the perf data with no test failure. The CDP call is now cdpCaptureMs; captureMs keeps its name and meaning.

The Grafana dashboard's uid stays boxel-screenshot-perf. Its title, filename and every LogQL query are renamed, but the uid is an opaque identifier rather than vocabulary — grafanactl has no prune, so changing it would orphan the existing dashboard and break saved links for no reader benefit. Historical panels will read empty for the Loki retention window, which is expected and was chosen deliberately.

What still answers to the old names

Three consumers of this vocabulary do not roll forward with a deploy of it, so the wire paths keep both names:

  • POST /_screenshot-card — boxel-cli is installed and pinned independently of the realm server, and a released version posts here. Same handler.
  • The _screenshot/ serving prefix — a capture URL is meant to be stored, and a browser keeps serving the previously installed auth service worker (which matches capture requests on the prefix to attach credentials) until it next updates. Served, and reserved against writes, under both prefixes.
  • /prerender-screenshot — the prerender server and manager roll out ahead of the workers and realm servers that call them, so the previous revision asks for the old path while the new one is already serving.

Card source gets no alias, because one there would carry the old vocabulary forward indefinitely. The two silent failures are made loud instead: static screenshots is refused by name at declaration-read rather than read by nothing, and a capture-only component still signalling with data-screenshot-pending fails its slot instead of persisting a half-painted frame.

Left as "screenshot" on purpose

Puppeteer's own page.screenshot / handle.screenshot; the CLI alias; boxel-cli/CHANGELOG.md; experiments-realm/transition-tray.gts (CSS view-transition captures, unrelated); ai-assistant-evals, software-factory, boxel-icons. Applied migrations were left immutable, so 1787603632440's comment still names the old type — a migration records the state at the time it ran.

Companion PRs

The host tool module moved, so these must land together or the catalog demo resolves to nothing:

Testing

Typecheck clean in host, realm-server, runtime-common and boxel-cli; eslint and prettier clean; migration name lint and the removal-phase guard pass; eslint-plugin-boxel 131/131; boxel-cli 638/640 (the 2 failures are a missing Playwright browser locally). CLI alias verified: boxel capture|screenshot. The host and realm-server browser suites need the full dev stack — CI is the arbiter.

BREAKING CHANGE: boxel capture --spec … --out <dir> (and its boxel screenshot alias) now writes its batch manifest to <dir>/capture-manifest.json instead of <dir>/screenshot-manifest.json. The JSON shape is unchanged. Scripts that read the old filename need to read the new one.

🤖 Generated with Claude Code

`static screenshots` was named before the feature could emit PDFs, so
`PdfScreenshotSpec` reads as an oxymoron and the wrong noun had leaked
into author-facing errors. The codebase had already half-migrated on its
own: capture-spec.ts, capture_spec_hash, CAPTURE_SERVING_PREFIX pointing
at '_screenshot/', and `_sign-capture-urls` signing `_screenshot/` URLs.

One word in the platform vocabulary: capture. `boxel screenshot` stays on
as a CLI verb alias, where concreteness pays and a second noun costs
nothing.

Beyond the word, this stops synonyms doing load-bearing work as a
distinction. Three format rosters and two output-type rosters were told
apart only by which synonym they picked; each now states its axis
(DECLARED_/ON_DEMAND_/CANONICAL_CAPTURE_FORMATS, RASTER_OUTPUT_TYPES vs
CAPTURE_OUTPUT_TYPES). The three spec types likewise get names that say
what they are: CaptureSpec is what an author declares, CaptureRequestSpec
is the wire spec behind the POST body and GET DSL, and CaptureIdentity is
the canonicalized subset that keys the MediaCache ledger.

Two timers were distinguished only by the synonym and must not collapse
into one: the CDP captureScreenshot/printToPDF call is now cdpCaptureMs,
while the whole-step captureMs keeps its name and meaning.

prerendered_html.screenshots becomes .captures across the production
table and both twins, expand/contract: additive add plus backfill
pre-deploy, drop post-deploy.

Telemetry moves to boxel:capture-perf and the dashboard's queries and
title follow. Its uid stays boxel-screenshot-perf: that is an opaque
identifier rather than vocabulary, and changing it would orphan the
existing dashboard and break saved links for no reader benefit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lukemelia and others added 2 commits September 24, 2026 22:24
…rfaces loudly

Three consumers of this vocabulary do not roll forward with a deploy of it.

`boxel-cli` is installed and pinned independently of the realm server, and a
released version posts to `/_screenshot-card`. The realm server answers that
path with the capture handler again.

A capture URL is meant to be stored — a card can hold one, and a browser keeps
serving the previously installed auth service worker, which matches capture
requests on the path prefix to attach credentials, until it next updates. The
serving subtree now covers the old prefix as well as the new one, and reserves
both against writes so nothing can be stored where a GET would never read it
back. Callers strip whichever prefix arrived rather than a fixed width.

The prerender server and its manager roll out ahead of the workers and realm
servers that call them, so during a deploy the previous revision is asking for
`/prerender-screenshot` while the new one is already serving. Both names now
register the capture route.

Card source is the one surface with no alias, because an alias there would
carry the old vocabulary forward indefinitely. Instead the two silent failures
become loud: `static screenshots` is refused by name at declaration-read rather
than read by nothing, and a capture-only component still signalling readiness
with `data-screenshot-pending` fails its slot instead of persisting whatever
half-painted frame was on screen.

Also fixes a log label the rename mangled to "card captureted".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Observability diff (vs staging)

Show diff
diff --git a/tmp/remote-canon.CvvwBW/dashboards/boxel-status/captures.json b/tmp/committed-canon.xS9d0p/dashboards/boxel-status/captures.json
index ecbe096..adc96a8 100644
--- a/tmp/remote-canon.CvvwBW/dashboards/boxel-status/captures.json
+++ b/tmp/committed-canon.xS9d0p/dashboards/boxel-status/captures.json
@@ -24,7 +24,7 @@
         }
       ]
     },
-    "description": "Screenshot capture pipeline stage telemetry (channel boxel:screenshot-perf): request records from the serving surfaces (realm-server) and capture records from the screenshot-card worker task, joined by jobId/correlationId. The same capture breakdown is persisted on media_cache_ledger.diagnostics for SQL reads.",
+    "description": "Capture pipeline stage telemetry (channel boxel:capture-perf): request records from the serving surfaces (realm-server) and capture records from the capture-card worker task, joined by jobId/correlationId. The same capture breakdown is persisted on media_cache_ledger.diagnostics for SQL reads.",
     "editable": true,
     "fiscalYearStartMonth": 0,
     "graphTooltip": 1,
@@ -38,7 +38,6 @@
           "x": 0,
           "y": 0
         },
-        "id": 1,
         "panels": [],
         "title": "Overview",
         "type": "row"
@@ -64,7 +63,6 @@
           "x": 0,
           "y": 1
         },
-        "id": 2,
         "options": {
           "colorMode": "none",
           "graphMode": "area",
@@ -82,7 +80,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "sum(count_over_time({service=\"realm-server\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" [5m])) or vector(0)",
+            "expr": "sum(count_over_time({service=\"realm-server\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" [5m])) or vector(0)",
             "queryType": "range",
             "refId": "A"
           }
@@ -95,7 +93,7 @@
           "type": "loki",
           "uid": "loki"
         },
-        "description": "screenshot-card jobs that finished (any status).",
+        "description": "capture-card jobs that finished (any status).",
         "fieldConfig": {
           "defaults": {
             "color": {
@@ -111,7 +109,6 @@
           "x": 6,
           "y": 1
         },
-        "id": 3,
         "options": {
           "colorMode": "none",
           "graphMode": "area",
@@ -129,7 +126,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "sum(count_over_time({service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" [5m])) or vector(0)",
+            "expr": "sum(count_over_time({service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" [5m])) or vector(0)",
             "queryType": "range",
             "refId": "A"
           }
@@ -165,7 +162,6 @@
           "x": 12,
           "y": 1
         },
-        "id": 4,
         "options": {
           "legend": {
             "displayMode": "list",
@@ -183,7 +179,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "sum by (outcome) (count_over_time({service=\"realm-server\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" [$__interval]))",
+            "expr": "sum by (outcome) (count_over_time({service=\"realm-server\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" [$__interval]))",
             "legendFormat": "{{outcome}}",
             "queryType": "range",
             "refId": "A"
@@ -220,7 +216,6 @@
           "x": 0,
           "y": 5
         },
-        "id": 5,
         "options": {
           "legend": {
             "displayMode": "list",
@@ -238,7 +233,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.50, {service=\"realm-server\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.50, {service=\"realm-server\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by ()",
             "legendFormat": "p50",
             "queryType": "range",
             "refId": "A"
@@ -248,7 +243,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=\"realm-server\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=\"realm-server\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by ()",
             "legendFormat": "p95",
             "queryType": "range",
             "refId": "B"
@@ -258,7 +253,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.99, {service=\"realm-server\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.99, {service=\"realm-server\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by ()",
             "legendFormat": "p99",
             "queryType": "range",
             "refId": "C"
@@ -275,7 +270,6 @@
           "x": 0,
           "y": 13
         },
-        "id": 6,
         "panels": [],
         "title": "Capture stage breakdown",
         "type": "row"
@@ -285,7 +279,7 @@
           "type": "loki",
           "uid": "loki"
         },
-        "description": "Where a capture's wall-clock goes, p95 per stage. queueWaitMs is enqueue→claim; launchMs is page acquire in the pool; renderMs is the server-observed render (nav/settle/imagePaint/screenshot are its components); persistMs is the MediaCache put.",
+        "description": "Where a capture's wall-clock goes, p95 per stage. queueWaitMs is enqueue→claim; launchMs is page acquire in the pool; renderMs is the server-observed render (nav/settle/imagePaint/capture are its components); persistMs is the MediaCache put.",
         "fieldConfig": {
           "defaults": {
             "color": {
@@ -308,7 +302,6 @@
           "x": 0,
           "y": 14
         },
-        "id": 7,
         "options": {
           "legend": {
             "displayMode": "list",
@@ -326,7 +319,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap queueWaitMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap queueWaitMs [$__interval]) by ()",
             "legendFormat": "queueWaitMs",
             "queryType": "range",
             "refId": "A"
@@ -336,7 +329,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap permissionsMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap permissionsMs [$__interval]) by ()",
             "legendFormat": "permissionsMs",
             "queryType": "range",
             "refId": "B"
@@ -346,7 +339,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap launchMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap launchMs [$__interval]) by ()",
             "legendFormat": "launchMs",
             "queryType": "range",
             "refId": "C"
@@ -356,7 +349,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap renderMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap renderMs [$__interval]) by ()",
             "legendFormat": "renderMs",
             "queryType": "range",
             "refId": "D"
@@ -366,7 +359,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap navMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap navMs [$__interval]) by ()",
             "legendFormat": "navMs",
             "queryType": "range",
             "refId": "E"
@@ -376,7 +369,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap settleMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap settleMs [$__interval]) by ()",
             "legendFormat": "settleMs",
             "queryType": "range",
             "refId": "F"
@@ -386,7 +379,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap imagePaintMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap imagePaintMs [$__interval]) by ()",
             "legendFormat": "imagePaintMs",
             "queryType": "range",
             "refId": "G"
@@ -396,8 +389,8 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap screenshotMs [$__interval]) by ()",
-            "legendFormat": "screenshotMs",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap cdpCaptureMs [$__interval]) by ()",
+            "legendFormat": "cdpCaptureMs",
             "queryType": "range",
             "refId": "H"
           },
@@ -406,7 +399,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap decodeMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap decodeMs [$__interval]) by ()",
             "legendFormat": "decodeMs",
             "queryType": "range",
             "refId": "I"
@@ -416,7 +409,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap persistMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap persistMs [$__interval]) by ()",
             "legendFormat": "persistMs",
             "queryType": "range",
             "refId": "J"
@@ -453,7 +446,6 @@
           "x": 0,
           "y": 23
         },
-        "id": 8,
         "options": {
           "legend": {
             "displayMode": "list",
@@ -471,7 +463,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap queueWaitMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap queueWaitMs [$__interval]) by ()",
             "legendFormat": "queueWaitMs",
             "queryType": "range",
             "refId": "A"
@@ -481,7 +473,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap prerenderMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap prerenderMs [$__interval]) by ()",
             "legendFormat": "prerenderMs (incl. transport)",
             "queryType": "range",
             "refId": "B"
@@ -491,7 +483,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap renderMs [$__interval]) by ()",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap renderMs [$__interval]) by ()",
             "legendFormat": "renderMs (server-observed)",
             "queryType": "range",
             "refId": "C"
@@ -528,7 +520,6 @@
           "x": 8,
           "y": 23
         },
-        "id": 9,
         "options": {
           "legend": {
             "displayMode": "list",
@@ -546,7 +537,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap launchMs [$__interval]) by (tabReused)",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap launchMs [$__interval]) by (tabReused)",
             "legendFormat": "tabReused={{tabReused}}",
             "queryType": "range",
             "refId": "A"
@@ -583,7 +574,6 @@
           "x": 16,
           "y": 23
         },
-        "id": 10,
         "options": {
           "legend": {
             "displayMode": "list",
@@ -601,7 +591,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "sum by (persistOutcome) (count_over_time({service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" [$__interval]))",
+            "expr": "sum by (persistOutcome) (count_over_time({service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" [$__interval]))",
             "legendFormat": "{{persistOutcome}}",
             "queryType": "range",
             "refId": "A"
@@ -618,7 +608,6 @@
           "x": 0,
           "y": 31
         },
-        "id": 11,
         "panels": [],
         "title": "Slices",
         "type": "row"
@@ -650,7 +639,6 @@
           "x": 0,
           "y": 32
         },
-        "id": 12,
         "options": {
           "legend": {
             "displayMode": "list",
@@ -668,7 +656,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by (realmURL)",
+            "expr": "quantile_over_time(0.95, {service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by (realmURL)",
             "legendFormat": "{{realmURL}}",
             "queryType": "range",
             "refId": "A"
@@ -682,7 +670,7 @@
           "type": "loki",
           "uid": "loki"
         },
-        "description": "get-dsl is the GET _screenshot/ DSL route; post is POST /_screenshot-card.",
+        "description": "get-dsl is the GET _capture/ DSL route; post is POST /_capture-card.",
         "fieldConfig": {
           "defaults": {
             "color": {
@@ -705,7 +693,6 @@
           "x": 12,
           "y": 32
         },
-        "id": 13,
         "options": {
           "legend": {
             "displayMode": "list",
@@ -723,7 +710,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "quantile_over_time(0.95, {service=\"realm-server\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by (surface)",
+            "expr": "quantile_over_time(0.95, {service=\"realm-server\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"request\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__interval]) by (surface)",
             "legendFormat": "{{surface}}",
             "queryType": "range",
             "refId": "A"
@@ -749,7 +736,6 @@
           "x": 0,
           "y": 41
         },
-        "id": 14,
         "options": {
           "showHeader": true,
           "sortBy": [
@@ -765,7 +751,7 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "topk(20, max by (sourceURL) (max_over_time({service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:screenshot-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:screenshot-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__range])))",
+            "expr": "topk(20, max by (sourceURL) (max_over_time({service=~\"realm-server|worker\", env=\"$env\"} |= \"boxel:capture-perf\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:capture-perf\" | eventType=\"capture\" | realmURL=~\".*${realm}.*\" | surface=~\"$surface\" | contentType=~\"$contentType\" | unwrap totalMs [$__range])))",
             "instant": true,
             "queryType": "instant",
             "refId": "A"
@@ -792,7 +778,6 @@
           "x": 0,
           "y": 51
         },
-        "id": 15,
         "panels": [],
         "title": "MediaCache storage",
         "type": "row"
@@ -818,7 +803,6 @@
           "x": 0,
           "y": 52
         },
-        "id": 16,
         "options": {
           "colorMode": "none",
           "graphMode": "none",
@@ -857,6 +841,65 @@
         "title": "Bucket size (CloudWatch)",
         "type": "stat"
       },
+      {
+        "datasource": {
+          "type": "cloudwatch",
+          "uid": "cef5x9o3yzawwf"
+        },
+        "description": "NumberOfObjects for boxel-media-cache-${env}, reported daily. Compare with 'Ledger object keys': the bucket stores one object per distinct object_key (dedupe-on-write), so the two counts should track each other; a persistent excess of bucket objects means orphans no ledger row references — invisible to GC, which scans ledger rows and never enumerates the bucket. Spans 2d regardless of the dashboard range so lastNotNull always finds the newest daily sample.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "unit": "short"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 4,
+          "w": 6,
+          "x": 0,
+          "y": 56
+        },
+        "options": {
+          "colorMode": "none",
+          "graphMode": "none",
+          "justifyMode": "center",
+          "reduceOptions": {
+            "calcs": [
+              "lastNotNull"
+            ],
+            "fields": "",
+            "values": false
+          },
+          "textMode": "value"
+        },
+        "targets": [
+          {
+            "datasource": {
+              "type": "cloudwatch",
+              "uid": "cef5x9o3yzawwf"
+            },
+            "dimensions": {
+              "BucketName": "boxel-media-cache-${env}",
+              "StorageType": "AllStorageTypes"
+            },
+            "id": "a",
+            "metricEditorMode": 0,
+            "metricName": "NumberOfObjects",
+            "metricQueryType": 0,
+            "namespace": "AWS/S3",
+            "period": "86400",
+            "refId": "A",
+            "region": "default",
+            "statistic": "Maximum"
+          }
+        ],
+        "timeFrom": "2d",
+        "title": "Bucket objects (CloudWatch)",
+        "type": "stat"
+      },
       {
         "datasource": {
           "type": "grafana-postgresql-datasource",
@@ -878,7 +921,6 @@
           "x": 6,
           "y": 52
         },
-        "id": 18,
         "options": {
           "colorMode": "none",
           "graphMode": "none",
@@ -908,6 +950,56 @@
         "title": "Ledger-referenced bytes",
         "type": "stat"
       },
+      {
+        "datasource": {
+          "type": "grafana-postgresql-datasource",
+          "uid": "cef5v5sl9k7i8f"
+        },
+        "description": "COUNT(DISTINCT object_key) in media_cache_ledger — how many objects the ledger references. The bucket-object counterpart to 'Ledger-referenced bytes'.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "unit": "short"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 4,
+          "w": 6,
+          "x": 6,
+          "y": 56
+        },
+        "options": {
+          "colorMode": "none",
+          "graphMode": "none",
+          "justifyMode": "center",
+          "reduceOptions": {
+            "calcs": [
+              "lastNotNull"
+            ],
+            "fields": "",
+            "values": false
+          },
+          "textMode": "value"
+        },
+        "targets": [
+          {
+            "datasource": {
+              "type": "grafana-postgresql-datasource",
+              "uid": "cef5v5sl9k7i8f"
+            },
+            "editorMode": "code",
+            "format": "table",
+            "rawQuery": true,
+            "rawSql": "SELECT COUNT(DISTINCT object_key) AS ledger_keys\n  FROM media_cache_ledger;",
+            "refId": "A"
+          }
+        ],
+        "title": "Ledger object keys",
+        "type": "stat"
+      },
       {
         "datasource": {
           "type": "cloudwatch",
@@ -954,7 +1046,6 @@
           "x": 12,
           "y": 52
         },
-        "id": 20,
         "options": {
           "legend": {
             "calcs": [],
@@ -1010,124 +1101,13 @@
         "timeFrom": "30d",
         "title": "Bucket growth (daily)",
         "type": "timeseries"
-      },
-      {
-        "datasource": {
-          "type": "cloudwatch",
-          "uid": "cef5x9o3yzawwf"
-        },
-        "description": "NumberOfObjects for boxel-media-cache-${env}, reported daily. Compare with 'Ledger object keys': the bucket stores one object per distinct object_key (dedupe-on-write), so the two counts should track each other; a persistent excess of bucket objects means orphans no ledger row references — invisible to GC, which scans ledger rows and never enumerates the bucket. Spans 2d regardless of the dashboard range so lastNotNull always finds the newest daily sample.",
-        "fieldConfig": {
-          "defaults": {
-            "color": {
-              "mode": "palette-classic"
-            },
-            "unit": "short"
-          },
-          "overrides": []
-        },
-        "gridPos": {
-          "h": 4,
-          "w": 6,
-          "x": 0,
-          "y": 56
-        },
-        "id": 17,
-        "options": {
-          "colorMode": "none",
-          "graphMode": "none",
-          "justifyMode": "center",
-          "reduceOptions": {
-            "calcs": [
-              "lastNotNull"
-            ],
-            "fields": "",
-            "values": false
-          },
-          "textMode": "value"
-        },
-        "targets": [
-          {
-            "datasource": {
-              "type": "cloudwatch",
-              "uid": "cef5x9o3yzawwf"
-            },
-            "dimensions": {
-              "BucketName": "boxel-media-cache-${env}",
-              "StorageType": "AllStorageTypes"
-            },
-            "id": "a",
-            "metricEditorMode": 0,
-            "metricName": "NumberOfObjects",
-            "metricQueryType": 0,
-            "namespace": "AWS/S3",
-            "period": "86400",
-            "refId": "A",
-            "region": "default",
-            "statistic": "Maximum"
-          }
-        ],
-        "timeFrom": "2d",
-        "title": "Bucket objects (CloudWatch)",
-        "type": "stat"
-      },
-      {
-        "datasource": {
-          "type": "grafana-postgresql-datasource",
-          "uid": "cef5v5sl9k7i8f"
-        },
-        "description": "COUNT(DISTINCT object_key) in media_cache_ledger — how many objects the ledger references. The bucket-object counterpart to 'Ledger-referenced bytes'.",
-        "fieldConfig": {
-          "defaults": {
-            "color": {
-              "mode": "palette-classic"
-            },
-            "unit": "short"
-          },
-          "overrides": []
-        },
-        "gridPos": {
-          "h": 4,
-          "w": 6,
-          "x": 6,
-          "y": 56
-        },
-        "id": 19,
-        "options": {
-          "colorMode": "none",
-          "graphMode": "none",
-          "justifyMode": "center",
-          "reduceOptions": {
-            "calcs": [
-              "lastNotNull"
-            ],
-            "fields": "",
-            "values": false
-          },
-          "textMode": "value"
-        },
-        "targets": [
-          {
-            "datasource": {
-              "type": "grafana-postgresql-datasource",
-              "uid": "cef5v5sl9k7i8f"
-            },
-            "editorMode": "code",
-            "format": "table",
-            "rawQuery": true,
-            "rawSql": "SELECT COUNT(DISTINCT object_key) AS ledger_keys\n  FROM media_cache_ledger;",
-            "refId": "A"
-          }
-        ],
-        "title": "Ledger object keys",
-        "type": "stat"
       }
     ],
     "preload": false,
     "refresh": "1m",
-    "schemaVersion": 42,
+    "schemaVersion": 41,
     "tags": [
-      "screenshot",
+      "capture",
       "captures",
       "media-cache"
     ],
@@ -1136,7 +1116,7 @@
         {
           "hide": 2,
           "name": "env",
-          "query": "staging",
+          "query": "__ENV__",
           "skipUrlSync": true,
           "type": "constant"
         },
@@ -1230,7 +1210,6 @@
     },
     "timepicker": {},
     "timezone": "browser",
-    "title": "Screenshot Capture Performance",
-    "weekStart": ""
+    "title": "Capture Performance"
   }
 }
diff --git a/tmp/remote-canon.CvvwBW/dashboards/boxel-status/service-prerender-manager.json b/tmp/committed-canon.xS9d0p/dashboards/boxel-status/service-prerender-manager.json
index 517ba1c..1d6e321 100644
--- a/tmp/remote-canon.CvvwBW/dashboards/boxel-status/service-prerender-manager.json
+++ b/tmp/committed-canon.xS9d0p/dashboards/boxel-status/service-prerender-manager.json
@@ -1671,7 +1671,7 @@
           "type": "loki",
           "uid": "loki"
         },
-        "description": "Proxy request volume broken out by endpoint, from `proxying ${label} prerender request` log lines (manager-app.ts:1071). Lets you see whether the manager is mostly handling `visit` (per-card prerender), `module` (compile-time module prerender), `command` (LLM tool-call execution), or `screenshot` traffic — useful when correlating CPU / latency spikes with workload mix.",
+        "description": "Proxy request volume broken out by endpoint, from `proxying ${label} prerender request` log lines (manager-app.ts:1071). Lets you see whether the manager is mostly handling `visit` (per-card prerender), `module` (compile-time module prerender), `command` (LLM tool-call execution), or `capture` traffic — useful when correlating CPU / latency spikes with workload mix.",
         "fieldConfig": {
           "defaults": {
             "color": {
@@ -1777,8 +1777,8 @@
               "type": "loki",
               "uid": "loki"
             },
-            "expr": "sum(count_over_time({service=\"prerender-manager\"} |~ \"proxying screenshot prerender request\" [1m]))",
-            "legendFormat": "screenshot",
+            "expr": "sum(count_over_time({service=\"prerender-manager\"} |~ \"proxying capture prerender request\" [1m]))",
+            "legendFormat": "capture",
             "queryType": "range",
             "refId": "D"
           }
@@ -2618,7 +2618,7 @@
         {
           "hide": 2,
           "name": "env",
-          "query": "staging",
+          "query": "__ENV__",
           "skipUrlSync": true,
           "type": "constant"
         },
diff --git a/tmp/remote-canon.CvvwBW/dashboards/boxel-status/service-worker.json b/tmp/committed-canon.xS9d0p/dashboards/boxel-status/service-worker.json
index 14d31ea..380a576 100644
--- a/tmp/remote-canon.CvvwBW/dashboards/boxel-status/service-worker.json
+++ b/tmp/committed-canon.xS9d0p/dashboards/boxel-status/service-worker.json
@@ -1229,7 +1229,7 @@
           "type": "grafana-postgresql-datasource",
           "uid": "cef5v5sl9k7i8f"
         },
-        "description": "Resolved jobs per minute by `job_type`, taken from the `jobs` table. Lets you see whether the worker fleet is mostly indexing or whether cron-driven jobs (`daily-credit-grant`, `screenshot-card`, `run-command`) are crowding the queue.",
+        "description": "Resolved jobs per minute by `job_type`, taken from the `jobs` table. Lets you see whether the worker fleet is mostly indexing or whether cron-driven jobs (`daily-credit-grant`, `capture-card`, `run-command`) are crowding the queue.",
         "fieldConfig": {
           "defaults": {
             "color": {
@@ -1811,7 +1811,7 @@
         {
           "hide": 2,
           "name": "env",
-          "query": "staging",
+          "query": "__ENV__",
           "skipUrlSync": true,
           "type": "constant"
         }

(Run: https://github.com/cardstack/boxel/actions/runs/36788585037)

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±    0      1 suites  ±0   1h 30m 42s ⏱️ + 1h 18m 15s
5 164 tests +4 488  5 150 ✅ +4 475  14 💤 +13  0 ❌ ±0 
5 179 runs  +4 502  5 165 ✅ +4 489  14 💤 +13  0 ❌ ±0 

Results for commit cb30358. ± Comparison against earlier commit 042fc67.

Realm Server Test Results

    1 files  ±0    291 suites  ±0   2h 21m 55s ⏱️ - 2m 0s
4 660 tests ±0  4 660 ✅ ±0  0 💤 ±0  0 ❌ ±0 
4 714 runs  ±0  4 714 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit cb30358. ± Comparison against earlier commit 042fc67.

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This review looked at what survives the rolling deploy: the prerendered_html expand/contract, the wire and prefix aliases against the deploy order in deploy-release.yml, and the consumers of the old names outside this repo's deploy. It did not re-read the mechanical rename across the ~175 files.

Bottom line: one blocking issue. Rows that old-revision workers commit during the rollout lose their capture manifests when screenshots is dropped, silently, and the GC then deletes their objects. The fix is one statement in the removal migration.

  1. Before the drop, re-copy the manifests that old-revision workers wrote after the backfill. Details are in the thread on the removal migration. Blocking.
  2. Shim screenshot-card to the capture-card module. Released boxel-cli plugin skills keep teaching that import. Details are in the thread on packages/host/app/tools/index.ts. Non-blocking; it's a decision.
  3. boxel capture is a new CLI verb, which makes this feat: (minor) rather than refactor: (patch) for boxel-cli. Non-blocking.
  4. A few new comments describe the old name historically rather than stating the alias as a contract: LEGACY_CAPTURE_SERVING_PREFIX in paths.ts ("before they were named captures"), the /_screenshot-card route in routes.ts, and the additive migration's "renamed from screenshots now that…". Something like "The legacy spelling of CAPTURE_SERVING_PREFIX, still answered because…" says the same thing timelessly. Non-blocking.

Host and realm-server shards are still running at the time of this review.

Comment on lines +1 to +16
// Removal half of the `screenshots` → `captures` rename. Runs post-deploy,
// once the realm-server rollout has stabilized and no task still reading
// `screenshots` is in flight. The additive migration already copied every
// manifest into `captures`, so nothing is lost here.

exports.shorthands = undefined;

exports.up = (pgm) => {
for (let table of [
'prerendered_html',
'prerendered_html_pending',
'prerendered_html_working',
]) {
pgm.dropColumns(table, ['screenshots']);
}
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] "Nothing is lost here" doesn't hold for rows committed between migrate-db and the old worker tasks draining. promotePrerenderedHtmlPending copies every column in getColumnNames('prerendered_html') (ON CONFLICT … SET name=EXCLUDED.name), and that list is read from the live schema on main too. So once the additive migration has run, an old-revision worker's commit writes its fresh manifest to screenshots and copies the pending row's NULL captures over the backfilled value. This drop then discards the only copy.

What follows: those rows serve no declared captures until their next invalidation. The reconcile sweep has no arm for a missing manifest. Once MEDIA_CACHE_GC_MIN_AGE_MS passes, gcUnreferencedPredicate treats their ledger objects as unreferenced and deletes them. That includes every image/file thumb indexed by an old worker during the deploy.

The same column-copy mechanism separates the two writers cleanly. New-revision commits leave screenshots NULL, because their pending rows never set it. So a pre-drop copy restores exactly the old-revision writes and never resurrects a manifest the new code cleared:

pgm.sql(
  `UPDATE ${table} SET captures = screenshots
   WHERE captures IS NULL AND screenshots IS NOT NULL`,
);
pgm.dropColumns(table, ['screenshots']);

The additive migration's comment ("the previous code revision keeps reading screenshots") could also say that it keeps writing it, and that its commits clear captures.

Regression. Blocking: silent data loss on the deploy that ships this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Willing to pay this price.

'screenshot-card',
ScreenshotCardToolModule,
);
shimHostToolModule(virtualNetwork, 'capture-card', CaptureCardToolModule);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The "no alias for card source" decision doesn't account for one set of consumers: the released boxel-cli versions this PR keeps POST /_screenshot-card for also ship plugin skills that teach import ScreenshotCardTool from '@cardstack/boxel-host/tools/screenshot-card' (integrate-screenshot-card-format) and boxel run-command @cardstack/boxel-host/commands/screenshot-card/default (boxel-command). Agents on those versions will keep generating code that fails to resolve, with a module-not-found error rather than the named refusal static screenshots gets.

One more line covers both spellings, since shimHostToolModule registers tools/ and commands/:

Suggested change
shimHostToolModule(virtualNetwork, 'capture-card', CaptureCardToolModule);
shimHostToolModule(virtualNetwork, 'capture-card', CaptureCardToolModule);
shimHostToolModule(virtualNetwork, 'screenshot-card', CaptureCardToolModule);

It also removes the landing-order constraint with the catalog companion PR, which otherwise can't deploy atomically with this one.

Not a bug in the rename itself. Non-blocking; it's a decision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Willing to pay this price too

lukemelia and others added 2 commits September 28, 2026 07:19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ve the legacy capture tool module

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukemelia lukemelia changed the title refactor: rename the declared-screenshot feature to "captures" feat: rename the declared-screenshot feature to "captures" Sep 28, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukemelia
lukemelia marked this pull request as ready for review September 28, 2026 15:47
lukemelia and others added 4 commits September 28, 2026 08:48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three conflicts, all where main removed the orphaned realm-copy index
path (#6331) that this branch had only renamed through:

- `index-writer.ts`: `copyFrom`, `copyPrerenderedHtmlFrom` and the
  declared-lane ledger duplication are gone on main, so the rename of
  their bodies goes with them. The `capture-spec` import keeps main's
  type-only form, since `captureLedgerSourceURL`'s only caller was in
  the removed block, and carries this branch's name for the manifest
  type, which the rest of the file still uses.
- `index-writer-test.ts`: the `copyFrom` tests cover the removed path.
- `pg-queue.ts`: main dropped the `tasks/copy` job registration; this
  branch renamed the capture job's module. Both apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@richardhjtan richardhjtan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This review traced whether every consumer of the old screenshot vocabulary — the CLI verb, the wire routes and serving prefixes, the DB columns, card-source declarations, and the host tool module — either moved or kept a compatibility path, and re-verified the two already-settled threads (the manifest-column drop's documented data loss, and the declined screenshot-card host-tool alias) still match their accepted resolutions on the current head.

One gap in the alias sweep: see the inline comment on capture.ts — the CLI's --spec batch output filename changed with no compatibility path, which the feat: title doesn't account for.

Everything else held up: /_screenshot-card, _screenshot/ (both the serving prefix and its write-reservation, which checks both spellings), and /prerender-screenshot all still answer; the additive/removal migration pair backfills correctly and documents the deploy-window loss the author already accepted; the regenerated SQLite schema matches; static screenshots is refused by name with a test pinning the message; and captureLedgerSourceURL isn't left orphaned by the rebase that dropped the realm-copy path.

The companion PRs (cardstack/boxel-catalog#753, cardstack/boxel-skills#150) are both still open, matching the merge-order dependency the description already discloses — not a new finding.

Matrix Client Tests (2, 3) is red, but both failing specs (tools.spec.ts's "Automatic Switch Command" flow, profile.spec.ts's login timeout) are unrelated to anything renamed here — reads as flaky infra, not a regression from this PR.

// Wait between busy-retries when the server's 503 carries no Retry-After.
const DEFAULT_RETRY_AFTER_SECONDS = 5;
const MANIFEST_FILE_NAME = 'screenshot-manifest.json';
const MANIFEST_FILE_NAME = 'capture-manifest.json';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The batch --spec manifest's filename changed with no compatibility path: screenshot-manifest.json → capture-manifest.json (this line; the JSON shape itself — {ok, captures: [...]} — was already this shape on main, only the filename moves). Every other externally-observed surface in this PR — the /_screenshot-card route, the _screenshot/ prefix, the boxel screenshot verb itself — kept a deliberate alias; a script that runs boxel screenshot --spec … --out <dir> and then reads <dir>/screenshot-manifest.json finds nothing there after an upgrade, with no error surfaced.

That's a breaking change to a published CLI's output contract, which the current feat: title doesn't capture — compute-release.ts only bumps major on feat!: or a BREAKING CHANGE: footer, and packages/boxel-cli/src/ is touched here, so the title drives the version this merge publishes.

Either retitle with feat!: (and a footer naming the break for the changelog), or close the gap the way the rest of the PR does — write (or symlink) the manifest under both names for a deprecation window.

Regression; blocking on the title/versioning decision before merge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Went with the retitle rather than a dual-write: the title is now feat!:, and the description has a BREAKING CHANGE: footer naming the screenshot-manifest.json → capture-manifest.json move, so the changelog carries it.

lukemelia and others added 2 commits September 29, 2026 14:42
# Conflicts:
#	packages/host/memory-baseline.json
#	packages/host/tests/test-module-timings.json
#	packages/realm-server/tests/helpers/index.ts
#	packages/realm-server/tests/realm-endpoints/capture-test.ts
#	packages/realm-server/tests/realm-endpoints/coarse-permission-outcome-test.ts
#	packages/realm-server/tests/test-module-timings.json
#	packages/runtime-common/realm.ts
main drops the shared prerendered_html_working table in a removal
migration, so the captures rename no longer adds to or drops from it: a
database where that drop already ran would otherwise fail the older-
timestamped rename migrations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] CI triage for the red checks on this run (CI #35214):

Realm Server Tests (2, 6): not caused by this PR. The shard ran 701 tests and failed one: realm-policy-non-grantable-test.ts › a non-grantable operation › the refusal holds though a compiled policy carries the grant. Its positive control, a granted annotate, was refused with operation-not-permitted (realm-policy-non-grantable-test.ts:501).

Realm Server Test Results is the aggregate of the shards, so it clears with shard 2.

percy/-cardstack-host: "1 visual change needs review". This means a snapshot is waiting for approval; no test failed. I didn't find any user-visible text in this PR's template changes that a snapshot would show, so the diff needs a look on the Percy build before it's approved.

lukemelia and others added 3 commits September 29, 2026 17:58
Picks up #6389, which fixes realm-policy-non-grantable-test on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Carries main's new capture-card code onto the captures names, including
the realm-authority test main added under the old shared-test export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	packages/realm-server/prerender/utils.ts
@lukemelia
lukemelia requested a review from a team September 30, 2026 20:06
@lukemelia lukemelia changed the title feat: rename the declared-screenshot feature to "captures" feat!: rename the declared-screenshot feature to "captures" Sep 30, 2026

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This review checked whether the rename holds across the boundaries it has to survive: rolling-deploy ordering between services, stored URLs and config, persisted JSON keys, and string-matched consumers. It didn't re-check the rename's internal consistency, which typecheck and CI already cover.

Three blocking gaps, all in compatibility the PR otherwise sets out to keep. The rename itself holds up.

  1. The screenshot-card job type is stranded during the deploy, and the stranded rows never leave the queue. A one-line worker alias fixes it. See runtime-common/worker.ts.
  2. The rename to allowArbitraryCaptures silently closes the gate on realms whose config still says allowArbitraryScreenshots. Either confirm none do, or read both keys. See base/realm-config.gts.
  3. The legacy _screenshot/ prefix is served, but the host signer, serve-index, and the auth service worker match only _capture/. See runtime-common/paths.ts.
  4. The diagnostics retry keys are renamed without rewriting existing rows (non-blocking). See prerender-html-reconcile.ts.
  5. The data-screenshot-pending guard has no test and misses data-screenshot-failed (non-blocking). See prerender/utils.ts.
  6. The skill directory and name don't match, and the dashboard name is wrong (non-blocking). See SKILL.md.

The cost you accepted on the removal migration has two more paths into it during the deploy window. Neither needs action if that decision stands:

  • The /prerender-visit body attribute is renamed with no alias, and so is the response key. The new prerender server ignores screenshots from previous-revision workers, so those visits skip their declared captures.
  • The new prerender server runs against the previous revision's base (data-screenshot-pending) until the realm server rolls. PDF, video, and 3D poster slots fail during that window, and the reconcile retry arm recovers them afterward.

Adjacent, out of scope: software-factory/src/render-gate.ts is an in-repo consumer that still posts to /_screenshot-card. That route's only test asserts a 401 from jwtMiddleware, so it doesn't show which handler the alias reaches. It's worth pinning if the alias is meant to outlive this deploy.


Generated by Claude Code

Comment on lines +340 to +341
'capture-card': () =>
this.#queue.register(`capture-card`, Tasks['captureCard'](taskArgs)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The job type rename leaves screenshot-card jobs that nothing will ever claim. The claim SQL in pg-queue.ts filters j.job_type IN (<registered handlers>), and deploy-release.yml rolls workers (deploy-worker, then post-deploy-worker) before deploy-realm-server. Previous-revision realm servers keep serving for that whole window, and every screenshot-card job they publish from GET _screenshot/ or POST /_screenshot-card stays unfulfilled permanently. Each such request 503s after its sync wait. The rows never go away, so _queue-status's pending_job_count over-reports from then on.

This is the same rolling-deploy class as the /prerender-screenshot alias the PR already keeps. The args are wire-identical (ScreenshotCardArgs and CaptureCardArgs have the same fields), so a one-release alias is enough:

      'capture-card': () =>
        this.#queue.register(`capture-card`, Tasks['captureCard'](taskArgs)),
      // Previous-revision realm servers publish under the old name until they drain.
      'screenshot-card': () =>
        this.#queue.register(`screenshot-card`, Tasks['captureCard'](taskArgs)),

Add 'screenshot-card' back to the worker-job-registration-test list. A removal-phase UPDATE jobs SET job_type = 'capture-card' WHERE job_type = 'screenshot-card' AND status = 'unfulfilled' would work as an alternative, but only for cleanup: the requests made during the window would still fail.

Regression. Blocking unless you'd rather accept it the way the other deploy-window costs were accepted. The stranded rows are the difference: they outlast the deploy.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Added the alias in c0dd491. screenshot-card is registered against the captureCard task, and worker-job-registration-test lists it again. Previous-revision realm servers that publish under the old name during the rollout now get their jobs claimed.

// request time, so editing this takes effect with the index update, no
// restart.
@field allowArbitraryScreenshots = contains(BooleanField);
@field allowArbitraryCaptures = contains(BooleanField);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Renaming this field closes the arbitrary-capture gate, with no warning, on every deployed realm that opted in. allowsArbitraryCaptures() in realm.ts reads only attributes?.allowArbitraryCaptures. A realm config that still says "allowArbitraryScreenshots": true therefore reads as gated, and every uncached DSL capture on that realm returns 403 "does not allow arbitrary captures". Only experiments-realm/realm.json was migrated. The PR's reasoning that no one has adopted the feature covers card source. It doesn't cover realm config, which is user data.

Which do you want?

  • Confirm that no deployed realm config sets the old key. A query against boxel_index for the realm-config instances would settle it. If none do, this is fine as-is.
  • Or read both keys in allowsArbitraryCaptures() (=== true on either) until the configs are rewritten.

Regression. Blocking until one of those is chosen.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Went with reading both keys (e9cde5e). RealmConfig keeps allowArbitraryScreenshots as a described legacy field, so the index still carries it, and allowsArbitraryCaptures() opens the gate when either key is true. A new media-cache-dsl-test case seeds a config with only the old key and asserts that an uncached capture renders.

Comment on lines +320 to +327
// The legacy spelling of `CAPTURE_SERVING_PREFIX`. Still answered, because a
// durable capture URL is meant to be stored: a card may hold one minted under
// this spelling, and a browser keeps serving an auth service worker that
// matches capture requests on this prefix to attach credentials until it next
// updates. Reserved against
// writes for as long as it is served, so nothing can be stored where a GET
// would never read it back.
export const LEGACY_CAPTURE_SERVING_PREFIX = '_screenshot/';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The realm serves the legacy prefix, but three consumers outside the realm match only CAPTURE_SERVING_PREFIX, so a stored _screenshot/ URL still breaks. These are exactly the cards this constant is kept for:

  • host/app/services/capture-url-signer.ts realmRootOfCaptureURL throws "is not a capture URL" on a _screenshot/ URL. getSignedUrl calls it before making any request, so lib/signed-capture.gts renders an error and retries for any legacy URL on a private realm. The server-side signer in realm.ts does accept both prefixes, so only the host half is missing.
  • realm-server/handlers/serve-index.ts isCaptureServingRequest checks pathname.includes('/' + CAPTURE_SERVING_PREFIX). A text/html navigation to a legacy URL, such as opening a PDF capture in a tab, gets the host app shell instead of the capture.
  • host/public/auth-service-worker.js CAPTURE_PATH_SEGMENT is /_capture/ only. Credentials are still attached, but the 503/Retry-After absorption no longer engages, so a legacy <img> whose capture is still rendering shows as broken.

All three can reuse isCaptureServingPath or the CAPTURE_SERVING_PREFIXES list. The service worker can't import it, so it needs a second segment constant. The comment above is also off: the old service worker keys only 503 absorption on the prefix, not credential attachment. Credentials are attached to any realm URL (the fetch listener), so that part of the rationale doesn't hold.

Regression against the compatibility the PR description promises. Blocking.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 447ed58. All three now match the legacy prefix too:

  • realmRootOfCaptureURL resolves against CAPTURE_SERVING_PREFIXES, which is now exported. The unit test covers a _screenshot/ URL.
  • isCaptureServingRequest pre-filters on either prefix. A new index-responses test sends a document navigation to a _screenshot/ URL and asserts that the realm answers, not the shell.
  • The service worker has a LEGACY_CAPTURE_PATH_SEGMENT, and isCaptureRoute checks both. The mirror and the shipped-worker test cover it.

I also corrected the LEGACY_CAPTURE_SERVING_PREFIX comment. It no longer claims the old worker keys credential attachment on the prefix.

Comment on lines +174 to +176
AND jsonb_typeof(ph.diagnostics->'captureErrors') = 'array'
AND COALESCE((ph.diagnostics->>'captureFailureRenders')::int, 1)
< ${DECLARED_CAPTURE_RETRY_CAP}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The diagnostics keys were renamed (screenshotErrors → captureErrors, screenshotCaptureFailureRenders → captureFailureRenders), but no migration rewrites the rows that already exist. Any published row with pending declared-capture failures at deploy time drops out of this retry arm permanently, and its captures stay missing until the source is invalidated some other way. For the same reason, the prior-state read in index-writer.ts (priorDiagnostics?.captureErrors) resets the retry counters on those rows.

To fix it, add a key rewrite to the additive migration on prerendered_html and prerendered_html_pending, for example:

UPDATE prerendered_html
   SET diagnostics = (diagnostics - 'screenshotErrors' - 'screenshotCaptureFailureRenders')
     || jsonb_strip_nulls(jsonb_build_object(
          'captureErrors', diagnostics->'screenshotErrors',
          'captureFailureRenders', diagnostics->'screenshotCaptureFailureRenders'))
 WHERE diagnostics ?| array['screenshotErrors', 'screenshotCaptureFailureRenders'];

If you accept the loss instead, say so in the migration comment.

Regression. Non-blocking: it affects only rows that were mid-retry, and the next edit recovers them.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Added the rewrite in 074bbc3. I put it in the removal migration rather than the additive one. The previous revision reads and writes the old keys until it drains, so running afterwards also catches the rows it wrote during the rollout, and it leaves the old reconcile working in the meantime. It covers both prerendered_html and prerendered_html_pending.

Comment on lines +2214 to +2229
// A component still signalling readiness under the attribute's former
// name reads here as no signal at all, so the wait above clears at once
// and the capture persists whatever half-painted frame is on screen —
// plausible bytes, wrong content, no error anywhere. Fail the slot
// instead, naming the attribute to rename.
if (
await page.evaluate(
() => document.querySelector('[data-screenshot-pending]') != null,
)
) {
return buildInvalidRenderResponseError(
page,
`capture-only component for capture "${name}" signals readiness with data-screenshot-pending, which is no longer read — rename it to data-capture-pending`,
{ title: 'Capture render never painted' },
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This guard has two gaps.

  • No test covers it. Nothing in the test directories renders a component that sets data-screenshot-pending, so this guard is one of the two loud failures the PR relies on and is currently pinned only by this comment. A prerendering test that renders a capture-only component setting the old attribute and asserts the slot error would cover it.
  • It misses data-screenshot-failed. A component that clears the old pending attribute and sets the old failure attribute passes the [data-capture-failed] wait, and its broken frame is persisted without any error. That is the same silent failure this block exists to prevent. Checking [data-screenshot-pending], [data-screenshot-failed] in one selector covers both.

Non-blocking.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Both fixed in bd4aec6. The guard now looks for data-screenshot-pending and data-screenshot-failed and names whichever it finds, along with its rename. A new declared-captures-indexing-test case declares a slot for each attribute next to a working sibling. It asserts that both slots fail with the rename in their error while the sibling still captures.

@@ -1,38 +1,38 @@
---
name: screenshot-perf-diagnosis
description: Diagnose why a screenshot capture took a given amount of time, from the per-capture stage telemetry the screenshot pipeline emits — one JSON log line per event on the `boxel:screenshot-perf` channel (the same `| json` convention as `boxel:client-perf`), plus the same `capture` breakdown persisted onto the capture's `media_cache_ledger.diagnostics` row so it is readable by SQL. Two event types cover the pipeline's processes — a `request` event from a serving surface (the GET `{realm}_screenshot/…` DSL route or `POST /_screenshot-card`) recording what the caller experienced (hit / rendered / congested / timeout / gated / error) and where the surface wall-clock went, and a `capture` event from the worker's `screenshot-card` task recording queue wait, the prerender stage breakdown, and the persist leg — joined on `jobId`, joined back to the realm-server's `realm:requests` lines by the `x-boxel-logging-correlation-id` `correlationId`, and joined to the prerender server/manager logs by the `prerenderRequestId`. Covers (1) a "this screenshot was slow" complaint given a served URL, a (realm + cardId + capture spec) triple, a matrix user/session, a time window, or a correlation id — locate the timing record(s) and break the wall-clock across the stages (queue-wait, page-acquire launch-vs-reuse, settle, image-paint wait, `page.screenshot`, persist upload-vs-dedupe-hit, serve) and name the dominant one; (2) separating a ledger hit (zero Chrome work, `outcome=hit`, no `capture` event) from an on-demand render (`outcome=rendered`); (3) separating a congested per-realm lane (a fail-fast `outcome=congested` 503 from the queue-depth pre-check, or a long `jobWaitMs`/`queueWaitMs` behind the serialized `screenshot:{realmURL}` lane) from a genuinely slow render (`renderMs`); (4) separating a cold-pool Chrome launch (`tabReused=false`, high `launchMs`/`tabStartupMs`) from a warm affinity reuse; (5) separating a real S3 upload (`persistOutcome=uploaded`) from a dedupe-on-write hit (`persistOutcome=deduped`); (6) separating a cache miss forced by a generation bump — the instance's index row was re-stamped (its own edit, a dependency change, or a realm-wide reindex), so a prior `media_cache_ledger` row exists for the same `(realm_url, source_url, capture_spec_hash)` at a lower `source_generation` — from a genuinely new capture spec (no prior row for that `capture_spec_hash`); and (7) recognizing an `outcome=timeout` (the sync wait expired with a 503 + Retry-After; the job keeps running, persists, and the caller's retry becomes a hit) and an `outcome=gated` (the realm's `allowArbitraryScreenshots` gate is closed; existing captures still serve); and (8) diagnosing a slow or failed **PDF** capture (`type=pdf`) — the output encoding is a `contentType` dimension (`application/pdf` vs the raster `image/*`) on both event types, the ledger row, and the dashboard's `contentType` template variable, so a "this PDF was slow" complaint runs the same hit-vs-render / stage-breakdown runbook scoped to `application/pdf`, with pagination cost landing in the render's capture stage and an over-bounds document (past the 20-page / 10 MB caps) surfacing as a `status=error` capture / `outcome=error` request rather than a slow success. Reads the "Screenshot Capture Performance" Grafana dashboard (grafanactl name `boxel-screenshot-perf`: capture latency by stage p50/p95, hit-vs-render and queue-vs-render splits, warm/cold page-acquire, persist outcome, sliced by realm, surface, and contentType encoding). For staging/prod this layers on `aws-access` (the AWS session + SSM port-forward to the read-only DB) and `tail-logs` (the Loki wrapper); hand off to `prerender-sizing` when the bottleneck is pool contention and to `indexing-diagnostics` for the prerender render internals (the `Timings`/`PoolMeta` the render itself records). Use when someone says a screenshot was slow, a capture URL took seconds, a `_screenshot/` request 503'd, or asks why a specific capture cost what it did.
name: capture-perf-diagnosis

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] The frontmatter name: capture-perf-diagnosis no longer matches the directory, which is still screenshot-perf-diagnosis. The skill is listed under the directory name, so search-shape-diagnosis/SKILL.md's new reference to capture-perf-diagnosis points at nothing. Rename the directory to match.

Two more spots in this file:

  • Line 124 says "Capture Capture Performance" and grafanactl name boxel-capture-perf. The dashboard JSON (boxel-status/captures.json) keeps metadata.name: boxel-screenshot-perf, which is deliberate, and its title is "Capture Performance".
  • The same doubled word appears in the packages/runtime-common/index.ts comment "Capture-capture renders only".

Non-blocking.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 4c0832e. The directory is now capture-perf-diagnosis, so the reference from search-shape-diagnosis resolves. The dashboard is named "Capture Performance" with grafanactl name boxel-screenshot-perf, and the doubled word in the index.ts comment is gone.

ylm and others added 3 commits September 30, 2026 17:20
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
…and service worker

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
ylm and others added 7 commits September 30, 2026 17:20
…t-deploy

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
…rd name

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
… integration-surfaces reference

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Zc264o35GJXEcQSG39bwm
@lukemelia
lukemelia merged commit b9495d7 into main Sep 30, 2026
86 of 87 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants