# Rp Telemetry

> Always-active telemetry companion for RePlatform migration runs. Records what happened during a run — halts, errors, fidelity losses, API gaps, skill coverage gaps, user decisions, pipeline defects — through a validated recorder script, plus the run rollup (stages, timings, volumes, verification). Loaded by the orchestrator at run start and kept active for the whole run.

- Skill: `wix/rp-telemetry` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds add wix/rp-telemetry`
- Raw SKILL.md: https://api.skillmd.com/api/skills/wix/rp-telemetry/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: wix (https://skillmd.com/u/wix)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/wix/rp-telemetry

---


# rp-telemetry

Capture per-run telemetry that tells us what to improve in the skills and the underlying
Wix tooling. This skill stays active alongside the migration skills for the **entire**
run; the migration skills themselves are unchanged and know nothing about telemetry.

## The one rule that governs everything you record

**Observation, not diagnosis.** Every field records what was observed — what happened,
what was expected, what actually occurred, where, and how often. Never record a root
cause, a fix, a recommendation, or a workaround-as-solution. There is deliberately no
field for them; the schema rejects unknown fields. Root cause is derived later, at review
time, by a reviewing agent with full context — not asserted by you in the moment.

Three discipline rules apply to every free-text field (`what_happened`, `expected`,
`actual`):

- **Types, not instances.** Refer to entity *types* and *classes*, never specific client
  data. `entity_type: "product"` — never a product's name, SKU, price, or body text.
- **Secret-safe.** No credentials, tokens, URLs, config values, or file contents — the
  recorder also runs a mechanical scrub as a last line of defense, but do not rely on it.
- **Short and structural.** A sentence or two, max 400 chars. Prefer stating
  expected-vs-actual over narrating. The coded fields carry the structure.
- **No remediation narration.** "The canonical lib was fixed and re-synced" is a fix
  story, not an observation — even when true (dev-mode backports). Record what was
  observed to work ("adding fieldsets=FULL returned the field; the lookup succeeded")
  and leave the repair to the execution log.

## The recorder

All telemetry is persisted through the bundled recorder — **never hand-write or edit
`run-telemetry.json` or `telemetry/` files.** Run it from this resource directory
(see `CONVENTIONS.md`), pointing at the active migration project:

```bash
node scripts/rp-telemetry.js <command> [...] --project <abs path to migrations/<project>>
```

The recorder owns everything mechanical: schema validation (it rejects invalid enums and
unknown fields — fix and retry, never guess around it), timestamps, per-class event
folding, the run/attempt/session resume model, the privacy scrub, and the well-formedness
gate at finalize. It prints one JSON result per call; `{"ok":false,...}` lists exactly
what to fix.

| Call | When |
|---|---|
| `start '<dims-json>'` | At run begin — before any other pipeline step. Resumes an unfinalized run automatically. |
| `dims '<dims-json>'` | Whenever a dimension becomes known mid-run (platform version and extensions after discovery, `site_id` after provisioning). |
| `stage start <stage>` / `stage end <stage> --outcome <outcome>` | At every stage boundary. Outcomes: `passed`, `halted`, `failed`, `skipped`. |
| `meter [--api-ms N --model-ms N --script-ms N --input-tokens N …]` | Measured latency/token counts for a stage. Call it whenever you have real numbers; see "Metering" below. |
| `wait start [--halt <subtype> --skill <s> [--what '<text>']]` / `wait end` | The moment the run halts for the user, and the moment it resumes. This is how user latency stays out of `active_ms` — never estimate elapsed time yourself. Pass `--halt` for a needs-user halt and the recorder emits the paired `halt_needs_user` event for you. |
| `record '<event-json>'` | The moment something observable happens (taxonomy below). |
| `digest --transcript <path> [--offline]` | Just before `finalize` — a deterministic parse of this run's Claude Code session transcript (cost, turn counts, tool failures, retry loops, hook errors). Never authored by you; `--offline` re-runs it later against a checkpointed or local transcript for a run that died before calling it. |
| `finalize '<rollup-json>'` | When the run reaches a terminal state. |
| `rebuild [--attempt <n>] [--push]` | Only on request, to re-assemble a past run's signal document from its archived journal after a recorder fix — never during a run. `--push` re-emits the rebuilt run to the BI sink (backfill after an outage; idempotent — the reviewer dedupes at query time). |
| `status` | To orient after a resume. |

### Run lifecycle

1. **`start`** as the first telemetry act of the run, with whatever dimensions are already
   known:

   ```bash
   node scripts/rp-telemetry.js start '{"source_platform":"wordpress",
     "source_site_url":"https://client-site.example","source_acquisition":"public_storefront",
     "delivery_mode":"management","destination_strategy":"new_site",
     "runtime_env":{"agent_runtime":"claude-code","model":"<model id>"}}' --project <dir>
   ```

   `start` on a project with an unfinalized run **resumes** it (same run, same attempt,
   one more session) and closes any open wait interval. `start` after a finalized run
   opens the next attempt. Never try to manage run identity yourself.

   **Always `start` on resume, even mid-stage.** Besides the session accounting, the
   resume is a BI heal point: the recorder re-pushes everything the previous session's
   transport may have lost (push failures are journaled, then healed at the next
   opportunity — a halted run may never reach finalize, so skipping the resume `start`
   can strand the run's tail in the local journal). `status` shows `bi_backlog: true`
   when unpushed rows are pending.

   `source_acquisition` is an open set of class tokens, but reuse an established one
   (`admin_api`, `public_storefront`, `public_content`, `file_export`) rather than
   minting a synonym — cross-run folding depends on stable tokens.

   **Skills provenance is auto-stamped — you don't pass it.** At `start` the recorder
   stamps two identifiers onto the run so every record can be traced back to the skills
   that produced it:

   - `skills_version` — the bundle's semver release label (from `VERSION`). Coarse and
     hand-bumped by design; it is the group-by/order-by key that binds issues to a
     *release line* ("all issues on 1.2.x", "regressed since 1.1.0").
   - `skills_commit` — the exact source commit the bundle was built from: the precise
     *vendored snapshot* within a release. Many commits ship under one hand-bumped
     `skills_version`, so a run reporting `1.0.0` is otherwise unattributable to the
     change that produced its issue. The recorder resolves it, in order of authority,
     from the `sourceCommit` stamped into `.publish-manifest.json` at publish time (the
     only source that works in a partner runtime, and the only correct one once the
     bundle is vendored into `wix/skills`), else a dev-mode `git rev-parse --short HEAD`
     of the source checkout, else `null`.

   Both may be overridden by passing `skills_version` / `skills_commit` in the `start`
   dims (a runtime with better provenance than the recorder can infer), but normally you
   leave them to auto-resolve.

2. **Stage boundaries** as the pipeline moves. Map orchestrator steps to stages like this:

   | Stage | Covers |
   |---|---|
   | `config` | Everything before discovery: project resolution, config files, up-front input collection |
   | `discovery` | Source discovery (`rp-discovery` + source adapter) |
   | `mcp_gate` | The Wix MCP prerequisite gate between discovery and mapping |
   | `mapping` | `rp-mapper` producing the mapping plan |
   | `mapping_review` | The mapping review checkpoint (user-facing) |
   | `setup_discovery` | `rp-setup-discovery` |
   | `codegen` | `rp-import-codegen` |
   | `approval_gate` | The execution-plan approval gate (user-facing) |
   | `setup_provisioning` | Site creation, app installs, collections — `rp-execute-setup` |
   | `storefront_build` | The `website`-mode `wix-headless` build + release; `skipped` in `management` mode |
   | `extract` | Source extraction to disk, before any write (`rp-execute-import`) |
   | `import` | The import writes (`rp-execute-import`) |
   | `finish` | Verification spot-checks, completion reporting, handoff |

   Stages bind to **when the work actually runs**, not to their canonical order. If the
   orchestration runs extraction early (e.g. extract + dry-run before the approval gate,
   inside the codegen phase), close the open stage, bracket the extraction in its own
   `extract` stage, then reopen — a stage may be entered more than once, and its
   `active_ms` sums the entries. Booking a real extraction into `codegen` (and leaving
   `extract` as a milliseconds-long token stage) is exactly the mis-attribution the
   `extract` stage exists to prevent.

   When the `discovery` stage ends, **always** stamp what it learned:
   `dims '{"source_platform_version":"...","source_extensions":["..."],
   "discovered_entity_types":["post","product","event"]}'` — pass `[]` explicitly when
   discovery found no extensions (or, improbably, no entity types); a null left behind is
   flagged in `telemetry_health` as `source_extensions_null_after_discovery` /
   `discovered_entity_types_null_after_discovery`. `discovered_entity_types` lists every
   source entity class discovery found in use — including classes with no Wix target; it
   is what finalize cross-checks the `volumes` rows against, so the "what we couldn't
   import" demand signal cannot be silently under-reported.

