Skip to content

Capture-stage telemetry: per-stage timings across the screenshot pipeline - #5871

Draft
lukemelia wants to merge 5 commits into
mainfrom
cs-12629-capture-stage-telemetry-per-stage-timings-across-the
Draft

Capture-stage telemetry: per-stage timings across the screenshot pipeline#5871
lukemelia wants to merge 5 commits into
mainfrom
cs-12629-capture-stage-telemetry-per-stage-timings-across-the

Conversation

@lukemelia

Copy link
Copy Markdown
Contributor

What this does

Answers "why did this screenshot take N ms?" with one structured, correlated per-capture record that attributes the wall-clock across every stage of the capture pipeline — queue, prerender pool, headless-Chrome render, MediaCache persist, and serve.

Emission (both existing patterns, each where it's strongest)

Log channel `boxel:screenshot-perf` (mirrors `boxel:client-perf`): one JSON-object line per event with an explicit `channel` field and flat `*Ms` duration fields, so Grafana reads it with the standard FireLens double-parse and any stage can be `unwrap`ped directly. Two event types:

  • `request` — emitted by the serving surfaces (GET `_screenshot/` DSL route and `POST /_screenshot-card`) when a capture-relevant request completes: outcome (`hit` / `rendered` / `congested` / `timeout` / `gated` / `error`) plus stage clocks (generation lookup, ledger lookup, gate read, congestion pre-check, enqueue, job wait, serve) and `totalMs`. Addressing 404s/400s never emit.
  • `capture` — emitted by the `screenshot-card` worker task per job: queue wait (enqueue→claim, from the queue's own clock via a new `JobInfo.queueWaitMs` field), permission fetches, the prerender breakdown (page acquire with its pool waits and `tabReused` warm/cold flag; render split into nav / settle / image-paint / `page.screenshot`), base64 decode, and the persist leg with its outcome (`uploaded` vs `deduped` vs `skipped`/`failed` — the adapters' `put` now reports dedupe-on-write).

Ledger diagnostics: the same capture record is persisted onto a new `media_cache_ledger.diagnostics` JSONB column (additive migration, same pattern as `boxel_index.diagnostics`), so a completed capture's breakdown is readable by SQL after the logs age out.

Correlation (three processes, one story)

The record carries all three join keys: the surface request's `x-boxel-logging-correlation-id` rides the job args to the worker; the worker forwards `jobId.reservationId` to the prerender server as the existing `x-boxel-job-id` header (the screenshot path previously didn't send it); and the prerender HTTP request id comes back on `response.meta`. The prerender stage detail travels in `meta.diagnostics` — the one timing surface that survives the remote prerenderer's wire boundary (the `meta.timing`/`meta.pool` envelope is dropped there).

Grafana

New `boxel-screenshot-perf` dashboard ("Screenshot Capture Performance", Boxel Status folder), in the shape of the client-performance board: request rate and outcomes, request p50/p95/p99, capture latency by stage (p95 per stage), queue-wait vs render split, warm/cold page-acquire split, persist outcomes, realm/surface slices, and a slowest-captures table.

Test plan

  • `media-cache-dsl-test.ts` (+6): rendered captures emit correlated request+capture records whose stage clocks sum within the wall-clock and land on the ledger row; ledger hits are visibly the hit path (one record, zero render attribution); gated/congested/timeout each emit their outcome; identical-bytes re-captures record `deduped`. Also pins the 503 contract the host service worker absorbs: integer `Retry-After`, CORS-exposed.
  • `screenshot-card-test.ts` (+1): the POST surface's record and the correlation id riding the job args.
  • `media-cache-adapter-test.ts`: both adapters report dedupe-on-write vs upload.
  • All 46 + 23 + 35 tests in the touched realm-server suites pass locally; runtime-common, realm-server, postgres, and host typecheck clean; `packages/observability` lint passes.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Grafana preview

Preview deployed for 1 dashboard in the staging Grafana.
Cross-dashboard drill-throughs still point at the canonical staging dashboards.

