What HS-X records about every invocation.
A linked HS-X Worker leaves three records behind every dispatch: an aggregate metric event in Workers Analytics Engine, sometimes a redacted exemplar in the project's own D1, and a stream of log lines in Workers Logs. This page is the exact schema of each record, the rules for when full inputs are kept and what gets scrubbed first, and the doors for reading it all back: hs-x checkpoint, hs-x logs, the dashboard Logs tab, and the control-plane read endpoints behind both.
The 30-second answer
The runtime instruments every dispatch it serves: capability invokes, workflow actions (inline and batched), card backends, sync runs, webhook deliveries, and queued trigger jobs. Each one produces a metric event with ten fields (who, what, when, how long, success or error). HS-X never samples metric events: every invocation writes one Analytics Engine data point. A subset of invocations additionally persists an exemplar, the full redacted input/output (or error) of that one run: 1% of successes, plus errors until a per-fingerprint cap. Log lines from ctx.logger land in Workers Logs for 7 days.
hs-x checkpoint --project <id> reads the 24-hour aggregate panel from the control plane; hs-x logs --project <id> and the dashboard's Logs tab read the per-invocation list and click-through traces from Workers Logs. All of it requires a linked deploy. An unlinked deploy serves traffic normally but records no invocation telemetry at all.
Everything in an exemplar passes through redaction before it is written; there is no raw-input persistence path. Tokens, emails, and phone numbers are scrubbed by pattern; sensitive keys are scrubbed by name. If a value survived into telemetry, the redactor classified it as non-sensitive.
The metric event
One CheckpointMetricEvent per invocation, regardless of outcome:
| Field | What it is |
|---|---|
accountId | HS-X account that owns the deploy |
hsXAccountId | Same account id (the runtime writes one value to both fields today) |
projectId | HS-X project |
deployId | The deploy revision that served this invocation |
workerName | The Worker inside the project |
capabilityId | The capability that ran |
portalId | Installing HubSpot portal; local-portal when the payload carries no install identity |
outcome | success or error |
durationMs | Wall-clock milliseconds from dispatch start to handler return |
observedAt | ISO 8601 timestamp |
Two behaviors shape how you should read the outcome field. First, rate-limit backpressure records as success: when the HubSpot budget runs dry, the capability returns a structured retry-later / fail-continue result, and that result is the invocation's recorded output. Backpressure is the system working, so it never inflates your error rate (the rate-limits reference covers those shapes). Second, telemetry never breaks dispatch: if recording itself throws, the runtime logs a warning and returns your handler's response anyway.
In Analytics Engine, the event maps onto one data point:
| AE slot | Value |
|---|---|
blob1–blob9 | accountId, hsXAccountId, projectId, deployId, workerName, capabilityId, portalId, outcome, observedAt |
double1 | durationMs |
double2 | 1 for error, 0 for success |
index1 | hsXAccountId |
Workers Analytics Engine accepts at most one index per data point, so hsXAccountId is the sampling key and every other dimension stays queryable through blobs. Analytics Engine may sample stored points under volume (each surviving row records how many it stands for in _sample_interval), and the control plane's queries weight by that interval (summed counts, quantileExactWeighted latencies), so the totals and percentiles you read are corrected for whatever sampling occurred.
When a full run is kept
Aggregates tell you that something failed; exemplars tell you what the run looked like. A CheckpointRunExemplar is the metric event plus the run's payloads: redactedInput always, redactedOutput on success (when the handler returned something), and fingerprint + redactedError on error.
The capture policy decides which runs persist:
| Outcome | Rule | Default |
|---|---|---|
success | Deterministic sample by hash of deployId:workerName:capabilityId:portalId:observedAt | 1% |
error | Kept until the per-fingerprint cap fills | 10 per fingerprint per 60-second window |
error | Hard ceiling across all fingerprints | 100 per worker per 60-second window |
The success sample is a hash, never Math.random(), so whether a given run persisted is reproducible from its identity. The error caps are counted in memory per Worker isolate; when Cloudflare recycles the isolate the counters reset, so the caps are a flood guard rather than an exact global quota.
Errors are grouped by fingerprint, a low-cardinality key of the form <capabilityId>:<errorClass>:<hash>:
errorClassresolves fromerror.name, then a stringerror.code, then an Effect_tag, falling back toUnknownError.- The hash input is a stack signature: the first usable line of the stack, normalized so that parenthesized file locations collapse to
(), bare paths to<path>, line/column pairs to:<line>:<col>, and long digit runs to<number>. For a V8-style stack that first line is the message line itself. With no stack at all, the error message is used instead, with emails and long numbers masked and a 160-character cap.
The point of all that normalization: per-run values (object ids, request ids, timestamps) never reach the fingerprint, so a thousand occurrences of one bug group as one failure row instead of a thousand. A persisted error exemplar looks like this:
{
"accountId": "acct-demo",
"hsXAccountId": "acct-demo",
"projectId": "deal-tagger",
"deployId": "dpl-01j9",
"workerName": "deals",
"capabilityId": "tag-high-value-deals",
"portalId": "46993937",
"outcome": "error",
"observedAt": "2026-06-10T11:47:55.310Z",
"durationMs": 412,
"fingerprint": {
"capabilityId": "tag-high-value-deals",
"errorClass": "TypeError",
"stackSignature": "TypeError:TypeError: Cannot read properties of undefined ()",
"fingerprint": "tag-high-value-deals:TypeError:9f31c2aa"
},
"redactedInput": { "threshold": 25000, "ownerEmail": "[redacted]" },
"redactedError": {
"name": "TypeError",
"message": "Cannot read properties of undefined (reading 'amount')",
"stack": "TypeError: Cannot read properties of undefined (reading 'amount') at tagDeal ..."
}
}What gets scrubbed before anything is written
Every value headed into an exemplar (input, output, and error alike) passes through one redactor. Its rules:
| Rule | Effect |
|---|---|
Sensitive key names: authorization, password, secret, token, api_key/api-key, pak, refresh_token, access_token, email, phone (matched as _/- delimited segments, any casing) | The entire value becomes [redacted], whatever it was |
Whole-string credentials: Bearer <token> values, HubSpot private-app tokens (pat- prefixed), HS-X tokens (hsx_ prefixed) | The string becomes [redacted] |
| Inline patterns in any string: email addresses, phone-number-shaped digit runs | The matching span becomes [redacted] |
| Nesting deeper than 8 levels | [truncated] |
| Circular references | [circular] |
__proto__ / constructor / prototype keys | Dropped entirely |
Errors get the same treatment: an Error instance is reduced to {name, message, stack} and then redacted like any other object. Note what redaction does not do: it has no schema awareness, so a customer name in a field called note survives. If your capability handles data that must never reach telemetry, keep it out of handler inputs and return values; the redactor is a guardrail for credentials and contact details, not a classifier.
Where the data lands
All three stores live in your Cloudflare account: a linked hs-x deploy writes the bindings into the Worker's generated wrangler config.
| Store | Binding | Holds | Retention |
|---|---|---|---|
Analytics Engine dataset hsx_checkpoint | CHECKPOINT | One metric data point per invocation | Cloudflare's AE retention |
Tenant D1, table checkpoint_exemplars | TENANT_DB | Exemplar rows; value_json is the full redacted exemplar | Until you delete them |
| Workers Logs | [observability] block in the generated config | ctx.logger output (console-backed) plus per-request invocation metadata | 7 days |
The exemplar table ships as a runtime migration the deploy applies to the project's D1 (0003_checkpoint_exemplars.sql), with indexes on (project_id, observed_at) and (project_id, outcome, observed_at), so the failure queries the dashboard runs stay cheap.
Two seams exist in the checkpoint writer that generated Workers do not use: an R2 archive (error exemplars as JSON objects under checkpoint/exemplars/<account>/<project>/<exemplar-id>) and a queue fan-out of metric and exemplar events. Both are supported by createCloudflareCheckpointWriter for custom wiring, and neither is bound by the deploy today; the archive: "r2" marker in the read schema exists for that future, and current reads never set it.
Reading aggregates with hs-x checkpoint
hs-x checkpoint fetches the project's aggregate panel from the control plane. It reads a fixed 24-hour window, resolves the project from --project/--project-id, then HSX_PROJECT_ID, then the .hs-x/project.json binding inside a deployed project directory, and requires a logged-in session (hs-x login) with membership on the project's account.
$ hs-x checkpoint --project deal-tagger
Project deal-tagger
Window: 2026-06-09T14:00:00.000Z -> 2026-06-10T14:00:00.000Z
Invocations: 4212 (4209 success, 3 error)
Latency p50/p95/p99: 184ms / 512ms / 1240ms
Recent failures: 1
- tag-high-value-deals:TypeError:9f31c2aa: 3 occurrences, 1 portals, last=2026-06-10T11:47:55.310Z
Sampled successes: 20
Sources: metrics=analytics-engine, exemplars=d1--json emits a flat envelope, pretty-printed with two-space indentation:
{
"ok": true,
"command": "checkpoint",
"mode": "control-plane-checkpoint",
"projectId": "deal-tagger",
"checkpoint": {
"projectId": "deal-tagger",
"window": { "from": "2026-06-09T14:00:00.000Z", "to": "2026-06-10T14:00:00.000Z" },
"invocationTotals": { "success": 4209, "error": 3 },
"latencyMs": { "p50": 184, "p95": 512, "p99": 1240 },
"recentFailures": [
{
"capabilityId": "tag-high-value-deals",
"fingerprint": "tag-high-value-deals:TypeError:9f31c2aa",
"errorClass": "TypeError",
"occurrences": 3,
"affectedPortals": 1,
"firstSeen": "2026-06-10T09:14:02.881Z",
"lastSeen": "2026-06-10T11:47:55.310Z",
"exemplar": { "...": "one full redacted exemplar, the §02 shape" }
}
],
"recentSampledSuccesses": [{ "...": "up to 20 success exemplars, the §02 shape without fingerprint or redactedError" }],
"hourly": [
{ "hourStart": "2026-06-09T14:00:00.000Z", "total": 171, "success": 171, "error": 0, "p50": 180, "p95": 490, "p99": 1100 }
],
"source": { "metrics": "analytics-engine", "exemplars": "d1" }
}
}How the panel is assembled: the window totals, latency percentiles, and hourly buckets come from sampling-corrected Analytics Engine SQL; recentFailures and recentSampledSuccesses come from the production environment's checkpoint_exemplars table, read through Cloudflare's D1 HTTP API with the token stored when you connected your Cloudflare account. The failure list groups the latest 200 exemplar rows by fingerprint and returns up to 20 groups (newest last-seen first) plus up to 20 sampled successes. On failure the exemplar leg degrades to empty lists rather than failing the panel.
A not_found response saying "No Checkpoint read view was found for this project" means the control plane could not produce the read view: its Analytics Engine reader isn't configured, or the aggregate query failed upstream, which includes the case where nothing has ever written to the dataset and there is nothing to query yet. A linked project that is queryable but simply idle returns a zero-count panel instead. The same panel also rides along in hs-x status --project <id> as its Invocations: line, and its hourly series drives the dashboard's invocation and latency charts.
hs-x logs, the Logs tab, and invocation traces
hs-x logs and the dashboard's Logs tab answer a different question than the checkpoint panel: which requests hit my Worker, and what did each one log? They read Workers Logs, not Analytics Engine, through a control-plane read-through at:
GET /v1/accounts/<accountId>/projects/<projectId>/tail| Query parameter | Default | Range |
|---|---|---|
sinceMinutes | 15 | 1–10080 (the full 7-day Workers Logs retention) |
limit | 50 | 1–200 |
view=invocations | — | One row per dispatch instead of raw log lines |
invocation=<id> | — | The trace: that invocation's logger lines, ordered |
The CLI wraps this endpoint directly. hs-x logs --project <id> calls view=invocations; --trace <invocation-id> (or --invocation) reads the logger lines for one dispatch; --raw reads raw tail rows; --filter applies client-side predicates such as source=action,status>=500; --sample 0.1 deterministically samples rows; and --follow polls for new rows until interrupted. --json emits { ok, command, mode: "workers-logs", view, accountId, projectId, rows, filters?, sample? }.
The invocations view maps each request path onto a product vocabulary, so a row tells you what kind of work ran without reading the path:
| Path | Source |
|---|---|
/webhooks/hubspot/<id> | webhook |
/sync/<id>/run | sync |
/workflow-actions/<id> | action |
/capabilities/<id> | capability |
/oauth* | oauth |
| anything else | request |
Each row carries invocationId, timestamp, source, the target id from the path, HTTP method and status, the Workers outcome, and durationMs (wall time). Internal /_hsx/* traffic is excluded from the list, and health probes plus attestation heartbeats are filtered out of the raw tail, so the surface shows your work rather than the platform's plumbing. Clicking a row fetches ?invocation=<id> and renders the logger lines of that one dispatch: anything your handler wrote through ctx.logger, which is console-backed in production and therefore ingested by Workers Logs.
That one trace stitches together work that crosses three trust boundaries in a single request — the card frontend, your Worker, and the HubSpot API — and shows which identifiers you actually have to follow it across each hop:
The Logs tab itself wraps this with a window picker (15m / 1h / 6h / 24h / 7d, default 1h), a 100-row fetch, and a trace panel with copy-to-clipboard. Errors stay honest: the response carries an error string instead of fake rows. cloudflare_not_connected means no Cloudflare credential is stored for the account; observability_permission_missing means the stored token lacks the Workers Observability: Read permission (the tab shows a banner naming exactly that; add the permission and refresh, no redeploy needed); telemetry_query_<status> covers anything else upstream.
What requires linking, and what unlinked deploys get
All of this telemetry is wired at deploy time, and only for linked deploys. When hs-x deploy runs against the control plane, the generated wrangler config binds the CHECKPOINT dataset and enables [observability], and the generated Worker entry passes the checkpoint writer (plus the D1 exemplar store when TENANT_DB is bound) into the runtime. An unlinked deploy generates none of that: the runtime's checkpoint option is absent, so the recording call returns immediately on every dispatch.
| Surface | Linked deploy | Unlinked deploy |
|---|---|---|
| Metric events (Analytics Engine) | Yes | No |
| Exemplars (tenant D1) | Yes, when the tenant data plane is provisioned | No |
| Workers Logs ingestion | Yes ([observability] enabled) | Not enabled by HS-X |
hs-x logs, hs-x checkpoint, hs-x status --project telemetry | Yes | not_found or empty rows (nothing was recorded) |
| Dashboard Logs tab | Yes | No (the project isn't on the control plane) |
The trade is deliberate: the capability surface is identical either way, and linking adds the read-back. Local dev sits outside this system entirely. hs-x dev wires no checkpoint, and instead returns each invocation's structured logs directly in the invoke response (see the dev HTTP API reference).