3. **`wait start` / `wait end`** around every needs-user halt: credential requests, the
   mapping-review checkpoint, the approval gate, any halt-to-needs-user. Pass the halt
   class on the same call —
   `wait start --halt missing_input --skill rp-mapper --what "run halted at the mapping-review checkpoint awaiting approval"`
   — and the recorder emits the paired `halt_needs_user` event mechanically; a stalled
   wait with no halt event in its stage is flagged in `telemetry_health`
   (`wait_without_halt_event:<stage>`). If the session is about to end on a halt, leave
   the wait open — the resume's `start` closes it, so overnight user latency lands in
   `waiting_ms` where it belongs. If you resume real work while a wait is open (e.g.
   investigating something before the user has answered), `wait end` first and
   `wait start` again when you go back to waiting — active work must never be booked as
   waiting.

4. **`record`** events as they happen (next section). Record in the moment, not
   retrospectively — improvisation and halts are only reliably knowable when they occur.

5. **`digest --transcript <path to this session's CC transcript>`** just before `finalize`.
   It parses the Claude Code session transcript, deterministically — no model, no
   judgment, nothing for you to say. If it fails, carry on to `finalize` anyway;
   telemetry never blocks a migration.

6. **`finalize`** once, at a terminal state, with the rollup:

   ```bash
   node scripts/rp-telemetry.js finalize '{"terminal_state":"completed",
     "volumes":[{"entity_type":"product","target":"native","target_surface":"stores/v3",
       "discovered":142,"planned":142,"attempted":142,"succeeded":139,"failed":3,
       "skipped":0,"already_imported":0}],
     "verification":[{"subject":"product","method":"query_back","checked":10,"passed":10,"failed":0}],
     "operator_acceptance":"accepted"}' --project <dir>
   ```

   - `volumes` come from the execution artifacts (manifest, audit log, crosswalk):
     `planned` and `target`/`target_surface` from the **approved** plan;
     `already_imported` is crosswalk-skipped scope from a prior attempt — never fold it
     into `skipped`.
   - Include a volume row for **every** entity type discovery found in use — including
     types excluded by user decision or with no clean Wix target (`discovered: N,
     skipped: N`, `target: none` where no target exists, `planned` 0 or null). "What we
     cannot or chose not to import" must be signal-layer arithmetic, never an evidence
     dig — rows only for imported types silently erase the excluded half of the approved
     plan. This is enforced mechanically: on a completed run, any class stamped in
     `discovered_entity_types` with no volume row is flagged
     `volume_row_missing:<type>` in `telemetry_health`.
   - `verification` makes the finish step's spot-checks countable. `checked: 0` is an
     honest "written but unverified" — report it rather than papering over it.
   - Do **not** finalize a halt you expect the user to resume — leave the run unfinalized
     with the wait open. Finalize with `halted_needs_user` only when the run is genuinely
     being closed out in a stalled state. A user who declines the plan is
     `abandoned_by_user` (plus a `user_decision` event with `subtype: declined`), never
     `halted_needs_user` or `failed`.
   - **Check the returned `bi_push`.** If it reports `failed > 0` or `skipped > 0`, the
     run (or part of it) did not reach the central BI record: retry once with
     `rebuild --push`, and if it still fails, tell the operator in the completion report
     that the run exists only locally — never let a run silently vanish from the shared
     record. `skipped > 0` means `RP_TELEMETRY_BI_DISABLED` was set during the session;
     on a real migration run that env var must never be set. **A clean `bi_push` result
     (`failed: 0`, `skipped: 0`) means the rows were handed to frog, not that they have
     arrived in BI** — per `bi-sink.js`'s own transport code, a 2xx is not an ingestion
     receipt; only a Trino read-back proves arrival, and no such read-back is available
     from inside a run. Report `bi_push` success as "handed off," never as "confirmed in
     BI."