Dashboards:

Preview is torn down automatically when this PR is closed or merged.

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

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Observability diff (vs staging)

Show diff
diff --git a/tmp/committed-canon.2H03QY/dashboards/boxel-status/screenshot-captures.json b/tmp/committed-canon.2H03QY/dashboards/boxel-status/screenshot-captures.json
new file mode 100644
index 0000000..c6d5c29
--- /dev/null
+++ b/tmp/committed-canon.2H03QY/dashboards/boxel-status/screenshot-captures.json
@@ -0,0 +1,851 @@
+{
+  "apiVersion": "dashboard.grafana.app/v1beta1",
+  "kind": "Dashboard",
+  "metadata": {
+    "annotations": {
+      "grafana.app/folder": "defd2d156sav4d"
+    },
+    "name": "boxel-screenshot-perf"
+  },
+  "spec": {
+    "annotations": {
+      "list": [
+        {
+          "builtIn": 1,
+          "datasource": {
+            "type": "grafana",
+            "uid": "-- Grafana --"
+          },
+          "enable": true,
+          "hide": true,
+          "iconColor": "rgba(0, 211, 255, 1)",
+          "name": "Annotations & Alerts",
+          "type": "dashboard"
+        }
+      ]
+    },
+    "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.",
+    "editable": true,
+    "fiscalYearStartMonth": 0,
+    "graphTooltip": 1,
+    "links": [],
+    "panels": [
+      {
+        "collapsed": false,
+        "gridPos": {
+          "h": 1,
+          "w": 24,
+          "x": 0,
+          "y": 0
+        },
+        "panels": [],
+        "title": "Overview",
+        "type": "row"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "description": "Requests on the two capture surfaces that touched capture work: hits, renders, 503s, 403s. Addressing 404s/400s never emit.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "unit": "short"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 4,
+          "w": 6,
+          "x": 0,
+          "y": 1
+        },
+        "options": {
+          "colorMode": "none",
+          "graphMode": "area",
+          "reduceOptions": {
+            "calcs": [
+              "lastNotNull"
+            ],
+            "fields": "",
+            "values": false
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" [5m])) or vector(0)",
+            "queryType": "range",
+            "refId": "A"
+          }
+        ],
+        "title": "Capture-relevant requests (5m)",
+        "type": "stat"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "description": "screenshot-card jobs that finished (any status).",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "unit": "short"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 4,
+          "w": 6,
+          "x": 6,
+          "y": 1
+        },
+        "options": {
+          "colorMode": "none",
+          "graphMode": "area",
+          "reduceOptions": {
+            "calcs": [
+              "lastNotNull"
+            ],
+            "fields": "",
+            "values": false
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" [5m])) or vector(0)",
+            "queryType": "range",
+            "refId": "A"
+          }
+        ],
+        "title": "Captures run (5m)",
+        "type": "stat"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "description": "hit = ledger hit with zero Chrome work; rendered = capture completed within the sync wait; timeout/congested = 503 + Retry-After; gated = 403.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "custom": {
+              "drawStyle": "line",
+              "fillOpacity": 8,
+              "lineWidth": 1,
+              "showPoints": "never",
+              "spanNulls": true
+            },
+            "unit": "short"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 8,
+          "w": 12,
+          "x": 12,
+          "y": 1
+        },
+        "options": {
+          "legend": {
+            "displayMode": "list",
+            "placement": "bottom",
+            "showLegend": true
+          },
+          "tooltip": {
+            "mode": "multi",
+            "sort": "desc"
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" [$__interval]))",
+            "legendFormat": "{{outcome}}",
+            "queryType": "range",
+            "refId": "A"
+          }
+        ],
+        "title": "Requests by outcome",
+        "type": "timeseries"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "description": "What callers experienced end-to-end on the serving surfaces, all outcomes.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "custom": {
+              "drawStyle": "line",
+              "fillOpacity": 8,
+              "lineWidth": 1,
+              "showPoints": "never",
+              "spanNulls": true
+            },
+            "unit": "ms"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 8,
+          "w": 12,
+          "x": 0,
+          "y": 5
+        },
+        "options": {
+          "legend": {
+            "displayMode": "list",
+            "placement": "bottom",
+            "showLegend": true
+          },
+          "tooltip": {
+            "mode": "multi",
+            "sort": "desc"
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" | unwrap totalMs [$__interval]) by ()",
+            "legendFormat": "p50",
+            "queryType": "range",
+            "refId": "A"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap totalMs [$__interval]) by ()",
+            "legendFormat": "p95",
+            "queryType": "range",
+            "refId": "B"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap totalMs [$__interval]) by ()",
+            "legendFormat": "p99",
+            "queryType": "range",
+            "refId": "C"
+          }
+        ],
+        "title": "Request wall-clock (totalMs)",
+        "type": "timeseries"
+      },
+      {
+        "collapsed": false,
+        "gridPos": {
+          "h": 1,
+          "w": 24,
+          "x": 0,
+          "y": 13
+        },
+        "panels": [],
+        "title": "Capture stage breakdown",
+        "type": "row"
+      },
+      {
+        "datasource": {
+          "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.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "custom": {
+              "drawStyle": "line",
+              "fillOpacity": 8,
+              "lineWidth": 1,
+              "showPoints": "never",
+              "spanNulls": true
+            },
+            "unit": "ms"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 9,
+          "w": 24,
+          "x": 0,
+          "y": 14
+        },
+        "options": {
+          "legend": {
+            "displayMode": "list",
+            "placement": "bottom",
+            "showLegend": true
+          },
+          "tooltip": {
+            "mode": "multi",
+            "sort": "desc"
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" | unwrap queueWaitMs [$__interval]) by ()",
+            "legendFormat": "queueWaitMs",
+            "queryType": "range",
+            "refId": "A"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap permissionsMs [$__interval]) by ()",
+            "legendFormat": "permissionsMs",
+            "queryType": "range",
+            "refId": "B"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap launchMs [$__interval]) by ()",
+            "legendFormat": "launchMs",
+            "queryType": "range",
+            "refId": "C"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap renderMs [$__interval]) by ()",
+            "legendFormat": "renderMs",
+            "queryType": "range",
+            "refId": "D"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap navMs [$__interval]) by ()",
+            "legendFormat": "navMs",
+            "queryType": "range",
+            "refId": "E"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap settleMs [$__interval]) by ()",
+            "legendFormat": "settleMs",
+            "queryType": "range",
+            "refId": "F"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap imagePaintMs [$__interval]) by ()",
+            "legendFormat": "imagePaintMs",
+            "queryType": "range",
+            "refId": "G"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap screenshotMs [$__interval]) by ()",
+            "legendFormat": "screenshotMs",
+            "queryType": "range",
+            "refId": "H"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap decodeMs [$__interval]) by ()",
+            "legendFormat": "decodeMs",
+            "queryType": "range",
+            "refId": "I"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap persistMs [$__interval]) by ()",
+            "legendFormat": "persistMs",
+            "queryType": "range",
+            "refId": "J"
+          }
+        ],
+        "title": "Capture latency by stage (p95)",
+        "type": "timeseries"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "description": "A deep serialized lane shows as queue wait; a slow render shows on the render side.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "custom": {
+              "drawStyle": "line",
+              "fillOpacity": 8,
+              "lineWidth": 1,
+              "showPoints": "never",
+              "spanNulls": true
+            },
+            "unit": "ms"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 8,
+          "w": 8,
+          "x": 0,
+          "y": 23
+        },
+        "options": {
+          "legend": {
+            "displayMode": "list",
+            "placement": "bottom",
+            "showLegend": true
+          },
+          "tooltip": {
+            "mode": "multi",
+            "sort": "desc"
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" | unwrap queueWaitMs [$__interval]) by ()",
+            "legendFormat": "queueWaitMs",
+            "queryType": "range",
+            "refId": "A"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap prerenderMs [$__interval]) by ()",
+            "legendFormat": "prerenderMs (incl. transport)",
+            "queryType": "range",
+            "refId": "B"
+          },
+          {
+            "datasource": {
+              "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\" | unwrap renderMs [$__interval]) by ()",
+            "legendFormat": "renderMs (server-observed)",
+            "queryType": "range",
+            "refId": "C"
+          }
+        ],
+        "title": "Queue wait vs render (p95)",
+        "type": "timeseries"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "description": "tabReused=false is the cold-start tax; true with a large launch is pool contention.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "custom": {
+              "drawStyle": "line",
+              "fillOpacity": 8,
+              "lineWidth": 1,
+              "showPoints": "never",
+              "spanNulls": true
+            },
+            "unit": "ms"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 8,
+          "w": 8,
+          "x": 8,
+          "y": 23
+        },
+        "options": {
+          "legend": {
+            "displayMode": "list",
+            "placement": "bottom",
+            "showLegend": true
+          },
+          "tooltip": {
+            "mode": "multi",
+            "sort": "desc"
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" | unwrap launchMs [$__interval]) by (tabReused)",
+            "legendFormat": "tabReused={{tabReused}}",
+            "queryType": "range",
+            "refId": "A"
+          }
+        ],
+        "title": "Page acquire by warm/cold tab (p95 launchMs)",
+        "type": "timeseries"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "description": "uploaded vs deduped (bytes already stored under the content key) vs skipped/failed.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "custom": {
+              "drawStyle": "line",
+              "fillOpacity": 8,
+              "lineWidth": 1,
+              "showPoints": "never",
+              "spanNulls": true
+            },
+            "unit": "short"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 8,
+          "w": 8,
+          "x": 16,
+          "y": 23
+        },
+        "options": {
+          "legend": {
+            "displayMode": "list",
+            "placement": "bottom",
+            "showLegend": true
+          },
+          "tooltip": {
+            "mode": "multi",
+            "sort": "desc"
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" [$__interval]))",
+            "legendFormat": "{{persistOutcome}}",
+            "queryType": "range",
+            "refId": "A"
+          }
+        ],
+        "title": "Persist outcome",
+        "type": "timeseries"
+      },
+      {
+        "collapsed": false,
+        "gridPos": {
+          "h": 1,
+          "w": 24,
+          "x": 0,
+          "y": 31
+        },
+        "panels": [],
+        "title": "Slices",
+        "type": "row"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "custom": {
+              "drawStyle": "line",
+              "fillOpacity": 8,
+              "lineWidth": 1,
+              "showPoints": "never",
+              "spanNulls": true
+            },
+            "unit": "ms"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 9,
+          "w": 12,
+          "x": 0,
+          "y": 32
+        },
+        "options": {
+          "legend": {
+            "displayMode": "list",
+            "placement": "bottom",
+            "showLegend": true
+          },
+          "tooltip": {
+            "mode": "multi",
+            "sort": "desc"
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" | unwrap totalMs [$__interval]) by (realmURL)",
+            "legendFormat": "{{realmURL}}",
+            "queryType": "range",
+            "refId": "A"
+          }
+        ],
+        "title": "Capture wall-clock p95 by realm",
+        "type": "timeseries"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "description": "get-dsl is the GET _screenshot/ DSL route; post is POST /_screenshot-card.",
+        "fieldConfig": {
+          "defaults": {
+            "color": {
+              "mode": "palette-classic"
+            },
+            "custom": {
+              "drawStyle": "line",
+              "fillOpacity": 8,
+              "lineWidth": 1,
+              "showPoints": "never",
+              "spanNulls": true
+            },
+            "unit": "ms"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 9,
+          "w": 12,
+          "x": 12,
+          "y": 32
+        },
+        "options": {
+          "legend": {
+            "displayMode": "list",
+            "placement": "bottom",
+            "showLegend": true
+          },
+          "tooltip": {
+            "mode": "multi",
+            "sort": "desc"
+          }
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" | unwrap totalMs [$__interval]) by (surface)",
+            "legendFormat": "{{surface}}",
+            "queryType": "range",
+            "refId": "A"
+          }
+        ],
+        "title": "Request wall-clock p95 by surface",
+        "type": "timeseries"
+      },
+      {
+        "datasource": {
+          "type": "loki",
+          "uid": "loki"
+        },
+        "fieldConfig": {
+          "defaults": {
+            "unit": "ms"
+          },
+          "overrides": []
+        },
+        "gridPos": {
+          "h": 10,
+          "w": 24,
+          "x": 0,
+          "y": 41
+        },
+        "options": {
+          "showHeader": true,
+          "sortBy": [
+            {
+              "desc": true,
+              "displayName": "Value"
+            }
+          ]
+        },
+        "targets": [
+          {
+            "datasource": {
+              "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\" | unwrap totalMs [$__range])))",
+            "instant": true,
+            "queryType": "instant",
+            "refId": "A"
+          }
+        ],
+        "title": "Slowest captures by card (range)",
+        "transformations": [
+          {
+            "id": "organize",
+            "options": {
+              "excludeByName": {
+                "Time": true
+              }
+            }
+          }
+        ],
+        "type": "table"
+      }
+    ],
+    "preload": false,
+    "refresh": "1m",
+    "schemaVersion": 41,
+    "tags": [
+      "screenshot",
+      "captures",
+      "media-cache"
+    ],
+    "templating": {
+      "list": [
+        {
+          "hide": 2,
+          "name": "env",
+          "query": "__ENV__",
+          "skipUrlSync": true,
+          "type": "constant"
+        },
+        {
+          "current": {
+            "text": "",
+            "value": ""
+          },
+          "description": "Substring match on the capture's realm URL; empty = all realms.",
+          "hide": 0,
+          "name": "realm",
+          "options": [
+            {
+              "selected": true,
+              "text": "",
+              "value": ""
+            }
+          ],
+          "query": "",
+          "type": "textbox"
+        },
+        {
+          "allValue": ".*",
+          "current": {
+            "selected": true,
+            "text": "All",
+            "value": "$__all"
+          },
+          "description": "Which serving surface enqueued the work.",
+          "hide": 0,
+          "includeAll": true,
+          "multi": false,
+          "name": "surface",
+          "options": [
+            {
+              "selected": true,
+              "text": "All",
+              "value": "$__all"
+            },
+            {
+              "selected": false,
+              "text": "get-dsl",
+              "value": "get-dsl"
+            },
+            {
+              "selected": false,
+              "text": "post",
+              "value": "post"
+            }
+          ],
+          "query": "get-dsl,post",
+          "type": "custom"
+        }
+      ]
+    },
+    "time": {
+      "from": "now-6h",
+      "to": "now"
+    },
+    "timepicker": {},
+    "timezone": "browser",
+    "title": "Screenshot Capture Performance"
+  }
+}

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

