diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a23ab9e7a..e10ef3fdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,6 +329,45 @@ jobs: - name: Test run: npm test --prefix js/packages/truapi-host + ts-debugger: + name: "@parity/truapi-debugger" + runs-on: ubuntu-latest + needs: codegen + env: + TRUAPI_REQUIRE_GENERATED: 1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Download codegen output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: codegen-output + + - name: Install + run: npm ci --ignore-scripts + + - name: Build @parity/truapi (workspace dependency) + run: npm run build --prefix js/packages/truapi + + - name: Build + run: npm run build --prefix js/packages/truapi-debugger + + - name: Typecheck tests + run: npm run typecheck:tests --prefix js/packages/truapi-debugger + + - name: Test + run: npm test --prefix js/packages/truapi-debugger + playground: name: Playground (build + lint + unit) runs-on: ubuntu-latest @@ -491,6 +530,7 @@ jobs: ios-swift, ts-client, ts-host, + ts-debugger, playground, explorer, e2e, @@ -510,6 +550,7 @@ jobs: "${{ needs.ios-swift.result }}" "${{ needs.ts-client.result }}" "${{ needs.ts-host.result }}" + "${{ needs.ts-debugger.result }}" "${{ needs.playground.result }}" "${{ needs.explorer.result }}" "${{ needs.e2e.result }}" diff --git a/Cargo.lock b/Cargo.lock index df1684406..ff9b6c1d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5199,6 +5199,7 @@ name = "truapi-server" version = "0.1.0" dependencies = [ "async-trait", + "base64", "blake2b_simd", "chacha20poly1305", "console_error_panic_hook", diff --git a/js/packages/truapi-debugger/.gitignore b/js/packages/truapi-debugger/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/js/packages/truapi-debugger/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/js/packages/truapi-debugger/README.md b/js/packages/truapi-debugger/README.md new file mode 100644 index 000000000..8ad2b780f --- /dev/null +++ b/js/packages/truapi-debugger/README.md @@ -0,0 +1,153 @@ +# @parity/truapi-debugger + +The debugger-side consumer for TrUAPI wire frames. **Private, in-repo, not published.** + +The host taps every product↔host wire frame in its Rust core (`truapi-server`'s +`DebugSink`) and streams each one outward as a `{ channelId, dir, frame: bytes }` +envelope. This package is the other end: it owns **all** decoding — the wire +envelope (`requestId` and frame id, via `decodeWireMessage`), the grouping into +per-operation traces, and the per-frame payload decode. The host core treats +frames as opaque bytes and never decodes. + +This keeps `@parity/truapi` (the product package) genuinely untouched: the tap is +in the Rust host, and the debugger's decode/trace logic lives here instead of in +the product transport. + +> **Scope note.** This package holds both the debugger *library* (the trace, +> envelope-decode, and value-decode engines plus the ingest that turns a wire +> envelope into a decoded frame) and its two *mounts* — the standalone app +> (`server.ts`) and the in-app embed (`in-app.ts`). It lives in-repo because the +> debugger is coupled to the protocol this repo owns: it decodes wire frames with +> `@parity/truapi`, tracking the generated wire surface. *Where the app +> ultimately lives* (stays a truapi tool / own repo / a desktop app) is an open +> decision for the host-protocol owner; in-repo is the low-regret default and +> moving it later is cheap. + +## What's here + +- **`createDebugSession()`** — the trace engine wired to the ingest. Feed it + envelopes with `handleEnvelope(...)`; read grouped traces from `traceEngine`, + per-frame values from `frameDetail(...)` / `decodedFrames(...)`. +- **`createDebugIngest(sink)`** — decodes a `DebugFrameEnvelope` into an + `ObservedFrame` and forwards it. The layer that turns raw wire bytes into + something the trace engine can group. +- **`createWireDebugger(...)`** — accumulates observed frames into per-`requestId` + traces (correlates with product-sdk telemetry spans on the same id). +- **`createFrameDecoder(...)`** — the level-2 value decoder (see below): a + per-frame decode of a payload to a plain JS value, reusing `@parity/truapi`'s + generated `WIRE_DECODE_TABLE`. Every frame it can decode, it does, with no + sensitive special-casing. The bare factory takes `enabled: true` to opt in; a + session turns it on for you. +- **`buildTraceView` / `wireTraceToView`, `renderOperationRow`, + `renderTraceDetail`, `renderFrameValueDetail`** — the one view model and the one + set of renderers both mounts share, so the two cannot drift apart. +- **`startDebugServer(...)`** (`server.ts`) — the standalone mount, below. +- **`createInAppDebugger(...)`** (`in-app.ts`) — the in-app mount, below. + +## The two mounts + +Both render the same view model with the same renderers and the same stylesheet. +They differ in where the debugger sits relative to the host: + +```text +standalone: host process ──ws://127.0.0.1:9231──▶ debugger server ──HTTP──▶ browser + (host dials out; frames leave the app; one server, many channels) + +in-app: host in the page ──handleFrame()──▶ InAppDebugger.mount(el) + (same page as the host; no server, no dial; frames never leave the app) +``` + +- **Standalone** (`startDebugServer`): a Bun WS+HTTP server bound to + `127.0.0.1` only. Hosts dial *in* and send one text message per frame, + `{ channelId, dir, frame }` with `frame` base64-encoded, plus the wire-identity + fields a versioned host stamps (`v`, `codec`, `schema`) and an optional + `dropped` count. The browser view is a thin client over server-rendered + fragments. +- **In-app** (`createInAppDebugger`): the second mount, for a host that runs in + the page. It takes the same raw SCALE frame bytes with the same + product-vantage `dir`, holds the session in-process, and renders the fragments + directly with no polling. Browser-only (uses `document`); each browser tab is + its own tenant, so there is nothing to host or scope. + +## Value decode (level 2 — on by default) + +This is a **dev-only tool that decodes everything**. The list views stay +payload-blind — they group frames and sum byte lengths, never their contents — +and the drill-down decodes a frame's payload to a plain JS value, for every +frame, with no "sensitive" special-casing. Its contract: + +- **On by default.** The standalone server decodes unless + `TRUAPI_DEBUGGER_DECODE_VALUES` is set to a falsy value + (`0`/`false`/`no`/`off`), or `startDebugServer({ decodeValues: false })` / + `createInAppDebugger({ decodeValues: false })` in code — useful for a demo. + With decode off, every frame reports byte length only and no bytes are even + retained. +- **Reuses the generated table.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` + from `@parity/truapi/wire-decode` — the same dev-only codecs the client uses. + The debugger writes none of its own. +- **No redaction, no reveal toggle.** Every frame the table can decode is + decoded, including signing, login, and payment. A developer inspecting their + own session's traffic sees the real values; there is no denylist, no reveal + escape hatch, and no `redacted` state. A frame the codec cannot type still + shows its raw payload as `B · 0x…` hex — a dev-only tool hides nothing it + has the bytes for. Only a frame with no retained bytes (decode off) reads + `payload not shown`. +- **Refused on contract drift.** Decode is allowed only for a channel whose + declared `schema` fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`) and `codec` match + this debugger's; a mismatched or absent identity is refused (`/frame` answers + 409) and banners in the view. Payload-blind grouping is unaffected. +- **Never over the wire, never in the list endpoints.** The host emits opaque + bytes only; nothing about decode changes what it sends. Decode happens in the + debugger, in the drill-down paths only. + +## Standalone endpoints + +| Endpoint | Serves | +| ------------------------------------- | --------------------------------------------------------- | +| `GET /` | The inspector page: polls the fragments below. | +| `GET /op-list?channel=&sort=` | One server-rendered row per op. `sort` is `recent`, `duration`, `frames`, or `method`; absent keeps arrival order. Payload-blind. | +| `GET /op?id=&channel=&gen=` | The selected op's drill-down, each frame's value inline. | +| `GET /view` | The drill-down as a standalone fragment, values inline. | +| `GET /channels` | Connected hosts/channels, liveness, codec-mismatch flag. | +| `GET /stats?channel=` | Aggregate roll-up: counts, bytes, durations, health, busiest methods. Payload-blind. | +| `GET /traces` | The grouped traces as JSON. Payload-blind — never serializes bytes or values. | +| `GET /frame?id=&i=&channel=` | One frame's decode as JSON (the programmatic drill-down). | + +Loopback is enforced on more than the bind: a request whose `Host` header is not +a loopback name gets a 403 (DNS-rebinding guard), and a WebSocket upgrade from a +foreign browser `Origin` is refused (CSWSH). + +## Run + +```bash +npm install # links @parity/truapi via the workspace +npm run build # tsc -b +npm run serve # bun run src/server.ts — listens on 127.0.0.1:9231, decodes by default + +# a different port, or decode off for a demo +TRUAPI_DEBUGGER_PORT=9300 npm run serve +TRUAPI_DEBUGGER_DECODE_VALUES=0 npm run serve +``` + +Point a host's debugger URL at `ws://127.0.0.1:9231` (the host dials out) and +open `http://127.0.0.1:9231/`; click an op for its drill-down detail. + +Use the literal `127.0.0.1`, not `localhost`. Both dial gates accept a `ws://` +URL on a loopback host **only** — `wss://`, certificates, and any non-loopback +target are rejected — and `localhost` passes that check but resolves `::1` first +on macOS, while the server binds `127.0.0.1` alone. A native host then dials an +address nothing is listening on and logs nothing. + +For the in-app mount, feed frames straight to the session: + +```ts +import { createInAppDebugger } from "@parity/truapi-debugger"; + +const inspector = createInAppDebugger(); +const dispose = inspector.mount(document.getElementById("wire-panel")!); +// from the host's tap, per frame: +inspector.handleFrame(channelId, "out", frameBytes); +``` + +The exact host↔debugger framing is provisional (envelope spec, track T3); +base64-in-JSON is what the server accepts today. diff --git a/js/packages/truapi-debugger/package.json b/js/packages/truapi-debugger/package.json new file mode 100644 index 000000000..bf7cf3aea --- /dev/null +++ b/js/packages/truapi-debugger/package.json @@ -0,0 +1,37 @@ +{ + "name": "@parity/truapi-debugger", + "version": "0.1.0", + "description": "In-repo debugger consumer for TrUAPI wire frames: decodes and groups the frames the truapi-server host tap streams out", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "author": "Parity Technologies ", + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b", + "typecheck:tests": "tsc -p tsconfig.test.json", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "happy-dom": "^20.11.2", + "typescript": "^6.0" + }, + "dependencies": { + "@parity/truapi": "^0.10.0" + } +} diff --git a/js/packages/truapi-debugger/src/decode.test.ts b/js/packages/truapi-debugger/src/decode.test.ts new file mode 100644 index 000000000..e8eb9ffc7 --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; + +import * as W from "@parity/truapi/wire-table"; +import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; + +import { createFrameDecoder, type FrameValueDetail } from "./decode.js"; +import type { ObservedFrame } from "./observed-frame.js"; + +/** A minimal observed frame for a given id/bytes; the fields decode ignores are stubbed. */ +function frame(frameId: number, bytes?: Uint8Array): ObservedFrame { + return { + channelId: "myapp.dot", + direction: "out", + requestId: "p:1", + frameId, + role: "unknown", + byteLength: bytes?.length ?? 0, + timestamp: 0, + // These tests exercise the DECODER. Decode is gated on the frame's producer + // having vouched for the wire contract, so an attested frame is the fixture; + // `unattested()` below covers the refusal. + // + // NOTE this INVERTS the production default: on the wire the field is absent + // and absent means untrusted. A new test reaching for `frame()` silently opts + // into trust, so anything asserting a refusal must start from `unattested()`. + identityConfirmed: true, + ...(bytes ? { bytes } : {}), + }; +} + +/** The same frame with no identity: its producer never vouched for the contract. */ +function unattested(frameId: number, bytes?: Uint8Array): ObservedFrame { + const f = frame(frameId, bytes); + delete f.identityConfirmed; + return f; +} + +describe("frame decoder (real table) — decodes everything, no special-casing", () => { + test("a non-sensitive frame decodes only with the toggle on", () => { + // `connection-status.subscribe` start payload is `V1(void)` = a single 0x00 + // index byte: a real frame the generated table can decode. + const id = W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start; + const bytes = new Uint8Array([0]); + + const off = createFrameDecoder({ enabled: false }); + const offDetail = off.detail(frame(id, bytes)); + expect(offDetail.kind).toBe("bytes"); + if (offDetail.kind === "bytes") expect(offDetail.byteLength).toBe(1); + + const on = createFrameDecoder({ enabled: true }); + const onDetail = on.detail(frame(id, bytes)); + expect(onDetail.kind).toBe("decoded"); + // Sanity: the id really is in the generated decode table. + expect(typeof WIRE_DECODE_TABLE[id]).toBe("function"); + }); + + test("a formerly-'sensitive' signing frame decodes too (dev-only tool)", () => { + // No denylist any more: a signing request decodes like every other frame. + const decoder = createFrameDecoder({ enabled: true }); + const detail = decoder.detail( + frame(W.SIGNING_SIGN_RAW.request, new Uint8Array([0])), + ); + // It either decodes (id has a codec + valid bytes) or, on a codec throw for + // the stub bytes, falls back to bytes — never a "redacted" state. + expect(["decoded", "bytes"]).toContain(detail.kind); + // Whatever the outcome, the kind is never the old "redacted" variant. + expect(detail.kind).not.toBe("redacted"); + }); + + test("disabled decoder is bytes-only for every frame", () => { + const decoder = createFrameDecoder({ enabled: false }); + for (const id of [ + W.ACCOUNT_GET_ACCOUNT.request, + W.SIGNING_SIGN_RAW.request, + W.CHAIN_CALL_HEAD.request, + ]) { + expect(decoder.detail(frame(id, new Uint8Array([9]))).kind).toBe("bytes"); + } + }); +}); + +describe("frame decoder (injected table)", () => { + const table = { 999: (b: Uint8Array) => ({ ok: Array.from(b) }) }; + + test("decodes an id when enabled and bytes present", () => { + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); + const detail = decoder.detail(frame(999, new Uint8Array([1, 2]))); + expect(detail).toEqual({ + kind: "decoded", + value: { ok: [1, 2] }, + } satisfies FrameValueDetail); + }); + + test("decodes a secret-named field too — no content guard withholds it", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ source: { sr25519SecretKey: "0xdead" } }) }, + }); + const detail = decoder.detail(frame(999, new Uint8Array([1]))); + expect(detail.kind).toBe("decoded"); + if (detail.kind === "decoded") { + expect(detail.value).toEqual({ source: { sr25519SecretKey: "0xdead" } }); + } + }); + + test("falls back to bytes when the frame retained no bytes", () => { + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); + expect(decoder.detail(frame(999)).kind).toBe("bytes"); + }); + + test("falls back to bytes when the codec throws", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => { + throw new Error("bad payload"); + }, + }, + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe("bytes"); + }); + + test("falls back to bytes when the id has no codec", () => { + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); + expect(decoder.detail(frame(1, new Uint8Array([1]))).kind).toBe("bytes"); + }); +}); + +test("an unattested frame never decodes, whatever its channel did", () => { + // The gate is per FRAME, not per channel. As a per-channel latch, one attested + // frame retroactively unlocked every unattested frame already retained under + // the same `channelId` - and `channelId` is the productId, shared by a stale + // host and a fresh one, and by every frame an embedding host tees before it + // learns its core's schema hash. + const decoder = createFrameDecoder({ enabled: true }); + const id = W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start; + const bytes = new Uint8Array([0]); + + // Same id, same bytes. The only difference is who vouched for the contract. + const attested = decoder.detail(frame(id, bytes)); + const refused = decoder.detail(unattested(id, bytes)); + + expect(attested.kind).toBe("decoded"); + expect(refused.kind).toBe("bytes"); + if (refused.kind === "bytes") expect(refused.hex).toBe("0x00"); +}); diff --git a/js/packages/truapi-debugger/src/decode.ts b/js/packages/truapi-debugger/src/decode.ts new file mode 100644 index 000000000..244c4789c --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.ts @@ -0,0 +1,108 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Level-2 decode: turn a frame's raw SCALE payload into a plain JS value, in the + * drill-down detail path. + * + * This is the one place the debugger looks *inside* a frame. Everything else - + * the trace engine, `/traces`, the host tap - is payload-blind and stays that + * way. The rules that make that work live here: + * + * - **Dev-only tool: decode everything.** This debugger decodes every frame it + * can, with no "sensitive" special-casing. A developer inspecting their own + * session's traffic sees the real values. When decoding is disabled every + * frame reports its byte length only. + * - **Reuse, don't reinvent.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` + * from `@parity/truapi/wire-decode` - the same generated, dev-only codecs the + * client uses. The debugger writes no codecs of its own. + * + * Nothing here is ever serialized into `/traces`; the detail it produces is + * returned only from the explicit per-frame drill-down. + * + * @module + */ + +import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; +import type { ObservedFrame } from "./observed-frame.js"; + +/** + * Per-frame decode result for the drill-down detail path. + * + * `"decoded"` carries the plain JS value, returned whenever the decoder is on + * and the frame's id has a codec that decodes its retained bytes. `"bytes"` is + * the fallback: the decoder is off, the frame carries no retained bytes, its id + * has no codec, or decoding threw. When the decoder is on and the bytes are + * retained, that fallback still carries the raw `hex` so a dev-only tool always + * shows *something* for a payload it could not type; `hex` is absent only in + * payload-blind mode (decoder off) or when no bytes were retained. + */ +export type FrameValueDetail = + | { kind: "decoded"; value: unknown } + | { kind: "bytes"; byteLength: number; hex?: string }; + +/** Options for {@link createFrameDecoder}. */ +export interface FrameDecoderOptions { + /** + * Master gate. `false` (the default) means the decoder never inspects a + * payload: every frame reports bytes only. + */ + enabled?: boolean; + /** + * Frame-id → decoder map. Defaults to the generated + * {@link WIRE_DECODE_TABLE}; overridable for tests. + */ + decodeTable?: Record unknown>; +} + +/** A gated per-frame value decoder for the drill-down detail path. */ +export interface FrameDecoder { + /** Whether decoding is on. `false` ⇒ every `detail` is bytes-only. */ + readonly enabled: boolean; + /** Resolve one frame to its {@link FrameValueDetail}. */ + detail(frame: ObservedFrame): FrameValueDetail; +} + +/** + * Build a {@link FrameDecoder}. Off by default: pass `enabled: true` to opt in. + * When on, every frame with a codec and retained bytes decodes to its value. + */ +export function createFrameDecoder( + options: FrameDecoderOptions = {}, +): FrameDecoder { + const enabled = options.enabled ?? false; + const decodeTable = options.decodeTable ?? WIRE_DECODE_TABLE; + + // Raw bytes as `0x…` hex so a payload the decoder can't type is still visible + // in the drill-down (a dev-only tool hides nothing it has the bytes for). + const toHex = (bytes: Uint8Array): string => + "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + + const detail = (frame: ObservedFrame): FrameValueDetail => { + if (!enabled) return { kind: "bytes", byteLength: frame.byteLength }; + // Decoder on: keep the raw hex on the bytes fallback so nothing reads + // "payload not shown" when the bytes are right there. + const bytesFallback = (): FrameValueDetail => ({ + kind: "bytes", + byteLength: frame.byteLength, + ...(frame.bytes ? { hex: toHex(frame.bytes) } : {}), + }); + // The frame's OWN producer must have vouched for the wire contract. This is + // ADDITIVE to the per-channel `decodeTrusted` gate both mounts still apply, + // not a replacement for it. Keying on the channel ALONE made it a latch: + // one attested frame unlocked every unattested frame already retained under + // that `channelId`, and a frame id means nothing without the table that + // assigned it. Unattested frames still group, list and show their hex. + if (frame.identityConfirmed !== true) return bytesFallback(); + const decode = decodeTable[frame.frameId]; + if (!decode || !frame.bytes) return bytesFallback(); + try { + return { kind: "decoded", value: decode(frame.bytes) }; + } catch { + // A malformed or version-skewed payload must not break the drill-down; + // fall back to the raw hex. + return bytesFallback(); + } + }; + + return { enabled, detail }; +} diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts new file mode 100644 index 000000000..812db4dfc --- /dev/null +++ b/js/packages/truapi-debugger/src/index.ts @@ -0,0 +1,55 @@ +export type { + FrameDirection, + FrameRole, + ObservedFrame, + TransportObserver, +} from "./observed-frame.js"; +export { createDebugIngest } from "./ingest.js"; +export type { DebugFrameEnvelope, DebugIngestOptions } from "./ingest.js"; +export { createDebugSession } from "./session.js"; +export type { DebugSession, DebugSessionOptions } from "./session.js"; +export { createFrameDecoder } from "./decode.js"; +export type { + FrameDecoder, + FrameDecoderOptions, + FrameValueDetail, +} from "./decode.js"; +export { createWireDebugger, createMethodNameMap } from "./wire-debugger.js"; +export type { + WireDebugger, + WireDebuggerOptions, + WireDebugSink, + WireFrameKind, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; +export { buildTraceView, wireTraceToView } from "./trace-view.js"; +export type { + TraceBadge, + TraceFrameBadge, + TraceFrameInput, + TraceFrameView, + TraceView, + TraceViewInput, +} from "./trace-view.js"; +export { + renderTraceDetail, + renderFrameValueDetail, + renderOperationRow, +} from "./trace-render.js"; +export type { RenderTraceDetailOptions } from "./trace-render.js"; +export { detectRetryStorms } from "./retry-storm.js"; +export type { RetryStormOptions } from "./retry-storm.js"; +export { TRACE_DETAIL_CSS } from "./trace-styles.js"; +export { + INSPECTOR_LAYOUT_CSS, + INSPECTOR_SHELL_CSS, +} from "./inspector-styles.js"; +export { + operationMethod, + isSubscription, + isLiveSubscription, +} from "./trace-view.js"; +export type { TraceDropCounts } from "./wire-debugger.js"; +export { computeTraceStats } from "./session.js"; +export type { TraceStats } from "./session.js"; diff --git a/js/packages/truapi-debugger/src/ingest.test.ts b/js/packages/truapi-debugger/src/ingest.test.ts new file mode 100644 index 000000000..38b229def --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeWireMessage } from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { createDebugIngest, DEFAULT_MAX_ID_CHARS, normalizeId } from "./ingest.js"; +import type { DebugFrameEnvelope } from "./ingest.js"; +import type { ObservedFrame } from "./observed-frame.js"; +import { detectRetryStorms } from "./retry-storm.js"; +import { createMethodNameMap, createWireDebugger } from "./wire-debugger.js"; + +/** The real generated table, keyed the way `createDebugSession` keys it. */ +const METHOD_NAMES = createMethodNameMap( + W as unknown as Record, + ["account", "signing", "chain", "chat", "resourceAllocation"], +); + +/** One host-tap envelope carrying `frameId` under correlation id `requestId`. */ +function envelope( + requestId: string, + frameId: number, + value = new Uint8Array([0]), + dir: "in" | "out" = "out", + channelId = "myapp.dot", +): DebugFrameEnvelope { + const encoded = encodeWireMessage({ requestId, payload: { id: frameId, value } }); + if (encoded.isErr()) throw encoded.error; + return { channelId, dir, frame: encoded.value }; +} + +/** + * One envelope as a host tap replays it out of its backlog: `buffered`, with the + * producer's own `observedAt` rather than the flush instant. + */ +function flushed( + observedAt: number | undefined, + requestId: string, + frameId: number, + dir: "in" | "out" = "out", +): DebugFrameEnvelope { + return { + ...envelope(requestId, frameId, new Uint8Array([0]), dir), + ...(observedAt === undefined ? {} : { observedAt }), + buffered: true, + }; +} + +/** Collect every frame an ingest emits. */ +function collect(options: Parameters[1] = {}) { + const seen: ObservedFrame[] = []; + return { seen, ingest: createDebugIngest((f) => seen.push(f), options) }; +} + +describe("ingest resolves role from the wire table", () => { + test("role is a pure function of frameId, across every leg of a method", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + + // A request/response pair and a subscription's start/receive legs. Each id + // carries its own role on the wire table; none of them needs correlation + // state, and they arrive here out of any lifecycle order on purpose. + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.response)); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + ingest(envelope("p:2", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.receive)); + ingest(envelope("p:2", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start)); + + expect(seen.map((f) => f.role)).toEqual([ + "response", + "request", + "receive", + "start", + ]); + }); + + test("an off-table id and a map-less ingest both fall back to unknown", () => { + const withMap = collect({ methodNames: METHOD_NAMES }); + // 250 is above every id the current table assigns. + withMap.ingest(envelope("p:1", 250)); + expect(withMap.seen[0]?.role).toBe("unknown"); + + const withoutMap = collect(); + withoutMap.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(withoutMap.seen[0]?.role).toBe("unknown"); + }); + + test("an undecodable frame is a malformed sentinel, not a drop", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest({ channelId: "myapp.dot", dir: "out", frame: new Uint8Array([0xff]) }); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ + role: "malformed", + requestId: "malformed", + frameId: -1, + byteLength: 1, + }); + }); +}); + +describe("every consumer sees the resolved role, not just the view adapter", () => { + test("the formatted sink line names the role, not 'unknown'", () => { + const lines: string[] = []; + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: (line) => lines.push(line), + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + // This is the line the default `console.debug` sink prints. It read + // "-> unknown account.getAccount" while role was resolved only downstream. + expect(lines[0]).toBe( + `[wire p:1] → request account.getAccount (id=${W.ACCOUNT_GET_ACCOUNT.request}, 1B)`, + ); + }); + + test("the forward hook receives the resolved role", () => { + const forwarded: ObservedFrame[] = []; + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + forward: (frame) => forwarded.push(frame), + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + expect(forwarded).toHaveLength(1); + expect(forwarded[0]?.role).toBe("request"); + }); +}); + +describe("ingest bounds ids and gates raw bytes", () => { + test("channelId and requestId over the bound are digested, not sliced", () => { + const long = "x".repeat(DEFAULT_MAX_ID_CHARS + 100); + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + + ingest( + envelope(long, W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([0]), "out", long), + ); + + // A slice of the id would be a prefix of it and would keep the whole 356-char + // parent string alive (JSC/V8 both back `slice` with a view of the parent, so + // a 250k-char id retains 250k chars while accounting for 256). The digest + // references nothing. + for (const id of [seen[0]?.channelId, seen[0]?.requestId]) { + expect(id).toBe(normalizeId(long)); + expect(long.startsWith(id ?? "")).toBe(false); + expect((id ?? "").length).toBeLessThan(40); + // The length the host actually sent stays visible to the operator. + expect(id).toContain(`:${String(long.length)}`); + } + }); + + test("two ids sharing the bound-length prefix stay two ops", () => { + // The consequence of truncating: these differ only past the cap, so they + // clamped to the same key, merged into one trace, and manufactured a + // roundTripMs between two unrelated ops (while clearing the `orphaned` badge + // each of them had earned). + const shared = "x".repeat(DEFAULT_MAX_ID_CHARS); + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope(`${shared}a`, W.ACCOUNT_GET_ACCOUNT.request)); + ingest(envelope(`${shared}b`, W.ACCOUNT_GET_ACCOUNT.request)); + + const traces = wireDebugger.traces(); + expect(traces).toHaveLength(2); + expect(new Set(traces.map((t) => t.requestId)).size).toBe(2); + }); + + test("ids within the bound are passed through untouched", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.requestId).toBe("p:1"); + expect(seen[0]?.channelId).toBe("myapp.dot"); + expect(normalizeId("x".repeat(DEFAULT_MAX_ID_CHARS))).toHaveLength( + DEFAULT_MAX_ID_CHARS, + ); + }); + + test("maxIdChars overrides the default bound", () => { + const { seen, ingest } = collect({ maxIdChars: 4 }); + ingest(envelope("p:1234567890", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.requestId).toBe(normalizeId("p:1234567890", 4)); + expect(seen[0]?.requestId).not.toBe("p:12"); + }); + + test("raw bytes are attached only under retainBytes", () => { + const off = collect({ methodNames: METHOD_NAMES }); + off.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([7]))); + expect(off.seen[0]?.bytes).toBeUndefined(); + // Byte length is recorded either way. + expect(off.seen[0]?.byteLength).toBe(1); + + const on = collect({ methodNames: METHOD_NAMES, retainBytes: true }); + on.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([7]))); + expect(Array.from(on.seen[0]?.bytes ?? [])).toEqual([7]); + }); + + test("the product-vantage direction is carried through untouched", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([0]), "out")); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.response, new Uint8Array([0]), "in")); + expect(seen.map((f) => f.direction)).toEqual(["out", "in"]); + }); +}); + +/** + * A host tap buffers a backlog while the debugger is absent and flushes it in one + * loop on connect. If the ingest clock is the only clock, that loop stamps every + * frame of the whole session with the same instant: durations collapse to 0ms and + * ops minutes apart fall inside the retry-storm window. These cover both halves + * against the real trace engine and the real storm detector. + */ +describe("a flushed backlog keeps the producer's clock, not the flush instant", () => { + /** Feed envelopes through a real ingest into a real trace engine. */ + function traceEngine() { + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + }); + return { + traces: () => wireDebugger.traces(), + ingest: createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }), + }; + } + + test("a 500ms round trip stays 500ms after the flush", () => { + const engine = traceEngine(); + // One op whose two frames genuinely crossed 500ms apart, both replayed out of + // the backlog in the same loop long afterwards. + engine.ingest(flushed(1_000_000, "p:1", W.ACCOUNT_GET_ACCOUNT.request, "out")); + engine.ingest(flushed(1_000_500, "p:1", W.ACCOUNT_GET_ACCOUNT.response, "in")); + + const [trace] = engine.traces(); + expect(trace?.lastAt - trace?.startedAt).toBe(500); + expect(trace?.frames.map((f) => f.timestamp)).toEqual([1_000_000, 1_000_500]); + // The frames say where their clock came from, and that they were replayed. + expect(trace?.frames.every((f) => f.timestampFromProducer === true)).toBe(true); + expect(trace?.frames.every((f) => f.buffered === true)).toBe(true); + }); + + test("six ops ten seconds apart are not a retry storm", () => { + const engine = traceEngine(); + // Six `account.getAccount` calls, one every 10s: a calm session by any + // reading. Flushed together, an ingest-stamped clock puts all six inside the + // detector's 1000ms window and badges every row "retry storm". + for (let i = 0; i < 6; i++) { + engine.ingest( + flushed(1_000_000 + i * 10_000, `p:${String(i)}`, W.ACCOUNT_GET_ACCOUNT.request), + ); + } + + const traces = engine.traces(); + expect(traces).toHaveLength(6); + expect(detectRetryStorms(traces).size).toBe(0); + }); + + test("a genuine burst is still detected through a flush", () => { + const engine = traceEngine(); + // The same six ops 100ms apart really are a storm: preserving the producer's + // clock must not blunt the signal, only stop fabricating it. + for (let i = 0; i < 6; i++) { + engine.ingest( + flushed(1_000_000 + i * 100, `p:${String(i)}`, W.ACCOUNT_GET_ACCOUNT.request), + ); + } + + expect(detectRetryStorms(engine.traces()).size).toBe(6); + }); + + test("a tap that stamps no time falls back to the ingest clock and marks the frame", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + const before = Date.now(); + ingest(flushed(undefined, "p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + // Nothing better exists for such a frame, so `timestamp` is the flush instant + // - but it is flagged `buffered` with no `timestampFromProducer`, which is the + // pair a consumer keys on to suppress its duration and its storm + // participation. + expect(seen[0]?.timestamp).toBeGreaterThanOrEqual(before); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + expect(seen[0]?.buffered).toBe(true); + }); + + test("a live frame is neither buffered nor producer-stamped", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.buffered).toBeUndefined(); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + }); + + test("an unusable observedAt is refused, not trusted into the trace list", () => { + // Anything reaching the tap can put anything here, and it feeds ordering and + // every duration. + for (const observedAt of [0, -1, Number.NaN, Infinity, -Infinity]) { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + const before = Date.now(); + ingest({ + ...envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request), + observedAt, + }); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + expect(seen[0]?.timestamp).toBeGreaterThanOrEqual(before); + } + }); + + test("a malformed frame carries the same provenance as a decodable one", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest({ + channelId: "myapp.dot", + dir: "out", + frame: new Uint8Array([0xff]), + observedAt: 1_000_000, + buffered: true, + }); + expect(seen[0]).toMatchObject({ + role: "malformed", + timestamp: 1_000_000, + timestampFromProducer: true, + buffered: true, + }); + }); +}); diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts new file mode 100644 index 000000000..74bb7b9c5 --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -0,0 +1,289 @@ +/** + * Ingest: turn the host tap's wire envelopes into {@link ObservedFrame}s. + * + * The Rust host tap (`truapi-server`'s `DebugSink`) emits one envelope per + * frame - `{ channelId, dir, frame: bytes }`, raw SCALE, opaque to the core. + * The debugger decodes here: {@link decodeWireMessage} recovers the correlation + * `requestId` and the wire discriminant, which is everything the trace engine + * needs to group an op. This is the layer PG's design puts "in the debugger, not + * the core". + * + * @module + */ + +import { decodeWireMessage } from "@parity/truapi"; +import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; +import type { WireMethodInfo } from "./wire-debugger.js"; + +/** + * Version of the host→debugger wire envelope (`{ channelId, dir, frame }`). + * Bumped when the envelope shape changes. Producers (the Rust `WsDebugSink`, the + * web host's debugger link) stamp it alongside a codec identity so the debugger + * can refuse to decode a frame against a wire contract that isn't its own - + * frame ids are `u8` discriminants that get reassigned as the API evolves, so an + * unversioned envelope from an older host would resolve to the wrong method and + * the wrong value. + */ +export const WIRE_ENVELOPE_VERSION = 1; + +/** + * Default cap on `channelId` / `requestId` length, above which the id is + * replaced by a digest ({@link normalizeId}). Shared so the debugger server's + * channel registry normalizes to the same bound as ingest and the two keys stay + * equal (the UI filters by the normalized key). + */ +export const DEFAULT_MAX_ID_CHARS = 256; + +/** + * FNV-1a over `text`'s UTF-16 code units, in 32 bits. Not cryptographic: this + * only has to keep two *distinct* ids distinct, which a shared prefix does not. + */ +function fnv1a32(text: string, seed: number): number { + let hash = seed >>> 0; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +/** Two independently-seeded FNV-1a passes, as 16 hex chars. */ +function digest(text: string): string { + const lo = fnv1a32(text, 0x811c9dc5).toString(16).padStart(8, "0"); + const hi = fnv1a32(text, 0x9dc5811c).toString(16).padStart(8, "0"); + return `${lo}${hi}`; +} + +/** + * Bound an id's retained length: returned unchanged when it is within + * `maxChars`, otherwise replaced by `…:`. + * + * A digest, not a truncation, for two reasons. + * + * - Retention. `String.prototype.slice` yields a view that keeps its *parent* + * alive in both JSC and V8, so truncating a 250k-char id retains the whole + * 250k chars while accounting for 256 - and `retainBytes: false` is no + * mitigation, because the ids are retained on every {@link ObservedFrame} + * regardless. The digest is computed arithmetically, so nothing references the + * input. + * - Identity. Two distinct ids sharing a `maxChars` prefix truncate to the same + * key and merge into one trace, fabricating a `roundTripMs` between two + * unrelated ops and clearing a genuinely `orphaned` badge. Distinct ids digest + * to distinct keys. + * + * Rejecting the frame instead would take the op dark, which is the opposite of + * the ingest's own rule for input it cannot use (an undecodable frame becomes a + * `"malformed"` sentinel, never a drop), and it would discard legitimate frames + * from any host whose ids are merely long. The digest keeps the op observable + * and correlatable while bounding what is retained. + * + * The length suffix is diagnostic: it says how long the id the host sent + * actually was, which is the fact an operator needs to see. + */ +export function normalizeId( + id: string, + maxChars: number = DEFAULT_MAX_ID_CHARS, +): string { + if (id.length <= maxChars) return id; + return `…${digest(id)}:${String(id.length)}`; +} + +/** + * One wire frame as it crosses the host tap, matching the Rust + * `DebugEvent::Frame { channel_id, dir, bytes }`. `frame` is the untouched + * `ProtocolMessage` bytes; the debugger owns all decoding. + */ +export interface DebugFrameEnvelope { + /** Product channel the frame belongs to, e.g. `"myapp.dot"`. */ + channelId: string; + /** + * Product-vantage: `out` left the product, `in` arrived at it. The Rust host + * tap names directions host-vantage internally and flips to this convention + * on the wire (`FrameDirection::wire_str`), so both ends agree here. + */ + dir: "in" | "out"; + /** Raw SCALE `ProtocolMessage` bytes. */ + frame: Uint8Array; + /** + * Whether this envelope's producer affirmatively vouched for the debugger's wire + * contract. Set by the mount that parsed the identity fields; carried onto every + * frame so decode is gated per frame rather than per channel. + */ + identityConfirmed?: boolean; + /** + * Epoch ms at which the *producer* saw the frame cross the tap, stamped by the + * host link at emit time. + * + * The debugger's own clock cannot stand in for this. A host tap buffers a + * backlog while the debugger is absent and flushes it in one loop on connect, + * so every frame of a session that ran before the debugger started would be + * stamped with the same flush instant: durations collapse to 0ms and ops + * minutes apart land inside the retry-storm window. The producer is the only + * party that knows when a frame actually crossed. + * + * Optional because a host may not stamp it (a pre-identity or foreign tap); + * such frames fall back to the ingest clock and are marked as such - see + * {@link ObservedFrame.timestampFromProducer}. + */ + observedAt?: number; + /** + * The producer replayed this frame from its backlog rather than streaming it + * live, so its arrival order and arrival time are the link's, not the + * session's. Piggybacked on the envelope the same way `dropped` is. + */ + buffered?: boolean; +} + +// Both fields below are produced *only* here, and `ObservedFrame` is the contract +// every consumer reads, so they are declared onto it rather than pushing every +// consumer through an ingest-specific subtype. Fold them into +// `observed-frame.ts` proper when that file is next touched. +declare module "./observed-frame.js" { + interface ObservedFrame { + /** + * The producer replayed this frame from its backlog (the debugger was absent + * or slow) instead of streaming it live. Present only when true. + * + * Provenance, not a verdict on `timestamp`: a buffered frame that also + * carries {@link ObservedFrame.timestampFromProducer} has a real observation + * time and its timings are sound. A buffered frame *without* it has only the + * flush instant, and every duration derived from it - `roundTripMs`, the + * retry-storm window - is meaningless. + */ + buffered?: true; + /** + * `timestamp` is the producer's own observation time rather than the moment + * ingest decoded the frame. Present only when true. + */ + timestampFromProducer?: true; + } +} + +/** + * An `observedAt` fit to be used as a timestamp, or `undefined`. + * + * Anything able to reach the tap can put anything in this field, and it feeds + * trace ordering and every duration, so a non-finite or non-positive value falls + * back to the ingest clock rather than poisoning the trace list. + */ +function producerTimestamp(observedAt: number | undefined): number | undefined { + if (typeof observedAt !== "number") return undefined; + // `isSafeInteger`, not merely finite: `1e308` is a finite positive number and + // was accepted as an epoch-ms timestamp, which made `durationMs` overflow to + // `Infinity` and serialize as JSON `null` on /stats - a hole in the payload a + // client parses back. An epoch-ms value is a safe integer by construction. + if (!Number.isSafeInteger(observedAt) || observedAt <= 0) return undefined; + return observedAt; +} + +/** Options for {@link createDebugIngest}. */ +export interface DebugIngestOptions { + /** + * Retain each frame's raw SCALE bytes on the {@link ObservedFrame}. Off by + * default: byte length is always recorded, but the bytes themselves are the + * dev-only opt-in that level-2 decode needs. `/traces` never serializes them + * either way; retaining them only makes the drill-down decoder able to run. + */ + retainBytes?: boolean; + /** + * Reverse map from wire `frameId` to method info (build one with + * {@link createMethodNameMap}). When set, each frame's lifecycle `role` is + * resolved here from the frame id's wire-table `kind`, so *every* consumer - + * the default console sink, the `forward` hook, and the trace engine - sees the + * real role. Without it, `role` is left `"unknown"` and only the view adapter + * recovers it. + */ + methodNames?: ReadonlyMap; + /** + * Length above which a `channelId` / `requestId` is replaced by a digest + * ({@link normalizeId}). Anything able to reach the host tap could otherwise + * send 200k-char ids, one copy per frame; real ids are short (`myapp.dot`, + * `p:1`). Default 256. + */ + maxIdChars?: number; +} + +/** + * Ingest that decodes each {@link DebugFrameEnvelope} and forwards the resulting + * {@link ObservedFrame} to `sink` (typically a {@link WireDebugger}'s `observe`). + * + * `role` is a pure function of the frame's wire discriminant: the generated wire + * table already states, per `frameId`, which leg of a method it is, so `role` is + * resolved here from `methodNames` rather than reconstructed from correlation + * state. Resolving it at ingest is what makes it true for *every* consumer - + * the default `console.debug` sink, the `forward` hook, and the trace engine - + * instead of only for the view adapter, which resolves one layer further down + * (`wireTraceToView`) and would leave the other two reading `"unknown"`. + * + * `role` falls back to `"unknown"` in exactly two cases: no `methodNames` map was + * given, or the id is off-table (a frame from a newer host). An undecodable frame + * is surfaced as a `"malformed"` sentinel rather than dropped, so the trace + * records the failure instead of going dark. + * + * Raw payload bytes are attached only when `retainBytes` is set - the dev-only + * byte-exposure opt-in that the level-2 decoder consumes; otherwise a frame + * carries its byte length and no payload. + * + * `timestamp` is the producer's `observedAt` whenever the tap stamped a usable + * one, and the ingest clock otherwise. Which of the two it is, and whether the + * frame was replayed from the tap's backlog, are recorded on the frame + * ({@link ObservedFrame.timestampFromProducer}, {@link ObservedFrame.buffered}), + * because a flushed backlog arrives in a single loop: read as observation times, + * those instants collapse every duration to 0ms and pull ops minutes apart into + * one retry-storm window. + */ +export function createDebugIngest( + sink: TransportObserver, + options: DebugIngestOptions = {}, +): (envelope: DebugFrameEnvelope) => void { + const retainBytes = options.retainBytes ?? false; + const methodNames = options.methodNames; + const maxIdChars = options.maxIdChars ?? DEFAULT_MAX_ID_CHARS; + return (envelope) => { + const channelId = normalizeId(envelope.channelId, maxIdChars); + // Prefer the producer's observation time; the ingest clock is a fallback, and + // one that is wrong by the whole duration of the session for a flushed + // backlog. `provenance` is what lets a consumer tell the two apart instead of + // reading every timestamp as an observation time. + const producerAt = producerTimestamp(envelope.observedAt); + const timestamp = producerAt ?? Date.now(); + const provenance = { + ...(envelope.buffered === true ? { buffered: true as const } : {}), + ...(producerAt !== undefined ? { timestampFromProducer: true as const } : {}), + // Per-frame, deliberately: see ObservedFrame.identityConfirmed. + ...(envelope.identityConfirmed === true + ? { identityConfirmed: true as const } + : {}), + }; + const decoded = decodeWireMessage(envelope.frame); + if (decoded.isErr()) { + sink({ + channelId, + direction: envelope.dir, + requestId: "malformed", + frameId: -1, + role: "malformed", + byteLength: envelope.frame.length, + timestamp, + ...provenance, + }); + return; + } + const { requestId, payload } = decoded.value; + const frame: ObservedFrame = { + channelId, + direction: envelope.dir, + requestId: normalizeId(requestId, maxIdChars), + frameId: payload.id, + // Resolve the lifecycle role from the frame id's wire-table kind (the same + // kind wireTraceToView falls back to). Left "unknown" when no map is given + // or the id is off-table. + role: methodNames?.get(payload.id)?.kind ?? "unknown", + byteLength: payload.value.length, + timestamp, + ...provenance, + ...(retainBytes ? { bytes: payload.value } : {}), + }; + sink(frame); + }; +} diff --git a/js/packages/truapi-debugger/src/inspector-styles.ts b/js/packages/truapi-debugger/src/inspector-styles.ts new file mode 100644 index 000000000..e300ded01 --- /dev/null +++ b/js/packages/truapi-debugger/src/inspector-styles.ts @@ -0,0 +1,247 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * The inspector chrome: every rule the Network-tab shell needs that is not a + * per-frame drill-down rule (those live in `trace-styles.ts`). + * + * Shared so the two mounts cannot drift apart visually. The standalone app puts + * the shell in a full page; the in-app embed puts the same shell in a panel + * inside the host. Neither owns these rules, so a change lands in both. + * + * The rules are written FLAT (`.ins-top`, `.td-op`, …) because that is correct + * for the standalone: it owns its document, and flat rules keep the shared source + * readable and diffable against dotli's stylesheet. An embed shares a document + * with the host application, where a flat `.td-*` rule would restyle the host's + * own debug panel, so the embed does not inject these constants directly - it runs + * them through {@link scopeCss} first. That keeps one source of truth with two + * correct injections instead of a second, pre-scoped copy. + * + * Deliberately free of page-level rules (`html`, `body`, viewport units): a mount + * scopes its own container, and an embed must never restyle its host's page. + * + * @module + */ + +/** + * At-rules whose body is a list of style rules, so scoping recurses into it. + * Anything else with a block (`@keyframes`, `@font-face`, `@property`) has a body + * that is NOT selectors and is passed through untouched. + */ +const NESTED_AT_RULES: ReadonlySet = new Set([ + "media", + "supports", + "layer", + "container", +]); + +/** Index of the `}` matching the `{` at `open`, or the end of the string. */ +function matchBrace(css: string, open: number): number { + let depth = 0; + for (let i = open; i < css.length; i++) { + const c = css[i]; + // A quoted value may contain a brace (`content: "}"`); skip the string. + if (c === '"' || c === "'") { + const end = css.indexOf(c, i + 1); + if (end === -1) return css.length; + i = end; + continue; + } + if (c === "{") depth += 1; + else if (c === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + return css.length; +} + +/** Prefix every selector in a comma-separated list with `scope`. */ +function scopeSelectorList(selectors: string, scope: string): string { + return selectors + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== "") + .map((s) => `${scope} ${s}`) + .join(", "); +} + +/** + * Rewrite every rule in `css` so it only matches inside `scope`. + * + * This is what lets one flat shared stylesheet serve both mounts: the standalone + * injects the constants as-is (it owns the page), and an embed injects + * `scopeCss(css, ".td-inapp")` so not one rule can reach the host application's + * own markup. Every selector is prefixed, so relative precedence inside the + * block is unchanged (each selector gains the same specificity) - the cascade the + * standalone sees is the cascade the embed sees. + * + * `scope` is a selector (`".td-inapp"`), not a class name. Rules that target the + * mount root itself are the mount's own business and are written already-scoped, + * not passed through here. + */ +export function scopeCss(css: string, scope: string): string { + // Comments can contain braces and selectors; drop them before parsing. + return scopeRules(css.replace(/\/\*[\s\S]*?\*\//g, ""), scope); +} + +/** Scope one block's worth of rules (top level, or an at-rule body). */ +function scopeRules(css: string, scope: string): string { + const out: string[] = []; + let i = 0; + while (i < css.length) { + const brace = css.indexOf("{", i); + if (brace === -1) break; + let prelude = css.slice(i, brace).trim(); + const end = matchBrace(css, brace); + const body = css.slice(brace + 1, end); + // Statement at-rules (`@import`, `@charset`) end in `;` and carry no block; + // they must stay verbatim and at the top, so split them off the prelude. + const semi = prelude.lastIndexOf(";"); + if (semi !== -1) { + out.push(prelude.slice(0, semi + 1).trim()); + prelude = prelude.slice(semi + 1).trim(); + } + if (prelude.startsWith("@")) { + const name = /^@([\w-]+)/.exec(prelude)?.[1] ?? ""; + out.push( + NESTED_AT_RULES.has(name) + ? `${prelude} {\n${scopeRules(body, scope)}\n}` + : `${prelude} {${body}}`, + ); + } else if (prelude === "") { + out.push(`{${body}}`); + } else { + out.push(`${scopeSelectorList(prelude, scope)} {${body}}`); + } + i = end + 1; + } + return out.join("\n"); +} + +/** + * The shell: top bar, channel chips, the list/detail split, and operation rows. + * Pair with {@link TRACE_DETAIL_CSS} and {@link INSPECTOR_LAYOUT_CSS}. + */ +export const INSPECTOR_SHELL_CSS = ` + .ins-top { display: flex; align-items: center; gap: 12px; padding: 6px 12px; + border-bottom: 1px solid rgba(255,255,255,.08); } + .ins-title { font-weight: 600; letter-spacing: .02em; white-space: nowrap; } + .ins-title .accent { color: #4ade80; } + .ins-channels { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; } + .ins-chan { display: inline-flex; align-items: center; gap: 5px; padding: 1px 9px; + border: 1px solid rgba(255,255,255,.12); border-radius: 10px; background: transparent; + color: #94a3b8; cursor: pointer; font: inherit; } + .ins-chan.active { color: #0a0a0a; background: #4ade80; border-color: #4ade80; } + .ins-chan .dot { width: 6px; height: 6px; border-radius: 50%; background: #4b5563; } + .ins-chan .dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; } + .ins-chan.active .dot.live { background: #0a0a0a; box-shadow: none; } + .ins-body { display: grid; grid-template-columns: var(--list-w, 340px) 6px 1fr; + min-height: 0; } + .ins-list { overflow: auto; outline: none; } + .ins-split { cursor: col-resize; background: rgba(255,255,255,.05); } + .ins-split:hover { background: rgba(74,222,128,.4); } + .ins-detail { overflow: auto; padding: 8px 12px; outline: none; } + .td-op { display: flex; align-items: center; gap: 8px; padding: 4px 10px; + cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.03); } + .td-op:hover { background: rgba(255,255,255,.04); } + .td-op.selected { background: rgba(74,222,128,.13); } + .ins-list:focus-visible .td-op.selected { box-shadow: inset 2px 0 0 #4ade80; } + .td-op-kind { width: 12px; text-align: center; } + .td-op-req .td-op-kind { color: #fbbf24; } + .td-op-sub .td-op-kind { color: #c084fc; } + /* Truncate the *start*, not the end: sibling methods share a service prefix + (account.getAccount vs account.getAccountAlias), so clipping the tail + renders two different methods identically. Keeping the tail makes them + distinguishable in a narrow list. */ + .td-op-method { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + direction: rtl; text-align: left; } + .td-op-method.anon { color: #525252; font-style: italic; } + .td-op-meta { color: #6b7280; font-size: 10.5px; white-space: nowrap; } + .td-op-live .td-op-meta { color: #4ade80; } + /* An op that went out and is still unanswered: counts up amber, and reads as a + problem rather than a completed 0ms call. + + PRECEDENCE (pinned by a test): a live subscription whose start frame was + never answered carries BOTH td-op-live and td-op-waiting, and waiting must + win - the row is reporting a stall, not health. Two guards, because either + alone is one edit away from silently flipping the colour back to green: this + rule sits AFTER the .td-op-live rule, and the extra .td-op raises its + specificity above it. */ + .td-op.td-op-waiting .td-op-meta { color: #fbbf24; } + .td-op-badges { display: inline-flex; gap: 4px; } + .td-op-empty, .td-detail-empty { color: #6b7280; padding: 14px; } + .td-frame.cursor { background: rgba(255,255,255,.06); box-shadow: inset 2px 0 0 #94a3b8; } +`; + +/** + * App-level layout applied on top of the shared drill-down rules: the two-column + * frame grid, the filter/sort controls, and the aggregate summary strip. + * Applied after {@link TRACE_DETAIL_CSS} because it overrides some of it. + */ +export const INSPECTOR_LAYOUT_CSS = ` + /* App-level layout for the drill-down (trace-styles.ts stays untouched). + Each frame is a two-column grid: meta on the left, a fixed-width payload + column on the right, so every frame's decoded / blurred box opens in the + same aligned partitioned space instead of trailing variable-width meta. */ + .ins-detail { padding: 6px 10px 10px; } + .td-frame { display: grid; align-items: start; column-gap: 10px; + grid-template-columns: minmax(0, 1fr); padding: 4px 8px; } + .td-frame:has(.td-frame-payload) { + grid-template-columns: minmax(0, 1fr) var(--payload-w, clamp(240px, 44%, 520px)); } + .td-frame-meta { display: flex; align-items: center; gap: 8px; min-width: 0; } + .td-frame-meta .td-frame-method { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .td-frames .td-frame:nth-child(even) { background: rgba(255,255,255,.02); } + .td-frame:hover { background: rgba(255,255,255,.05); } + /* The payload column: same width for every frame; content scrolls inside. */ + .td-frame-payload { min-width: 0; } + .td-frame-decoded > * { margin: 0; } + .td-frame-decoded .td-detail-pre { max-height: 240px; overflow: auto; margin: 0; + white-space: pre; } + /* Top-bar filter / sort controls. */ + .ins-filter { width: 148px; padding: 2px 8px; border: 1px solid rgba(255,255,255,.14); + border-radius: 5px; background: rgba(255,255,255,.03); color: #e0e0e0; font: inherit; } + .ins-filter:focus { outline: none; border-color: rgba(74,222,128,.5); } + .ins-sort { padding: 2px 6px; border: 1px solid rgba(255,255,255,.14); border-radius: 5px; + background: #0a0a0a; color: #cbd5e1; font: inherit; cursor: pointer; } + .td-op.filtered-out { display: none; } + /* Clickable top-method pills. */ + .ins-method { cursor: pointer; } + .ins-method:hover { border-color: rgba(74,222,128,.5); color: #d1fae5; } + /* Aggregate summary strip: the "at a glance" row of metric tiles. */ + .ins-summary { display: flex; gap: 6px; align-items: flex-start; flex-wrap: nowrap; + padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,.08); + background: rgba(255,255,255,.02); overflow-x: auto; } + .ins-stat { display: flex; flex-direction: column; gap: 1px; padding: 2px 10px 2px 0; + border-right: 1px solid rgba(255,255,255,.06); } + .ins-stat:last-child { border-right: 0; } + .ins-stat .n { font-size: 14px; font-weight: 600; color: #f1f5f9; + font-variant-numeric: tabular-nums; line-height: 1.15; } + .ins-stat .k { font-size: 9.5px; text-transform: uppercase; letter-spacing: .06em; color: #64748b; } + .ins-stat.warn .n { color: #f87171; } + /* The zero class dims a count that is currently nothing. It must NOT require + the warn class alongside it: an informational tile (infoStat/infoTile) emits + "ins-stat zero" with no warn, and while this rule was warn-only that tile + rendered its 0 at full headline brightness, identical to a non-zero - no + signal at all, which defeats the point of counting it. NOTE no backticks in + this block: it lives inside a TS template literal and a backtick would + terminate the string. */ + .ins-stat.zero .n { color: #475569; } + .ins-stat.warn.zero .n { color: #475569; } + .ins-stat.good .n { color: #4ade80; } + .ins-stat .sub { color: #64748b; font-weight: 400; font-size: 10px; } + /* Pills stay on one row, pushed right; when the viewport is too narrow the + whole summary scrolls (overflow-x above) rather than the pills wrapping to a + second line. */ + .ins-methods { display: flex; align-items: center; gap: 6px; margin-left: auto; + flex: 0 0 auto; flex-wrap: nowrap; } + .ins-method { white-space: nowrap; } + .ins-method { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; + border: 1px solid rgba(255,255,255,.08); border-radius: 10px; color: #94a3b8; + font-size: 10.5px; white-space: nowrap; } + .ins-method b { color: #cbd5e1; font-variant-numeric: tabular-nums; } + .ins-summary.empty { color: #64748b; } + .ins-status { display: flex; gap: 16px; padding: 4px 12px; color: #6b7280; + border-top: 1px solid rgba(255,255,255,.08); } + .ins-status .live { color: #4ade80; } + .ins-status .mismatch { color: #f87171; } +`; diff --git a/js/packages/truapi-debugger/src/observed-frame.ts b/js/packages/truapi-debugger/src/observed-frame.ts new file mode 100644 index 000000000..041ce7dbf --- /dev/null +++ b/js/packages/truapi-debugger/src/observed-frame.ts @@ -0,0 +1,109 @@ +/** + * The frame model the debugger works in. + * + * A host tap streams raw wire frames as `{ channelId, dir, frame: bytes }` + * envelopes; {@link createDebugIngest} decodes each one into an + * {@link ObservedFrame} - correlation id, wire discriminant, byte length, and + * (dev-only) the raw bytes - which the trace and host engines consume. The core + * never decodes; decoding happens here, in the debugger. + * + * @module + */ + +/** + * Direction of an observed wire frame relative to the product: `out` left the + * product, `in` arrived at it. + */ +export type FrameDirection = "out" | "in"; + +/** + * Role of an observed frame within the request/subscription lifecycle, derived + * from its wire discriminant against the method's frame ids. + */ +/** + * Roles that OPEN an op. Lives here, in the leaf module, because both the + * retention engine and the view layer need it: the engine protects the opener + * from eviction and the storm detector keys on its frame id, and both of those + * were previously written as `frames[0]` on the assumption the opener is the + * first frame observed. It is not - both mounts start mid-session, so the first + * frame seen for an id is often a closer for a request that predates the tap. + */ +export const OPENING_ROLES: ReadonlySet = new Set([ + "request", + "start", +]); + +/** + * Index of the frame that opened this op, or `-1` when no opener was observed. + * Takes anything carrying a {@link FrameRole}, so both the raw + * {@link ObservedFrame} sequence and the view layer's projections can use it + * (the op began before the tap attached). Callers that need a frame to anchor on + * regardless should fall back to `0`, never assume `0` IS the opener. + */ +export function openerIndexOf( + frames: readonly { readonly role: FrameRole }[], +): number { + return frames.findIndex((f) => OPENING_ROLES.has(f.role)); +} + +export type FrameRole = + | "request" + | "response" + | "start" + | "stop" + | "receive" + | "interrupt" + | "handshake" + | "malformed" + | "unknown"; + +/** + * A single decoded wire frame. Carries the correlation `requestId`, the wire + * discriminant, a best-effort lifecycle `role`, and the encoded byte length. + * The raw `bytes` are present only when byte exposure is enabled - a dev-only + * opt-in, since the raw wire can carry key material. + */ +export interface ObservedFrame { + /** + * Product channel the frame crossed, e.g. `"myapp.dot"`. Carried from the + * host tap envelope. Because `requestId` is minted per transport (each host + * mints `p:1`, `p:2`, …), it is unique only *within* a channel; grouping and + * lookups key on `(channelId, requestId)` so two hosts' ops never merge. + */ + channelId: string; + /** Whether the frame was sent by the product (`out`) or received by it (`in`). */ + direction: FrameDirection; + /** Correlation id shared by every frame of one request/subscription, within a channel. */ + requestId: string; + /** Wire-table numeric discriminant of the frame's payload. */ + frameId: number; + /** Best-effort lifecycle role inferred from the frame id. */ + role: FrameRole; + /** Encoded SCALE payload length in bytes. */ + byteLength: number; + /** Epoch ms at which the frame was observed. */ + timestamp: number; + /** The raw SCALE payload bytes, present only when byte exposure is enabled. */ + bytes?: Uint8Array; + /** + * Whether the producer of THIS frame affirmatively vouched for the wire contract + * the debugger decodes with (matching envelope version, codec version and wire + * schema hash). + * + * Identity has to travel with the frame, not with its channel. A per-channel + * verdict is a latch: one attested frame flipped the channel to trusted and every + * unattested frame already retained under that `channelId` became decodable + * retroactively. `channelId` is the productId, so a stale host and a fresh host + * serving the same product share it, and an embedding host that learns its core's + * schema hash asynchronously tees unattested frames before it knows it. + * + * Absent means "not vouched for": group it, list it, never decode it. + */ + identityConfirmed?: boolean; +} + +/** + * Emit-only consumer of observed frames. The trace engine's + * {@link WireDebugger.observe} is one; a host relay is another. + */ +export type TransportObserver = (frame: ObservedFrame) => void; diff --git a/js/packages/truapi-debugger/src/operation-row.test.ts b/js/packages/truapi-debugger/src/operation-row.test.ts new file mode 100644 index 000000000..70356f147 --- /dev/null +++ b/js/packages/truapi-debugger/src/operation-row.test.ts @@ -0,0 +1,133 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT + +import { describe, expect, test } from "bun:test"; +import type { ObservedFrame, FrameRole } from "./observed-frame.js"; +import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; +import { wireTraceToView } from "./trace-view.js"; +import { renderOperationRow } from "./trace-render.js"; + +function frame( + role: FrameRole, + frameId: number, + timestamp: number, +): ObservedFrame { + return { + channelId: "host-a.dot", + direction: role === "response" || role === "receive" ? "in" : "out", + requestId: "p:1", + frameId, + role, + byteLength: 8, + timestamp, + }; +} + +function traceOf(frames: ObservedFrame[]): WireTrace { + return { + channelId: "host-a.dot", + requestId: "p:1", + frames, + startedAt: frames[0]?.timestamp ?? 0, + lastAt: frames[frames.length - 1]?.timestamp ?? 0, + generation: 0, + truncated: false, + dropped: { framesByCount: 0, framesByBytes: 0, payloadsShed: 0 }, + }; +} + +const methodNames: ReadonlyMap = new Map([ + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + [40, { method: "account.connectionStatus", kind: "start" }], + [41, { method: "account.connectionStatus", kind: "receive" }], + [42, { method: "account.connectionStatus", kind: "stop" }], +]); + +describe("renderOperationRow", () => { + test("request/response op: method, frame count, duration, request glyph", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1120)]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("account.getAccount"); + expect(html).toContain("2 frames"); + expect(html).toContain("120ms"); + expect(html).toContain("td-op-req"); + expect(html).toContain('data-request-id="p:1"'); + expect(html).not.toContain("td-op-live"); + }); + + test("subscription with no stop is marked live", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("receive", 41, 1200), + ]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).toContain("td-op-live"); + expect(html).toContain("live"); + }); + + test("subscription with a stop is not live", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("stop", 42, 1300), + ]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("td-op-live"); + }); + + test("op badges render as chips (orphaned request)", () => { + const view = wireTraceToView(traceOf([frame("request", 22, 1000)]), methodNames); + const html = renderOperationRow(view); + expect(html).toContain("td-badge-orphaned"); + }); + + test("carries channelId as a data attribute when present", () => { + const base = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const view = { ...base, channelId: "host-a.dot" }; + const html = renderOperationRow(view); + expect(html).toContain('data-channel-id="host-a.dot"'); + }); + + test("omits data-channel-id when the vantage has no channel", () => { + const base = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const view = { ...base, channelId: undefined }; + expect(renderOperationRow(view)).not.toContain("data-channel-id"); + }); + + test("payload-blind: never emits a decoded value", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).not.toContain("decode"); + expect(html).not.toContain(" { + const base = wireTraceToView(traceOf([frame("request", 22, 1000)])); + const view = { ...base, requestId: '">' }; + const html = renderOperationRow(view); + expect(html).not.toContain(", +): string[] { + return [...map.keys()].map((t) => t.requestId).sort(); +} + +/** + * A trace whose FIRST observed frame is a stale closer and whose opener lands at + * index 1 - the cold-start shape, since both mounts attach mid-session. + */ +function closerFirstTrace( + requestId: string, + openerFrameId: number, + startedAt: number, + channelId = "c", +): WireTrace { + const mk = ( + frameId: number, + role: ObservedFrame["role"], + ): ObservedFrame => ({ + channelId, + direction: "out", + requestId, + frameId, + role, + byteLength: 0, + timestamp: startedAt, + }); + return { + channelId, + requestId, + frames: [mk(openerFrameId + 1, "response"), mk(openerFrameId, "request")], + startedAt, + lastAt: startedAt, + generation: 0, + truncated: false, + dropped: { framesByCount: 0, framesByBytes: 0, payloadsShed: 0 }, + }; +} + +describe("detectRetryStorms", () => { + test("flags a burst of like ops in a short window", () => { + const traces = [ + trace("a", 30, 0), + trace("b", 30, 200), + trace("c", 30, 400), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["a", "b", "c"]); + expect(storms.get(traces[0])).toEqual(["retry-storm"]); + }); + + test("does not flag a burst below the threshold", () => { + const storms = detectRetryStorms([trace("a", 30, 0), trace("b", 30, 100)]); + expect(storms.size).toBe(0); + }); + + test("does not flag like ops spread wider than the window", () => { + const storms = detectRetryStorms([ + trace("a", 30, 0), + trace("b", 30, 1500), + trace("c", 30, 3000), + ]); + expect(storms.size).toBe(0); + }); + + test("groups by op signature — only the bursting method storms", () => { + // Three createTransaction (id 30) inside 400ms = a storm; two getAccount + // (id 22) far apart are not, even interleaved in time. + const traces = [ + trace("sign-1", 30, 0), + trace("get-1", 22, 50), + trace("sign-2", 30, 150), + trace("get-2", 22, 5000), + trace("sign-3", 30, 300), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["sign-1", "sign-2", "sign-3"]); + }); + + test("flags only the dense sub-window within a longer sparse run", () => { + // Two early, far-apart ops then a tight burst of three: only the burst. + const traces = [ + trace("x", 30, 0), + trace("y", 30, 4000), + trace("b1", 30, 8000), + trace("b2", 30, 8300), + trace("b3", 30, 8600), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["b1", "b2", "b3"]); + }); + + test("honors custom window and burst thresholds", () => { + const traces = [trace("a", 30, 0), trace("b", 30, 300)]; + // Default (minBurst 3) → nothing; minBurst 2 within 500ms → both. + expect(detectRetryStorms(traces).size).toBe(0); + const storms = detectRetryStorms(traces, { windowMs: 500, minBurst: 2 }); + expect(stormedIds(storms)).toEqual(["a", "b"]); + }); + + test("minBurst below 2 detects nothing", () => { + const traces = [trace("a", 30, 0), trace("b", 30, 10)]; + expect(detectRetryStorms(traces, { minBurst: 1 }).size).toBe(0); + }); + + test("tolerates a frameless trace without throwing", () => { + const empty: WireTrace = { + channelId: "c", + requestId: "empty", + frames: [], + startedAt: 0, + lastAt: 0, + generation: 0, + truncated: false, + dropped: { framesByCount: 0, framesByBytes: 0, payloadsShed: 0 }, + }; + const traces = [ + empty, + trace("a", 30, 0), + trace("b", 30, 100), + trace("c", 30, 200), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["a", "b", "c"]); + expect(storms.has(empty)).toBe(false); + }); + + test("is per-channel — two hosts each firing once is not a storm", () => { + // Same requestId and frameId across two channels, all within the window, + // but each channel fires the op only twice (< minBurst 3): no storm, and + // the two channels are never merged into one burst. + const traces = [ + trace("p:1", 30, 0, "hostA"), + trace("p:1", 30, 50, "hostB"), + trace("p:2", 30, 100, "hostA"), + trace("p:2", 30, 150, "hostB"), + ]; + expect(detectRetryStorms(traces).size).toBe(0); + }); + + test("flags a per-channel burst without pulling in the other channel", () => { + // hostA hammers the op 3x in-window (storm); hostB fires it once (calm). + const traces = [ + trace("p:1", 30, 0, "hostA"), + trace("p:2", 30, 200, "hostA"), + trace("p:1", 30, 250, "hostB"), + trace("p:3", 30, 400, "hostA"), + ]; + const storms = detectRetryStorms(traces); + // Only hostA's three ops storm; hostB's p:1 does not, even though it shares + // requestId "p:1" with a stormed hostA op. + expect(storms.size).toBe(3); + const stormedChannels = new Set([...storms.keys()].map((t) => t.channelId)); + expect([...stormedChannels]).toEqual(["hostA"]); + }); + test("keys on the opener, so a stale-closer-first op still storms", () => { + // Three retries of ONE method, each of whose stale closer arrived before its + // request. This first group is NOT the load-bearing case: under the old + // `frames[0]` keying it also stormed, because three closers of one method + // share one response id and group together. It is here to pin that the fix + // does not break the uniform case. + const retries = [ + closerFirstTrace("a", 22, 0), + closerFirstTrace("b", 22, 10), + closerFirstTrace("c", 22, 20), + ]; + expect(stormedIds(detectRetryStorms(retries, { windowMs: 1000 }))).toEqual([ + "a", + "b", + "c", + ]); + + // THIS is the case the fix exists for: a method whose ops are MIXED, some + // observed from their request and some from a stale closer. The old keying + // split them across the request-id and response-id groups, each below + // `minBurst`, so a genuine storm reported nothing - `[]` instead of d/e/f. + // Keying on the opener collapses them into one group, since both resolve to + // frameId 22. + const mixed = [ + trace("d", 22, 0), + closerFirstTrace("e", 22, 10), + trace("f", 22, 20), + ]; + expect(stormedIds(detectRetryStorms(mixed, { windowMs: 1000 }))).toEqual([ + "d", + "e", + "f", + ]); + }); + +}); diff --git a/js/packages/truapi-debugger/src/retry-storm.ts b/js/packages/truapi-debugger/src/retry-storm.ts new file mode 100644 index 000000000..915d4847f --- /dev/null +++ b/js/packages/truapi-debugger/src/retry-storm.ts @@ -0,0 +1,124 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Retry-storm detection: a *cross-op* signal the single-trace renderer cannot + * see on its own. + * + * A retry storm is a burst of like ops in a short window — a product hammering + * `signing.createTransaction` five times in 400ms because each attempt failed, + * say. Whether any one op is part of a storm depends on the *other* traces, so + * it belongs in the engine/list layer, not the per-trace renderer. This module + * computes it over the whole trace set and hands each stormed trace a + * `retry-storm` {@link TraceBadge}, which the mount feeds to `wireTraceToView`'s + * `extraBadges`. The renderer stays display-only. + * + * @module + */ + +import { openerIndexOf } from "./observed-frame.js"; +import type { TraceBadge } from "./trace-view.js"; +import type { WireTrace } from "./wire-debugger.js"; + +/** Tuning for {@link detectRetryStorms}. */ +export interface RetryStormOptions { + /** + * The window, in ms, within which like ops count as one burst. Default 1000. + */ + windowMs?: number; + /** + * How many like ops within `windowMs` make a storm. Default 3. Values below 2 + * are meaningless (a single op is never a storm) and detect nothing. + */ + minBurst?: number; +} + +/** + * The op signature two traces must share to count as "like". A storm is one host + * hammering one method, so the signature is scoped to the channel: `channelId` + * plus the OPENER frame's wire `frameId`, whose id identifies the method. Same + * channel + same op id = the same op being repeated; two different hosts each + * firing the op once is not a storm. A trace with no frames has no signature and + * never storms. + * + * Keyed on the opener's real index rather than `frames[0]`. Both mounts attach + * mid-session, so the first frame observed is often a closer for a request that + * predates the tap, and `frames[0]` then yields the RESPONSE id. + * + * The defect that causes is GROUP DILUTION, not a wholesale miss. Ops that are all + * closer-first still group together (their closers share one response id), so they + * still storm. What breaks is a method whose ops are MIXED - some observed from + * their request, some from a stale closer: those split across two signatures, and + * each half can fall under `minBurst` so a real storm goes unreported. Keying on + * the opener collapses them back into one group. Falls back to `frames[0]` when no + * opener was observed at all, which at least keys consistently within a group. + */ +function signature(trace: WireTrace): string | undefined { + const opener = openerIndexOf(trace.frames); + const frameId = trace.frames[opener === -1 ? 0 : opener]?.frameId; + return frameId === undefined ? undefined : `${trace.channelId}\u0000${frameId}`; +} + +/** + * Find every trace that is part of a retry storm and map it to its badge. + * + * Traces are grouped by op {@link signature}; within each group, a sliding + * window over `startedAt` flags any trace that sits in a span of `minBurst` or + * more ops no wider than `windowMs`. The result is keyed by the {@link WireTrace} + * object itself (not `requestId`, which is not unique across channels): only + * stormed traces appear, each mapped to `["retry-storm"]`. Feed + * `result.get(trace) ?? []` into `wireTraceToView`'s `extraBadges`. + */ +export function detectRetryStorms( + traces: readonly WireTrace[], + options: RetryStormOptions = {}, +): ReadonlyMap { + const windowMs = options.windowMs ?? 1000; + const minBurst = options.minBurst ?? 3; + const result = new Map(); + if (minBurst < 2) return result; + + const groups = new Map(); + for (const trace of traces) { + // A replayed backlog arrives in one burst. When the producer stamped its own + // observation time the spacing is real and a genuine storm still shows, so + // only the case with no producer clock is excluded: those ops all carry the + // flush instant, and six calls a genuine ten seconds apart would otherwise + // land inside the window and every one be badged "the product is hammering + // this method" on a completely calm session. + // Deliberately `frames[0]`, NOT the opener: `trace.startedAt` is set from + // `frames[0].timestamp` and never recomputed, so "is startedAt a real + // observation time or a replay flush instant?" is a question about the frame + // that set it. Pointing this at the opener broke it both ways - six ops a + // genuine ten seconds apart scored six false storms, and three real retries + // scored zero. Only `signature()` above wants the opener. + const first = trace.frames[0]; + if (first?.buffered === true && first.timestampFromProducer !== true) { + continue; + } + const sig = signature(trace); + if (sig === undefined) continue; + const group = groups.get(sig); + if (group) group.push(trace); + else groups.set(sig, [trace]); + } + + for (const group of groups.values()) { + if (group.length < minBurst) continue; + const sorted = [...group].sort((a, b) => a.startedAt - b.startedAt); + let left = 0; + for (let right = 0; right < sorted.length; right++) { + while (sorted[right].startedAt - sorted[left].startedAt > windowMs) { + left++; + } + // [left, right] now spans <= windowMs, so every trace in it is within + // windowMs of every other. If that's a full burst, they all storm. + if (right - left + 1 >= minBurst) { + for (let k = left; k <= right; k++) { + result.set(sorted[k], ["retry-storm"]); + } + } + } + } + + return result; +} diff --git a/js/packages/truapi-debugger/src/session.ts b/js/packages/truapi-debugger/src/session.ts new file mode 100644 index 000000000..42ba0d8c9 --- /dev/null +++ b/js/packages/truapi-debugger/src/session.ts @@ -0,0 +1,362 @@ +/** + * A debug session: the trace engine wired to the ingest. + * + * A host dials the debugger and streams {@link DebugFrameEnvelope}s over a + * socket; each is handed to {@link DebugSession.handleEnvelope}, decoded, and + * grouped into per-`requestId` traces readable via {@link DebugSession.traces}. + * + * The socket itself is deliberately not here. The debugger app is a WS server + * (hosts dial outward to it), but binding the socket is a thin edge: accept a + * connection, JSON/CBOR-decode each message into a {@link DebugFrameEnvelope}, + * and call `handleEnvelope`. Keeping that edge out of this module lets the + * session compile and unit-test without a socket transport or Node types. + * + * @module + */ + +import { + createWireDebugger, + createMethodNameMap, + type WireDebugger, + type WireMethodInfo, +} from "./wire-debugger.js"; +import { createDebugIngest, type DebugFrameEnvelope } from "./ingest.js"; +import { createFrameDecoder, type FrameValueDetail } from "./decode.js"; +import { + isLiveSubscription, + isSubscription, + operationMethod, + type TraceView, +} from "./trace-view.js"; +import * as W from "@parity/truapi/wire-table"; +import { createClient, createTransport } from "@parity/truapi"; + +/** A provider that sends and receives nothing; used only to enumerate service names. */ +const NOOP_PROVIDER = { + postMessage() {}, + subscribe() { + return () => {}; + }, + dispose() {}, +}; + +/** Options for {@link createDebugSession}. */ +export interface DebugSessionOptions { + /** + * Turn on level-2 value decode in the drill-down detail path. On by default + * (this is a dev-only tool that decodes everything). When on, the session + * retains raw frame bytes so {@link DebugSession.frameDetail} can decode a + * frame; `/traces` stays payload-blind regardless (it never reads bytes or + * decoded values). When off, `frameDetail` reports byte length only. + */ + decodeValues?: boolean; + /** + * Cap on retained operations, LRU-evicted (see + * {@link WireDebuggerOptions.maxTraces}). Defaults to the engine's own default. + * A mount that shares a tab with the observed app should lower it: the product + * pays for whatever the panel retains. + */ + maxTraces?: number; + /** + * Cap on retained frames within one operation (see + * {@link WireDebuggerOptions.maxFramesPerTrace}). Defaults to the engine's own + * default. + */ + maxFramesPerTrace?: number; + /** + * Cap on retained payload bytes within one operation (see + * {@link WireDebuggerOptions.maxBytesPerTrace}); only bites while + * {@link DebugSessionOptions.decodeValues} retains bytes. Defaults to the + * engine's own default. + */ + maxBytesPerTrace?: number; +} + +/** How many methods the busiest-methods roll-up reports. */ +const TOP_METHOD_LIMIT = 5; + +/** What the busiest-methods roll-up calls an op whose ids were all off-table. */ +const UNKNOWN_METHOD = "(unknown)"; + +/** + * Facts about a session that no single {@link TraceView} can carry, supplied by + * the mount that owns the link: whole-op eviction, link-level drops, and whether + * a feeding host's wire contract disagrees with this debugger's. + */ +export interface TraceStatsExtras { + /** Whole operations LRU-evicted (`traceEngine.evictedTraces()`). */ + evictedTraces?: number; + /** Frames the feeding host reported dropping before delivery. */ + droppedByHost?: number; + /** Whether any feeding host declared a wire contract this debugger can't decode against. */ + codecMismatch?: boolean; +} + +/** + * The payload-blind aggregate roll-up behind a mount's summary strip: counts, + * byte totals, durations, health tallies, the direction split, and the busiest + * methods. Shape and timing only - never a byte or a decoded value. + */ +export interface TraceStats { + ops: number; + frames: number; + bytes: number; + subscriptions: number; + liveSubscriptions: number; + malformed: number; + orphaned: number; + /** + * Ops carrying a closing frame with no opener in view. Reported separately from + * `orphaned` and NOT as a warning: the common cause is the debugger attaching + * mid-op, which is not a host fault. Counted rather than dropped because a real + * double-answer lands here too and would otherwise appear in no aggregate at + * all - only in one op's row. + */ + unpaired: number; + retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; +} + +/** + * Roll a set of {@link TraceView}s up into the summary strip's numbers. + * + * This is THE aggregate computation for every mount. A second implementation is + * how the two mounts silently disagree about the same stream (one reporting + * `malformed 1`, the other reporting no malformed at all), so the standalone + * server's `/stats` and the in-app embed's strip both go through here rather than + * each summing views their own way. + * + * `avgDurationMs` averages over ALL ops, not only completed ones. Note what that + * does NOT mean: an op's span is `lastAt - startedAt`, i.e. first frame to last + * frame OBSERVED, with no reference to now. A request that is still hanging has + * one frame, so its span is 0 and it pulls the average DOWN - a stream full of + * hung calls reads as a fast session here, even though the operation row renders + * a live `waiting 9m 59s`. Reporting an open op's true elapsed time would need a + * clock passed in; the row-level fix was never carried up to this aggregate. + */ +export function computeTraceStats( + views: readonly TraceView[], + extras: TraceStatsExtras = {}, +): TraceStats { + let frames = 0; + let bytes = 0; + let subscriptions = 0; + let liveSubscriptions = 0; + let malformed = 0; + let orphaned = 0; + let unpaired = 0; + let retryStorms = 0; + let truncated = 0; + let out = 0; + let inbound = 0; + let durationTotal = 0; + let durationMax = 0; + const methodCounts = new Map(); + for (const view of views) { + frames += view.frames.length; + durationTotal += view.durationMs; + if (view.durationMs > durationMax) durationMax = view.durationMs; + if (view.badges.includes("malformed")) malformed += 1; + if (view.badges.includes("orphaned")) orphaned += 1; + if (view.badges.includes("unpaired")) unpaired += 1; + if (view.badges.includes("retry-storm")) retryStorms += 1; + if (view.badges.includes("truncated")) truncated += 1; + // Subscription liveness comes from the shared definitions rather than a + // local role test, so the strip's "subs · N live" can't disagree with the + // `live` marker the op rows show. + if (isSubscription(view)) { + subscriptions += 1; + if (isLiveSubscription(view)) liveSubscriptions += 1; + } + for (const f of view.frames) { + bytes += f.byteLength ?? 0; + if (f.direction === "out") out += 1; + else inbound += 1; + } + const method = operationMethod(view) ?? UNKNOWN_METHOD; + methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); + } + const ops = views.length; + return { + ops, + frames, + bytes, + subscriptions, + liveSubscriptions, + malformed, + orphaned, + unpaired, + retryStorms, + truncated, + evictedTraces: extras.evictedTraces ?? 0, + droppedByHost: extras.droppedByHost ?? 0, + codecMismatch: extras.codecMismatch ?? false, + out, + in: inbound, + avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), + maxDurationMs: Math.round(durationMax), + topMethods: [...methodCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_METHOD_LIMIT) + .map(([method, count]) => ({ method, count })), + }; +} + +/** + * `512 B` / `1.4 KB` / `2.10 MB`, for a {@link TraceStats} byte total. Shared so + * the two mounts' summary strips read the same number the same way. + */ +export function formatStatBytes(n: number): string { + if (n < 1024) return `${String(n)} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +} + +/** `340ms` / `1.20s`, for a {@link TraceStats} duration. Shared, as above. */ +export function formatStatMs(ms: number): string { + return ms < 1000 ? `${String(Math.round(ms))}ms` : `${(ms / 1000).toFixed(2)}s`; +} + +/** Live debug session: feed it envelopes, read back grouped traces. */ +export interface DebugSession { + /** Handle one wire envelope from the host tap. */ + handleEnvelope(envelope: DebugFrameEnvelope): void; + /** The underlying trace engine (traces, per-id lookup, clear). */ + readonly traceEngine: WireDebugger; + /** Reverse map from wire `frameId` to method, for labelling frames in a view. */ + readonly methodNames: ReadonlyMap; + /** Whether level-2 value decode is enabled for this session. */ + readonly decodeValues: boolean; + /** + * Drill-down: resolve one frame (by its trace `requestId` and index within + * that trace) to a {@link FrameValueDetail}. Pass `channelId` to disambiguate + * when more than one host is connected (each mints the same `p:N` ids). + * Returns `undefined` if no such frame exists. This is the *only* path that can + * surface a decoded value, and only when {@link DebugSessionOptions.decodeValues} + * is on; otherwise it reports byte length only. + */ + frameDetail( + requestId: string, + index: number, + channelId?: string, + generation?: number, + ): FrameValueDetail | undefined; + /** + * Decode every frame of one op in a single trace resolution, keyed by frame + * index (`seq`). This is the batch path the inline drill-down uses, so a mount + * resolves the op once rather than re-resolving it per frame. Empty when decode + * is off or the op is not found. + */ + decodedFrames( + requestId: string, + channelId?: string, + generation?: number, + ): Map; +} + +/** + * Build a {@link DebugSession}. The `frameId → method` map is derived from the + * generated wire table and client service names, so traces show + * `account.getAccount` rather than a bare `id=22`. + */ +export function createDebugSession( + options: DebugSessionOptions = {}, +): DebugSession { + // Dev-only tool: decode everything by default. The developer is looking at + // their own session's traffic, so value decode is ON unless a caller explicitly + // turns it off (tests do). + const decodeValues = options.decodeValues ?? true; + const serviceNames = Object.keys(createClient(createTransport(NOOP_PROVIDER))); + const methodNames = createMethodNameMap( + W as unknown as Record, + serviceNames, + ); + // No `sink`: a session accumulates traces for the view/`/traces`; it must not + // spam the server console with a line per frame (the sink default is + // `console.debug`). Consumers read `traceEngine`, not stdout. + // + // The retention caps are the session's memory ceiling + // (`maxTraces × maxFramesPerTrace`, bounded in bytes by `maxBytesPerTrace`), so + // they are forwarded rather than left at the engine default: a mount that lives + // in the observed app's own tab has to be able to lower them. + const wireDebugger = createWireDebugger({ + methodNames, + sink: () => {}, + ...(options.maxTraces === undefined ? {} : { maxTraces: options.maxTraces }), + ...(options.maxFramesPerTrace === undefined + ? {} + : { maxFramesPerTrace: options.maxFramesPerTrace }), + ...(options.maxBytesPerTrace === undefined + ? {} + : { maxBytesPerTrace: options.maxBytesPerTrace }), + }); + // Raw bytes are retained only when decode is on - they exist solely to feed + // the drill-down decoder, and `/traces` never serializes them. `methodNames` + // resolves each frame's role at ingest, so the engine and any forward hook see + // the real role rather than "unknown". + const handleEnvelope = createDebugIngest(wireDebugger.observe, { + retainBytes: decodeValues, + methodNames, + }); + const decoder = createFrameDecoder({ enabled: decodeValues }); + + const frameDetail = ( + requestId: string, + index: number, + channelId?: string, + generation?: number, + ): FrameValueDetail | undefined => { + const frame = wireDebugger.trace(requestId, channelId, generation)?.frames[ + index + ]; + return frame ? decoder.detail(frame) : undefined; + }; + + const decodedFrames = ( + requestId: string, + channelId?: string, + generation?: number, + ): Map => { + const decoded = new Map(); + if (!decodeValues) return decoded; + // Resolve the op once, then decode each frame off the resolved trace, rather + // than re-resolving (a linear scan over every retained trace) per frame. + const trace = wireDebugger.trace(requestId, channelId, generation); + if (!trace) return decoded; + trace.frames.forEach((frame, index) => { + const detail = decoder.detail(frame); + if (detail !== undefined) decoded.set(index, detail); + }); + return decoded; + }; + + return { + handleEnvelope, + traceEngine: wireDebugger, + methodNames, + decodeValues, + frameDetail, + decodedFrames, + }; +} + +/** + * Decode every frame of an op up front, keyed by frame `seq`, ready to hand to + * {@link renderTraceDetail}'s `decoded` option. A dev-only tool shows values + * inline rather than behind a per-frame control, so a mount decodes the whole + * op in one pass. Returns an empty map when the session has decode off. + */ +export function decodeTraceFrames( + session: DebugSession, + view: TraceView, +): Map { + return session.decodedFrames(view.requestId, view.channelId, view.generation); +} diff --git a/js/packages/truapi-debugger/src/trace-render.test.ts b/js/packages/truapi-debugger/src/trace-render.test.ts new file mode 100644 index 000000000..5ec663a84 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.test.ts @@ -0,0 +1,468 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT + +import { describe, expect, test } from "bun:test"; +import type { FrameValueDetail } from "./decode.js"; +import type { FrameRole, ObservedFrame } from "./observed-frame.js"; +import type { TraceView } from "./trace-view.js"; +import { wireTraceToView } from "./trace-view.js"; +import type { + TraceDropCounts, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; +import { + renderFrameValueDetail, + renderOperationRow, + renderTraceDetail, +} from "./trace-render.js"; + +/** Wire ids for one unary method and one subscription, as the wire table has them. */ +const WIRE: ReadonlyMap = new Map([ + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + [40, { method: "account.connectionStatus", kind: "start" }], + [41, { method: "account.connectionStatus", kind: "receive" }], + [42, { method: "account.connectionStatus", kind: "stop" }], + [43, { method: "account.connectionStatus", kind: "interrupt" }], +]); + +/** + * Build a view the way a mount does - through the wire adapter - so the badges + * under test are the ones the engine really assigns, not hand-written ones. + */ +function viewOf( + frames: readonly [number, number][], + dropped?: TraceDropCounts, +): TraceView { + const observed: ObservedFrame[] = frames.map(([frameId, timestamp]) => ({ + channelId: "localhost:3000", + // Real ingest cannot know the lifecycle role; the adapter resolves it from + // the frame id's wire-table kind. + role: "unknown" as FrameRole, + direction: "out", + requestId: "p:1", + frameId, + byteLength: 8, + timestamp, + })); + const trace: WireTrace = { + channelId: "localhost:3000", + requestId: "p:1", + generation: 0, + frames: observed, + startedAt: observed[0]?.timestamp ?? 0, + lastAt: observed[observed.length - 1]?.timestamp ?? 0, + truncated: dropped !== undefined, + dropped: dropped ?? { + framesByCount: 0, + framesByBytes: 0, + payloadsShed: 0, + }, + }; + return wireTraceToView(trace, WIRE); +} + +const view: TraceView = { + requestId: "req-1", + startedAt: 1000, + lastAt: 1150, + durationMs: 150, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 8, + timestamp: 1000, + latencyFromStartMs: 0, + badges: [], + decodable: true, + }, + { + seq: 1, + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 40, + timestamp: 1150, + latencyFromStartMs: 150, + roundTripMs: 150, + badges: [], + decodable: true, + }, + ], + badges: [], +}; + +describe("renderTraceDetail", () => { + test("renders the frame sequence with method, bytes, and round-trip", () => { + const html = renderTraceDetail(view); + expect(html).toContain("account.getAccount"); + expect(html).toContain("40B"); + expect(html).toContain("150ms"); + expect(html).toContain('data-seq="1"'); + }); + + test("is payload-blind by default: no decode control", () => { + const html = renderTraceDetail(view); + expect(html).not.toContain("decode payload"); + }); + + test("shows byte length for a decodable frame with no resolved value", () => { + // Decode on but no value supplied for the frame: it falls back to its size, + // never a click-to-decode control (a dev-only tool decodes up front). + const html = renderTraceDetail(view, { offerDecode: true }); + expect(html).not.toContain("td-frame-decode-btn"); + expect(html).toContain("payload not shown"); + }); + + test("renders a resolved decoded value in place of the control", () => { + const decoded = new Map([ + [1, { kind: "decoded", value: { free: 42 } }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain(""free": 42"); + }); + + test("a bytes-only detail shows byte length, never a value", () => { + const decoded = new Map([ + [0, { kind: "bytes", byteLength: 96 }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain("96B"); + expect(html).toContain("payload not shown"); + expect(html).not.toContain("free"); + }); + + test("escapes wire-sourced strings", () => { + const evil: TraceView = { + ...view, + requestId: '', + frames: [], + }; + const html = renderTraceDetail(evil); + expect(html).not.toContain(" { + const html = renderTraceDetail({ + ...view, + badges: ["orphaned", "retry-storm"], + }); + expect(html).toContain("td-badge-orphaned"); + expect(html).toContain("retry storm"); + }); +}); + +describe("renderFrameValueDetail", () => { + test("bytes-only with no retained hex shows byte length only", () => { + const html = renderFrameValueDetail({ kind: "bytes", byteLength: 12 }); + expect(html).toContain("12B"); + expect(html).toContain("payload not shown"); + }); + + test("bytes with retained hex shows the raw hex, never 'payload not shown'", () => { + const html = renderFrameValueDetail({ + kind: "bytes", + byteLength: 3, + hex: "0x010203", + }); + expect(html).toContain("0x010203"); + expect(html).not.toContain("payload not shown"); + }); +}); + +describe("renderOperationRow — an unanswered op reports how long it has waited", () => { + /** A request that went out and got nothing back: the shape of a hung call. */ + const unanswered: TraceView = { + requestId: "p:4", + channelId: "localhost:3000", + startedAt: 1_000, + lastAt: 1_000, + // One frame, so last === started and the honest span really is 0. + durationMs: 0, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccountAlias", + frameId: 24, + byteLength: 97, + timestamp: 1_000, + latencyFromStartMs: 0, + decodable: false, + badges: ["orphaned"], + }, + ], + badges: ["orphaned"], + }; + + test("counts up from the request instead of reporting 0ms", () => { + // 45s after the request went out, with no reply. + const html = renderOperationRow(unanswered, { now: 46_000 }); + expect(html).toContain("waiting 45.00s"); + expect(html).not.toContain("· 0ms"); + // Flagged so the row can be styled as a problem, not a fast success. + expect(html).toContain("td-op-waiting"); + }); + + test("the wait grows as the call stays unanswered", () => { + const early = renderOperationRow(unanswered, { now: 3_000 }); + const later = renderOperationRow(unanswered, { now: 30_000 }); + expect(early).toContain("waiting 2.00s"); + expect(later).toContain("waiting 29.00s"); + }); + + test("without a clock it falls back to the recorded span", () => { + // Callers that cannot supply a clock (or replay a fixed trace) keep the old + // behaviour rather than inventing a time. + const html = renderOperationRow(unanswered); + expect(html).toContain("0ms"); + expect(html).not.toContain("waiting"); + expect(html).not.toContain("td-op-waiting"); + }); + + test("an answered op still shows its real round trip, not a wait", () => { + const answered: TraceView = { + ...unanswered, + requestId: "p:2", + lastAt: 1_150, + durationMs: 150, + frames: [ + { ...unanswered.frames[0]!, badges: [] }, + { + seq: 1, + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 35, + // Distinct from the request it answers: identical timestamps would make + // this fixture describe an impossible 0ms reply if it is ever fed to + // renderTraceDetail, which does read these. + timestamp: 1_120, + latencyFromStartMs: 120, + decodable: false, + badges: [], + }, + ], + badges: [], + }; + const html = renderOperationRow(answered, { now: 999_999 }); + expect(html).toContain("150ms"); + expect(html).not.toContain("waiting"); + }); + + test("an unanswered subscribe (orphaned start) also counts up", () => { + // The true-positive on the `start` leg: a subscribe that never delivered. + const view = viewOf([[40, 1_000]]); + expect(view.frames[0].badges).toContain("orphaned"); + const html = renderOperationRow(view, { now: 6_000 }); + expect(html).toContain("waiting 5.00s"); + // It is a subscription with no terminator, so it is live AND waiting: the row + // carries both classes and the stylesheet's precedence rule decides the + // colour. The meta text reports the wait, not the span. + expect(html).toContain("td-op-live"); + expect(html).toContain("td-op-waiting"); + }); +}); + +describe("renderOperationRow — `waiting` needs an unanswered OPENER", () => { + // `orphaned` is now opener-only by construction: every closer with no opener + // earns `unpaired`. These cases used to earn `orphaned` too, and reading that as + // "unanswered" pre-empted the honest duration with a nonsense wait. The badge + // split removes the ambiguity; these tests pin that the render still never + // reports a wait for any of them. + + test("a receive that raced past the stop keeps the op's real duration", () => { + const view = viewOf([ + [40, 1_000], // start + [41, 1_100], // receive + [42, 1_200], // stop + [41, 1_205], // a receive already in flight lands after the stop + ]); + // The late receive is a closer with no opener left on the stack: `unpaired`, + // not an unanswered request. + expect(view.badges).toContain("unpaired"); + expect(view.badges).not.toContain("orphaned"); + expect(view.durationMs).toBe(205); + const html = renderOperationRow(view, { now: 1_000 + 3_600_000 }); + expect(html).toContain("205ms"); + expect(html).not.toContain("waiting"); + expect(html).not.toContain("td-op-waiting"); + }); + + test("a subscription observed receive-only reports live, not a wait", () => { + // The debugger attached mid-session, so the `start` was never observed and no + // receive has an opener. The sub is delivering a frame a second - `unpaired` + // states that plainly instead of implying the host never answered. + const view = viewOf([ + [41, 1_000], + [41, 2_000], + [41, 3_000], + ]); + expect(view.badges).toContain("unpaired"); + expect(view.badges).not.toContain("orphaned"); + const html = renderOperationRow(view, { now: 301_000 }); + expect(html).not.toContain("waiting"); + expect(html).toContain("live"); + }); + + test("an off-table opener leaves a completed round trip reading as one", () => { + // Frame id 999 is not on this debugger's table, so the opener resolves to role + // "unknown", is not recognised as an opener, and its response has none — but + // the call did complete, so `orphaned` would be a lie about the host. + const view = viewOf([ + [999, 1_000], + [23, 1_120], + ]); + expect(view.badges).toContain("unpaired"); + expect(view.badges).not.toContain("orphaned"); + const html = renderOperationRow(view, { now: 1_000 + 3_600_000 }); + expect(html).toContain("120ms"); + expect(html).not.toContain("waiting"); + }); +}); + +describe("renderOperationRow — liveness", () => { + test("a subscription the host interrupted is not live", () => { + // `interrupt` is the host's terminator. Testing only for `stop` leaves every + // host-ended subscription reading live for the rest of the session. + const view = viewOf([ + [40, 1_000], + [41, 1_100], + [43, 1_200], // interrupt + ]); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("td-op-live"); + expect(html).not.toContain("live"); + }); + + test("a subscription with no terminator is still live", () => { + const html = renderOperationRow( + viewOf([ + [40, 1_000], + [41, 1_100], + ]), + ); + expect(html).toContain("td-op-live"); + }); +}); + +describe("truncation is reported per axis, not as one boolean", () => { + test("the badge carries the count and names the cap that took the frames", () => { + const view = viewOf([[40, 1_000]], { + framesByCount: 77, + framesByBytes: 0, + payloadsShed: 0, + }); + const html = renderOperationRow(view); + expect(html).toContain("td-badge-truncated"); + expect(html).toContain("truncated 77"); + expect(html).toContain("77 frames dropped (frame cap)"); + }); + + test("one frame lost does not render like seventy-seven", () => { + const one = renderOperationRow( + viewOf([[40, 1_000]], { + framesByCount: 1, + framesByBytes: 0, + payloadsShed: 0, + }), + ); + const many = renderOperationRow( + viewOf([[40, 1_000]], { + framesByCount: 77, + framesByBytes: 0, + payloadsShed: 0, + }), + ); + expect(one).toContain("truncated 1"); + expect(many).toContain("truncated 77"); + expect(one).not.toBe(many); + }); + + test("the byte axis is distinguishable from the frame axis", () => { + const html = renderTraceDetail( + viewOf([[40, 1_000]], { + framesByCount: 0, + framesByBytes: 4, + payloadsShed: 2, + }), + ); + expect(html).toContain("4 frames dropped (byte cap)"); + expect(html).toContain("2 payloads shed"); + expect(html).not.toContain("frame cap"); + }); +}); + +describe("duration formatting", () => { + test("a long wait reads in hours, not thousands of seconds", () => { + const view: TraceView = { + requestId: "p:9", + startedAt: 0, + lastAt: 0, + durationMs: 0, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 8, + timestamp: 0, + latencyFromStartMs: 0, + badges: ["orphaned"], + decodable: false, + }, + ], + badges: ["orphaned"], + }; + expect(renderOperationRow(view, { now: 10_800_000 })).toContain( + "waiting 3h 00m", + ); + expect(renderOperationRow(view, { now: 10_800_000 })).not.toContain( + "10800.00s", + ); + expect(renderOperationRow(view, { now: 205_000 })).toContain( + "waiting 3m 25s", + ); + // Under a minute still reads in seconds. + expect(renderOperationRow(view, { now: 45_000 })).toContain( + "waiting 45.00s", + ); + }); + + test("a multi-minute op's span reads in minutes", () => { + const html = renderOperationRow( + viewOf([ + [40, 0], + [41, 205_000], + ]), + ); + expect(html).toContain("3m 25s"); + }); +}); + +describe("method labels survive left-truncation", () => { + test("the method is emitted inside an explicit LTR isolate", () => { + // `.td-op-method` uses `direction: rtl` to put the ellipsis on the left, which + // reorders any label that is not a pure LTR identifier (`account.getAccount:` + // → `:account.getAccount`). The isolate keeps it one left-to-right run. + const html = renderOperationRow( + viewOf([ + [22, 1_000], + [23, 1_100], + ]), + ); + expect(html).toContain('account.getAccount'); + }); +}); diff --git a/js/packages/truapi-debugger/src/trace-render.ts b/js/packages/truapi-debugger/src/trace-render.ts new file mode 100644 index 000000000..033b27de5 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -0,0 +1,404 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * The one drill-down renderer, mounted in both the standalone app and dotli's + * panel. + * + * "One level deeper": given a selected op, render its frame sequence - + * request→response, or subscribe→receive×N→stop - with method, direction, byte + * length, latency, and orphaned/unpaired/malformed/retry-storm badges. It is a pure + * `TraceView → HTML` function so the two mounts render identically; each mount + * supplies the {@link TraceView} through its own adapter (see {@link + * wireTraceToView} for the wire vantage). + * + * Payload-blind by default. Level-2 value decode is offered only when a mount + * opts in (`offerDecode`) and passes decode results back in (`decoded`); the + * renderer never touches bytes itself. Decode results come from the Core + + * Decode thread's {@link FrameValueDetail}: a frame renders either its decoded + * value or its byte length. + * + * The renderer emits HTML strings (both mounts assign `innerHTML`) using `td-*` + * classes so one stylesheet covers both. Every interpolated string that came + * off the wire (`requestId`, `method`) is escaped. + * + * @module + */ + +import type { FrameValueDetail } from "./decode.js"; +import { + isLiveSubscription, + isSubscription, + operationMethod, +} from "./trace-view.js"; +import type { + TraceBadge, + TraceFrameBadge, + TraceFrameView, + TraceView, +} from "./trace-view.js"; +import type { TraceDropCounts } from "./wire-debugger.js"; + +/** Options controlling a single drill-down render. */ +export interface RenderTraceDetailOptions { + /** + * Offer the per-frame level-2 decode affordance for decodable frames. Off by + * default: the view stays payload-blind and shows no decode control. + */ + offerDecode?: boolean; + /** + * Decoded values for this op, keyed by frame `seq`. A dev-only mount decodes + * every frame up front (calling the Core session's `frameDetail`) and passes + * the results here. A frame absent from the map falls back to its byte length. + */ + decoded?: ReadonlyMap; +} + +/** HTML-escape a wire-sourced string before it touches `innerHTML`. */ +function esc(value: string): string { + return value.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +/** + * Compact duration: `42` → `42ms`, `1234` → `1.23s`, `205_000` → `3m 25s`, + * `10_800_000` → `3h 00m`. + * + * Seconds cannot be the largest unit: this also formats how long an unanswered + * call has been waiting, and a session left open renders "10800.00s" - a number + * nobody reads as three hours. + */ +function formatMs(ms: number): string { + if (ms < 1000) return `${String(Math.round(ms))}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`; + const pad = (n: number): string => String(n).padStart(2, "0"); + const totalSeconds = Math.floor(ms / 1000); + if (ms < 3_600_000) { + return `${String(Math.floor(totalSeconds / 60))}m ${pad(totalSeconds % 60)}s`; + } + const totalMinutes = Math.floor(totalSeconds / 60); + return `${String(Math.floor(totalMinutes / 60))}h ${pad(totalMinutes % 60)}m`; +} + +const DIRECTION_GLYPH: Record = { + out: "▶", + in: "◀", +}; + +/** + * Render the drill-down detail for one op. Returns an HTML fragment for a + * mount's detail pane (`.td-detail` in dotli, the detail column in the app). + */ +export function renderTraceDetail( + view: TraceView, + options: RenderTraceDetailOptions = {}, +): string { + const offerDecode = options.offerDecode ?? false; + const decoded = options.decoded; + + const header = renderHeader(view); + const rows = view.frames + .map((frame) => renderFrameRow(frame, offerDecode, decoded?.get(frame.seq))) + .join(""); + + return ( + `
` + + header + + `
${rows}
` + + `
` + ); +} + +function renderHeader(view: TraceView): string { + const badges = view.badges + .map((b) => renderOpBadge(b, view.dropped)) + .join(""); + const frameCount = view.frames.length; + return ( + `
` + + `${esc(view.requestId)}` + + `${String(frameCount)} frame${frameCount === 1 ? "" : "s"} · ${formatMs(view.durationMs)}` + + (badges === "" ? "" : `${badges}`) + + `
` + ); +} + +const OP_BADGE_LABEL: Record = { + orphaned: "orphaned", + unpaired: "unpaired", + malformed: "malformed", + "retry-storm": "retry storm", + truncated: "truncated", +}; + +function renderOpBadge(badge: TraceBadge, dropped?: TraceDropCounts): string { + // `truncated` carries a count when the vantage supplies one, so "1 frame lost" + // and "77 lost" don't render identically. + const label = + badge === "truncated" && dropped !== undefined + ? `truncated ${String(droppedTotal(dropped))}` + : OP_BADGE_LABEL[badge]; + return `${esc(label)}`; +} + +/** Frames missing plus payloads shed: everything the caps took from this op. */ +function droppedTotal(dropped: TraceDropCounts): number { + return dropped.framesByCount + dropped.framesByBytes + dropped.payloadsShed; +} + +/** Spell out which cap took what, so the two axes are distinguishable. */ +function truncationTitle(dropped: TraceDropCounts): string { + const parts: string[] = []; + if (dropped.framesByCount > 0) { + parts.push(`${String(dropped.framesByCount)} frames dropped (frame cap)`); + } + if (dropped.framesByBytes > 0) { + parts.push(`${String(dropped.framesByBytes)} frames dropped (byte cap)`); + } + if (dropped.payloadsShed > 0) { + parts.push( + `${String(dropped.payloadsShed)} payloads shed (single frame over the byte cap; frame kept)`, + ); + } + return parts.length === 0 + ? "Older frames were dropped to stay under the frame/byte cap" + : parts.join(" · "); +} + +function badgeTitle(badge: TraceBadge, dropped?: TraceDropCounts): string { + switch (badge) { + case "orphaned": + return "An opening frame has no matching close - it went out and nothing came back"; + case "unpaired": + return "A closing frame with no opener observed - an op that began before the debugger attached, a close the engine outlived, or a second close. Not a host fault on its own"; + case "malformed": + return "A frame failed to decode on the wire"; + case "retry-storm": + return "This op is one of a burst of like ops in a short window"; + case "truncated": + return dropped === undefined + ? "Older frames were dropped to stay under the frame/byte cap" + : truncationTitle(dropped); + } +} + +const FRAME_BADGE_LABEL: Record = { + malformed: "malformed", + orphaned: "orphaned", + unpaired: "unpaired", +}; + +function renderFrameRow( + frame: TraceFrameView, + offerDecode: boolean, + detail: FrameValueDetail | undefined, +): string { + const glyph = DIRECTION_GLYPH[frame.direction]; + const method = + frame.method === undefined + ? `id ${String(frame.frameId ?? "?")}` + : `${esc(frame.method)}`; + const role = `${esc(frame.role)}`; + const size = + frame.byteLength === undefined + ? "" + : `${String(frame.byteLength)}B`; + const latency = renderLatency(frame); + const badges = frame.badges + .map( + (b) => + `${esc(FRAME_BADGE_LABEL[b])}`, + ) + .join(""); + + // The frame's meta (direction, role, method, size, latency, badges) is one + // grouped cell so a mount can pin the level-2 payload into a fixed second + // column beside it - every frame's decoded box then opens in the same aligned + // space rather than trailing variable-width meta. + const meta = + `
` + + `${glyph}` + + role + + method + + size + + latency + + (badges === "" ? "" : `${badges}`) + + `
`; + + const payload = + offerDecode && frame.decodable + ? `
${renderDecodeBlock(frame, detail)}
` + : ""; + + return ( + `
` + + meta + + payload + + `
` + ); +} + +function renderLatency(frame: TraceFrameView): string { + // A closing frame that answers an opener shows its round-trip; everything + // else shows its offset from the op's first frame. + if (frame.roundTripMs !== undefined) { + return `⟳ ${formatMs(frame.roundTripMs)}`; + } + if (frame.latencyFromStartMs === 0) { + return `+0`; + } + return `+${formatMs(frame.latencyFromStartMs)}`; +} + +/** + * The level-2 payload slot for one frame. A dev-only tool decodes every frame, + * so this shows the decoded value; a frame whose value could not be resolved + * (bytes not retained, or a decode miss) shows its byte length instead. + */ +function renderDecodeBlock( + frame: TraceFrameView, + detail: FrameValueDetail | undefined, +): string { + if (detail !== undefined) { + return `
${renderFrameValueDetail(detail)}
`; + } + const size = + frame.byteLength === undefined ? "" : `${String(frame.byteLength)}B · `; + return `
${size}payload not shown
`; +} + +/** + * Render a Core-thread {@link FrameValueDetail}. Shared by both mounts so the + * outcome is identical everywhere: a frame shows its decoded value, or its byte + * length when no value is available. + */ +export function renderFrameValueDetail(detail: FrameValueDetail): string { + switch (detail.kind) { + case "bytes": + // Show the raw hex when we have it (dev-only: nothing is hidden); only a + // frame with no retained bytes reads "payload not shown". + return detail.hex !== undefined + ? `
${String(detail.byteLength)}B · ${esc(detail.hex)}
` + : `
${String(detail.byteLength)}B · payload not shown
`; + case "decoded": + return `
${esc(stringifyValue(detail.value))}
`; + } +} + +/** Pretty-print a decoded value for a `
`, tolerating cyclic/bigint inputs. */
+function stringifyValue(value: unknown): string {
+  try {
+    return JSON.stringify(
+      value,
+      (_key, v: unknown) => (typeof v === "bigint" ? `${v.toString()}n` : v),
+      2,
+    );
+  } catch {
+    return String(value);
+  }
+}
+
+/**
+ * Whether the op went out and nothing came back: an *opening* frame carrying the
+ * `orphaned` badge. This is the shape a timed-out or hung call takes on the wire
+ * - there is no "timeout" frame to observe, only a request with no reply - so it
+ * is the signal the op list has to surface as elapsed time.
+ *
+ * The role check is now belt-and-braces rather than load-bearing: `orphaned` is
+ * opener-only by construction, since a closer with no opener earns `unpaired`
+ * instead. It used to be essential - the badge fired on both, and the
+ * shapes it caught are often perfectly live: a `receive` that arrived after the
+ * `stop`, a subscription the debugger attached to mid-session and only ever saw
+ * receives of, an opener whose frame id was off this debugger's table. Reading the
+ * op badge as "unanswered" reported a subscription delivering a frame a second as
+ * "waiting 300s", and turned a completed 120ms round trip into "waiting 120s".
+ * Kept so this predicate stays correct on its own terms if the badge derivation
+ * ever widens again.
+ */
+function isUnanswered(view: TraceView): boolean {
+  return view.frames.some(
+    (f) =>
+      (f.role === "request" || f.role === "start") &&
+      f.badges.includes("orphaned"),
+  );
+}
+
+/**
+ * Render one operation-list row: the primary view's unit, one per op. Shows the
+ * method, a request/subscription glyph, op-level badges, frame count, and
+ * duration. A subscription with no `stop` frame is marked live.
+ *
+ * Pure and stateless: the mount toggles `.selected` and manages the keyed diff.
+ * `data-request-id` (+ `data-channel-id` when known) identify the row for
+ * selection and channel filtering. Payload-blind: only shape and timing here.
+ */
+export function renderOperationRow(
+  view: TraceView,
+  options: { now?: number } = {},
+): string {
+  const method = operationMethod(view);
+  const sub = isSubscription(view);
+  // Liveness comes from the canonical predicate: a subscription the host ended
+  // with an `interrupt` is not live either, and counting it as live inflates the
+  // live-subscription total for the rest of the session.
+  const live = isLiveSubscription(view);
+  const kindGlyph = sub ? "⟳" : "▶";
+  const kindClass = sub ? "td-op-sub" : "td-op-req";
+
+  // `.td-op-method` is truncated on the left (`direction: rtl`), which reorders
+  // any label that is not a pure LTR identifier: `account.getAccount:` renders as
+  // `:account.getAccount` and `22.getAccount` as `getAccount.22`, because `.`,
+  // `:` and digits are direction-neutral. An explicit LTR isolate around the
+  // method keeps it a single left-to-right run while the ellipsis stays on the
+  // left, where the whole point of the rtl trick is to put it.
+  const methodHtml =
+    method === undefined
+      ? `(unknown)`
+      : `${esc(method)}`;
+  const badges = view.badges
+    .map((b) => renderOpBadge(b, view.dropped))
+    .join("");
+  const count = view.frames.length;
+  // An unanswered request has one frame, so `lastAt - startedAt` is 0 and the op
+  // reads "0ms" - the opposite of the truth for the case a developer most needs
+  // to see, a call that went out and is still hanging. Report the age of the
+  // request instead, so a stuck op counts up rather than looking instant.
+  const waiting = isUnanswered(view) && options.now !== undefined;
+  const meta = waiting
+    ? `${String(count)} frame${count === 1 ? "" : "s"} · waiting ${formatMs(
+        Math.max(0, (options.now ?? 0) - view.startedAt),
+      )}`
+    : `${String(count)} frame${count === 1 ? "" : "s"} · ` +
+      (live
+        ? `live · ${formatMs(view.durationMs)}`
+        : formatMs(view.durationMs));
+
+  const channelAttr =
+    view.channelId === undefined
+      ? ""
+      : ` data-channel-id="${esc(view.channelId)}"`;
+  // Generation disambiguates ops that recycle a `(channelId, requestId)`; the
+  // client keys rows and the drill-down on it so reused ids stay distinct.
+  const genAttr = ` data-generation="${String(view.generation ?? 0)}"`;
+
+  return (
+    `
` + + `` + + methodHtml + + (badges === "" ? "" : `${badges}`) + + `${meta}` + + `
` + ); +} diff --git a/js/packages/truapi-debugger/src/trace-styles.ts b/js/packages/truapi-debugger/src/trace-styles.ts new file mode 100644 index 000000000..45f85689d --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-styles.ts @@ -0,0 +1,190 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Canonical styling for the shared drill-down renderer's `td-*` classes + * ({@link renderTraceDetail} / {@link renderFrameValueDetail}), co-located with + * the class emitter. + * + * These rules are lifted VERBATIM from dotli's debug-panel stylesheet + * (`hosts/dotli/packages/truapi-debug/src/styles.css`, the drill-down section) + * so the standalone app and dotli render the frame sequence identically, with + * zero drift. dotli keeps its own copy for now and converges onto this one once + * the build-graph seam lets it import `@parity/truapi-debugger`. Keep the two in + * sync until then; do not hand-edit these rules here. + * + * Note the vendored `hosts/dotli` submodule is the stale pre-port copy, so most + * of these drill-down classes are NOT yet byte-comparable against it - this file + * is the source of truth for them, and the dotli-community port picks them up at + * convergence. App-level layout (grid, the summary strip, `--payload-w`, etc.) + * deliberately lives OUTSIDE this file, as overrides after `TRACE_DETAIL_CSS` in + * the standalone shell, so it never contaminates the shared rules. + */ + +/** Verbatim `td-*` drill-down rules; inline into a `