### Metering — splitting `active_ms` into where the time actually went

`active_ms` alone cannot locate a bottleneck: it is wall-clock between two stage boundaries, fusing
**model reasoning + subprocess execution + remote API latency + defect-repair time** into one
number. `meter` splits it, and cost is derived from the token counts.

```bash
# a generated script reporting its own measured work
node scripts/rp-telemetry.js meter --api-ms 250000 --api-calls 73 --api-retries 2 --script-ms 1200 --project <dir>
# the agent runtime reporting its own usage for the stage it just finished
node scripts/rp-telemetry.js meter --model-ms 42000 --input-tokens 8000 --output-tokens 1500 --cache-read-tokens 120000 --project <dir>
```

**Derive the API half mechanically — do not hand-count it.** After a generated import runs:

```bash
node scripts/meter-from-audit.js --project <migration dir> --stage import   # --dry-run to preview
```

It reads `logs/import-audit.ndjson` and emits measured `api_ms` / `api_calls` / `api_retries`.
It exists because a bulk write logs **one audit row per item, each carrying the batch's total
latency** — summing rows multiplies one call's latency by its item count, which on a real run
reported 1,050,992 ms of API time inside a stage that only existed for 346,505 ms. The helper
collapses rows per distinct `(runId, endpoint, batch, latencyMs)`, so a batch counts once while
genuinely separate calls each count.