@lukemelia
lukemelia marked this pull request as draft August 24, 2026 21:15
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   2h 13m 22s ⏱️ + 3m 28s
4 581 tests ±0  4 567 ✅ ±0  14 💤 ±0  0 ❌ ±0 
4 600 runs  ±0  4 586 ✅ ±0  14 💤 ±0  0 ❌ ±0 

Results for commit 9e11309. ± Comparison against earlier commit cf0f802.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   18m 42s ⏱️ +37s
2 354 tests ±0  2 354 ✅ ±0  0 💤 ±0  0 ❌ ±0 
2 437 runs  ±0  2 437 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 9e11309. ± Comparison against earlier commit cf0f802.

@lukemelia
lukemelia force-pushed the cs-12629-capture-stage-telemetry-per-stage-timings-across-the branch from 06e15a2 to 0170688 Compare August 24, 2026 22:03
lukemelia and others added 2 commits August 25, 2026 16:43
One correlated per-capture timing record spanning the three processes a
capture crosses:

- boxel:screenshot-perf log channel (JSON-object lines, flat *Ms fields
  for LogQL unwrap): 'request' events from the two serving surfaces (GET
  _screenshot/ DSL, POST /_screenshot-card) with generation/ledger/gate/
  precheck/enqueue/job-wait/serve stage clocks and an outcome (hit,
  rendered, congested, timeout, gated, error); 'capture' events from the
  screenshot-card worker task with queue wait, permissions, prerender
  breakdown, decode, and persist stages.