Fields: durations `model_ms` / `api_ms` / `script_ms`; counts `input_tokens`, `output_tokens`,
`cache_read_tokens`, `cache_write_tokens`, `api_calls`, `api_retries`. Defaults to the open stage;
`--stage <stage>` targets another. Repeated calls **accumulate**, so each script invocation and each
stage re-entry reports independently.

Rules that keep the numbers trustworthy:

- **Measure, never estimate.** Every field is a number something actually observed — a script's own
  elapsed time, an audit log's summed `latencyMs`, the runtime's reported usage. If you do not have
  a measurement, omit the field; the gap is reported as `unattributed_ms` and flagged, which is far
  more useful than a guess.
- **Do not meter the same interval twice.** Meters sum, so re-reporting a stage's API time after a
  resume double-counts it. The recorder flags `stage_over_attributed:<stage>` when attributed time
  exceeds elapsed time, because `unattributed_ms` clamps at zero and would otherwise hide it.
- **Cost is derived, never recorded.** Pass `model_pricing_snapshot` in the `start` dims
  (`{ "<model>": { "input_per_mtok": 15, "output_per_mtok": 75, "cache_read_per_mtok": 1.5 } }`) and
  the rollup computes `cost.estimated_cost_usd`. A stored dollar figure silently goes wrong when list
  prices change; tokens plus a dated snapshot stay recomputable. Without a snapshot, cost is `null`
  with `cost_basis: "no_pricing_snapshot"` — never a fabricated number.
- **`contained_recovery` is derived, not self-reported.** Any `error` or `pipeline_defect` in a stage
  marks it, because a stage that spent its time debugging is the one an agent is least likely to
  remember to flag. `timing.stages_with_recovery` lists them, so a clean run and a thrash are
  distinguishable instead of both reading as "this is what the stage costs".

The rollup's `timing.agentic_ms` vs `timing.deterministic_ms` (and `agentic_share`) is the number
that shows whether moving work into deterministic code is paying off. `agentic_share` is `null` when
nothing was metered rather than `0`, which would falsely read as a fully deterministic run.

### Collecting `operator_acceptance`

At the finish handoff, ask the operator one plain question: does the migrated result look
right to them — `accepted`, `rework_needed`, or `rejected`? Record their answer in
`finalize`. If the run never reaches finish or they do not answer, it stays `unknown`.
This is the only field separating "completed and good" from "completed and unusable" —
ask it, but never pressure or interpret; their verdict as given, coarse by design.