- Correlation: the surface's x-boxel-logging-correlation-id rides the job
  args to the worker; the worker forwards jobId.reservationId to the
  prerender server as x-boxel-job-id; the prerender request id comes back
  on response.meta — the record carries all three.
- JobInfo.queueWaitMs: the claim-time queue-wait the queue already
  computed is handed to handlers instead of only logged.
- Prerender: captureScreenshot times nav/settle/imagePaint/screenshot and
  stamps them into meta.diagnostics (the one timing surface that survives
  the remote wire); prerenderScreenshot now records tabReused/priority the
  way runCommand does.
- Persist: MediaCacheAdapter.put reports dedupe-on-write vs upload;
  putMedia returns it; the capture record lands on the new
  media_cache_ledger.diagnostics jsonb column (additive migration) so a
  completed capture's breakdown is readable by SQL after logs age out.
- Grafana: new boxel-screenshot-perf dashboard (stage p95s, queue-wait vs
  render, warm/cold acquire, persist outcomes, realm/surface slices).
- Tests: DSL suite covers rendered/hit/gated/congested/timeout/dedupe
  telemetry, the ledger diagnostics write, and pins the 503 contract the
  host service worker absorbs (integer Retry-After, CORS-exposed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lukemelia
lukemelia force-pushed the cs-12629-capture-stage-telemetry-per-stage-timings-across-the branch from 0170688 to 0fc300b Compare August 25, 2026 20:43
@lukemelia
lukemelia changed the base branch from cs-12561-post-screenshot-card-persists-captures-to-mediacache to main August 25, 2026 20:43
ylm and others added 2 commits August 26, 2026 18:50
…e-telemetry-per-stage-timings-across-the

# Conflicts:
#	packages/realm-server/prerender/utils.ts
…ings-across-the

Resolves conflicts with the envelope screenshot work (#5880): the capture
telemetry's per-stage timers move into the renderFor structure, accumulating
nav/settle/imagePaint across per-envelope re-renders and counting viewport
switches + CDP screenshots as the capture stage so the stages stay disjoint.

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

@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 went after the telemetry plumbing: the request/capture event construction on both serving surfaces, the correlation-id join across the three processes, the prerender-timing hand-off through meta.diagnostics, the dedupe-on-write reporting, the ledger persistence, and how the change lands in captureScreenshot. It did not exercise the Grafana dashboard or the remote-prerenderer wire path end-to-end (the in-process path is covered by tests).

No blocking issues — the telemetry is additive and the capture path's observable behavior is unchanged. Four non-blocking items: two inline, two artifact notes below.

  1. totalMs vs queueWaitMs on capture events — see the thread on ScreenshotPerfBase.totalMs in screenshot-perf.ts.
  2. The "minus the envelope fields" claim doesn't match the ledger write — see the thread on the module header comment in screenshot-perf.ts.
  3. Stale SQLite schema snapshot. packages/host/config/schema/1787603632440_schema.sql is named for this PR's migration, but its media_cache_ledger table carries no diagnostics column — jsonb regenerates as BLOB, and it's present on the other diagnostics tables, so this snapshot was dumped from a DB without the migration applied. Non-blocking: the host build only checks the snapshot's timestamp prefix, and nothing in the in-browser SQLite writes that column. Re-running pnpm make-schema against a migrated DB would make the committed snapshot truthful and spare the next person a confusing diff.
  4. screenshotCaptureMs wording. The RenderTimeoutDiagnostics comment in index.ts calls it "the CDP screenshot call," but for a multi-entry batch the field measures the whole capture loop (per-entry viewport switch + paint wait + screenshot), not just page.screenshot; it's exact only for the singular case. Worth a reword. Non-blocking.

Comment on lines +78 to +81
// Wall-clock of the whole event: request receipt → response for `request`
// events, job claim → job return for `capture` events. Stage fields sum to
// at most this; the remainder is unattributed overhead.
totalMs: number;

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 invariant here — "stage fields sum to at most this" — holds for request events but breaks for capture events, because of queueWaitMs. In tasks/screenshot-card.ts, taskStart is set after the job is claimed, so a capture's totalMs is claim→return; queueWaitMs measures enqueue→claim, entirely before taskStart. A capture that waited behind a backlog therefore reports a queueWaitMs larger than the whole totalMs it's presented as a slice of, and a dashboard stacking stages against totalMs mis-attributes queue wait. The request-side test checks the sum only for request events, so nothing flags it.

Pick one: fold queue wait into the capture's totalMs (queueWaitMs + (Date.now() - taskStart)), or keep totalMs as the claim→return window and carve queueWaitMs out of the "stages sum to at most totalMs" statement (it's a pre-claim clock from a different process). Non-blocking — a semantics call, not a fix.

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 9e11309 — kept totalMs as the claim→return window and carved queueWaitMs out of the invariant: the comment now states it clocks enqueue→claim, before the claim-start of totalMs, so it sits outside the sum. Chose this over folding queue wait into totalMs so the field stays the task’s own execution window and the queue clock (a different process) reads as its own field.

//
// - `capture` — emitted by the worker's `screenshot-card` task when a job
// finishes: queue wait, the prerender stage breakdown, and the persist
// leg. The same record (minus the envelope fields) is persisted onto the

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 🤖] "minus the envelope fields" doesn't match what's persisted: tasks/screenshot-card.ts passes the whole event to updateMediaCacheDiagnostics ({ ...event }), so the ledger row's diagnostics keeps eventType, surface, correlationId, jobId, reservationId, totalMs, and the rest — and the DSL test asserts diagnostics.correlationId === 'corr-dsl-1', which pins the envelope onto the row. Either drop the "(minus the envelope fields)" clause, or strip the envelope keys before the ledger write if the row is meant to hold only the breakdown. Non-blocking.

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 9e11309 — dropped the “(minus the envelope fields)” clause. The ledger row keeps the whole record, envelope included (the DSL test pins correlationId on it), so the comment now matches the write.