## Event taxonomy — when to record what

Events are folded per problem class by the recorder (same type + stage + entity type +
API surfaces + app + error code + subtype fold into one event with a `count`), so record
every occurrence class once and pass `count` when you observed many at once — e.g. a
generated script reporting 4,000 identical write failures is **one** `record` call with
`"count": 4000`. Distinct problems are distinct subtypes or error codes, not bigger
counts.

Every event needs: `event_type`, `stage` (defaults to the open stage), `skill` (the
active `rp-*` resource, e.g. `"rp-mapper"`), `severity` (`blocking` | `degraded` |
`cosmetic` | `info`), and `what_happened`. Add `entity_type`, `wix_api_surface` (e.g.
`stores/v3`), `source_api_surface` (endpoint class like `wp/v2/posts` — never a URL),
`wix_app_id`, and `error_code` whenever they apply — they are the cross-run group-by keys.

1. **`halt_needs_user`** — the run stopped at a defined needs-user state. Prefer the
   mechanical form — `wait start --halt <subtype> …` emits it for you (lifecycle step 3);
   record it yourself only for a halt with no wait interval. `subtype`: `missing_input`
   (missing/invalid required input or credential) | `manual_only` (genuinely manual step,
   no API) | `systemic_failure` (systemic failure or data-loss risk).

2. **`manual_action_required`** — the execution plan flagged something as "can't be done,
   needs manual action" (e.g. a storage-plan upgrade), whether or not the run halted on
   it. `subtype`: `plan_or_billing` | `dashboard_only` | `external_dependency` | `other`.

3. **`error`** — an API or script error, including recovered ones. Carry `error_code`
   (required — it is the discriminator; no subtype), `retry_count`, and `recovered`.
   Record **one event per resolved retry chain**, after the outcome is known — the final
   `recovered`, the total `retry_count` — never one call per attempt (per-attempt calls
   fold into one class, and the failing first attempt's fields would bury the recovery).
   When recovery required a *change* (not just a retry), `actual` must record **what
   change made it succeed**, as an observation: "retried with the description as plain
   text instead of HTML; the write succeeded" — never "the fix is X". If that change
   deviated from the documented path — including a change to a *planned command or
   value*, like simplifying an input the plan specified — also emit a paired
   `skill_coverage_gap` (`undocumented_workaround`).

4. **`fidelity_loss`** — the migration technically proceeded but lost something.
   `subtype`: `dropped_field` | `unverified_enum` | `no_target` (source entity with no
   clean Wix target) | `coerced_value`. For a loss triggered by specific records, inline
   the offending record's **sanitized shape** in `observed_shapes` — field names and
   types and which field was dropped/coerced, never values.

5. **`api_gap`** — a missing or insufficient Wix API capability. `subtype`:
   `missing_api` | `missing_capability` (API exists but cannot express the operation) |
   `internal_only` (capability exists but is not publicly exposed) | `other`. Carry
   `wix_api_surface` (required) and `error_code` — that signature is matched centrally
   across runs. **Never write to any backlog file; the event is the whole per-run
   obligation.**

6. **`skill_coverage_gap`** — **the standing self-report, and the highest-value signal
   for improving the skills.** Whenever you act beyond what the active skill explicitly
   told you to do — you guess a value, work around a missing instruction, resolve an
   ambiguity by judgment, or handle a case the skill does not cover — record it **at that
   moment**. `subtype`: `guessed_value` | `undocumented_workaround` |
   `ambiguous_instruction` | `path_not_covered`. Describe the situation and the action
   taken as an observation: "skill did not specify which enum value maps to X, so a value
   was chosen to proceed" — never "the skill should add Y". If you are unsure whether
   something counts as improvisation, it does — record it.

7. **`user_decision`** — the user's answer at every defined checkpoint or fork: the
   mapping-review checkpoint, the approval gate, and intake forks (comments
   anonymization, delivery mode, media reachability, …). `subtype`: `accepted` |
   `declined` | `deferred` | `amended`; `decision_point` (required) names the fork, e.g.
   `mapping_review`, `approval_gate`, `comments_anonymization`. Record the decision, not
   the user's reasoning verbatim. An `amended` acceptance whose change exposed a mapping
   problem additionally surfaces that problem as `fidelity_loss` or
   `skill_coverage_gap`. A `declined` at a terminal checkpoint pairs with
   `terminal_state: abandoned_by_user`.

8. **`pipeline_defect`** — our own migration machinery misbehaved: not a Wix API error,
   not a source failure, not your improvisation. `subtype`: `state_inconsistency` (two
   pipeline state artifacts disagree) | `ordering_violation` (a step ran before its
   prerequisite's output existed) | `record_defect` (a pipeline record/log is malformed
   or misleading as evidence) | `other`. Name the artifacts and their states in
   `what_happened` — names and states, never contents.

9. **`source_plugin_coverage`** — which of the source site's plugins we could **not** resolve,
   plus one run-level summary. Record after discovery writes
   `discovery/plugin-coverage.json`.

   **Record one event per unresolved item only.** The unresolved set is: every `pending` row
   (including `reason: cannot-tell` and unread candidate namespaces), every
   `requires-development` row, and every row carrying `blocked[]` entries (a
   `user-file` / `credential` / `surface-changed` blocker, declined or not). **These are the
   whole point of this event type** — they are the only signal that says which plugin profile
   to write next, and without them that ordering is guesswork.

   **Do not emit a per-row event for anything already resolved**: `migration-planned` rows
   with `confidence: confirmed`, or `no-need-to-migrate` rows with `basis: list` or
   `basis: decision` (a human already signed that verdict). On a
   55-plugin store that would be ~29 events restating a checked-in list — telemetry with no
   information content, which makes the real backlog harder to see, not easier. (A
   `migration-planned` or `no-need-to-migrate` row that automation *derived* —
   `confidence: proposed` / `basis: proposed` — is reported through `skill_coverage_gap`
   (`guessed_value`), not here.)

   **Emit exactly one aggregate event per run** carrying the distribution: plugins installed,
   counts by the four statuses (and by `via` / `confidence` for planned rows), blocked counts
   by kind, and whether the plugin list was available. That preserves the cross-run view of
   how coverage is trending without one event per plugin.

   Per-event fields: `subtype` is the row's status (`pending` | `requires_development`) or
   `blocked` for a blocker entry (then also carry the blocker `kind`); carry the plugin slug
   and version, the capability, the read channel, whether the row was `recognized`, and
   `source_api_surface` as the endpoint class. Slug and version are required — without them
   a cross-run view cannot tell which plugin caused what. Plugin slugs and versions are
   software identifiers, not client data; still never record site URLs or record contents.

   A `requires-development` row additionally pairs with `api_gap`, and a lossy `via: cms` row
   with `fidelity_loss` — this event reports *distribution and backlog*, never the gap itself.

### `evidence_refs` — rare, not routine

Author every event to be **self-sufficient**: coded fields + bounded free text +
`observed_shapes` should let a reviewer triage it without opening any artifact. Add
`evidence_refs` (`{"artifact":"execution-log.md","locator":"## Import"}`) only for the
exceptional event whose signal genuinely cannot carry the full observation. Use
project-root-relative paths and prefer section headings as locators (line ranges break
when files regenerate). Never reference secret-bearing config files.

## What never to do

- Never hand-write, edit, or re-read `run-telemetry.json` or `telemetry/` files — the
  recorder appends; you only call it.
- Never record client data values, names, URLs beyond the recorded source origin, or
  secrets — in any field, including shapes and locators.
- Never record a fix, root cause, or recommendation — observations only.
- Never estimate durations — timing comes from `stage`/`wait` boundary calls.
- Never skip a rejected call: fix the listed fields and retry. Rejections are counted
  against telemetry health either way.
- Never create or update improvement/feature-request backlog files during a run — those
  are maintained centrally from many runs' telemetry, not per run.
- Never delete or trim project artifacts for telemetry reasons — capture is a
  non-destructive view; size is not a reason to drop anything.