- screenshot-perf.ts: `totalMs`'s "stages sum to at most this" invariant
  excludes a capture event's `queueWaitMs`, which clocks enqueue → claim
  and so lands before the claim-start of `totalMs`; state the exception.
- screenshot-perf.ts: the ledger `diagnostics` row keeps the whole capture
  record, envelope fields included (a test pins `correlationId` on the row),
  so drop the "minus the envelope fields" clause.
- index.ts: `screenshotCaptureMs` measures the whole capture loop — the lone
  `page.screenshot` for a singular capture, or each entry's viewport switch +
  screenshot for a batch — not just the CDP screenshot call.
- Add the `media_cache_ledger.diagnostics` column (jsonb → BLOB) to the
  generated sqlite schema snapshot, which the regen commit omitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GsYGsuCqHJz9GAti4jheG
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] The two body-level notes from the review are addressed in 9e11309:

  • Stale schema snapshot — added the media_cache_ledger.diagnostics column (jsonb → BLOB) to packages/host/config/schema/1787603632440_schema.sql. The local boxel DB has no pgmigrations table, so a make-schema regen would have dumped an unmigrated schema; the column and its placement are the deterministic converter output (jsonb → BLOB, appended after height), matching every other diagnostics column already in the snapshot. Worth a real pnpm make-schema against a fully-migrated DB before merge to confirm byte-for-byte.
  • screenshotCaptureMs wording — the RenderTimeoutDiagnostics comment in index.ts now describes the field as the whole capture loop (the lone page.screenshot for a singular capture, or each entry’s viewport switch + screenshot for a batch), not just the CDP screenshot call.

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.

1 participant