From ff33ef42c4bd762cecd6958e3aabbc8e684ceb05 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 4 Nov 2026 11:59:14 +0000 Subject: [PATCH] feat(chord): telemetry from pages and workers stored by the server, who a span was for The four telemetry gaps (rows on commits, chord.host, microsecond times, a read-only store) and: pages record and send span batches over their link to the server's ingest object (checked, capped, rate-limited, stamped browser and with the link's principal); workers send theirs too, so the server is the store's only writer; principalAttributes lets an app choose what a span records about its principal. The tutorial shows a real page's click traced to the worker, with the trace viewer. --- packages/chord/CHANGELOG.md | 5 +- packages/chord/docs/design.md | 53 ++- .../chord/docs/guide-plugins-hosts/book.md | 43 +- .../chord/docs/guide-plugins-hosts/index.html | 44 +- packages/chord/docs/tutorial/book.md | 422 +++++++++++------- packages/chord/docs/tutorial/index.html | 399 ++++++++++------- .../docs/tutorial/screens/click-after.png | Bin 0 -> 18172 bytes .../docs/tutorial/screens/click-before.png | Bin 0 -> 16248 bytes .../docs/tutorial/screens/traces-ada.png | Bin 0 -> 38968 bytes .../chord/docs/tutorial/screens/traces.png | Bin 81083 -> 92380 bytes packages/chord/package.json | 5 + packages/chord/src/documents/documents.ts | 4 +- packages/chord/src/documents/transaction.ts | 16 +- packages/chord/src/host/attempt.ts | 16 +- packages/chord/src/host/ctx.ts | 31 +- packages/chord/src/host/facet.ts | 5 + packages/chord/src/host/host.ts | 94 +++- packages/chord/src/host/index.ts | 1 + packages/chord/src/host/native.ts | 11 +- packages/chord/src/host/object.ts | 2 + packages/chord/src/host/root.ts | 7 + packages/chord/src/host/route.ts | 6 +- packages/chord/src/host/routines.ts | 1 + packages/chord/src/host/staging.ts | 2 +- packages/chord/src/jobs/job.ts | 15 +- packages/chord/src/node/app/app.ts | 48 +- packages/chord/src/node/app/worker.ts | 45 +- packages/chord/src/node/http/http.ts | 11 +- packages/chord/src/remote/relay.ts | 14 +- packages/chord/src/remote/remote-object.ts | 4 + packages/chord/src/sqlite/node.ts | 19 +- packages/chord/src/store/step.ts | 6 +- packages/chord/src/store/store.ts | 11 +- packages/chord/src/store/table-writes.ts | 7 +- packages/chord/src/surfaces/client.ts | 12 +- packages/chord/src/telemetry-ingest/batch.ts | 136 ++++++ packages/chord/src/telemetry-ingest/facet.ts | 124 +++++ packages/chord/src/telemetry-ingest/index.ts | 20 + .../chord/src/telemetry-ingest/recorder.ts | 348 +++++++++++++++ packages/chord/src/telemetry-ingest/sender.ts | 101 +++++ packages/chord/src/telemetry-sqlite/index.ts | 419 +++++------------ packages/chord/src/telemetry-sqlite/node.ts | 34 ++ packages/chord/src/telemetry-sqlite/schema.ts | 69 +++ packages/chord/src/telemetry-sqlite/store.ts | 36 +- packages/chord/src/telemetry.ts | 43 +- packages/chord/src/web/README.md | 4 + packages/chord/src/web/page.ts | 34 +- .../chord/test/documents/documents.test.ts | 43 +- .../chord/test/examples/61-tutorial/main.ts | 46 +- .../examples/61-tutorial/plugins/lists/web.ts | 10 +- .../61-tutorial/plugins/traces/clock.test.ts | 60 +++ .../61-tutorial/plugins/traces/clock.ts | 42 ++ .../61-tutorial/plugins/traces/server.ts | 67 +-- .../test/examples/61-tutorial/screenshots.ts | 96 ++-- .../examples/61-tutorial/src/api/v1/server.ts | 7 +- .../examples/61-tutorial/src/api/v1/web.ts | 2 +- .../examples/61-tutorial/src/principals.ts | 20 + .../examples/61-tutorial/src/telemetry.ts | 6 +- .../test/examples/61-tutorial/src/web/main.ts | 2 + .../examples/61-tutorial/src/web/style.css | 3 +- .../examples/61-tutorial/src/web/traces.ts | 42 +- .../chord/test/examples/61-tutorial/start.ts | 14 +- .../chord/test/examples/61-tutorial/trace.ts | 33 +- .../chord/test/examples/61-tutorial/worker.ts | 9 +- .../chord/test/examples/62-telemetry/main.ts | 22 +- .../chord/test/examples/62-telemetry/start.ts | 8 +- .../test/examples/62-telemetry/worker.ts | 11 +- packages/chord/test/host/ctx.test.ts | 2 +- packages/chord/test/host/facet.test.ts | 40 +- packages/chord/test/host/host.test.ts | 60 ++- packages/chord/test/host/native.test.ts | 5 +- packages/chord/test/host/root.test.ts | 41 ++ packages/chord/test/host/route.test.ts | 7 +- packages/chord/test/host/routines.test.ts | 9 +- packages/chord/test/jobs/job.test.ts | 18 +- packages/chord/test/jobs/persisted.test.ts | 13 +- .../chord/test/node/fixtures/web/telemetry.ts | 53 +++ packages/chord/test/node/http/http.test.ts | 4 +- packages/chord/test/node/web/page.test.ts | 50 ++- packages/chord/test/node/web/support.ts | 5 +- packages/chord/test/remote/provider.test.ts | 2 + .../chord/test/remote/remote-object.test.ts | 1 + packages/chord/test/sqlite/node.test.ts | 34 ++ .../chord/test/telemetry-ingest/facet.test.ts | 189 ++++++++ .../test/telemetry-ingest/recorder.test.ts | 113 +++++ .../test/telemetry-ingest/sender.test.ts | 98 ++++ .../chord/test/telemetry-sqlite-node.test.ts | 72 +++ packages/chord/test/telemetry-sqlite.test.ts | 89 +++- packages/chord/test/telemetry.test.ts | 28 +- packages/telemetry/CHANGELOG.md | 4 + packages/telemetry/src/index.ts | 8 +- packages/telemetry/src/memory.ts | 37 +- packages/telemetry/src/render.ts | 16 +- packages/telemetry/test/render.test.ts | 9 + packages/telemetry/test/store.test.ts | 48 +- 95 files changed, 3358 insertions(+), 967 deletions(-) create mode 100644 packages/chord/docs/tutorial/screens/click-after.png create mode 100644 packages/chord/docs/tutorial/screens/click-before.png create mode 100644 packages/chord/docs/tutorial/screens/traces-ada.png create mode 100644 packages/chord/src/telemetry-ingest/batch.ts create mode 100644 packages/chord/src/telemetry-ingest/facet.ts create mode 100644 packages/chord/src/telemetry-ingest/index.ts create mode 100644 packages/chord/src/telemetry-ingest/recorder.ts create mode 100644 packages/chord/src/telemetry-ingest/sender.ts create mode 100644 packages/chord/src/telemetry-sqlite/node.ts create mode 100644 packages/chord/src/telemetry-sqlite/schema.ts create mode 100644 packages/chord/test/examples/61-tutorial/plugins/traces/clock.test.ts create mode 100644 packages/chord/test/examples/61-tutorial/plugins/traces/clock.ts create mode 100644 packages/chord/test/node/fixtures/web/telemetry.ts create mode 100644 packages/chord/test/telemetry-ingest/facet.test.ts create mode 100644 packages/chord/test/telemetry-ingest/recorder.test.ts create mode 100644 packages/chord/test/telemetry-ingest/sender.test.ts create mode 100644 packages/chord/test/telemetry-sqlite-node.test.ts diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index e6b0922b9..d1b3d216e 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -6,6 +6,7 @@ - Chord v2 is the package's root: `@earendil-works/chord` exports v2 (was `@earendil-works/chord/next`). Chord v1 moved unchanged to `@earendil-works/chord/old` (was the root) and `@earendil-works/chord/old/node` (was `/node`); it stays until p2 runs on v2. The foundations v1's root re-exported have their own subpaths: `@earendil-works/chord/json` (`JsonValue`, `JsonRepresentation`, `copyJson`, `isJsonValue`) and `@earendil-works/chord/watch` (`Watch`, `NativeWatch` and the watch types), beside `/context` and `/delta`. Migration: imports of `@earendil-works/chord/next` drop `/next`; v1's imports of `@earendil-works/chord` become `@earendil-works/chord/old`, and `/node` becomes `/old/node`; imports of `Context`, `Draft`, `JsonValue`, `copyJson` or the watch types from the root move to `/context`, `/delta`, `/json` and `/watch`. In the repository, v2's `src/next` is now `src`, `src/next-node` is `src/node`, `src/next-web`, `src/next-react` and `src/next-tui` are `src/web`, `src/react` and `src/tui`, v1 is in `src/old`, and tests and docs moved likewise (`test/next` to `test`, `docs/next` to `docs`, v1's to `test/old` and `docs/old`). - Chord v1 (`@earendil-works/chord/old`, `@earendil-works/chord/old/node`) removed, with its tests and docs (`src/old`, `test/old`, `docs/old`). Apps still on v1 pin `@earendil-works/chord@1.5.0`. +- Chord v2's `startWorker` no longer takes `telemetry` (it was added unreleased in slice 9): with a telemetry store on the server, the kit's workers send their spans and metrics to it, and no worker opens the store's file. `startServer`'s `telemetry` is a store with `ingest` (`createSqliteTelemetry`'s). Migration: drop `telemetry` from `startWorker` and the store's path from the worker's environment. ### Added @@ -21,7 +22,9 @@ - Chord v2 gains live queries: `self.query(table, q, limit)` in the factory of a table's owner is a watchable of the query's rows (at most `limit`, in its order), usable as a state member and a derive source. Chord runs it before the object goes live and again inside the commit step of every commit of the object that writes the table, publishing only the diff in that commit's batch; a commit writing no table with a live query runs nothing (`chord.query.runs` counts the runs). Only the object's own tables; the limit is required (`invalid/args` at declaration). A run that fails (a row the code cannot read) is a failed member, reported once as the new `HostError` `query_failed { object, table, error }`, and heals at the next good run. A live query is no native, so an object showing table rows live swaps on reload instead of restarting, as reading them in `self.open` made it. Each touching commit runs the query once (100 rows: about 60 µs, 80 µs watched): not for tables written hundreds of times a second. - Chord v2 gains messages and languages in the app kit (design §23): `defineMessages(namespace, { en: {...}, de: {...} })` returns a typed `t(key, ...args)` (the first catalog types the others: a missing key, an unknown one or a wrong argument is a type error; values are strings or functions), `t.message(key, ...args)` builds a message as data (`MessageData`, `{ namespace, key, params }`, for state members typed with `MessageSchema`), `t.render(message)` renders one (a foreign namespace or an unknown key from a link shows the key), `t.translations` lists its catalogs, and `MessagesOf` types a translation. The `Translations` registry takes a list of `{ namespace, locale, messages, override? }` per contributor (one catalog per namespace and locale: the first by plugin name wins, reported; `override: true` wins); a missing key falls back down the locale's chain (`pt-BR`, `pt`) to the first catalog, reported once. `LocaleChoice` keeps one locale per principal on the server (the theme choice's store, now shared as one preference); a client follows it with `followActiveLanguage`, defaulting to `navigator.languages` on a page and the system locale in the terminal (`startTerminal`'s `locales`), matched against the catalogs, switched live. `useMessages(t)` (`/react`) and `TuiScope.messages(t)` (`/tui`) render in the host's language and re-render or redraw on a change; `startPage` sets `lang` and `dir` on the page (`followLanguage`, `/web`); `formatTime` and `formatNumber` use `Intl` in the language being rendered. The kit serves `Translations` on the server, workers, pages and terminals and `LocaleChoice` on the server; client errors gain `{ kind: "messages", report }`. -- Chord v2 gains telemetry across hosts (slice 9): `CHORD_TELEMETRY_SCHEMA` (root export) types every span, event and metric Chord records. With a host adapter (`createHost({ telemetry })`), calls (`chord.call`), methods (`chord.method`, local and remote), relays (`chord.relay`), holds (`chord.hold`), activations (`chord.activate`, ending `live`, `failed`, `parked` or `ended`, with `chord.retry` and `chord.park` events), natives' opens and closes, `load`, `reload` (a `chord.reload.fate` event per object), resolves (a worker start included), revocations, routine calls, job phases (`intent`, `run`, `finish`: a persisted run resumed in a new attempt or process stays under its stored carrier) and HTTP requests (`chord.http`: method, route pattern, status, never the path) are spans; failures carry a fixed category and the message cut at 500 characters, a cancel is no error; commits are `chord.commit { documents, ops, duration }` events of the span they ran under. `hold` and `call` carry the caller's trace carrier (`trace`, at most 8 keys, keys and values of at most 256 characters of printable ASCII, else the message fails the message schema and the link closes; the binary codec's kind byte flag `0x40`), relays forward their own span's, and the provider runs the method under it (an absent or unknown carrier: the adapter's root); a reference's carrier rides on its first hold only, a re-hold after a reconnect being a fresh root linked to it. A hold's `type` and its address's `kind` are bounded at 256 characters by the message schema, and span attributes a peer chose at 256 characters. `ctx.trace(name, attributes, fn)` runs `fn` in a child span with a child ctx. Host-wide metrics: `chord.link.bytes`, `chord.link.state_messages`, `chord.commit.duration`, `chord.calls.in_flight`, `chord.holds.active`, `chord.links.open`, `chord.park.duration`, `chord.backpressure.wait`. Without an adapter nothing is started, built or sent: a call over a link costs what it did before. `createHttp`, `startServer` and `startWorker` take a `telemetry` adapter (`createHttp` continues a W3C `traceparent` only from a request a contributed relay verified: an app's proxy must strip it from browsers). A host with an adapter counts the cost counters of its links' and root scopes' work on it also when the app passed a context without a span. The SQLite telemetry store keeps spans of one millisecond in the order they started. +- Chord v2 gains telemetry across hosts (slice 9): `CHORD_TELEMETRY_SCHEMA` (root export) types every span, event and metric Chord records. With a host adapter (`createHost({ telemetry })`), calls (`chord.call`), methods (`chord.method`, local and remote), relays (`chord.relay`), holds (`chord.hold`), activations (`chord.activate`, ending `live`, `failed`, `parked` or `ended`, with `chord.retry` and `chord.park` events), natives' opens and closes, `load`, `reload` (a `chord.reload.fate` event per object), resolves (a worker start included), revocations, routine calls, job phases (`intent`, `run`, `finish`: a persisted run resumed in a new attempt or process stays under its stored carrier) and HTTP requests (`chord.http`: method, route pattern, status, never the path) are spans; failures carry a fixed category and the message cut at 500 characters, a cancel is no error; commits are `chord.commit { documents, ops, rows, duration }` events of the span they ran under. `hold` and `call` carry the caller's trace carrier (`trace`, at most 8 keys, keys and values of at most 256 characters of printable ASCII, else the message fails the message schema and the link closes; the binary codec's kind byte flag `0x40`), relays forward their own span's, and the provider runs the method under it (an absent or unknown carrier: the adapter's root); a reference's carrier rides on its first hold only, a re-hold after a reconnect being a fresh root linked to it. A hold's `type` and its address's `kind` are bounded at 256 characters by the message schema, and span attributes a peer chose at 256 characters. `ctx.trace(name, attributes, fn)` runs `fn` in a child span with a child ctx. Host-wide metrics: `chord.link.bytes`, `chord.link.state_messages`, `chord.commit.duration`, `chord.calls.in_flight`, `chord.holds.active`, `chord.links.open`, `chord.park.duration`, `chord.backpressure.wait`. Without an adapter nothing is started, built or sent: a call over a link costs what it did before. `createHttp`, `startServer` and `startWorker` take a `telemetry` adapter (`createHttp` continues a W3C `traceparent` only from a request a contributed relay verified: an app's proxy must strip it from browsers). A host with an adapter counts the cost counters of its links' and root scopes' work on it also when the app passed a context without a span. The SQLite telemetry store keeps spans of one microsecond in the order they started. +- Chord v2's telemetry closes four gaps found writing the tutorial: `chord.commit` counts the table rows a commit's writes changed (`rows`; a commit writing only rows no longer reads `documents=0 ops=0`); every span Chord starts, `ctx.trace`'s too, carries `chord.host`, the host's address key or `client` without an address (`Host` gains `address`); the SQLite telemetry store keeps span times in whole microseconds from pi-telemetry's `telemetryNow` (store version 2: a recorder migrates a version 1 file in place, its rows kept); and `openTelemetryStore(path)` (new subpath `@earendil-works/chord/telemetry-sqlite/node`) reads a store file read-only, implementing `TelemetryStore` only, refusing a file without a store or with one of another version. `openNodeSqliteDatabase` takes `mode: "read-only"`. +- Chord v2's telemetry is stored in one place, the server (decisions "Telemetry from browser pages", "The server is the one place telemetry is stored", "Who a span was for"). Pages and workers record in memory with Chord's `TelemetryRecorder` (the SQLite store's own recorder too) and send finished spans in batches over their link to the server's `PageTelemetry` and `WorkerTelemetry` (`ingest(batch)`), which the app kit serves when `startServer` has a telemetry store; the store gains `ingest` (never replacing a stored span). `startPage({ telemetry: true })` records the page and sends its spans every 3 s and when hidden; the server checks a page's batch strictly (at most 100 spans, every field bounded, ids printable ASCII, no control characters), keeps only spans under the page's own ids (`browser::`), caps them at 1,200 per page and minute, marks them `chord.host: browser` and stamps them with the principal of their link. A worker sends its spans and metrics every second, stamped with its host. `host.root(fn, ctx, { trace: { name, attributes } })` runs `fn` in a new root span (a click). Method and call spans record whom they were for: `chord.principal` (the principal key) by default, or what the host's new `principalAttributes` hook returns (`createHost`, `startServer`, `startWorker`). `ServeOptions.untraced` serves a type whose calls start no method span. `Host` gains `incarnation` and `principalAttributes`. ### Changed diff --git a/packages/chord/docs/design.md b/packages/chord/docs/design.md index f3c165e54..a28987f01 100644 --- a/packages/chord/docs/design.md +++ b/packages/chord/docs/design.md @@ -2769,7 +2769,9 @@ Chord reports traces, events and metrics through `@earendil-works/pi-telemetry`; `chord.open` (under the activation), `chord.close` (a root), `chord.load`, `chord.reload` (a `chord.reload.fate` event per object), `chord.resolve`, `chord.revoke` (recorded after the synchronous revocation, its `count`), `chord.routine` (a root per call), `chord.job` (`intent` under the starter, `run` and `finish` under the start's - carrier), `chord.http`. Outcomes are attributes; a failure is `status: "error"` with a fixed category (`call_failed`, + carrier), `chord.http`. Every span Chord starts, `ctx.trace`'s too, names the host that recorded it: `chord.host`, the + host's address key (`list|groceries`, `server`), `client` without an address, capped like the other attributes (a + viewer tells hosts apart by it, never by a store's span ids). Outcomes are attributes; a failure is `status: "error"` with a fixed category (`call_failed`, `method_failed`, `relay_failed`, `hold_failed`, `activation_failed`, `open_failed`, `close_failed`, `load_failed`, `reload_failed`, `resolve_failed`, `routine_failed`, `job_failed`, `http_failed` for a status of 500 or more, `trace_failed`) and the message cut at 500 characters; a cancel (the caller's signal, `aborted`, an open its attempt @@ -2796,8 +2798,10 @@ Chord reports traces, events and metrics through `@earendil-works/pi-telemetry`; starts no span, builds no attribute object and injects no carrier; with the NOOP adapter a call over a link is within noise of the code before slice 9 (`benchmarks.md`). A recording host's call costs its spans (`OpenSpan`, a span kept open until its work's promise settles). -- **Events and metrics:** `chord.commit { documents, ops, duration }` on the span of the context a transaction ran - under (a method, a routine, a job phase, an HTTP request), only for a commit that changed something; the host-wide +- **Events and metrics:** `chord.commit { documents, ops, rows, duration }` on the span of the context a transaction + ran under (a method, a routine, a job phase, an HTTP request), only for a commit that changed something; `rows` is the + table rows its writes changed, as the commit step already counts them (a `put` one, an update or delete the rows its + statement changed; nothing is measured for it); the host-wide metrics go to the host's adapter (`host.metrics`), not to a context: `chord.link.bytes { direction }` (every encoded message of the host's links), `chord.link.state_messages`, `chord.commit.duration`, `chord.calls.in_flight`, `chord.holds.active`, `chord.links.open`, `chord.park.duration`, `chord.backpressure.wait`. The cost counters still @@ -2809,10 +2813,45 @@ Chord reports traces, events and metrics through `@earendil-works/pi-telemetry`; carrier `{ traceparent, tracestate }`) only from a request a contributed relay verified, each header at most 256 and 512 characters of printable ASCII (else ignored). A verified relay vouches for the proxy, not the browser behind it: an app's proxy must strip `traceparent` and `tracestate` from browsers' requests (or set its own), or a browser - chooses the trace its requests join. `startServer({ telemetry })` passes the adapter to - its host and HTTP; `startWorker({ telemetry })` to its host. Each process exports to the store it is given; a shared - SQLite file per data directory is the app's choice (example 62). The SQLite store orders spans of one millisecond as - their recorder started them (by start time, then id length and id). + chooses the trace its requests join. `startServer({ telemetry })` passes the store to its host and HTTP and serves + the ingest objects; workers and recording pages send it their spans (below). The SQLite store orders spans of one + microsecond as their recorder started them (by start time, then id length and id). +- **Span times (after slice 9):** a span's start is `telemetryNow()` (pi-telemetry): wall-clock milliseconds at + microsecond resolution from `performance.now()` anchored to the wall clock, anchored anew when the two part by more + than 10 ms (a clock set, a sleep the monotonic clock did not count); its end and its events' times are the monotonic + clock's from there. The in-memory adapter and the SQLite store share it, and `renderTrace` prints lengths to the + microsecond. The SQLite store keeps span times in whole microseconds (`started_us`, `ended_us`): store version 2, to + which a recorder migrates a version 1 file in place (its rows' milliseconds multiplied), since several processes + share one file and a new store version would strand the rows already there. +- **The server is the one place telemetry is stored** (decisions "Telemetry from browser pages", "The server is the + one place telemetry is stored", "Who a span was for"; `src/telemetry-ingest/`): the server's store is the only + writer of its file. Pages and workers record with Chord's in-memory `TelemetryRecorder` (the SQLite store records + with it too: one carrier format, one bounded queue of settled spans and metric aggregates per 10 s) and send finished + spans in batches over the link they already have to the server's `PageTelemetry` and `WorkerTelemetry` + (`ingest(batch)`), served by the kit when `startServer` has a telemetry store (`TelemetryContext` with the store's + `ingest`). A worker sends every second (and its last spans and partial metrics when it closes), written as they are, + stamped with its host (`chord.host`: its address key, known from the principal its link stamps). A page records + when `startPage({ telemetry: true })` and sends spans only, every 3 s and when hidden; the server treats it as + untrusted: the batch schema bounds everything (100 spans; names 128 characters; 32 attributes, keys 64, values 256 + characters or numbers or booleans; 16 events; 8 links; error messages 500; ids printable ASCII; no control + characters anywhere), a span counts only when its id, trace and parent are under the page's own prefix + (`browser::`, so it neither writes into another sender's trace nor poses as a server span), + 1,200 spans per page and minute (past it dropped, counted in the answer), each marked `chord.host: browser` and + stamped with the principal attributes of its link, overriding what the page wrote. The store writes ingested spans + with `INSERT OR IGNORE`: a stored span is never replaced. The ingest objects' calls start no `chord.method` + (`ServeOptions.untraced`): a span per batch would be telemetry about telemetry. A trace of a page's own starts with + `host.root(fn, ctx, { trace: { name, attributes } })`, a root span of the host's adapter (`trace_failed` on a throw). + A viewer clamps a page's spans against the server spans under them (the page's clock may be off). +- **Who a span was for:** `chord.method` and `chord.call` record their principal's attributes: by default + `chord.principal`, its principal key (capped); a host's `principalAttributes(principal)` hook replaces them (none, + the key, more: a user's name the app resolves). Chord calls it for every span it records with a principal, so it is + synchronous and cheap, its caching its own; the kit passes `startServer`'s and `startWorker`'s to their hosts, and + the server stamps a page's spans with it for the link's principal. `Host` exposes `principalAttributes`, + `incarnation` and `address` for the kit and the page recorder. +- **Reading a shared file:** `openTelemetryStore(path)` (`@earendil-works/chord/telemetry-sqlite/node`) implements + `TelemetryStore` only, on a read-only SQLite connection (`openNodeSqliteDatabase(path, { mode: "read-only" })`: + deferred transactions, nothing configured or checkpointed): it never writes, creates or migrates the file, and + refuses one without a store or with a store of another version. ## 30. Cost model diff --git a/packages/chord/docs/guide-plugins-hosts/book.md b/packages/chord/docs/guide-plugins-hosts/book.md index 6561d2ceb..98287cebf 100644 --- a/packages/chord/docs/guide-plugins-hosts/book.md +++ b/packages/chord/docs/guide-plugins-hosts/book.md @@ -817,18 +817,25 @@ answer. Chord's telemetry makes the four steps one **trace**: a tree of **spans* host recording its own with its own **adapter**, joined by **carriers** (a few strings naming the caller's span) that ride on the wire with every `hold` and `call`. -Give each host an adapter. With the app kit that is one option per process; the SQLite store -(`@earendil-works/chord/telemetry-sqlite`) records settled spans and metrics, and here every process of the app opens -the same file in the data directory (example 62): +Give the server a store and the kit does the rest. The SQLite store (`@earendil-works/chord/telemetry-sqlite`) +records settled spans and metrics in one file in the data directory, and the server is its only writer: every worker +records in memory and sends its spans and metrics to the server once a second over its link, and a page that records +(`startPage({ telemetry: true })`) sends its own every few seconds and when hidden (example 62): ```ts -// start.ts: the server +// start.ts: the server, its store, and whom spans were for (default: the principal's key) const telemetry = await createSqliteTelemetry(await openNodeSqliteDatabase(join(dataDir, "telemetry.sqlite"))); -await startServer({ ...options, telemetry }, ctx); -// worker.ts: each worker, its own recorder on the same file (the path in its environment) -startWorker({ api, kinds, telemetry: await openTelemetry(process.env.TALLY_TELEMETRY!), onError }); +await startServer({ ...options, telemetry, principalAttributes: (p) => ({ "chord.principal": p.kind }) }, ctx); +// worker.ts: nothing about telemetry (its own `principalAttributes`, if any) +startWorker({ api, kinds, onError }); +// the web entry: the page records +startPage({ ...options, telemetry: true }); ``` +A page is untrusted: the server checks each batch's every field (sizes, no control characters, ids printable ASCII), +keeps only spans under the page's own ids, caps them per page and minute, marks them `chord.host: browser` and stamps +them with the principal of their link. A click is a trace of its own with `host.root(fn, ctx, { trace: { name } })`. + Nothing else changes in plugin code. A method's `ctx.telemetry` is its `chord.method` span, so whatever it calls or commits is parented under it, on its host or the next one. `ctx.trace(name, attributes, fn)` adds a span of your own: @@ -842,19 +849,21 @@ add: async (n, ctx) => { ``` The store renders the click's trace across the three hosts (the client's spans, the server's relay, the worker's -methods and the commit, an event of the method that made it): +methods and the commit, an event of the method that made it); every span of Chord names the host that recorded it +(`chord.host`: its address key, `client` without an address), and method and call spans whom they were for +(`chord.principal`, the host's `principalAttributes` hook): ``` tally.click - chord.hold type="tally.counter" object="tally.counter(t1)" address.kind="tally" outcome="bound" - chord.relay type="tally.counter" member="hold" upstream.kind="tally" outcome="ok" + chord.hold chord.host="client" type="tally.counter" object="tally.counter(t1)" address.kind="tally" outcome="bound" + chord.relay chord.host="server" type="tally.counter" member="hold" upstream.kind="tally" outcome="ok" → link to chord.resolve - chord.call type="tally.counter" member="add" object="tally.counter(t1)" address.kind="tally" outcome="ok" - chord.relay type="tally.counter" member="#0" upstream.kind="tally" outcome="ok" - chord.method type="tally.counter" member="add" object="tally.counter(t1)" principal.kind="owner" outcome="ok" - tally.check n=3 - chord.method type="tally.store" member="add" object="tally.store" principal.kind="worker" outcome="ok" - · chord.commit documents=1 ops=1 duration=1.9 + chord.call chord.host="client" type="tally.counter" member="add" object="tally.counter(t1)" address.kind="tally" outcome="ok" + chord.relay chord.host="server" type="tally.counter" member="#0" upstream.kind="tally" outcome="ok" + chord.method chord.host="tally|t1" type="tally.counter" member="add" object="tally.counter(t1)" principal.kind="owner" chord.principal="{\"kind\":\"owner\"}" outcome="ok" + tally.check chord.host="tally|t1" n=3 + chord.method chord.host="tally|t1" type="tally.store" member="add" object="tally.store" principal.kind="worker" chord.principal="{\"kind\":\"worker\",\"tally\":\"tally|t1\"}" outcome="ok" + · chord.commit documents=1 ops=1 rows=0 duration=1.9 ``` Work shared by several requests is its own trace, linked rather than nested: the worker's start (`chord.resolve`), @@ -862,6 +871,8 @@ an object's activation (`chord.activate`, linked to whoever asked first). Routin `run` and `finish` stay under the carrier its start stored, even when a restarted worker resumes the run. Metrics (`chord.link.bytes`, `chord.commit.duration`, `chord.calls.in_flight`, ...) are aggregated per 10 s; ids are never their attributes. Without an adapter (the default), Chord starts no span and sends no carrier: a call costs what it did before. +A script or a viewer reads the file with `openTelemetryStore(path)` (`@earendil-works/chord/telemetry-sqlite/node`), +which opens it read-only and offers only the read interface (`findTraces`, `getTrace`, `findMetrics`). # Reference {.appendix} diff --git a/packages/chord/docs/guide-plugins-hosts/index.html b/packages/chord/docs/guide-plugins-hosts/index.html index 7c4184fe6..bab3c7ffe 100644 --- a/packages/chord/docs/guide-plugins-hosts/index.html +++ b/packages/chord/docs/guide-plugins-hosts/index.html @@ -734,14 +734,20 @@ method commits. When it is slow, where did the time go? Each host is a different answer. Chord's telemetry makes the four steps one trace: a tree of spans (named, timed pieces of work), each host recording its own with its own adapter, joined by carriers (a few strings naming the caller's span) that ride on the wire with every hold and call.

-

Give each host an adapter. With the app kit that is one option per process; the SQLite store -(@earendil-works/chord/telemetry-sqlite) records settled spans and metrics, and here every process of the app opens -the same file in the data directory (example 62):

-
// start.ts: the server
+

Give the server a store and the kit does the rest. The SQLite store (@earendil-works/chord/telemetry-sqlite) +records settled spans and metrics in one file in the data directory, and the server is its only writer: every worker +records in memory and sends its spans and metrics to the server once a second over its link, and a page that records +(startPage({ telemetry: true })) sends its own every few seconds and when hidden (example 62):

+
// start.ts: the server, its store, and whom spans were for (default: the principal's key)
 const telemetry = await createSqliteTelemetry(await openNodeSqliteDatabase(join(dataDir, "telemetry.sqlite")));
-await startServer({ ...options, telemetry }, ctx);
-// worker.ts: each worker, its own recorder on the same file (the path in its environment)
-startWorker({ api, kinds, telemetry: await openTelemetry(process.env.TALLY_TELEMETRY!), onError });
+await startServer({ ...options, telemetry, principalAttributes: (p) => ({ "chord.principal": p.kind }) }, ctx); +// worker.ts: nothing about telemetry (its own `principalAttributes`, if any) +startWorker({ api, kinds, onError }); +// the web entry: the page records +startPage({ ...options, telemetry: true });
+

A page is untrusted: the server checks each batch's every field (sizes, no control characters, ids printable ASCII), +keeps only spans under the page's own ids, caps them per page and minute, marks them chord.host: browser and stamps +them with the principal of their link. A click is a trace of its own with host.root(fn, ctx, { trace: { name } }).

Nothing else changes in plugin code. A method's ctx.telemetry is its chord.method span, so whatever it calls or commits is parented under it, on its host or the next one. ctx.trace(name, attributes, fn) adds a span of your own:

add: async (n, ctx) => {
@@ -751,22 +757,26 @@ commits is parented under it, on its host or the next one. ctx.trace(name,
 	return store.add(n, ctx);
 },

The store renders the click's trace across the three hosts (the client's spans, the server's relay, the worker's -methods and the commit, an event of the method that made it):

+methods and the commit, an event of the method that made it); every span of Chord names the host that recorded it +(chord.host: its address key, client without an address), and method and call spans whom they were for +(chord.principal, the host's principalAttributes hook):

tally.click
-  chord.hold type="tally.counter" object="tally.counter(t1)" address.kind="tally" outcome="bound"
-    chord.relay type="tally.counter" member="hold" upstream.kind="tally" outcome="ok"
+  chord.hold chord.host="client" type="tally.counter" object="tally.counter(t1)" address.kind="tally" outcome="bound"
+    chord.relay chord.host="server" type="tally.counter" member="hold" upstream.kind="tally" outcome="ok"
       → link to chord.resolve
-  chord.call type="tally.counter" member="add" object="tally.counter(t1)" address.kind="tally" outcome="ok"
-    chord.relay type="tally.counter" member="#0" upstream.kind="tally" outcome="ok"
-      chord.method type="tally.counter" member="add" object="tally.counter(t1)" principal.kind="owner" outcome="ok"
-        tally.check n=3
-        chord.method type="tally.store" member="add" object="tally.store" principal.kind="worker" outcome="ok"
-          · chord.commit documents=1 ops=1 duration=1.9
+ chord.call chord.host="client" type="tally.counter" member="add" object="tally.counter(t1)" address.kind="tally" outcome="ok" + chord.relay chord.host="server" type="tally.counter" member="#0" upstream.kind="tally" outcome="ok" + chord.method chord.host="tally|t1" type="tally.counter" member="add" object="tally.counter(t1)" principal.kind="owner" chord.principal="{\"kind\":\"owner\"}" outcome="ok" + tally.check chord.host="tally|t1" n=3 + chord.method chord.host="tally|t1" type="tally.store" member="add" object="tally.store" principal.kind="worker" chord.principal="{\"kind\":\"worker\",\"tally\":\"tally|t1\"}" outcome="ok" + · chord.commit documents=1 ops=1 rows=0 duration=1.9

Work shared by several requests is its own trace, linked rather than nested: the worker's start (chord.resolve), an object's activation (chord.activate, linked to whoever asked first). Routines and job phases are roots too; a job's run and finish stay under the carrier its start stored, even when a restarted worker resumes the run. Metrics (chord.link.bytes, chord.commit.duration, chord.calls.in_flight, ...) are aggregated per 10 s; ids are never their -attributes. Without an adapter (the default), Chord starts no span and sends no carrier: a call costs what it did before.

+attributes. Without an adapter (the default), Chord starts no span and sends no carrier: a call costs what it did before. +A script or a viewer reads the file with openTelemetryStore(path) (@earendil-works/chord/telemetry-sqlite/node), +which opens it read-only and offers only the read interface (findTraces, getTrace, findMetrics).

A

Reference

diff --git a/packages/chord/docs/tutorial/book.md b/packages/chord/docs/tutorial/book.md index 941541e05..8f86cbd30 100644 --- a/packages/chord/docs/tutorial/book.md +++ b/packages/chord/docs/tutorial/book.md @@ -61,9 +61,9 @@ that step says so and the rest runs. server, the workers, the pages and the terminals, in place. 8. **Text is rendered per viewer.** A plugin's messages are typed code, a translation is a plugin, each user picks a language, and servers store keys, never sentences. -9. **One click is one trace.** Every process records spans to the telemetry store it is given (here one SQLite file - in the data directory); carriers in the calls' headers join the page's call, the server's relay and the worker's - method into one trace, which `TelemetryStore` reads back. `ctx.trace` adds spans of your own. +9. **One click is one trace.** The server writes the telemetry store (here one SQLite file in the data directory); the + page and the workers send it their spans; carriers in the calls' headers join the page's click, the server's relay + and the worker's method into one trace, which `TelemetryStore` reads back. `ctx.trace` adds spans of your own. 10. **Six traps:** load before use; a reference acts as whoever made it; await what you start; a watch starts at the current value; the plain `t` has no language of its own; a trace starts where a span starts. ::: @@ -231,14 +231,15 @@ export function startTodo( api: API, kinds: KINDS, principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) }, - // The server's spans and metrics; each list's worker opens the same file (`worker.ts`). + // The store the server writes: its own spans and metrics, and what the workers and recording pages send. telemetry: telemetry.store, + // Who a span was for: a short key and a user's name (each worker's `startWorker` gets the same hook). + principalAttributes, workers: [ { kind: v1.List, entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], - env: { TODO_TELEMETRY: telemetryFile(dataDir) }, start: () => ({}), // Who may reach a list's worker: everyone signed in, and the server itself. authorize: (principal) => principal.kind !== "worker", @@ -254,22 +255,21 @@ export function startTodo( } ``` -`workers` is chapter II, `web` chapter IV (its second path, `/traces`, chapter XI), `tui` chapter V, `telemetry` -chapter XI. `listen` asks for a free port on loopback, as the example does; a real app picks a port. The worker entry -is a few lines (its telemetry store is chapter XI's too): +`workers` is chapter II, `web` chapter IV (its second path, `/traces`, chapter XI), `tui` chapter V, `telemetry` and +`principalAttributes` chapter XI. `listen` asks for a free port on loopback, as the example does; a real app picks a +port. The worker entry is a few lines (its `principalAttributes` is chapter XI's too): ```ts // The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects -// to the server and loads the plugins' worker facets. The worker records to the app's telemetry store, the file its -// server named in the environment: its methods called through the server continue the caller's trace. +// to the server and loads the plugins' worker facets. The worker records its spans in memory and sends them to the +// server, which stores them: its methods called through the server continue the caller's trace. import { startWorker } from "../../../src/node/app/index.ts"; -import { openTelemetry } from "./src/telemetry.ts"; +import { principalAttributes } from "./src/principals.ts"; import { API, KINDS } from "./start.ts"; -const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!); -startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) }); +startWorker({ api: API, kinds: KINDS, principalAttributes, onError: (error) => console.error("list:", error) }); ``` ## Running it @@ -396,7 +396,6 @@ it on to its parent or to a route (design §3, "Host kinds and addresses"). The kind: v1.List, entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], - env: { TODO_TELEMETRY: telemetryFile(dataDir) }, start: () => ({}), // Who may reach a list's worker: everyone signed in, and the server itself. authorize: (principal) => principal.kind !== "worker", @@ -404,7 +403,7 @@ it on to its parent or to a route (design §3, "Host kinds and addresses"). The ], ``` -`env` names the app's telemetry store for the worker (chapter XI). `authorize` runs for every request routed to a +`authorize` runs for every request routed to a list before anything starts; the first request for an address starts its worker, and a worker idle for five minutes (the default `idleMs`) stops. **Variants** are the member sets a contract offers: the owner's `Todos` has `clear`, a member's does not. The factory picks one per principal. @@ -669,7 +668,7 @@ for them: import type { ComponentType } from "react"; import { defineRegistry } from "../../../../../../src/index.ts"; -export { useHold, useMessages, usePageState, useWatch } from "../../../../../../src/react/index.ts"; +export { useHold, useHost, useMessages, usePageState, useWatch } from "../../../../../../src/react/index.ts"; /** A panel of the page: its name (a key, never shown) and its React component, which shows its own title. */ export type Panel = { readonly name: string; readonly Component: ComponentType }; @@ -702,11 +701,13 @@ void startPage({ connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`), modules: { "@todo/app/v1": v1, "@todo/app/v1/web": v1web, react: React, "react/jsx-runtime": jsx }, core: [{ name: "@todo/web", facet: core }], + // The page records its clicks and the calls under them, and sends them to the server every few seconds. + telemetry: true, render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))), }); ``` -The path `/traces` is the owner's trace viewer (chapter XI). `startPage` makes the page's host, connects it to `/chord` +The path `/traces` is the owner's trace viewer, and `telemetry` what it shows (chapter XI). `startPage` makes the page's host, connects it to `/chord` (reconnecting after drops), loads the current plugin scripts with the entry's core facets, and keeps them current. `HostProvider` gives the React tree that host. @@ -719,6 +720,7 @@ when its items change: ```ts function TodoList() { const { t } = useMessages(messages); + const host = useHost(); const list = location.hash.slice(1) || "groceries"; const todos = useHold(Todos, { list }); const items = useWatch(todos?.items) ?? []; @@ -727,8 +729,11 @@ function TodoList() { const [text, setText] = usePageState(`lists.draft.${list}`, ""); const add = (event: { preventDefault(): void }) => { event.preventDefault(); - if (todos === undefined || text.trim() === "") return; - void todos.add(text, context); + // An empty todo too: the list's store refuses it, and the click's trace shows where. + if (todos === undefined) return; + // The click is a trace of its own: a root span of the page, the call to the list's worker under it. + const trace = { name: "page.add", attributes: { chars: text.length } }; + void host.root((ctx) => todos.add(text, ctx), context, { trace }).catch(() => {}); setText(""); }; return h( @@ -777,7 +782,8 @@ export default defineFacet((_host, self) => { `messages.ts`, and the facet contributes its catalogs to the page's `Translations`). `useHold` holds a reference while the component is mounted and releases it on unmount; it is `undefined` on the first render. `useWatch` follows a member. `usePageState` is `useState` whose value the page keeps under a key instead of the component: chapter IX shows why the half-typed todo needs it. Clicks call methods with `BACKGROUND_CONTEXT`, which never ends, so a click's call -is not cancelled when the handler returns. `todos?.variant === "owner"` narrows the reference: only the owner's has +is not cancelled when the handler returns; adding a todo runs in `host.root` (`useHost` gives the page's host), which +makes the click a trace of its own (chapter XI). `todos?.variant === "owner"` narrows the reference: only the owner's has `clear`, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed by the plugin's name (a `*.module.css` file would scope them instead): @@ -1443,7 +1449,7 @@ the server takes the worker's process lock and reads the store itself. `importDa replacing a list is a delete, then an import (decision "App kit naming and export"). Registering an imported list in an index of the app's, if it had one, is the app's. -# Seeing What Happened {#telemetry lede="One telemetry store for every process, one trace from a click to a commit, a span of your own, and a small viewer for the owner."} +# Seeing What Happened {#telemetry lede="One telemetry store on the server, one trace from a click in the browser to a commit on a worker, a span of your own, whom each span was for, and a small viewer for the owner."} Ada says that adding a todo from her phone feels slow some evenings. Her todo crosses three processes: her page, the server's relay and the list's worker. A log line per process would say what each one did, but not which lines belong @@ -1452,12 +1458,14 @@ piece of work with attributes), **events** on spans, and **metrics** (counters a **adapter**, given to `createHost({ telemetry })` and NOOP by default, which decides where they go. A **carrier** in each call's header names the caller's span, so the spans of one request, recorded by different processes, form one **trace** (design §29). Mara wants to see them without reading SQL: a small trace viewer would be nice. This chapter -records, follows one click, adds a span, reads the store from a script, and ends with the viewer. +stores telemetry in one place, follows a click from the browser to the worker, adds a span, names whom each span was +for, reads the store from a script, and ends with the viewer. -## Recording +## One store, on the server The app keeps its telemetry in one SQLite file in the data directory, through Chord's store -(`@earendil-works/chord/telemetry-sqlite`). The app's base code opens it, for any process that asks: +(`@earendil-works/chord/telemetry-sqlite`), and the server is the only process that writes it. The app's base code opens +it for the server: ```ts @@ -1483,89 +1491,89 @@ export async function openTelemetry(file: string): Promise { ``` `createSqliteTelemetry` returns a **recorder**: an adapter that keeps each settled span in memory and writes them in -one batch a second, one row per span with its events and links inline; metrics are summed per series and 10 seconds; -rows older than seven days are deleted in the same batch, and when 10,000 spans wait, new ones are dropped and counted. -Recording never runs SQL. The same object is the store's **read interface**, `TelemetryStore` (below). +one batch a second, one row per span with its events and links inline and its start and end in microseconds (a +monotonic clock anchored to the wall clock, so spans of one process keep their order and spacing); metrics are summed +per series and 10 seconds; rows older than seven days are deleted in the same batch, and when 10,000 spans wait, new +ones are dropped and counted. Recording never runs SQL. The same object is the store's **read interface**, +`TelemetryStore` (below). -Step 1 opened the store before the server (chapter I), and `startTodo` gives it to `startServer`, which passes it to the -server's host and its HTTP component: +Step 1 opened the store before the server (chapter I), and `startTodo` gives it to `startServer`: ```ts - // The server's spans and metrics; each list's worker opens the same file (`worker.ts`). + // The store the server writes: its own spans and metrics, and what the workers and recording pages send. telemetry: telemetry.store, ``` -The kit carries no spans between processes: each process records to the adapter it was given (slice 9, §5). So each -list's worker opens the same file with a recorder of its own; the server names the file in the worker's environment: - - -```ts - env: { TODO_TELEMETRY: telemetryFile(dataDir) }, -``` +With a store, the kit does the rest. Every list's worker records in memory and sends its finished spans and metrics to +the server once a second, over the link it already has; nothing tells a worker where the file is, and the worker's +entry has no telemetry of its own to set up: ```ts -const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!); -startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) }); +startWorker({ api: API, kinds: KINDS, principalAttributes, onError: (error) => console.error("list:", error) }); ``` -One file for every process is the app's choice. SQLite in WAL mode lets several processes write it, each waiting for -the others' locks (up to 5 seconds), and every span id is global, so rows of different processes never collide. An app -with a collector would give each process an adapter that exports there instead; nothing else changes. +A page records when the app asks it to, in its web entry: -## One trace across three hosts - -A page in a browser cannot record yet: `startPage` takes no telemetry adapter. Step 11 makes the page itself, as Ada's -client was made in chapter VII: a client host of this process with a recorder of its own on the same file, signed in -with the owner's cookie from step 1. A click is one span of the page, and the call runs under it: - - + ```ts -const pageTelemetry = await openTelemetry(telemetryFile(dataDir)); -const page = createHost({ - telemetry: pageTelemetry.store, - onError: (error) => console.error("page:", error.kind), -}); -await page.load([], context); -const pageSocket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie } }); -page.connect(webSocketLink(pageSocket as unknown as WebSocketLike), { principal: { kind: "server" } }, context); -const pageTodos = await page.get(Todos, { list: "groceries" }, context); -const click = (text: string) => - pageTelemetry.store.startSpan({ name: "page.add" }, (span) => pageTodos.add(text, withTelemetry(span, context))); -await click("honey"); -await click(" ").catch(() => "refused"); + // The page records its clicks and the calls under them, and sends them to the server every few seconds. + telemetry: true, ``` -`startSpan` runs the callback in a new root span; `withTelemetry(span, context)` makes it the context's span, so the -call of `add` starts its `chord.call` under it and puts that span's carrier in the call's header. Each recorder writes -once a second, so the step reads the store until the worker's spans are there, takes the older of the two traces whose -first span is `page.add` and prints it with pi-telemetry's `renderTrace` (main.ts masks the lengths of time, which -differ at every run): +The page keeps its finished spans in memory and sends them every three seconds, and at once when it is hidden (a tab +switched away may not come back). Both send to an object the kit serves on the server, `WorkerTelemetry` and +`PageTelemetry`, whose `ingest(batch)` hands the spans to the store; its calls record no span of their own, or every +batch would write a span about itself. A worker is the server's own child: its batches are written as they are, +marked with its host. A page is anyone's: sign-in and the link's limits apply as for any call, and the server checks +everything (next section). - +## One trace from the browser + +The lists plugin's panel starts a trace where Ada's click starts: in its form's `submit`, a root span of the page's host +with the call under it: + + +```ts + // The click is a trace of its own: a root span of the page, the call to the list's worker under it. + const trace = { name: "page.add", attributes: { chars: text.length } }; + void host.root((ctx) => todos.add(text, ctx), context, { trace }).catch(() => {}); +``` + +`host.root(fn, context, { trace })` runs `fn` in a new root span of the host's adapter, `ctx.telemetry` that span, so +the call of `add` starts its `chord.call` under it and puts that span's carrier in the call's header (`useHost` gives a +component its page's host). Without a recording adapter it starts nothing. `screenshots.ts` signs the owner in to the +page in Chromium, types honey and submits, then submits again with nothing typed; it reads the store until the +worker's spans of both clicks are there and prints honey's trace with pi-telemetry's `renderTrace` (ids and lengths of +time masked, which differ at every run): + + ```text -11. adding honey from the page: - page.add - chord.call type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="ok" - chord.relay type="todo.todos" member="#0" upstream.kind="list" outcome="ok" - chord.method type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" outcome="ok" - chord.method type="todo.store" member="add" object="todo.store" principal.kind="worker" outcome="ok" - · chord.commit documents=0 ops=0 duration= - todos.validate chars=5 +adding honey from the page: + page.add chord.host="browser" chars=5 chord.principal="owner" + chord.call chord.host="browser" type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="ok" chord.principal="owner" + chord.relay chord.host="server" type="todo.todos" member="#0" upstream.kind="list" outcome="ok" + chord.method chord.host="list|groceries" type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" chord.principal="owner" outcome="ok" + chord.method chord.host="list|groceries" type="todo.store" member="add" object="todo.store" principal.kind="worker" chord.principal="worker:list|groceries" outcome="ok" + · chord.commit documents=0 ops=0 rows=1 duration= + todos.validate chord.host="list|groceries" chars=5 ``` -Three processes recorded these six spans. The page recorded `page.add` and `chord.call`, the consumer's call until its -reply. The server recorded `chord.relay`: it forwarded the call to the list's worker without knowing member names -(`#0` is the method's index). The worker recorded the rest: `chord.method` for the owner's `Todos` view, then the -view's call of the store, which is a method span too, by `worker`, because the store acts as its host (chapter II). -The commit is an event on the span its transaction ran under, and the renderer prints a span's events before its -children. Its counts are documents and their ops; a commit of table rows counts neither, so this one says 0 and 0. +Three processes recorded these six spans, and the server wrote them all. Each span says which host recorded it: +`chord.host` is the host's address key (`server`, `list|groceries`), and `browser` for what a page sent. The page +recorded `page.add` and `chord.call`, the consumer's call until its reply. The server recorded `chord.relay`: it +forwarded the call to the list's worker without knowing member names (`#0` is the method's index). The worker recorded +the rest: `chord.method` for the owner's `Todos` view, then the view's call of the store, which is a method span too, +by `worker`, because the store acts as its host (chapter II). The commit is an event on the span its transaction ran +under, and the renderer prints a span's events before its children. It counts the documents it changed, their ops and +the table rows its writes changed: the store keeps todos in a table, so one row and no documents. `chord.principal` +says whom a span was for (below). -:::sequence animate | One click, one trace: each process starts its spans with its own recorder, and the carrier in each call's header names the parent span on the other side. -actor page "page" "recorder 1" -actor server "server" "recorder 2" -actor worker "list|groceries" "recorder 3" +:::sequence animate | One click, one trace: each process starts its spans with its own adapter, the carrier in each call's header names the parent span on the other side, and the page and the worker send their spans to the server, which writes them. +actor page "page" "browser" +actor server "server" "the store" +actor worker "list|groceries" "worker" page -> page: page.add, chord.call page => server: call add, carrier of chord.call server -> server: chord.relay @@ -1573,25 +1581,49 @@ server => worker: call #0, carrier of chord.relay worker -> worker: chord.method ×2, todos.validate, commit worker --> server: reply server --> page: reply +worker => server: ingest: its spans (every second) +page => server: ingest: its spans (every 3 s, or hidden) ::: The carrier is a field of the `call` and `hold` headers, at most 8 keys of at most 256 characters each. The receiver reads it with its own adapter; an absent or malformed one gives a new root, never an error. With the NOOP adapter nothing is injected and no span starts: a span site costs one check (§30). +What a page sends, the server treats as untrusted. A batch is checked by its schema: at most 100 spans, every field +bounded (names 128 characters, 32 attributes of at most 256 characters, 16 events, 8 links, a 500-character error +message), ids printable ASCII and no control characters anywhere; a batch outside it is refused whole. A page's span +ids are `browser::`, and the server takes only spans whose id, trace and parent carry the +calling page's own prefix: a page can neither write into another sender's trace nor pose as a server span, and the +store never replaces a stored span. A page may send 1,200 spans a minute; past that they are dropped and counted in +`ingest`'s answer. The server marks each span `chord.host: browser` and stamps it with the principal of the link it came +over: what a page says about itself counts for nothing. + The trace holds the click and nothing else. Work that serves many requests is its own trace, **linked** to the span that first needed it: a worker's start (`chord.resolve`), an object's activation (`chord.activate`). A link is a reference to a span of another trace; the renderer prints it and never follows it. ## Trap: a trace starts where a span starts -The page took its reference to `Todos` before the click, under `BACKGROUND_CONTEXT`, and that hold is in no trace of -the page: **a context without a recording span records nothing.** A host gives such work no span of its own, so its -calls start no `chord.call` and send no carrier, and the provider's `chord.method` becomes a root of its own trace +The panel took its reference to `Todos` before the click (`useHold`), under `BACKGROUND_CONTEXT`, and that hold is in no +trace of the page: **a context without a recording span records nothing.** A host gives such work no span of its own, +so its calls start no `chord.call` and send no carrier, and the provider's `chord.method` becomes a root of its own trace (the server's relay of the page's hold is one: `chord.relay` with `member="hold"`). So were the server's calls in step 2: -on the worker each is a root `chord.method`. Start a span where the work begins, a click handler or a job, and pass it -down with `withTelemetry`. The kit's HTTP component does this for every request: a `chord.http` span (method, route -pattern, status), with the route's `ctx` under it. +on the worker each is a root `chord.method`. Start a span where the work begins, a click handler or a job +(`host.root(fn, context, { trace })`), and pass its `ctx` down. The kit's HTTP component does this for every request: a +`chord.http` span (method, route pattern, status), with the route's `ctx` under it. Step 11 adds honey from the command +line, as step 3 did; the request is the trace's root, the server's call to the worker under it: + + +```text +11. adding honey from the command line: + chord.http chord.host="server" method="POST" route="POST /api/lists/:list" status=200 + chord.hold chord.host="server" type="todo.todos" object="todo.todos(groceries)" address.kind="list" outcome="bound" + chord.call chord.host="server" type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" chord.principal="owner" outcome="ok" + chord.method chord.host="list|groceries" type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" chord.principal="owner" outcome="ok" + chord.method chord.host="list|groceries" type="todo.store" member="add" object="todo.store" principal.kind="worker" chord.principal="worker:list|groceries" outcome="ok" + · chord.commit documents=0 ops=0 rows=1 duration= + todos.validate chord.host="list|groceries" chars=5 +``` ## A span of your own @@ -1606,26 +1638,63 @@ The store's `add` checks the text in a span of its own: ``` `ctx.trace(name, attributes, fn)` starts a child of the method's span and gives `fn` a child `ctx` with the same -principal and `get`, so calls made inside are parented under it. It is the last line of the trace above: -`todos.validate chars=5`. Its attributes are small, flat values; the todo's text is data and stays out of telemetry, -its length is enough. When `fn` throws, the span fails and the error passes through as it was: the caller still gets -`invalid/args`. A check this small does not need a span of its own; a lookup, a model request or a file read does. +principal and `get`, so calls made inside are parented under it, and names its host as Chord's spans do. It is the last +line of both traces above: `todos.validate chord.host="list|groceries" chars=5`. Its attributes are small, flat values; +the todo's text is data and stays out of telemetry, its length is enough. When `fn` throws, the span fails and the error +passes through as it was: the caller still gets `invalid/args`. A check this small does not need a span of its own; a +lookup, a model request or a file read does. + +## Whom a span was for + +Method and call spans record whom they were for: by default `chord.principal`, the principal's key (its canonical +JSON). An app may record other attributes instead, through a hook each host takes (`principalAttributes`): the todo +app's records a short key and, for a user, the name she signed in with: + + +```ts +export function principalAttributes(principal: TodoPrincipal): Readonly> { + let attributes = recorded.get(principal); + if (attributes === undefined) { + attributes = + principal.kind === "user" + ? { "chord.principal": `user:${principal.id}`, "user.name": principal.name } + : { "chord.principal": principal.kind === "worker" ? `worker:${principal.list}` : principal.kind }; + recorded.set(principal, attributes); + } + return attributes; +} +``` + +Chord calls the hook for every span it records with a principal, so it must be synchronous and cheap; any caching is +the hook's own (here a `WeakMap` by principal: a link stamps one principal object on all its calls). `startTodo` gives +it to the server (`principalAttributes` beside `telemetry`) and the worker's entry to `startWorker` (above): each host +has its own, the same function. The server also stamps a page's spans with it, for the principal of the link the batch +came over: `page.add` above was the owner's. Only what the hook returns is recorded, never other app data, and the +attributes are for the owner's eyes: the store, like the viewer below, is the owner's. ## Reading The store is read through `TelemetryStore`, pi-telemetry's read interface modeled on Jaeger's: `findTraces` (complete traces, newest first, filtered by their first span's name, attributes, errors or a time window, paged by a cursor), `getTrace(id)` and `findMetrics` (a metric's rows, one per series and 10 seconds, with count, sum, min and max). -`trace.ts` is a script for a data directory, the app running or not: +`trace.ts` is a script for a data directory, the app running or not. It opens the file with Chord's read-only store, +`openTelemetryStore` (`@earendil-works/chord/telemetry-sqlite/node`), which implements only the read interface and +opens SQLite read-only: it never writes the file the server writes, never creates or migrates it, and refuses a file +without a store or with a store of another version. ```ts +// The shared file, read-only: the app's processes may be writing it meanwhile. +const store = await openTelemetryStore(telemetryFile(dataDir)); +try { if (id !== undefined) { - const trace = await telemetry.store.getTrace(id); + const trace = await store.getTrace(id); console.log(trace === undefined ? `no trace ${id}` : renderTrace(trace.spans)); } else { - // Complete traces, newest first; a trace's first span (its root, by start time) names it. - const { traces } = await telemetry.store.findTraces({ name: values.name, limit: 10 }); + // Complete traces, newest first; a trace's first span (its root, by start time) names it, and the app's + // `principalAttributes` hook named whom each span was for. + const attributes = values.principal === undefined ? undefined : { "chord.principal": values.principal }; + const { traces } = await store.findTraces({ name: values.name, attributes, limit: 10 }); for (const { traceId, spans } of traces) { const start = Math.min(...spans.map((span) => span.startedMs)); const length = Math.max(...spans.map((span) => span.endedMs)) - start; @@ -1634,7 +1703,7 @@ traces, newest first, filtered by their first span's name, attributes, errors or } // Metrics are stored per series and 10 s: a series is one set of attributes, here the direction. for (const direction of ["sent", "received"]) { - const rows = await telemetry.store.findMetrics({ name: "chord.link.bytes", attributes: { direction } }); + const rows = await store.findMetrics({ name: "chord.link.bytes", attributes: { direction } }); const bytes = rows.reduce((sum, row) => sum + row.sum, 0); const messages = rows.reduce((sum, row) => sum + row.count, 0); console.log(`chord.link.bytes ${direction}: ${bytes} bytes in ${messages} messages`); @@ -1642,62 +1711,63 @@ traces, newest first, filtered by their first span's name, attributes, errors or } ``` -Step 11 runs it twice from a process of its own: the newest `page.add` traces, then the refused one by its id (ids, -lengths and counts masked): +`screenshots.ts` runs it twice from a process of its own while the app runs: the newest `page.add` traces with a span +for the owner, then the refused click by its id (ids, lengths and counts masked): - + ```text - trace.ts --name page.add: +trace.ts --name page.add --principal owner: page.add 6 spans error page.add 6 spans chord.link.bytes sent: bytes in messages chord.link.bytes received: bytes in messages - trace.ts : - page.add error ChordError: a todo needs text - chord.call type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="error" error call_failed: a todo needs text - chord.relay type="todo.todos" member="#0" upstream.kind="list" outcome="error" error relay_failed: a todo needs text - chord.method type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" outcome="error" error method_failed: a todo needs text - chord.method type="todo.store" member="add" object="todo.store" principal.kind="worker" outcome="error" error method_failed: a todo needs text - todos.validate chars=0 error trace_failed: a todo needs text +trace.ts : + page.add chord.host="browser" chars=0 chord.principal="owner" error trace_failed: a todo needs text + chord.call chord.host="browser" type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="error" chord.principal="owner" error call_failed: a todo needs text + chord.relay chord.host="server" type="todo.todos" member="#0" upstream.kind="list" outcome="error" error relay_failed: a todo needs text + chord.method chord.host="list|groceries" type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" chord.principal="owner" outcome="error" error method_failed: a todo needs text + chord.method chord.host="list|groceries" type="todo.store" member="add" object="todo.store" principal.kind="worker" chord.principal="worker:list|groceries" outcome="error" error method_failed: a todo needs text + todos.validate chord.host="list|groceries" chars=0 error trace_failed: a todo needs text ``` A trace's id is its root span's. The refused add failed on every span of its path, each with a fixed category -(`call_failed`, `relay_failed`, `method_failed`, `trace_failed`) and the message cut at 500 characters; a cancelled call -is not an error. It has no commit: nothing was written. `chord.link.bytes` counts the encoded messages of every host's -links, each message once where it was sent and once where it was received. A metric carries no ids, only attributes -with few values (`direction`), so its series stay few. A running recorder writes a metric's current 10 seconds only -once they are over, or when it closes: `trace.ts` sees the page's bytes, whose recorder closed, and the server's and -workers' of the intervals already over. The script is a recorder too, which records nothing; Chord ships only the -store, so reads go through a recorder of the file. +(`trace_failed`, `call_failed`, `relay_failed`, `method_failed`) and the message cut at 500 characters; a cancelled call +is not an error. It has no commit: nothing was written. `chord.link.bytes` counts the encoded messages of the server's +and the workers' links (a page sends no metrics), each message once where it was sent and once where it was received. +A metric carries no ids, only attributes with few values (`direction`), so its series stay few. A metric's current 10 +seconds are sent and written only once they are over, or when a process closes. ## A trace viewer Two halves make the viewer: routes on the server that read the store, and a page that shows what they answer. Server code of an app is either the start script or a plugin, and routes come from plugins (chapter III), so the routes are a -small plugin, `plugins/traces/`. Its server facet serves one host-lifetime object, which opens the store's file as a -native and contributes the routes while it is live: +small plugin, `plugins/traces/`. Its server facet serves one host-lifetime object, which opens the store's file +read-only as a native and contributes the routes while it is live: ```ts host.serve(Traces, (_params, self) => { const info = self.get(AppInfo, {}); - // The server records to the same file with its own recorder; this one only reads. + // The server records to the same file with its own recorder; this object only reads it. const telemetry = self.open( - async () => openTelemetry(telemetryFile(info.dataDir.value)), - (opened) => opened.close(), + async () => openTelemetryStore(telemetryFile(info.dataDir.value)), + (store) => store.close(), ); self.contribute(Http, { routes: { - "GET /api/traces": async (_request, ctx) => { + "GET /api/traces": async (request, ctx) => { ownerOnly(ctx); - const { traces } = await telemetry.value.store.findTraces({ limit: RECENT }); + // `?principal=user:u1`: the traces with a span for that principal (`principalAttributes`). + const principal = request.query.get("principal"); + const attributes = principal === null ? undefined : { "chord.principal": principal }; + const { traces } = await telemetry.value.findTraces({ limit: RECENT, attributes }); return { kind: "json", value: traces.map(({ traceId, spans }) => summaryOf(traceId, spans)) }; }, "GET /api/traces/:id": async (request, ctx) => { ownerOnly(ctx); - const trace = await telemetry.value.store.getTrace(request.params.id!); + const trace = await telemetry.value.getTrace(request.params.id!); if (trace === undefined) return { kind: "error", status: 404 }; - return { kind: "json", value: { id: trace.traceId, spans: rowsOf(trace.spans) } }; + return { kind: "json", value: { id: trace.traceId, spans: rowsOf(clamped(trace.spans)) } }; }, }, }); @@ -1706,10 +1776,10 @@ native and contributes the routes while it is live: ``` The kit gives a plugin the server's process values as objects: `AppInfo` names the data directory, and the API's server -module re-exports it with the app's `openTelemetry`. `self.open` declares a native, opened once `AppInfo` is live and -closed when the object ends; a reload of the plugin restarts the object and opens the file anew. An object may -contribute when it lives as long as its host (`local`, empty params; decision R21). Spans name who called what, so -only the owner reads them: +module re-exports it with `telemetryFile` and Chord's `openTelemetryStore`. `self.open` declares a native, opened once +`AppInfo` is live and closed when the object ends; a reload of the plugin restarts the object and opens the file anew. +An object may contribute when it lives as long as its host (`local`, empty params; decision R21). Spans name who called +what, so only the owner reads them: ```ts @@ -1718,20 +1788,27 @@ function ownerOnly(ctx: Ctx): void { } ``` -A route's principal is `undefined` only on a public route, which these are not; a signed-in member gets 403. `rowsOf` -orders a trace's spans as a tree, parents before their children, and turns attributes, events and links into text. The -spans name no host; the SQLite store's span ids begin with the id of the recorder, one per process, and the viewer -calls each recorder a host: +A route's principal is `undefined` only on a public route, which these are not; a signed-in member gets 403. The list +filters by whom the traces were for (`?principal=`), and shows each with its user's name or principal key. `rowsOf` +orders a trace's spans as a tree, parents before their children, each row's host its span's `chord.host`, and turns +attributes, events and links into text. A page's spans carry the page's clock, which may be off by seconds against the +server's, so a span of the page could show starting after the server span under it. Before drawing, `clamped` moves +the page's spans of the trace by the least amount that puts every server span under a page span inside it +(`plugins/traces/clock.ts`, with its own test): - + ```ts -function processOf(span: StoredSpan): string { - return span.id.slice(0, span.id.lastIndexOf(":")); +export function clamped(spans: readonly StoredSpan[]): readonly StoredSpan[] { + const skew = skewOf(spans); + if (skew === 0) return spans; + return spans.map((span) => + hostOf(span) === "browser" ? { ...span, startedMs: span.startedMs + skew, endedMs: span.endedMs + skew } : span, + ); } ``` -That is a detail of this store, not of the read interface (see the epilogue). The page is the app's own page at a -second path: `start.ts` serves it at `/` and `/traces` (chapter I), and the web entry picks the viewer by the path: +The page is the app's own page at a second path: `start.ts` serves it at `/` and `/traces` (chapter I), and the web +entry picks the viewer by the path: ```ts @@ -1740,9 +1817,9 @@ second path: `start.ts` serves it at `/` and `/traces` (chapter I), and the web ``` `TraceViewer` (`src/web/traces.ts`) fetches the list and the trace chosen in the URL's fragment, and draws each span -with its host, its length as a bar on the trace's time line, its attributes and events, a failed span in the theme's -error colour, and each link as the ids it refers to, never fetched. The bars are positioned with inline styles, which -the page's CSP allows only because React sets them through the DOM: +with its host's name, its length as a bar on the trace's time line, its attributes and events, a failed span in the +theme's error colour, and each link as the ids it refers to, never fetched. The bars are positioned with inline +styles, which the page's CSP allows only because React sets them through the DOM: ```ts @@ -1752,8 +1829,7 @@ the page's CSP allows only because React sets them through the DOM: }; ``` -`screenshots.ts` adds honey from a recording page and refuses a blank todo, as step 11 does, then opens the viewer on -honey's trace: +After the two clicks, `screenshots.ts` opens the viewer on honey's trace, then types `user:u1` into its filter: ```ts @@ -1766,15 +1842,22 @@ honey's trace: ```text wrote docs/tutorial/screens/traces.png the viewer shows page.add: 6 spans on 3 hosts +wrote docs/tutorial/screens/traces-ada.png +the viewer for user:u1 lists chord.method for ada ``` -:::figure wide | The owner's trace viewer at `/traces`, in the Harbor theme: the newest traces (the refused add marked in red, the rest the app's own work: relays, activations, HTTP requests), and honey's trace as a tree across the page, the server and the worker. Starts are stored in whole milliseconds, so the bars of spans this short shift by up to one. +:::figure wide | The owner's trace viewer at `/traces`, in the Harbor theme: the newest traces (the refused click marked in red, each with whom it was for; the rest the app's own work: relays, activations, HTTP requests), and honey's trace as a tree across the page (`browser`), the server and the worker (`list|groceries`), all written by the server. Starts are stored to the microsecond, so even bars of a fraction of a millisecond line up. ![The trace viewer](screens/traces.png) ::: +:::figure wide | The traces for Ada (`user:u1`): the coffee `screenshots.ts` adds at its start as her (a root of the server acting as her), recorded by the list's worker; the app's hook named her `ada`. +![The trace viewer filtered by principal](screens/traces-ada.png) +::: + The list is mostly the app's own work: each HTTP request, relayed hold and activation is a trace of its own, as the -trap above says. A real viewer would filter by name (`findTraces({ name })`) or by error (`errors: true`), both of -which the read interface has; this one is about 270 lines of TypeScript in two files and 20 of CSS, and no Chord API was added for it. +trap above says. A real viewer would filter by name too (`findTraces({ name })`) or by error (`errors: true`), both of +which the read interface has; this one is about 330 lines of TypeScript in three files and 20 of CSS, on Chord's +read-only store. # Testing One Plugin {#testing lede="testPlugin builds a plugin as production does and loads it alone on a host of its own."} @@ -1997,36 +2080,37 @@ chapter I), plugins could not throw Chord's `invalid` (Chord exports `invalid` a live window over a table needed a one-time read in `self.open`, which made the store restart at every reload of its plugin (now `self.query`, a live query Chord runs, chapters II and IX). -**Found while writing the telemetry chapter, still open.** A page cannot record: `startPage` takes no telemetry -adapter, so chapter XI's page is a client host in Node, and a browser's click starts its trace at the server's relay. -`chord.commit` counts documents and their ops only, so a commit that wrote table rows reports `documents=0 ops=0`. -Spans name no host, and the viewer tells processes apart by the SQLite store's id format (`:`), which -the read interface does not promise. The store keeps a span's start in whole milliseconds (`Date.now()`) and its -length in fractions (`performance.now()`), so on a trace of a few milliseconds the viewer's bars shift by up to one. And -reading needs a recorder: `chord/telemetry-sqlite` exports no read-only -store, so a script or a viewer opens a recorder that records nothing. +**Found while writing the telemetry chapter, and fixed in Chord since.** `chord.commit` counted documents and their +ops only, so a commit that wrote table rows reported `documents=0 ops=0` (it counts `rows` now). Spans named no host, +and the viewer told processes apart by the SQLite store's id format (every Chord span carries `chord.host` now). The +store kept a span's start in whole milliseconds, so the viewer's bars shifted by up to one (starts are microseconds +now, the store's version 2, which a recorder migrates its file to). Reading needed a recorder that recorded nothing +(`openTelemetryStore` reads read-only). A page could not record, so the chapter's click was a client host in Node +standing in for the page (`startPage({ telemetry: true })` now, its spans sent to the server). And every process wrote +the one file itself, each worker told its path (the server is the store's only writer now, the workers and pages +sending it their spans), and spans said nothing of whom they were for (`principalAttributes`). # The App's Files {.appendix} | File | Kind | What it is | | --- | --- | --- | -| `src/principals.ts` | base | the principal type | +| `src/principals.ts` | base | the principal type, `principalAttributes` (whom a span was for) | | `package.json` | base | the API as the workspace package `@todo/app`, its `exports` | | `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part (with `invalid`, `forbidden`, `defineMessages`, `Translations`), `defineFacet`, `List` | -| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login`, `AppInfo`, `openTelemetry` | -| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `useMessages`, `usePageState`, `useWatch` | +| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login`, `AppInfo`, `telemetryFile`, `openTelemetryStore` | +| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `useHost`, `useMessages`, `usePageState`, `useWatch` | | `src/api/v1/tui.ts` | base, tui | `Screens` | -| `start.ts`, `worker.ts` | base | `startServer`, `startWorker`, each with the telemetry store | -| `src/telemetry.ts` | base | the telemetry store's file in the data directory, `openTelemetry` | -| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels, the viewer at `/traces` | +| `start.ts`, `worker.ts` | base | `startServer` with the telemetry store, `startWorker`, each with `principalAttributes` | +| `src/telemetry.ts` | base | the telemetry store's file in the data directory, `openTelemetry` (the server's) | +| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage` (recording), the panels, the viewer at `/traces` | | `src/web/traces.ts` | base, web entry | the owner's trace viewer | | `src/tui/main.ts` | base, terminal entry | `startTerminalRuntime`, the screens | | `plugins/lists/` | plugin | contract (`Item`, `Notice`, `Todos`), messages (English, German), worker (table, store, views), server (routes), web (panel), tui (screen), its test | | `plugins/french/` | plugin | French for the lists plugin, for pages and terminals | | `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login | -| `plugins/traces/` | plugin | the trace viewer's routes over the telemetry store (server), the owner's only | +| `plugins/traces/` | plugin | the trace viewer's routes over the telemetry store (server), the owner's only; the page's clock moved onto the server's (`clock.ts`, its test) | | `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals | -| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks | +| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots (with the page's traced click), the checks | | `trace.ts` | example | a data directory's newest traces, one trace, a metric | # Reference {.appendix} @@ -2034,7 +2118,7 @@ store, so a script or a viewer opens a recorder that records nothing. | Call | Chapter | What it does | | --- | --- | --- | | `startServer

({ name, dataDir, root, listen, api, kinds, principal, workers, web, tui, onError }, ctx)` | I | the app's server, with the kit | -| `startWorker({ api, kinds, onError })` | I | a worker's entry | +| `startWorker({ api, kinds, principalAttributes, onError })` | I, XI | a worker's entry | | `app.ownerLink()`, `app.ownerTokenFile` | I, III | the owner's sign-in, for a browser and for local clients | | `hostKind(name, params)`, `remote(name, { version, params, hostAddress, variants })`, `local(name, { version, params, members })` | II | host kinds and contracts | | `host.serve(Type, factory)`; factory `self`: `table`, `query`, `derive`, `tx`, `get`, `contribute` | II | an object's parts | @@ -2051,8 +2135,8 @@ store, so a script or a viewer opens a recorder that records nothing. | `MessagesOf`, `LocaleChoice` | VIII | a translation plugin, the user's language | | `app.plugins.state`, `app.applyChanges(ctx)` | VI, IX | marks and rollouts | | `app.exportData(address, path, ctx)`, `app.importData(path, address, ctx)` | X | moving a worker's data | -| `createSqliteTelemetry(db)`, `startServer({ telemetry })`, `startWorker({ telemetry })`, `createHost({ telemetry })` | XI | recording to the telemetry store | -| `adapter.startSpan({ name }, fn)`, `withTelemetry(span, ctx)`, `ctx.trace(name, attributes, fn)` | XI | spans of your own | -| `store.findTraces({ name, attributes, errors, startMin, startMax, limit, cursor })`, `store.getTrace(id)`, `store.findMetrics({ name, attributes })`, `renderTrace(spans)` | XI | reading traces and metrics | +| `createSqliteTelemetry(db)`, `startServer({ telemetry, principalAttributes })`, `startPage({ telemetry: true })`, `createHost({ telemetry, principalAttributes })` | XI | the telemetry store on the server, pages and workers sending it their spans | +| `host.root(fn, ctx, { trace })`, `ctx.trace(name, attributes, fn)` | XI | spans of your own | +| `openTelemetryStore(path)`, `store.findTraces({ name, attributes, errors, startMin, startMax, limit, cursor })`, `store.getTrace(id)`, `store.findMetrics({ name, attributes })`, `renderTrace(spans)` | XI | reading traces and metrics | | `AppInfo`, `self.open(open, close)` | XI | the data directory, a native (the viewer's store) | | `testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)` | XII | one plugin alone | diff --git a/packages/chord/docs/tutorial/index.html b/packages/chord/docs/tutorial/index.html index 7a1569a3b..7b92fdfce 100644 --- a/packages/chord/docs/tutorial/index.html +++ b/packages/chord/docs/tutorial/index.html @@ -176,9 +176,9 @@ plugins contribute, one plugin (or the entry) composes. server, the workers, the pages and the terminals, in place.

  • Text is rendered per viewer. A plugin's messages are typed code, a translation is a plugin, each user picks a language, and servers store keys, never sentences.
  • -
  • One click is one trace. Every process records spans to the telemetry store it is given (here one SQLite file -in the data directory); carriers in the calls' headers join the page's call, the server's relay and the worker's -method into one trace, which TelemetryStore reads back. ctx.trace adds spans of your own.
  • +
  • One click is one trace. The server writes the telemetry store (here one SQLite file in the data directory); the +page and the workers send it their spans; carriers in the calls' headers join the page's click, the server's relay +and the worker's method into one trace, which TelemetryStore reads back. ctx.trace adds spans of your own.
  • Six traps: load before use; a reference acts as whoever made it; await what you start; a watch starts at the current value; the plain t has no language of its own; a trace starts where a span starts.
  • @@ -315,14 +315,15 @@ facet kinds (which files a plugin may have), and the principals:

    api: API, kinds: KINDS, principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) }, - // The server's spans and metrics; each list's worker opens the same file (`worker.ts`). + // The store the server writes: its own spans and metrics, and what the workers and recording pages send. telemetry: telemetry.store, + // Who a span was for: a short key and a user's name (each worker's `startWorker` gets the same hook). + principalAttributes, workers: [ { kind: v1.List, entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], - env: { TODO_TELEMETRY: telemetryFile(dataDir) }, start: () => ({}), // Who may reach a list's worker: everyone signed in, and the server itself. authorize: (principal) => principal.kind !== "worker", @@ -336,20 +337,19 @@ facet kinds (which files a plugin may have), and the principals:

    context, ); } -

    workers is chapter II, web chapter IV (its second path, /traces, chapter XI), tui chapter V, telemetry -chapter XI. listen asks for a free port on loopback, as the example does; a real app picks a port. The worker entry -is a few lines (its telemetry store is chapter XI's too):

    +

    workers is chapter II, web chapter IV (its second path, /traces, chapter XI), tui chapter V, telemetry and +principalAttributes chapter XI. listen asks for a free port on loopback, as the example does; a real app picks a +port. The worker entry is a few lines (its principalAttributes is chapter XI's too):

    // The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects
    -// to the server and loads the plugins' worker facets. The worker records to the app's telemetry store, the file its
    -// server named in the environment: its methods called through the server continue the caller's trace.
    +// to the server and loads the plugins' worker facets. The worker records its spans in memory and sends them to the
    +// server, which stores them: its methods called through the server continue the caller's trace.
     
     import { startWorker } from "../../../src/node/app/index.ts";
    -import { openTelemetry } from "./src/telemetry.ts";
    +import { principalAttributes } from "./src/principals.ts";
     import { API, KINDS } from "./start.ts";
     
    -const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!);
    -startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) });
    +startWorker({ api: API, kinds: KINDS, principalAttributes, onError: (error) => console.error("list:", error) });

    Running it

    Step 1 of main.ts starts the app, tries the page without signing in, then does what a browser does with the owner link:

    @@ -453,13 +453,12 @@ it on to its parent or to a route (design §3, "Host kinds and addresses&qu kind: v1.List, entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], - env: { TODO_TELEMETRY: telemetryFile(dataDir) }, start: () => ({}), // Who may reach a list's worker: everyone signed in, and the server itself. authorize: (principal) => principal.kind !== "worker", }, ], -

    env names the app's telemetry store for the worker (chapter XI). authorize runs for every request routed to a +

    authorize runs for every request routed to a list before anything starts; the first request for an address starts its worker, and a worker idle for five minutes (the default idleMs) stops. Variants are the member sets a contract offers: the owner's Todos has clear, a member's does not. The factory picks one per principal.

    @@ -666,7 +665,7 @@ for them:

    import type { ComponentType } from "react";
     import { defineRegistry } from "../../../../../../src/index.ts";
     
    -export { useHold, useMessages, usePageState, useWatch } from "../../../../../../src/react/index.ts";
    +export { useHold, useHost, useMessages, usePageState, useWatch } from "../../../../../../src/react/index.ts";
     
     /** A panel of the page: its name (a key, never shown) and its React component, which shows its own title. */
     export type Panel = { readonly name: string; readonly Component: ComponentType };
    @@ -695,9 +694,11 @@ for them:

    connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`), modules: { "@todo/app/v1": v1, "@todo/app/v1/web": v1web, react: React, "react/jsx-runtime": jsx }, core: [{ name: "@todo/web", facet: core }], + // The page records its clicks and the calls under them, and sends them to the server every few seconds. + telemetry: true, render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))), });
    -

    The path /traces is the owner's trace viewer (chapter XI). startPage makes the page's host, connects it to /chord +

    The path /traces is the owner's trace viewer, and telemetry what it shows (chapter XI). startPage makes the page's host, connects it to /chord (reconnecting after drops), loads the current plugin scripts with the entry's core facets, and keeps them current. HostProvider gives the React tree that host.

    The web facet

    @@ -706,6 +707,7 @@ when its items change:

    function TodoList() {
     	const { t } = useMessages(messages);
    +	const host = useHost();
     	const list = location.hash.slice(1) || "groceries";
     	const todos = useHold(Todos, { list });
     	const items = useWatch(todos?.items) ?? [];
    @@ -714,8 +716,11 @@ when its items change:

    const [text, setText] = usePageState(`lists.draft.${list}`, ""); const add = (event: { preventDefault(): void }) => { event.preventDefault(); - if (todos === undefined || text.trim() === "") return; - void todos.add(text, context); + // An empty todo too: the list's store refuses it, and the click's trace shows where. + if (todos === undefined) return; + // The click is a trace of its own: a root span of the page, the call to the list's worker under it. + const trace = { name: "page.add", attributes: { chars: text.length } }; + void host.root((ctx) => todos.add(text, ctx), context, { trace }).catch(() => {}); setText(""); }; return h( @@ -762,7 +767,8 @@ when its items change:

    messages.ts, and the facet contributes its catalogs to the page's Translations). useHold holds a reference while the component is mounted and releases it on unmount; it is undefined on the first render. useWatch follows a member. usePageState is useState whose value the page keeps under a key instead of the component: chapter IX shows why the half-typed todo needs it. Clicks call methods with BACKGROUND_CONTEXT, which never ends, so a click's call -is not cancelled when the handler returns. todos?.variant === "owner" narrows the reference: only the owner's has +is not cancelled when the handler returns; adding a todo runs in host.root (useHost gives the page's host), which +makes the click a trace of its own (chapter XI). todos?.variant === "owner" narrows the reference: only the owner's has clear, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed by the plugin's name (a *.module.css file would scope them instead):

    @@ -1280,7 +1286,7 @@ replacing a list is a delete, then an import (decision "App kit naming and an index of the app's, if it had one, is the app's.

    -
    XI

    Seeing What Happened

    One telemetry store for every process, one trace from a click to a commit, a span of your own, and a small viewer for the owner.

    +
    XI

    Seeing What Happened

    One telemetry store on the server, one trace from a click in the browser to a commit on a worker, a span of your own, whom each span was for, and a small viewer for the owner.

    Ada says that adding a todo from her phone feels slow some evenings. Her todo crosses three processes: her page, the server's relay and the list's worker. A log line per process would say what each one did, but not which lines belong to her click, or where the time went. Chord records telemetry through pi-telemetry: spans (a named, timed @@ -1288,10 +1294,12 @@ piece of work with attributes), events on spans, and me adapter, given to createHost({ telemetry }) and NOOP by default, which decides where they go. A carrier in each call's header names the caller's span, so the spans of one request, recorded by different processes, form one trace (design §29). Mara wants to see them without reading SQL: a small trace viewer would be nice. This chapter -records, follows one click, adds a span, reads the store from a script, and ends with the viewer.

    -

    Recording

    +stores telemetry in one place, follows a click from the browser to the worker, adds a span, names whom each span was +for, reads the store from a script, and ends with the viewer.

    +

    One store, on the server

    The app keeps its telemetry in one SQLite file in the data directory, through Chord's store -(@earendil-works/chord/telemetry-sqlite). The app's base code opens it, for any process that asks:

    +(@earendil-works/chord/telemetry-sqlite), and the server is the only process that writes it. The app's base code opens +it for the server:

    /** The telemetry store's file in the data directory `dataDir`. */
     export function telemetryFile(dataDir: string): string {
    @@ -1313,77 +1321,94 @@ records, follows one click, adds a span, reads the store from a script, and ends
     	};
     }

    createSqliteTelemetry returns a recorder: an adapter that keeps each settled span in memory and writes them in -one batch a second, one row per span with its events and links inline; metrics are summed per series and 10 seconds; -rows older than seven days are deleted in the same batch, and when 10,000 spans wait, new ones are dropped and counted. -Recording never runs SQL. The same object is the store's read interface, TelemetryStore (below).

    -

    Step 1 opened the store before the server (chapter I), and startTodo gives it to startServer, which passes it to the -server's host and its HTTP component:

    +one batch a second, one row per span with its events and links inline and its start and end in microseconds (a +monotonic clock anchored to the wall clock, so spans of one process keep their order and spacing); metrics are summed +per series and 10 seconds; rows older than seven days are deleted in the same batch, and when 10,000 spans wait, new +ones are dropped and counted. Recording never runs SQL. The same object is the store's read interface, +TelemetryStore (below).

    +

    Step 1 opened the store before the server (chapter I), and startTodo gives it to startServer:

    -
    			// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).
    +
    			// The store the server writes: its own spans and metrics, and what the workers and recording pages send.
     			telemetry: telemetry.store,
    -

    The kit carries no spans between processes: each process records to the adapter it was given (slice 9, §5). So each -list's worker opens the same file with a recorder of its own; the server names the file in the worker's environment:

    - -
    					env: { TODO_TELEMETRY: telemetryFile(dataDir) },
    +

    With a store, the kit does the rest. Every list's worker records in memory and sends its finished spans and metrics to +the server once a second, over the link it already has; nothing tells a worker where the file is, and the worker's +entry has no telemetry of its own to set up:

    -
    const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!);
    -startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) });
    -

    One file for every process is the app's choice. SQLite in WAL mode lets several processes write it, each waiting for -the others' locks (up to 5 seconds), and every span id is global, so rows of different processes never collide. An app -with a collector would give each process an adapter that exports there instead; nothing else changes.

    -

    One trace across three hosts

    -

    A page in a browser cannot record yet: startPage takes no telemetry adapter. Step 11 makes the page itself, as Ada's -client was made in chapter VII: a client host of this process with a recorder of its own on the same file, signed in -with the owner's cookie from step 1. A click is one span of the page, and the call runs under it:

    - -
    const pageTelemetry = await openTelemetry(telemetryFile(dataDir));
    -const page = createHost<TodoPrincipal>({
    -	telemetry: pageTelemetry.store,
    -	onError: (error) => console.error("page:", error.kind),
    -});
    -await page.load([], context);
    -const pageSocket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie } });
    -page.connect(webSocketLink(pageSocket as unknown as WebSocketLike), { principal: { kind: "server" } }, context);
    -const pageTodos = await page.get(Todos, { list: "groceries" }, context);
    -const click = (text: string) =>
    -	pageTelemetry.store.startSpan({ name: "page.add" }, (span) => pageTodos.add(text, withTelemetry(span, context)));
    -await click("honey");
    -await click(" ").catch(() => "refused");
    -

    startSpan runs the callback in a new root span; withTelemetry(span, context) makes it the context's span, so the -call of add starts its chord.call under it and puts that span's carrier in the call's header. Each recorder writes -once a second, so the step reads the store until the worker's spans are there, takes the older of the two traces whose -first span is page.add and prints it with pi-telemetry's renderTrace (main.ts masks the lengths of time, which -differ at every run):

    - -
    11. adding honey from the page:
    -   page.add
    -     chord.call type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="ok"
    -       chord.relay type="todo.todos" member="#0" upstream.kind="list" outcome="ok"
    -         chord.method type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" outcome="ok"
    -           chord.method type="todo.store" member="add" object="todo.store" principal.kind="worker" outcome="ok"
    -             · chord.commit documents=0 ops=0 duration=<ms>
    -             todos.validate chars=5
    -

    Three processes recorded these six spans. The page recorded page.add and chord.call, the consumer's call until its -reply. The server recorded chord.relay: it forwarded the call to the list's worker without knowing member names -(#0 is the method's index). The worker recorded the rest: chord.method for the owner's Todos view, then the -view's call of the store, which is a method span too, by worker, because the store acts as its host (chapter II). -The commit is an event on the span its transaction ran under, and the renderer prints a span's events before its -children. Its counts are documents and their ops; a commit of table rows counts neither, so this one says 0 and 0.

    -
    pagerecorder 1serverrecorder 2list|groceriesrecorder 3page.add, chord.callcall add, carrier of chord.callchord.relaycall #0, carrier of chord.relaychord.method ×2, todos.validate, commitreplyreply
    Figure 5. One click, one trace: each process starts its spans with its own recorder, and the carrier in each call's header names the parent span on the other side.
    +
    startWorker({ api: API, kinds: KINDS, principalAttributes, onError: (error) => console.error("list:", error) });
    +

    A page records when the app asks it to, in its web entry:

    + +
    	// The page records its clicks and the calls under them, and sends them to the server every few seconds.
    +	telemetry: true,
    +

    The page keeps its finished spans in memory and sends them every three seconds, and at once when it is hidden (a tab +switched away may not come back). Both send to an object the kit serves on the server, WorkerTelemetry and +PageTelemetry, whose ingest(batch) hands the spans to the store; its calls record no span of their own, or every +batch would write a span about itself. A worker is the server's own child: its batches are written as they are, +marked with its host. A page is anyone's: sign-in and the link's limits apply as for any call, and the server checks +everything (next section).

    +

    One trace from the browser

    +

    The lists plugin's panel starts a trace where Ada's click starts: in its form's submit, a root span of the page's host +with the call under it:

    + +
    		// The click is a trace of its own: a root span of the page, the call to the list's worker under it.
    +		const trace = { name: "page.add", attributes: { chars: text.length } };
    +		void host.root((ctx) => todos.add(text, ctx), context, { trace }).catch(() => {});
    +

    host.root(fn, context, { trace }) runs fn in a new root span of the host's adapter, ctx.telemetry that span, so +the call of add starts its chord.call under it and puts that span's carrier in the call's header (useHost gives a +component its page's host). Without a recording adapter it starts nothing. screenshots.ts signs the owner in to the +page in Chromium, types honey and submits, then submits again with nothing typed; it reads the store until the +worker's spans of both clicks are there and prints honey's trace with pi-telemetry's renderTrace (ids and lengths of +time masked, which differ at every run):

    + +
    adding honey from the page:
    +   page.add chord.host="browser" chars=5 chord.principal="owner"
    +     chord.call chord.host="browser" type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="ok" chord.principal="owner"
    +       chord.relay chord.host="server" type="todo.todos" member="#0" upstream.kind="list" outcome="ok"
    +         chord.method chord.host="list|groceries" type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" chord.principal="owner" outcome="ok"
    +           chord.method chord.host="list|groceries" type="todo.store" member="add" object="todo.store" principal.kind="worker" chord.principal="worker:list|groceries" outcome="ok"
    +             · chord.commit documents=0 ops=0 rows=1 duration=<ms>
    +             todos.validate chord.host="list|groceries" chars=5
    +

    Three processes recorded these six spans, and the server wrote them all. Each span says which host recorded it: +chord.host is the host's address key (server, list|groceries), and browser for what a page sent. The page +recorded page.add and chord.call, the consumer's call until its reply. The server recorded chord.relay: it +forwarded the call to the list's worker without knowing member names (#0 is the method's index). The worker recorded +the rest: chord.method for the owner's Todos view, then the view's call of the store, which is a method span too, +by worker, because the store acts as its host (chapter II). The commit is an event on the span its transaction ran +under, and the renderer prints a span's events before its children. It counts the documents it changed, their ops and +the table rows its writes changed: the store keeps todos in a table, so one row and no documents. chord.principal +says whom a span was for (below).

    +
    pagebrowserserverthe storelist|groceriesworkerpage.add, chord.callcall add, carrier of chord.callchord.relaycall #0, carrier of chord.relaychord.method ×2, todos.validate, commitreplyreplyingest: its spans (every second)ingest: its spans (every 3 s, or hidden)
    Figure 5. One click, one trace: each process starts its spans with its own adapter, the carrier in each call's header names the parent span on the other side, and the page and the worker send their spans to the server, which writes them.

    The carrier is a field of the call and hold headers, at most 8 keys of at most 256 characters each. The receiver reads it with its own adapter; an absent or malformed one gives a new root, never an error. With the NOOP adapter nothing is injected and no span starts: a span site costs one check (§30).

    +

    What a page sends, the server treats as untrusted. A batch is checked by its schema: at most 100 spans, every field +bounded (names 128 characters, 32 attributes of at most 256 characters, 16 events, 8 links, a 500-character error +message), ids printable ASCII and no control characters anywhere; a batch outside it is refused whole. A page's span +ids are browser:<its host's incarnation>:<n>, and the server takes only spans whose id, trace and parent carry the +calling page's own prefix: a page can neither write into another sender's trace nor pose as a server span, and the +store never replaces a stored span. A page may send 1,200 spans a minute; past that they are dropped and counted in +ingest's answer. The server marks each span chord.host: browser and stamps it with the principal of the link it came +over: what a page says about itself counts for nothing.

    The trace holds the click and nothing else. Work that serves many requests is its own trace, linked to the span that first needed it: a worker's start (chord.resolve), an object's activation (chord.activate). A link is a reference to a span of another trace; the renderer prints it and never follows it.

    Trap: a trace starts where a span starts

    -

    The page took its reference to Todos before the click, under BACKGROUND_CONTEXT, and that hold is in no trace of -the page: a context without a recording span records nothing. A host gives such work no span of its own, so its -calls start no chord.call and send no carrier, and the provider's chord.method becomes a root of its own trace +

    The panel took its reference to Todos before the click (useHold), under BACKGROUND_CONTEXT, and that hold is in no +trace of the page: a context without a recording span records nothing. A host gives such work no span of its own, +so its calls start no chord.call and send no carrier, and the provider's chord.method becomes a root of its own trace (the server's relay of the page's hold is one: chord.relay with member="hold"). So were the server's calls in step 2: -on the worker each is a root chord.method. Start a span where the work begins, a click handler or a job, and pass it -down with withTelemetry. The kit's HTTP component does this for every request: a chord.http span (method, route -pattern, status), with the route's ctx under it.

    +on the worker each is a root chord.method. Start a span where the work begins, a click handler or a job +(host.root(fn, context, { trace })), and pass its ctx down. The kit's HTTP component does this for every request: a +chord.http span (method, route pattern, status), with the route's ctx under it. Step 11 adds honey from the command +line, as step 3 did; the request is the trace's root, the server's call to the worker under it:

    + +
    11. adding honey from the command line:
    +   chord.http chord.host="server" method="POST" route="POST /api/lists/:list" status=200
    +     chord.hold chord.host="server" type="todo.todos" object="todo.todos(groceries)" address.kind="list" outcome="bound"
    +     chord.call chord.host="server" type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" chord.principal="owner" outcome="ok"
    +       chord.method chord.host="list|groceries" type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" chord.principal="owner" outcome="ok"
    +         chord.method chord.host="list|groceries" type="todo.store" member="add" object="todo.store" principal.kind="worker" chord.principal="worker:list|groceries" outcome="ok"
    +           · chord.commit documents=0 ops=0 rows=1 duration=<ms>
    +           todos.validate chord.host="list|groceries" chars=5

    A span of your own

    The store's add checks the text in a span of its own:

    @@ -1392,22 +1417,53 @@ pattern, status), with the route's ctx under it.

    if (text === "") throw invalid("args", "a todo needs text"); });

    ctx.trace(name, attributes, fn) starts a child of the method's span and gives fn a child ctx with the same -principal and get, so calls made inside are parented under it. It is the last line of the trace above: -todos.validate chars=5. Its attributes are small, flat values; the todo's text is data and stays out of telemetry, -its length is enough. When fn throws, the span fails and the error passes through as it was: the caller still gets -invalid/args. A check this small does not need a span of its own; a lookup, a model request or a file read does.

    +principal and get, so calls made inside are parented under it, and names its host as Chord's spans do. It is the last +line of both traces above: todos.validate chord.host="list|groceries" chars=5. Its attributes are small, flat values; +the todo's text is data and stays out of telemetry, its length is enough. When fn throws, the span fails and the error +passes through as it was: the caller still gets invalid/args. A check this small does not need a span of its own; a +lookup, a model request or a file read does.

    +

    Whom a span was for

    +

    Method and call spans record whom they were for: by default chord.principal, the principal's key (its canonical +JSON). An app may record other attributes instead, through a hook each host takes (principalAttributes): the todo +app's records a short key and, for a user, the name she signed in with:

    + +
    export function principalAttributes(principal: TodoPrincipal): Readonly<Record<string, string>> {
    +	let attributes = recorded.get(principal);
    +	if (attributes === undefined) {
    +		attributes =
    +			principal.kind === "user"
    +				? { "chord.principal": `user:${principal.id}`, "user.name": principal.name }
    +				: { "chord.principal": principal.kind === "worker" ? `worker:${principal.list}` : principal.kind };
    +		recorded.set(principal, attributes);
    +	}
    +	return attributes;
    +}
    +

    Chord calls the hook for every span it records with a principal, so it must be synchronous and cheap; any caching is +the hook's own (here a WeakMap by principal: a link stamps one principal object on all its calls). startTodo gives +it to the server (principalAttributes beside telemetry) and the worker's entry to startWorker (above): each host +has its own, the same function. The server also stamps a page's spans with it, for the principal of the link the batch +came over: page.add above was the owner's. Only what the hook returns is recorded, never other app data, and the +attributes are for the owner's eyes: the store, like the viewer below, is the owner's.

    Reading

    The store is read through TelemetryStore, pi-telemetry's read interface modeled on Jaeger's: findTraces (complete traces, newest first, filtered by their first span's name, attributes, errors or a time window, paged by a cursor), getTrace(id) and findMetrics (a metric's rows, one per series and 10 seconds, with count, sum, min and max). -trace.ts is a script for a data directory, the app running or not:

    +trace.ts is a script for a data directory, the app running or not. It opens the file with Chord's read-only store, +openTelemetryStore (@earendil-works/chord/telemetry-sqlite/node), which implements only the read interface and +opens SQLite read-only: it never writes the file the server writes, never creates or migrates it, and refuses a file +without a store or with a store of another version.

    -
    	if (id !== undefined) {
    -		const trace = await telemetry.store.getTrace(id);
    +
    // The shared file, read-only: the app's processes may be writing it meanwhile.
    +const store = await openTelemetryStore(telemetryFile(dataDir));
    +try {
    +	if (id !== undefined) {
    +		const trace = await store.getTrace(id);
     		console.log(trace === undefined ? `no trace ${id}` : renderTrace(trace.spans));
     	} else {
    -		// Complete traces, newest first; a trace's first span (its root, by start time) names it.
    -		const { traces } = await telemetry.store.findTraces({ name: values.name, limit: 10 });
    +		// Complete traces, newest first; a trace's first span (its root, by start time) names it, and the app's
    +		// `principalAttributes` hook named whom each span was for.
    +		const attributes = values.principal === undefined ? undefined : { "chord.principal": values.principal };
    +		const { traces } = await store.findTraces({ name: values.name, attributes, limit: 10 });
     		for (const { traceId, spans } of traces) {
     			const start = Math.min(...spans.map((span) => span.startedMs));
     			const length = Math.max(...spans.map((span) => span.endedMs)) - start;
    @@ -1416,110 +1472,122 @@ traces, newest first, filtered by their first span's name, attributes, error
     		}
     		// Metrics are stored per series and 10 s: a series is one set of attributes, here the direction.
     		for (const direction of ["sent", "received"]) {
    -			const rows = await telemetry.store.findMetrics({ name: "chord.link.bytes", attributes: { direction } });
    +			const rows = await store.findMetrics({ name: "chord.link.bytes", attributes: { direction } });
     			const bytes = rows.reduce((sum, row) => sum + row.sum, 0);
     			const messages = rows.reduce((sum, row) => sum + row.count, 0);
     			console.log(`chord.link.bytes ${direction}: ${bytes} bytes in ${messages} messages`);
     		}
     	}
    -

    Step 11 runs it twice from a process of its own: the newest page.add traces, then the refused one by its id (ids, -lengths and counts masked):

    - -
       trace.ts <data directory> --name page.add:
    +

    screenshots.ts runs it twice from a process of its own while the app runs: the newest page.add traces with a span +for the owner, then the refused click by its id (ids, lengths and counts masked):

    + +
    trace.ts <data directory> --name page.add --principal owner:
        <id>  page.add  6 spans  <ms>  error
        <id>  page.add  6 spans  <ms>
        chord.link.bytes sent: <n> bytes in <n> messages
        chord.link.bytes received: <n> bytes in <n> messages
    -   trace.ts <data directory> <the refused one's id>:
    -   page.add error ChordError: a todo needs text
    -     chord.call type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="error" error call_failed: a todo needs text
    -       chord.relay type="todo.todos" member="#0" upstream.kind="list" outcome="error" error relay_failed: a todo needs text
    -         chord.method type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" outcome="error" error method_failed: a todo needs text
    -           chord.method type="todo.store" member="add" object="todo.store" principal.kind="worker" outcome="error" error method_failed: a todo needs text
    -             todos.validate chars=0 error trace_failed: a todo needs text
    +trace.ts <data directory> <the refused one's id>: + page.add chord.host="browser" chars=0 chord.principal="owner" error trace_failed: a todo needs text + chord.call chord.host="browser" type="todo.todos" member="add" object="todo.todos(groceries)" address.kind="list" outcome="error" chord.principal="owner" error call_failed: a todo needs text + chord.relay chord.host="server" type="todo.todos" member="#0" upstream.kind="list" outcome="error" error relay_failed: a todo needs text + chord.method chord.host="list|groceries" type="todo.todos" member="add" object="todo.todos(groceries)" principal.kind="owner" chord.principal="owner" outcome="error" error method_failed: a todo needs text + chord.method chord.host="list|groceries" type="todo.store" member="add" object="todo.store" principal.kind="worker" chord.principal="worker:list|groceries" outcome="error" error method_failed: a todo needs text + todos.validate chord.host="list|groceries" chars=0 error trace_failed: a todo needs text

    A trace's id is its root span's. The refused add failed on every span of its path, each with a fixed category -(call_failed, relay_failed, method_failed, trace_failed) and the message cut at 500 characters; a cancelled call -is not an error. It has no commit: nothing was written. chord.link.bytes counts the encoded messages of every host's -links, each message once where it was sent and once where it was received. A metric carries no ids, only attributes -with few values (direction), so its series stay few. A running recorder writes a metric's current 10 seconds only -once they are over, or when it closes: trace.ts sees the page's bytes, whose recorder closed, and the server's and -workers' of the intervals already over. The script is a recorder too, which records nothing; Chord ships only the -store, so reads go through a recorder of the file.

    +(trace_failed, call_failed, relay_failed, method_failed) and the message cut at 500 characters; a cancelled call +is not an error. It has no commit: nothing was written. chord.link.bytes counts the encoded messages of the server's +and the workers' links (a page sends no metrics), each message once where it was sent and once where it was received. +A metric carries no ids, only attributes with few values (direction), so its series stay few. A metric's current 10 +seconds are sent and written only once they are over, or when a process closes.

    A trace viewer

    Two halves make the viewer: routes on the server that read the store, and a page that shows what they answer. Server code of an app is either the start script or a plugin, and routes come from plugins (chapter III), so the routes are a -small plugin, plugins/traces/. Its server facet serves one host-lifetime object, which opens the store's file as a -native and contributes the routes while it is live:

    +small plugin, plugins/traces/. Its server facet serves one host-lifetime object, which opens the store's file +read-only as a native and contributes the routes while it is live:

    	host.serve(Traces, (_params, self) => {
     		const info = self.get(AppInfo, {});
    -		// The server records to the same file with its own recorder; this one only reads.
    +		// The server records to the same file with its own recorder; this object only reads it.
     		const telemetry = self.open(
    -			async () => openTelemetry(telemetryFile(info.dataDir.value)),
    -			(opened) => opened.close(),
    +			async () => openTelemetryStore(telemetryFile(info.dataDir.value)),
    +			(store) => store.close(),
     		);
     		self.contribute(Http, {
     			routes: {
    -				"GET /api/traces": async (_request, ctx) => {
    +				"GET /api/traces": async (request, ctx) => {
     					ownerOnly(ctx);
    -					const { traces } = await telemetry.value.store.findTraces({ limit: RECENT });
    +					// `?principal=user:u1`: the traces with a span for that principal (`principalAttributes`).
    +					const principal = request.query.get("principal");
    +					const attributes = principal === null ? undefined : { "chord.principal": principal };
    +					const { traces } = await telemetry.value.findTraces({ limit: RECENT, attributes });
     					return { kind: "json", value: traces.map(({ traceId, spans }) => summaryOf(traceId, spans)) };
     				},
     				"GET /api/traces/:id": async (request, ctx) => {
     					ownerOnly(ctx);
    -					const trace = await telemetry.value.store.getTrace(request.params.id!);
    +					const trace = await telemetry.value.getTrace(request.params.id!);
     					if (trace === undefined) return { kind: "error", status: 404 };
    -					return { kind: "json", value: { id: trace.traceId, spans: rowsOf(trace.spans) } };
    +					return { kind: "json", value: { id: trace.traceId, spans: rowsOf(clamped(trace.spans)) } };
     				},
     			},
     		});
     		return {};
     	});

    The kit gives a plugin the server's process values as objects: AppInfo names the data directory, and the API's server -module re-exports it with the app's openTelemetry. self.open declares a native, opened once AppInfo is live and -closed when the object ends; a reload of the plugin restarts the object and opens the file anew. An object may -contribute when it lives as long as its host (local, empty params; decision R21). Spans name who called what, so -only the owner reads them:

    +module re-exports it with telemetryFile and Chord's openTelemetryStore. self.open declares a native, opened once +AppInfo is live and closed when the object ends; a reload of the plugin restarts the object and opens the file anew. +An object may contribute when it lives as long as its host (local, empty params; decision R21). Spans name who called +what, so only the owner reads them:

    function ownerOnly(ctx: Ctx): void {
     	if (ctx.principal?.kind !== "owner") throw forbidden("the traces are the owner's");
     }
    -

    A route's principal is undefined only on a public route, which these are not; a signed-in member gets 403. rowsOf -orders a trace's spans as a tree, parents before their children, and turns attributes, events and links into text. The -spans name no host; the SQLite store's span ids begin with the id of the recorder, one per process, and the viewer -calls each recorder a host:

    - -
    function processOf(span: StoredSpan): string {
    -	return span.id.slice(0, span.id.lastIndexOf(":"));
    +

    A route's principal is undefined only on a public route, which these are not; a signed-in member gets 403. The list +filters by whom the traces were for (?principal=), and shows each with its user's name or principal key. rowsOf +orders a trace's spans as a tree, parents before their children, each row's host its span's chord.host, and turns +attributes, events and links into text. A page's spans carry the page's clock, which may be off by seconds against the +server's, so a span of the page could show starting after the server span under it. Before drawing, clamped moves +the page's spans of the trace by the least amount that puts every server span under a page span inside it +(plugins/traces/clock.ts, with its own test):

    + +
    export function clamped(spans: readonly StoredSpan[]): readonly StoredSpan[] {
    +	const skew = skewOf(spans);
    +	if (skew === 0) return spans;
    +	return spans.map((span) =>
    +		hostOf(span) === "browser" ? { ...span, startedMs: span.startedMs + skew, endedMs: span.endedMs + skew } : span,
    +	);
     }
    -

    That is a detail of this store, not of the read interface (see the epilogue). The page is the app's own page at a -second path: start.ts serves it at / and /traces (chapter I), and the web entry picks the viewer by the path:

    +

    The page is the app's own page at a second path: start.ts serves it at / and /traces (chapter I), and the web +entry picks the viewer by the path:

    	// The page's one other path (`start.ts` serves the page at `/` and `/traces`).
     	if (location.pathname === "/traces") return h("main", { className: "wide" }, h("h1", null, "Todo"), h(TraceViewer));

    TraceViewer (src/web/traces.ts) fetches the list and the trace chosen in the URL's fragment, and draws each span -with its host, its length as a bar on the trace's time line, its attributes and events, a failed span in the theme's -error colour, and each link as the ids it refers to, never fetched. The bars are positioned with inline styles, which -the page's CSP allows only because React sets them through the DOM:

    +with its host's name, its length as a bar on the trace's time line, its attributes and events, a failed span in the +theme's error colour, and each link as the ids it refers to, never fetched. The bars are positioned with inline +styles, which the page's CSP allows only because React sets them through the DOM:

    	const bar = {
     		left: `${(row.offsetMs / total) * 100}%`,
     		width: `${Math.max((row.durationMs / total) * 100, 0.5)}%`,
     	};
    -

    screenshots.ts adds honey from a recording page and refuses a blank todo, as step 11 does, then opens the viewer on -honey's trace:

    +

    After the two clicks, screenshots.ts opens the viewer on honey's trace, then types user:u1 into its filter:

    	await browser.open(`${origin}/traces#${encodeURIComponent(trace.traceId)}`);
     	await browser.wait("document.querySelectorAll('.traces-tree li').length === 6");
     	await shoot("traces.png");
    wrote docs/tutorial/screens/traces.png
    -the viewer shows page.add: 6 spans on 3 hosts
    +the viewer shows page.add: 6 spans on 3 hosts +wrote docs/tutorial/screens/traces-ada.png +the viewer for user:u1 lists chord.method for ada

    The trace viewer

    -
    Figure 6. The owner's trace viewer at /traces, in the Harbor theme: the newest traces (the refused add marked in red, the rest the app's own work: relays, activations, HTTP requests), and honey's trace as a tree across the page, the server and the worker. Starts are stored in whole milliseconds, so the bars of spans this short shift by up to one.
    +
    Figure 6. The owner's trace viewer at /traces, in the Harbor theme: the newest traces (the refused click marked in red, each with whom it was for; the rest the app's own work: relays, activations, HTTP requests), and honey's trace as a tree across the page (browser), the server and the worker (list|groceries), all written by the server. Starts are stored to the microsecond, so even bars of a fraction of a millisecond line up.
    +

    The trace viewer filtered by principal

    +
    Figure 7. The traces for Ada (user:u1): the coffee screenshots.ts adds at its start as her (a root of the server acting as her), recorded by the list's worker; the app's hook named her ada.

    The list is mostly the app's own work: each HTTP request, relayed hold and activation is a trace of its own, as the -trap above says. A real viewer would filter by name (findTraces({ name })) or by error (errors: true), both of -which the read interface has; this one is about 270 lines of TypeScript in two files and 20 of CSS, and no Chord API was added for it.

    +trap above says. A real viewer would filter by name too (findTraces({ name })) or by error (errors: true), both of +which the read interface has; this one is about 330 lines of TypeScript in three files and 20 of CSS, on Chord's +read-only store.

    XII

    Testing One Plugin

    testPlugin builds a plugin as production does and loads it alone on a host of its own.

    @@ -1735,14 +1803,15 @@ chapter IX), a plugin's contract.ts could not be imported outsi chapter I), plugins could not throw Chord's invalid (Chord exports invalid and forbidden, chapter II), and a live window over a table needed a one-time read in self.open, which made the store restart at every reload of its plugin (now self.query, a live query Chord runs, chapters II and IX).

    -

    Found while writing the telemetry chapter, still open. A page cannot record: startPage takes no telemetry -adapter, so chapter XI's page is a client host in Node, and a browser's click starts its trace at the server's relay. -chord.commit counts documents and their ops only, so a commit that wrote table rows reports documents=0 ops=0. -Spans name no host, and the viewer tells processes apart by the SQLite store's id format (<recorder>:<n>), which -the read interface does not promise. The store keeps a span's start in whole milliseconds (Date.now()) and its -length in fractions (performance.now()), so on a trace of a few milliseconds the viewer's bars shift by up to one. And -reading needs a recorder: chord/telemetry-sqlite exports no read-only -store, so a script or a viewer opens a recorder that records nothing.

    +

    Found while writing the telemetry chapter, and fixed in Chord since. chord.commit counted documents and their +ops only, so a commit that wrote table rows reported documents=0 ops=0 (it counts rows now). Spans named no host, +and the viewer told processes apart by the SQLite store's id format (every Chord span carries chord.host now). The +store kept a span's start in whole milliseconds, so the viewer's bars shifted by up to one (starts are microseconds +now, the store's version 2, which a recorder migrates its file to). Reading needed a recorder that recorded nothing +(openTelemetryStore reads read-only). A page could not record, so the chapter's click was a client host in Node +standing in for the page (startPage({ telemetry: true }) now, its spans sent to the server). And every process wrote +the one file itself, each worker told its path (the server is the store's only writer now, the workers and pages +sending it their spans), and spans said nothing of whom they were for (principalAttributes).

    A

    The App's Files

    @@ -1757,7 +1826,7 @@ store, so a script or a viewer opens a recorder that records nothing.

    src/principals.ts base -the principal type +the principal type, principalAttributes (whom a span was for) package.json @@ -1772,12 +1841,12 @@ store, so a script or a viewer opens a recorder that records nothing.

    src/api/v1/server.ts base, server -Http, the ephemeral login, AppInfo, openTelemetry +Http, the ephemeral login, AppInfo, telemetryFile, openTelemetryStore src/api/v1/web.ts base, web -Panels, useHold, useMessages, usePageState, useWatch +Panels, useHold, useHost, useMessages, usePageState, useWatch src/api/v1/tui.ts @@ -1787,17 +1856,17 @@ store, so a script or a viewer opens a recorder that records nothing.

    start.ts, worker.ts base -startServer, startWorker, each with the telemetry store +startServer with the telemetry store, startWorker, each with principalAttributes src/telemetry.ts base -the telemetry store's file in the data directory, openTelemetry +the telemetry store's file in the data directory, openTelemetry (the server's) src/web/main.ts, src/web/style.css base, web entry -startPage, the panels, the viewer at /traces +startPage (recording), the panels, the viewer at /traces src/web/traces.ts @@ -1827,7 +1896,7 @@ store, so a script or a viewer opens a recorder that records nothing.

    plugins/traces/ plugin -the trace viewer's routes over the telemetry store (server), the owner's only +the trace viewer's routes over the telemetry store (server), the owner's only; the page's clock moved onto the server's (clock.ts, its test) later/harbor/ @@ -1837,7 +1906,7 @@ store, so a script or a viewer opens a recorder that records nothing.

    main.ts, screenshots.ts, tutorial.test.ts example -the steps, the screenshots, the checks +the steps, the screenshots (with the page's traced click), the checks trace.ts @@ -1862,8 +1931,8 @@ store, so a script or a viewer opens a recorder that records nothing.

    the app's server, with the kit -startWorker({ api, kinds, onError }) -I +startWorker({ api, kinds, principalAttributes, onError }) +I, XI a worker's entry @@ -1947,17 +2016,17 @@ store, so a script or a viewer opens a recorder that records nothing.

    moving a worker's data -createSqliteTelemetry(db), startServer({ telemetry }), startWorker({ telemetry }), createHost({ telemetry }) +createSqliteTelemetry(db), startServer({ telemetry, principalAttributes }), startPage({ telemetry: true }), createHost({ telemetry, principalAttributes }) XI -recording to the telemetry store +the telemetry store on the server, pages and workers sending it their spans -adapter.startSpan({ name }, fn), withTelemetry(span, ctx), ctx.trace(name, attributes, fn) +host.root(fn, ctx, { trace }), ctx.trace(name, attributes, fn) XI spans of your own -store.findTraces({ name, attributes, errors, startMin, startMax, limit, cursor }), store.getTrace(id), store.findMetrics({ name, attributes }), renderTrace(spans) +openTelemetryStore(path), store.findTraces({ name, attributes, errors, startMin, startMax, limit, cursor }), store.getTrace(id), store.findMetrics({ name, attributes }), renderTrace(spans) XI reading traces and metrics diff --git a/packages/chord/docs/tutorial/screens/click-after.png b/packages/chord/docs/tutorial/screens/click-after.png new file mode 100644 index 0000000000000000000000000000000000000000..60154b5842d35f43afd423e73de476450daab4ad GIT binary patch literal 18172 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 literal 0 HcmV?d00001 diff --git a/packages/chord/docs/tutorial/screens/click-before.png b/packages/chord/docs/tutorial/screens/click-before.png new file mode 100644 index 0000000000000000000000000000000000000000..d3ee64471a4c9da41ef4652aeef880df94188c9a GIT binary patch literal 16248 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 literal 0 HcmV?d00001 diff --git a/packages/chord/docs/tutorial/screens/traces.png b/packages/chord/docs/tutorial/screens/traces.png index 567509d9f3a5740bce1c2ddbd10a78bf6661d870..7d5083c1ad53939d886b1252ab26c23833a6de68 100644 GIT binary patch literal 92380 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

    14$Ha|&_jN6d1F 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 | undefined; /** What its commit adopts, once it started (telemetry's `chord.commit` counts it when asked). */ #adoptions: readonly Adoption[] = []; + /** The table rows its commit's step changed, once it committed through the store. */ + #rows = 0; constructor( documents: Documents, @@ -168,11 +170,14 @@ export class Transaction implements Tx { return this.#writes.staged; } - /** What its commit adopted: its documents, their ops and its store writes (all 0 before it committed). */ - committed(): { readonly documents: number; readonly ops: number; readonly writes: number } { + /** + * What its commit adopted: its documents, their ops, its store writes and the table rows they changed (all 0 before + * it committed). + */ + committed(): { readonly documents: number; readonly ops: number; readonly writes: number; readonly rows: number } { let ops = 0; for (const { prepared } of this.#adoptions) ops += prepared?.ops.length ?? 0; - return { documents: this.#adoptions.length, ops, writes: this.#writes?.staged.length ?? 0 }; + return { documents: this.#adoptions.length, ops, writes: this.#writes?.staged.length ?? 0, rows: this.#rows }; } /** The object whose transaction this is. */ @@ -388,7 +393,10 @@ export class Transaction implements Tx { this.#state = "stepping"; return true; }, - adopt: () => this.#adopt(adoptions), + adopt: (_seq: number, _ids: readonly number[], rows: number) => { + this.#rows = rows; + this.#adopt(adoptions); + }, }; this.#queued = Promise.withResolvers(); try { diff --git a/packages/chord/src/host/attempt.ts b/packages/chord/src/host/attempt.ts index 360b2ee18..95ffd9491 100644 --- a/packages/chord/src/host/attempt.ts +++ b/packages/chord/src/host/attempt.ts @@ -505,6 +505,7 @@ export class Attempt implements StoreSelf, KeyDeclarer, RegistryWaiter { position: this.#natives.length + 1, context: this.#context, telemetry: host.telemetry, + host: host.spanHost, report: (error: HostError) => host.report(error), }; const native = new NativeHandle(open, close, closeTimeout, owner); @@ -631,7 +632,12 @@ export class Attempt implements StoreSelf, KeyDeclarer, RegistryWaiter { #startActivation(link: TelemetryContext): void { const { host, served, label } = this.#object; if (untraced(host.telemetry)) return; - const attributes = { type: served.contract.name, object: capped(label), attempt: this.#trace.attempt }; + const attributes = { + "chord.host": host.spanHost, + type: served.contract.name, + object: capped(label), + attempt: this.#trace.attempt, + }; const activation = new OpenSpan(host.telemetry, "chord.activate", attributes); if (!untraced(link)) activation.span.addLink({ context: link }); this.#activation = activation; @@ -878,7 +884,7 @@ export class Attempt implements StoreSelf, KeyDeclarer, RegistryWaiter { // caller. The caller stays the reference's (presence, §19). const acting = served.kind === "remote" ? caller : { principal: host.principal, incarnation: host.incarnation }; // Its span only when something records (§30: nothing else is made per call). - const span = untraced(parent) ? undefined : this.#methodSpan(parent, method, acting); + const span = !served.traced || untraced(parent) ? undefined : this.#methodSpan(parent, method, acting, context); const ctx = new CallContext( span === undefined ? callContext : withTelemetry(span.span, callContext), acting, @@ -908,13 +914,15 @@ export class Attempt implements StoreSelf, KeyDeclarer, RegistryWaiter { } /** The `chord.method` span of a call of `method` acting as `acting`, under `parent`. */ - #methodSpan(parent: TelemetryContext, method: string, acting: Caller): OpenSpan<"chord.method"> { - const { served, label } = this.#object; + #methodSpan(parent: TelemetryContext, method: string, acting: Caller, context: Context): OpenSpan<"chord.method"> { + const { host, served, label } = this.#object; return new OpenSpan(parent, "chord.method", { + "chord.host": host.spanHost, type: served.contract.name, member: method, object: capped(label), "principal.kind": capped(acting.principal?.kind ?? "none"), + ...host.principalAttributes(acting.principal, context), }); } diff --git a/packages/chord/src/host/ctx.ts b/packages/chord/src/host/ctx.ts index f144dab67..49c7b8c6a 100644 --- a/packages/chord/src/host/ctx.ts +++ b/packages/chord/src/host/ctx.ts @@ -22,10 +22,10 @@ export interface Ctx

    extends Context { */ get(Type: C, params: ParamsOf, options?: GetOptions): Promise>; /** - * Runs `fn` in a child span `name` of this ctx's span (`telemetry`) with `attributes`: `fn` gets a child ctx (the same - * principal, incarnation, signal and `get`, its references this ctx's), so calls inside are parented under it. A - * throw marks the span failed (`trace_failed`; a cancel keeps it ok) and is rethrown. Without an adapter that - * records, `fn` gets this ctx. + * Runs `fn` in a child span `name` of this ctx's span (`telemetry`) with its host's `chord.host` and `attributes`: + * `fn` gets a child ctx (the same principal, incarnation, signal and `get`, its references this ctx's), so calls + * inside are parented under it. A throw marks the span failed (`trace_failed`; a cancel keeps it ok) and is rethrown. + * Without an adapter that records, `fn` gets this ctx. */ trace(name: string, attributes: SpanAttributes, fn: (ctx: Ctx

    ) => Promise): Promise; } @@ -67,6 +67,11 @@ export class CallContext implements Ctx { return `${this.#context}.Ctx`; } + /** Its host's `chord.host`, for its child spans. */ + get spanHost(): string { + return this.#host.spanHost; + } + async get(Type: C, params: ParamsOf, options: GetOptions = {}): Promise> { // A settled scope refuses `released` whatever aborted it; an aborted call creates nothing. if (this.#references === undefined) throw settled(Type); @@ -111,8 +116,11 @@ export class CallContext implements Ctx { } } -/** A ctx that makes its child under a span (`ctx.trace`): the same kind of ctx (a job's has `jobId` and `state`). */ -type Traceable = Ctx & { traced(span: TelemetrySpan): Ctx }; +/** + * A ctx that makes its child under a span (`ctx.trace`): the same kind of ctx (a job's has `jobId` and `state`), and + * its host's `chord.host`. + */ +type Traceable = Ctx & { readonly spanHost: string; traced(span: TelemetrySpan): Ctx }; /** * `ctx.trace` of `ctx`: `fn` in a child span with its child ctx; with nothing recorded, `fn` with `ctx` itself. Either @@ -127,7 +135,7 @@ function traceUnder( const parent = ctx.telemetry; // Plugin code: a synchronous throw becomes the rejection. if (untraced(parent)) return (async () => fn(ctx))(); - return parent.startSpan({ name, attributes }, async (span) => { + return parent.startSpan({ name, attributes: { "chord.host": ctx.spanHost, ...attributes } }, async (span) => { try { return await fn(ctx.traced(span)); } catch (error) { @@ -139,14 +147,19 @@ function traceUnder( /** A ctx under a child span (`ctx.trace`): its parent's caller, signal and references, `telemetry` the span. */ export class SpanCtx implements Ctx { - readonly #parent: Ctx; + readonly #parent: Traceable; readonly #context: Context; - constructor(parent: Ctx, span: TelemetrySpan) { + constructor(parent: Traceable, span: TelemetrySpan) { this.#parent = parent; this.#context = withTelemetry(span, parent); } + /** Its host's `chord.host`, for its child spans. */ + get spanHost(): string { + return this.#parent.spanHost; + } + get principal(): Principal | undefined { return this.#parent.principal; } diff --git a/packages/chord/src/host/facet.ts b/packages/chord/src/host/facet.ts index 4ff384f07..75d2d73ea 100644 --- a/packages/chord/src/host/facet.ts +++ b/packages/chord/src/host/facet.ts @@ -38,6 +38,11 @@ export type ServeOptions = { * default): a reference within that time reuses it, with its documents and dependencies. */ readonly linger?: number; + /** + * Its calls start no `chord.method` span (`true`): Chord's telemetry ingest, whose every call would otherwise record + * a span about the telemetry it writes. Traced by default. + */ + readonly untraced?: true; }; /** What a facet's setup declares with besides the host: its contributions to registries (R21). */ diff --git a/packages/chord/src/host/host.ts b/packages/chord/src/host/host.ts index 8ba34fb56..fbe69a589 100644 --- a/packages/chord/src/host/host.ts +++ b/packages/chord/src/host/host.ts @@ -4,7 +4,12 @@ // (slice 7b: staging, the commit, each object's fate, the report), closing; one batch per host, the backoff map and the // endings new instances wait for. -import { NOOP_TELEMETRY_CONTEXT, type TelemetryContext, type TelemetrySpan } from "@earendil-works/pi-telemetry"; +import { + NOOP_TELEMETRY_CONTEXT, + type SpanAttributes, + type TelemetryContext, + type TelemetrySpan, +} from "@earendil-works/pi-telemetry"; import { awaitWithContext, BACKGROUND_CONTEXT, @@ -50,6 +55,7 @@ import { chordSpans, countingOnly, ended, + hostAttribute, parentOf, untraced, } from "../telemetry.ts"; @@ -100,6 +106,13 @@ export type HostOptions

    ; /** Everything that fails in the background; every app handles it. */ readonly onError: (error: HostError) => void; /** How long a native's close, and an ending object's running calls, are waited for; 30 s by default. */ @@ -129,6 +142,9 @@ export type HostOptions

    = (principal: P) => Readonly; + /** Where a reported part sits on its object: a routine (`on`, `onLive`) or a native, by kind, from 1 in declaration order. */ export type Position = { readonly kind: "on" | "onLive" | "native"; readonly position: number }; @@ -233,6 +249,15 @@ export type AnyHost = Omit { + /** This host's address (`HostOptions.address`); `undefined` on a client. */ + readonly address: Address | undefined; + /** This run of the host, sent in every handshake (R20): a link's peer sees it as its calls' `ctx.incarnation`. */ + readonly incarnation: string; + /** + * The attributes this host's spans record for `principal`: its `principalAttributes` hook's, else `chord.principal`, + * its principal key; none without a principal. + */ + principalAttributes(principal: P | undefined, context: Context): Readonly; /** * Runs every facet's setup and publishes what they declared in one batch (the types and registries served, the * registries composed once, the contributions); holds that waited for the publication are placed after that batch, @@ -452,6 +477,9 @@ const NO_CHAIN: readonly string[] = []; /** The params of a type with empty params, as checked: nothing, and the empty key. */ const EMPTY_PARAMS: CheckedParams = { params: {}, key: "" }; +/** What a span records for no principal. */ +const NO_ATTRIBUTES: Readonly = Object.freeze({}); + /** The host: its served types, its objects by identity, its batch, backoff and endings. */ export class ChordHost

    implements Host { readonly batch: Batch; @@ -466,12 +494,15 @@ export class ChordHost

    | undefined; + readonly #principalAttributes: PrincipalAttributes

    | undefined; readonly #revocationKeys: RevocationKeys; /** The presence list of each type someone asked about (`references`), by type name. */ readonly #references = new Map(); @@ -523,8 +554,10 @@ export class ChordHost

    { + return chordSpans(parent)("chord.load", { "chord.host": this.spanHost, plugins: facets.length }, async (span) => { try { const report = await this.#load(facets, withTelemetry(span, context)); span.setAttributes({ failed: report.failed.length }); @@ -649,20 +682,24 @@ export class ChordHost

    { const parent = context.telemetry; if (untraced(parent)) return this.#reloadSteps(facets, before, context); - return chordSpans(parent)("chord.reload", { plugins: facets.length }, async (span) => { - try { - const report = await this.#reloadSteps(facets, before, withTelemetry(span, context)); - for (const { object, kind } of report.objects) - span.addEvent("chord.reload.fate", { object: capped(object), fate: kind }); - const { added, changed, removed } = report.plugins; - const counts = { added: added.length, changed: changed.length, removed: removed.length }; - span.setAttributes({ ...counts, failed: report.failed.length, fates: report.objects.length }); - return report; - } catch (error) { - ended(span, "reload_failed", error, context.abortSignal); - throw error; - } - }); + return chordSpans(parent)( + "chord.reload", + { "chord.host": this.spanHost, plugins: facets.length }, + async (span) => { + try { + const report = await this.#reloadSteps(facets, before, withTelemetry(span, context)); + for (const { object, kind } of report.objects) + span.addEvent("chord.reload.fate", { object: capped(object), fate: kind }); + const { added, changed, removed } = report.plugins; + const counts = { added: added.length, changed: changed.length, removed: removed.length }; + span.setAttributes({ ...counts, failed: report.failed.length, fates: report.objects.length }); + return report; + } catch (error) { + ended(span, "reload_failed", error, context.abortSignal); + throw error; + } + }, + ); } /** One reload's steps: staging, the commit, the fates, the report. */ @@ -989,7 +1026,10 @@ export class ChordHost

    span.setAttributes({ count })); + if (!untraced(parent)) { + const attributes = { "chord.host": this.spanHost }; + void chordSpans(parent)("chord.revoke", attributes, (span) => span.setAttributes({ count })); + } } /** `revoke`'s work; how many objects it ended and child links it closed. */ @@ -1085,7 +1125,25 @@ export class ChordHost

    Promise, principal, this.#counted(context)); + const run = fn as (ctx: Ctx) => Promise; + const trace = options.trace; + if (trace === undefined || untraced(this.telemetry)) + return this.#roots.run(run, principal, this.#counted(context)); + const attributes = { "chord.host": this.spanHost, ...trace.attributes }; + return this.telemetry.startSpan({ name: trace.name, attributes }, async (span) => { + try { + return await this.#roots.run(run, principal, withTelemetry(span, context)); + } catch (error) { + ended(span, "trace_failed", error, context.abortSignal); + throw error; + } + }); + } + + principalAttributes(principal: P | undefined, context: Context): Readonly { + if (principal === undefined) return NO_ATTRIBUTES; + if (this.#principalAttributes !== undefined) return this.#principalAttributes(principal); + return { "chord.principal": capped(principalKey(principal, context, this.#principalKey)) }; } /** diff --git a/packages/chord/src/host/index.ts b/packages/chord/src/host/index.ts index 20f401729..f067b1537 100644 --- a/packages/chord/src/host/index.ts +++ b/packages/chord/src/host/index.ts @@ -17,6 +17,7 @@ export { type LoadReport, type PluginFailure, type Position, + type PrincipalAttributes, type ReloadedObject, type ReloadOptions, type ReloadReport, diff --git a/packages/chord/src/host/native.ts b/packages/chord/src/host/native.ts index afb9f51d5..3f071a48c 100644 --- a/packages/chord/src/host/native.ts +++ b/packages/chord/src/host/native.ts @@ -36,6 +36,8 @@ type NativeOwner = { readonly position: number; readonly context: Context; readonly telemetry: TelemetryContext; + /** Its host's `chord.host`. */ + readonly host: string; readonly report: (error: HostError) => void; }; @@ -94,8 +96,8 @@ export class NativeHandle implements Native, NativeSlot { await this.#opened(await this.#open(context)); return; } - const { type, position } = this.#owner; - await chordSpans(parent)("chord.open", { type, native: position }, async (span) => { + const { host, type, position } = this.#owner; + await chordSpans(parent)("chord.open", { "chord.host": host, type, native: position }, async (span) => { let value: T; try { value = await this.#open(withTelemetry(span, context)); @@ -132,9 +134,10 @@ export class NativeHandle implements Native, NativeSlot { * for it up to then; longer is reported. */ async #closeValue(value: T, reason: NativeCloseReason): Promise { - const { telemetry, type, position } = this.#owner; + const { telemetry, host, type, position } = this.#owner; if (untraced(telemetry)) return this.#closeWithin(value, reason, this.#owner.context); - return chordSpans(telemetry)("chord.close", { type, native: position, reason: reason.kind }, async (span) => { + const attributes = { "chord.host": host, type, native: position, reason: reason.kind }; + return chordSpans(telemetry)("chord.close", attributes, async (span) => { const failure = await this.#closeWithin(value, reason, withTelemetry(span, this.#owner.context)); if (failure !== undefined) spanFailed(span, "close_failed", failure.error); return failure; diff --git a/packages/chord/src/host/object.ts b/packages/chord/src/host/object.ts index be6b91f2a..59e78131d 100644 --- a/packages/chord/src/host/object.ts +++ b/packages/chord/src/host/object.ts @@ -65,6 +65,8 @@ type ServedType = { readonly provider: Provider; /** How long a live object stays live after its last reference, in milliseconds (`serve`'s `linger`, 0 by default). */ readonly linger: number; + /** Whether its calls start method spans (`serve`'s `untraced`: not). */ + readonly traced: boolean; }; /** What holds an object: a reference's binding, or a hold arriving over a link. */ diff --git a/packages/chord/src/host/root.ts b/packages/chord/src/host/root.ts index 633804f9c..d09efe80a 100644 --- a/packages/chord/src/host/root.ts +++ b/packages/chord/src/host/root.ts @@ -2,6 +2,7 @@ // (a Durable tool, a CLI), sealed when `fn` settles, when `context` aborts and when the host closes: its signal aborted, // its `ctx.get` refused `released`, every reference it took released. +import type { SpanAttributes } from "@earendil-works/pi-telemetry"; import { awaitWithContext, type Context, withAbortSignal, withoutAbortSignal } from "../context/index.ts"; import { ChordError, type Principal } from "../data/index.ts"; import { CallContext, type Ctx } from "./ctx.ts"; @@ -14,6 +15,12 @@ export type RootOptions

    = { * explicit `undefined` anonymous (`ctx.principal` undefined). */ readonly principal?: P | undefined; + /** + * Runs `fn` in a new root span of this host's adapter (a click, a command: a trace of its own), `ctx.telemetry` that + * span, with its host's `chord.host` and `attributes`; a throw marks it failed (`trace_failed`, a cancel keeps it + * ok). Without an adapter that records, nothing is started. + */ + readonly trace?: { readonly name: string; readonly attributes?: SpanAttributes }; }; /** An open root scope: its seal, and how its caller is rejected when the host closes. */ diff --git a/packages/chord/src/host/route.ts b/packages/chord/src/host/route.ts index f4c98c38e..1076286f1 100644 --- a/packages/chord/src/host/route.ts +++ b/packages/chord/src/host/route.ts @@ -202,7 +202,11 @@ export class Route { const adapter = this.#host.telemetry; const span = untraced(adapter) ? undefined - : new OpenSpan(adapter, "chord.resolve", { kind: this.kind, address: capped(key) }); + : new OpenSpan(adapter, "chord.resolve", { + "chord.host": this.#host.spanHost, + kind: this.kind, + address: capped(key), + }); if (span !== undefined && trigger !== undefined) { span.span.addLink({ context: trigger }); trigger.addLink({ context: span.span }); diff --git a/packages/chord/src/host/routines.ts b/packages/chord/src/host/routines.ts index 2ccc350af..46fa16c9d 100644 --- a/packages/chord/src/host/routines.ts +++ b/packages/chord/src/host/routines.ts @@ -186,6 +186,7 @@ function call( const span = untraced(host.telemetry) ? undefined : new OpenSpan(host.telemetry, "chord.routine", { + "chord.host": host.spanHost, type: owner.type, object: capped(owner.label), routine: at.kind, diff --git a/packages/chord/src/host/staging.ts b/packages/chord/src/host/staging.ts index f085e394d..563f424cc 100644 --- a/packages/chord/src/host/staging.ts +++ b/packages/chord/src/host/staging.ts @@ -150,7 +150,7 @@ function stageEntry( throw new Error(`${contract.name}: a host without an address serves only local types`); } const provider = bindProvider(contract, options.accepts ?? {}); - const common = { plugin, provider, linger: options.linger ?? 0 }; + const common = { plugin, provider, linger: options.linger ?? 0, traced: options.untraced !== true }; // The contract's kind decides the factory's signature and the contract's shape. const served: Served = contract.kind === "local" diff --git a/packages/chord/src/jobs/job.ts b/packages/chord/src/jobs/job.ts index 49e72e2a3..c0ab9f0c8 100644 --- a/packages/chord/src/jobs/job.ts +++ b/packages/chord/src/jobs/job.ts @@ -152,7 +152,12 @@ export class Job implements JobHandle const parent = context.telemetry; if (untraced(parent)) return documents.tx((tx) => this.stage(tx, args, context, options), token, context); // The intent: the start's transaction, in its span (the run keeps it: its later phases are its children). - const attributes = { job: this.#definition.name, phase: "intent", object: capped(documents.label) } as const; + const attributes = { + "chord.host": this.#owner.host.spanHost, + job: this.#definition.name, + phase: "intent", + object: capped(documents.label), + } as const; return chordSpans(parent)("chord.job", attributes, async (span) => { const traced = withTelemetry(span, context); try { @@ -328,6 +333,7 @@ export class Job implements JobHandle const parent = parentOf(run.trace, host.telemetry); if (untraced(parent)) return undefined; return new OpenSpan(parent, "chord.job", { + "chord.host": host.spanHost, job: this.#definition.name, phase, object: capped(documents.label), @@ -533,7 +539,12 @@ class JobSpanContext extends SpanCtx { readonly jobId: string; declare readonly state: RunState | undefined; - constructor(parent: Ctx, span: TelemetrySpan, jobId: string, state: RunState | undefined) { + constructor( + parent: JobContext | JobSpanContext, + span: TelemetrySpan, + jobId: string, + state: RunState | undefined, + ) { super(parent, span); this.jobId = jobId; if (state !== undefined) this.state = state; diff --git a/packages/chord/src/node/app/app.ts b/packages/chord/src/node/app/app.ts index 8348c666d..79852d5c2 100644 --- a/packages/chord/src/node/app/app.ts +++ b/packages/chord/src/node/app/app.ts @@ -10,11 +10,19 @@ import { type Context, withAbortSignal } from "../../context/index.ts"; import { type Address, type HostKind, hostKind, type KindShape } from "../../contracts/index.ts"; import type { Principal } from "../../data/index.ts"; import { canonicalJson } from "../../data/principal.ts"; -import { createHost, type Facet, type Host, type HostError, type PluginFacet } from "../../host/index.ts"; +import { + createHost, + type Facet, + type Host, + type HostError, + type PluginFacet, + type PrincipalAttributes, +} from "../../host/index.ts"; import type { JsonValue } from "../../json.ts"; import { openNodeSqliteDatabase } from "../../sqlite/node.ts"; import { openStore, type Store, sqliteBackend } from "../../store/index.ts"; import { localeChoiceFacet, themeChoiceFacet, themesFacet, translationsFacet } from "../../surfaces/index.ts"; +import { type TelemetryIngestTarget, telemetryIngestFacet } from "../../telemetry-ingest/index.ts"; import type { Watchable } from "../../watch.ts"; import { createHttp, type HttpComponent, type HttpError, type ListenAddress } from "../http/index.ts"; import { @@ -115,10 +123,17 @@ export type AppOptions

    = { /** The terminal surface (slice 11): the app's terminal entry; the `tui` facet kind. None: no terminal clients. */ readonly tui?: { readonly entry: string }; /** - * The server's telemetry adapter (§29): its host's spans and metrics and its HTTP requests'; NOOP by default. Each - * worker exports to the adapter its own `startWorker` is given; carriers join their spans into one trace. + * The app's telemetry store (§29; decision "The server is the one place telemetry is stored"): the server's host, + * its HTTP requests and the kit's ingest record to it (`createSqliteTelemetry`'s store). With one, every worker + * records in memory and sends its spans and metrics to the server's `WorkerTelemetry`, and pages that record + * (`startPage({ telemetry: true })`) theirs to `PageTelemetry`; carriers join them into one trace. None: NOOP. */ - readonly telemetry?: TelemetryContext; + readonly telemetry?: TelemetryContext & TelemetryIngestTarget; + /** + * Who a span was for (`createHost`'s `principalAttributes`): the server's hook, also stamped on the spans pages + * send (the principal of their link). Each worker's is its `startWorker`'s. + */ + readonly principalAttributes?: PrincipalAttributes

    ; readonly onError: (error: AppError) => void; } & (OwnerPrincipal extends P ? unknown : { readonly "the app's principal type must have { kind: 'owner' }": never }); @@ -191,6 +206,8 @@ export type KitStart = { readonly principal: JsonValue; readonly server: JsonValue; readonly files: { readonly maxFile?: number; readonly maxTotal?: number }; + /** Whether the server has a telemetry store: the worker then sends what it records to `WorkerTelemetry`. */ + readonly telemetry: boolean; /** The cap of uploads under way per `Files` object (`limits.uploads`). */ readonly uploads?: number; }; @@ -206,12 +223,13 @@ export async function startServer

    (options: AppOptions

    , c ); const { onError, api, limits = {} } = options; const sessions = createSessions

    (cookieSecret, { maxEphemeral: limits.ephemeralSessions }); - const { telemetry } = options; + const { telemetry, principalAttributes } = options; const host = createHost

    ({ address: Server(), principal: options.principal.server, store, telemetry, + ...(principalAttributes === undefined ? {} : { principalAttributes }), onError: (error) => onError({ kind: "host", error }), }); const http = createHttp( @@ -241,8 +259,10 @@ export async function startServer

    (options: AppOptions

    , c context, ); const workers = new Map>(); + // The address key of each worker by its principal's key: the principal its link stamps on its telemetry batches. + const workerHosts = new Map(); for (const kind of options.workers ?? []) { - workers.set(kind.kind.kind, workersOf(host, kind, options, layout.dataDir, plugins, context)); + workers.set(kind.kind.kind, workersOf(host, kind, options, layout.dataDir, plugins, workerHosts, context)); } const link = new OwnerLink(); const core: PluginFacet

    [] = [ @@ -265,6 +285,14 @@ export async function startServer

    (options: AppOptions

    , c { name: "chord.translations", facet: translationsFacet() }, { name: "chord.locale.choice", facet: localeChoiceFacet() }, ]; + if (telemetry !== undefined) { + const facet = telemetryIngestFacet

    ({ + target: telemetry, + workerHost: (principal) => workerHosts.get(canonicalJson(principal as unknown as JsonValue, context)), + principalAttributes: (principal) => host.principalAttributes(principal, context), + }); + core.push({ name: "chord.telemetry", facet }); + } const surfaces: Surface

    [] = []; const app = new RunningApp(host, http, workers, plugins, surfaces, { store, lock }, layout, options); try { @@ -353,6 +381,7 @@ function workersOf

    ( options: AppOptions

    , dataDir: string, plugins: Plugins, + workerHosts: Map, context: Context, ): Workers { const facet = kind.facet ?? "worker"; @@ -371,13 +400,16 @@ function workersOf

    ( authorize: (principal, address) => isServer(principal) || kind.authorize(principal, address), principal: options.principal.worker, start: async (address, ctx) => { + // Principals are JSON (they cross links). + const principal = options.principal.worker(address) as unknown as JsonValue; + workerHosts.set(canonicalJson(principal, ctx), address.key); const kit: KitStart = { dir: workerDirectory(dataDir, address), facet, - // Principals are JSON (they cross links). - principal: options.principal.worker(address) as unknown as JsonValue, + principal, server: options.principal.server as unknown as JsonValue, files: { ...options.files }, + telemetry: options.telemetry !== undefined, ...(options.limits?.uploads === undefined ? {} : { uploads: options.limits.uploads }), }; return { kit, app: await kind.start(address, ctx) }; diff --git a/packages/chord/src/node/app/worker.ts b/packages/chord/src/node/app/worker.ts index ae5239d9f..94f5b3fe7 100644 --- a/packages/chord/src/node/app/worker.ts +++ b/packages/chord/src/node/app/worker.ts @@ -1,16 +1,19 @@ // `startWorker` (slice 13 §1): a worker of an app built with the kit, in its entry module. `workerMain` plus the kit's // part: the worker's directory under its kind and address with its store, the host at the worker's address acting as // the principal the server gave it, connected to the server first, its plugins loaded with the kit's worker facets -// (`WorkerInfo`, `Files`, `Translations` for a worker that renders final text). +// (`WorkerInfo`, `Files`, `Translations` for a worker that renders final text); when the server has a telemetry store, +// the host records in memory and sends its spans and metrics to the server every second (decision "The server is the +// one place telemetry is stored"). import { join } from "node:path"; -import type { TelemetryContext } from "@earendil-works/pi-telemetry"; +import { setTimeout as sleep } from "node:timers/promises"; import type { Principal } from "../../data/index.ts"; -import { createHost } from "../../host/index.ts"; +import { createHost, type PrincipalAttributes } from "../../host/index.ts"; import type { JsonValue } from "../../json.ts"; import { openNodeSqliteDatabase } from "../../sqlite/node.ts"; import { openStore, sqliteBackend } from "../../store/index.ts"; import { translationsFacet } from "../../surfaces/index.ts"; +import { sendWorkerTelemetry, TelemetryRecorder } from "../../telemetry-ingest/index.ts"; import type { Watchable } from "../../watch.ts"; import { workerMain } from "../workers/index.ts"; import type { AppApi, AppKind, KitStart } from "./app.ts"; @@ -20,23 +23,25 @@ import { workerInfoFacet } from "./info.ts"; import { privateDirectory, privateStore } from "./layout.ts"; /** What `startWorker` takes: the app's API and kinds as the server's `startServer` has them, this process's modules. */ -export type WorkerOptions = { +export type WorkerOptions

    = { readonly api: AppApi; readonly kinds: Readonly>; /** Extra keep-alive counts of the app (a record of numbers, all zero when idle). */ readonly busy?: Watchable>>; - /** - * This worker's telemetry adapter (§29): its host's spans and metrics; NOOP by default. Its methods called through - * the server continue the caller's trace by the carriers on the wire; where the spans are stored is the app's (a - * SQLite telemetry store per process, or one file the processes share). - */ - readonly telemetry?: TelemetryContext; + /** Who a span was for (`createHost`'s `principalAttributes`): this worker's hook, as the server's is its own. */ + readonly principalAttributes?: PrincipalAttributes

    ; /** The worker host's errors (`HostError`) and a failed start's. */ readonly onError: (error: unknown) => void; }; +/** How many finished spans a worker keeps while it cannot send (past it new ones are dropped). */ +const WORKER_QUEUE = 10_000; + +/** How often a worker sends what it recorded. */ +const WORKER_SEND_MS = 1000; + /** Runs this process as a worker of the app: once the start message arrived, its host is up and its plugins loaded. */ -export function startWorker

    (options: WorkerOptions): void { +export function startWorker

    (options: WorkerOptions

    ): void { // The worker's facet kind comes with the start message: every kind's packages are offered, its own are imported. const packages = Object.assign({}, ...Object.values(options.kinds).map((kind) => kind.packages ?? {})); workerMain( @@ -50,12 +55,16 @@ export function startWorker

    (options: WorkerOptions): void { context, ); onClose((ctx) => store.close(ctx)); + const scope = crypto.randomUUID(); + const recorder = kit.telemetry ? new TelemetryRecorder(() => scope, WORKER_QUEUE) : undefined; + const { principalAttributes } = options; // Principals are the app's: the server made these with its `principal` options. const host = createHost

    ({ address, principal: kit.principal as unknown as P, store, - telemetry: options.telemetry, + telemetry: recorder, + ...(principalAttributes === undefined ? {} : { principalAttributes }), onError: options.onError, }); onClose((ctx) => host.close(ctx)); @@ -75,6 +84,18 @@ export function startWorker

    (options: WorkerOptions): void { { name: "chord.translations", facet: translationsFacet

    () }, ]; await plugins.load(host, { kind: kit.facet, core }, context); + if (recorder !== undefined) { + const sender = sendWorkerTelemetry(host, recorder, context); + const timer = setInterval(() => void sender.send(false), WORKER_SEND_MS); + timer.unref(); + // Held after the load (a host holds nothing before). Closers run in reverse: this one before the host + // closes, the last spans and partial metrics sent while the link is up, for at most a second. + onClose(async () => { + clearInterval(timer); + await Promise.race([sender.send(true), sleep(WORKER_SEND_MS, undefined, { ref: false })]); + sender.stop(); + }); + } return options.busy === undefined ? {} : { busy: options.busy }; }, { diff --git a/packages/chord/src/node/http/http.ts b/packages/chord/src/node/http/http.ts index 09066e293..8b217de88 100644 --- a/packages/chord/src/node/http/http.ts +++ b/packages/chord/src/node/http/http.ts @@ -21,7 +21,7 @@ import { local } from "../../contracts/index.ts"; import { isChordError, type Principal } from "../../data/index.ts"; import { type Ctx, defineFacet, type Facet, type Host } from "../../host/index.ts"; import type { RegistryValue } from "../../registries/index.ts"; -import { OpenSpan, parentOf, spanFailed, untraced } from "../../telemetry.ts"; +import { hostAttribute, OpenSpan, parentOf, spanFailed, untraced } from "../../telemetry.ts"; import { type Admitted, admit, @@ -137,6 +137,8 @@ class Component

    implements HttpComponent

    , Handler { readonly upgrade: (message: IncomingMessage, socket: Duplex, head: Buffer) => void; readonly #host: Host

    ; readonly #options: HttpOptions; + /** The `chord.host` of its `chord.http` spans: its host's. */ + readonly #spanHost: string; readonly #onError: (error: HttpError) => void; readonly #context: Context; /** The component's context without its cancellation: each request's scope adds its own signal. */ @@ -156,6 +158,7 @@ class Component

    implements HttpComponent

    , Handler { constructor(host: Host

    , options: HttpOptions, context: Context) { this.#host = host; this.#options = options; + this.#spanHost = hostAttribute(host.address); this.#onError = options.onError; this.#context = context; this.#base = withoutAbortSignal(context); @@ -311,12 +314,12 @@ class Component

    implements HttpComponent

    , Handler { #span(request: Incoming, match: RouteMatch, admission: Admitted): OpenSpan<"chord.http"> | undefined { const adapter = this.#options.telemetry; if (adapter === undefined || untraced(adapter)) return undefined; + const attributes = { "chord.host": this.#spanHost, method: request.method, route: match.pattern }; const traceparent = admission.provenance.kind === "relayed" ? traceHeader(request, "traceparent") : undefined; - if (traceparent === undefined) - return new OpenSpan(adapter, "chord.http", { method: request.method, route: match.pattern }); + if (traceparent === undefined) return new OpenSpan(adapter, "chord.http", attributes); const tracestate = traceHeader(request, "tracestate"); const carrier: TelemetryCarrier = tracestate === undefined ? { traceparent } : { traceparent, tracestate }; - return new OpenSpan(parentOf(carrier, adapter), "chord.http", { method: request.method, route: match.pattern }); + return new OpenSpan(parentOf(carrier, adapter), "chord.http", attributes); } /** Runs the wraps and the route in the request's scope `ctx`, and makes its answer (a failure: a reported 500). */ diff --git a/packages/chord/src/remote/relay.ts b/packages/chord/src/remote/relay.ts index 038cf86bc..6da71db21 100644 --- a/packages/chord/src/remote/relay.ts +++ b/packages/chord/src/remote/relay.ts @@ -110,10 +110,18 @@ export class RelayedHold { return carrier; } - /** A relay span's attributes: names from the wire, bounded by the message schema (untrusted links) and capped here. */ - #attributes(member: string): { type: string; member: string; "upstream.kind": string } { + /** + * A relay span's attributes: its host's `chord.host` (the downstream link's host), and names from the wire, bounded + * by the message schema (untrusted links) and capped here. + */ + #attributes(member: string): { "chord.host": string; type: string; member: string; "upstream.kind": string } { const { type, address } = this.#request; - return { type: capped(type), member, "upstream.kind": capped(address.kind) }; + return { + "chord.host": this.#downstream.link.host.spanHost, + type: capped(type), + member, + "upstream.kind": capped(address.kind), + }; } /** The upstream link dropped: downstream its calls fail and the hold goes loading until the path's next link. */ diff --git a/packages/chord/src/remote/remote-object.ts b/packages/chord/src/remote/remote-object.ts index 4fb300a89..b5ffb8a24 100644 --- a/packages/chord/src/remote/remote-object.ts +++ b/packages/chord/src/remote/remote-object.ts @@ -108,6 +108,7 @@ export class RemoteObject implements Target { // Its first hold carries the carrier of its `chord.hold` span, under its holder's span (`#holdTrace`). if (!untraced(holder)) { this.#holding = new OpenSpan(holder, "chord.hold", { + "chord.host": host.spanHost, type, object: capped(this.label), "address.kind": address.kind, @@ -183,6 +184,7 @@ export class RemoteObject implements Target { // A hold that dropped before it was bound ends here. this.#held("ended"); const attributes = { + "chord.host": this.#host.spanHost, type: this.contract.name, object: capped(this.label), "address.kind": this.request.address.kind, @@ -225,10 +227,12 @@ export class RemoteObject implements Target { const parent = context.telemetry; if (untraced(parent)) return hold.side.call(hold.id, index, data, args, context); const attributes = { + "chord.host": this.#host.spanHost, type: this.contract.name, member: method, object: capped(this.label), "address.kind": this.request.address.kind, + ...this.#host.principalAttributes(this.caller.principal, context), }; return chordSpans(parent)("chord.call", attributes, async (span) => { try { diff --git a/packages/chord/src/sqlite/node.ts b/packages/chord/src/sqlite/node.ts index 269249027..94e3eed77 100644 --- a/packages/chord/src/sqlite/node.ts +++ b/packages/chord/src/sqlite/node.ts @@ -6,12 +6,20 @@ import type { SqliteDatabase, SqliteExecutor, SqliteValue } from "./database.ts" /** Node SQLite connection settings for a database file. */ export type NodeSqliteDatabaseOptions = { + /** + * `read-only` opens an existing file for reads (another process may write it): no directory is made, nothing is + * configured or checkpointed, and transactions are deferred (`BEGIN`), reading one snapshot. `read-write` by default. + */ + readonly mode?: NodeSqliteMode; /** SQLite WAL auto-checkpoint threshold. SQLite and this adapter default to 1,000 pages; 0 disables it. */ readonly walAutoCheckpointPages?: number; /** Time SQLite waits for a competing file lock. SQLite defaults to 0; this adapter defaults to 5,000 ms. */ readonly busyTimeoutMs?: number; }; +/** How a connection opens its file. */ +export type NodeSqliteMode = "read-write" | "read-only"; + const DEFAULT_WAL_AUTO_CHECKPOINT_PAGES = 1_000; const DEFAULT_BUSY_TIMEOUT_MS = 5_000; @@ -148,15 +156,18 @@ class NodeSqliteTransaction extends NodeSqliteExecutor { /** `SqliteDatabase` adapter backed by Node's built-in `node:sqlite`. */ export class NodeSqliteDatabase extends NodeSqliteExecutor implements SqliteDatabase { private readonly access = new SerialOperationQueue(); + private readonly mode: NodeSqliteMode; private closed = false; - constructor(database: DatabaseSync) { + constructor(database: DatabaseSync, mode: NodeSqliteMode = "read-write") { super(database, new Map()); + this.mode = mode; } transaction(callback: (transaction: SqliteExecutor) => Promise): Promise { return this.access.runAsync(async () => { - this.database.exec("BEGIN IMMEDIATE"); + // A writer takes the write lock at once; a read-only connection cannot, and reads one snapshot. + this.database.exec(this.mode === "read-only" ? "BEGIN" : "BEGIN IMMEDIATE"); const scope = { active: true }; try { const result = await callback(new NodeSqliteTransaction(this.database, this.statements, scope)); @@ -181,7 +192,7 @@ export class NodeSqliteDatabase extends NodeSqliteExecutor implements SqliteData this.closed = true; this.statements.clear(); try { - this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + if (this.mode === "read-write") this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } finally { this.database.close(); } @@ -200,6 +211,8 @@ export async function openNodeSqliteDatabase( ): Promise { const checkpointPages = options.walAutoCheckpointPages ?? DEFAULT_WAL_AUTO_CHECKPOINT_PAGES; const timeout = options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS; + if (options.mode === "read-only") + return new NodeSqliteDatabase(new DatabaseSync(path, { timeout, readOnly: true }), "read-only"); if (path !== ":memory:") await mkdir(dirname(path), { recursive: true }); const database = new DatabaseSync(path, { timeout }); const adapter = new NodeSqliteDatabase(database); diff --git a/packages/chord/src/store/step.ts b/packages/chord/src/store/step.ts index 30bd604f7..51fab53a0 100644 --- a/packages/chord/src/store/step.ts +++ b/packages/chord/src/store/step.ts @@ -26,8 +26,8 @@ export type StagedWrite = { /** * One commit step: its backend transaction, its seq, the store's id allocator (D3: ids are taken from it as the * writes apply, never copied), the ids of the documents it created, the table records it changed (the store's catalog - * takes them after `COMMIT`) and the statements it ran (`chord.store.statements`). Its reads are the step reader of - * adapters: they see the step's own writes. + * takes them after `COMMIT`), the statements it ran (`chord.store.statements`) and the table rows its writes changed + * (`chord.commit`'s `rows`). Its reads are the step reader of adapters: they see the step's own writes. */ export class Step { readonly executor: SqliteExecutor; @@ -39,6 +39,8 @@ export class Step { readonly tables = new Map(); readonly #catalog: ReadonlyMap; statements = 0; + /** Table rows its writes changed: a `put` one, the others the count their statement returned. */ + rows = 0; constructor(executor: SqliteExecutor, seq: number, ids: IdAllocator, catalog: ReadonlyMap) { this.executor = executor; diff --git a/packages/chord/src/store/store.ts b/packages/chord/src/store/store.ts index 0325d61dd..8350d0dcf 100644 --- a/packages/chord/src/store/store.ts +++ b/packages/chord/src/store/store.ts @@ -84,8 +84,11 @@ async function switchOutbox(db: SqliteDatabase, meta: MetaRow, outbox: boolean | export type CommitFence = { /** At the step's entry: `false` rejects the commit `released` and writes nothing. */ admit(): boolean; - /** After `COMMIT`: adopts the commit, `ids` being its created documents' ids in the order of their writes. */ - adopt(seq: number, ids: readonly number[]): void; + /** + * After `COMMIT`: adopts the commit, `ids` being its created documents' ids in the order of their writes, `rows` the + * table rows its writes changed. + */ + adopt(seq: number, ids: readonly number[], rows: number): void; }; /** @@ -421,7 +424,7 @@ export class ChordStore implements Store { if (writes.length === 0) { // An empty commit takes no seq; a throwing `adopt` stops the store as for any commit. try { - fence.adopt(this.#lastSeq, []); + fence.adopt(this.#lastSeq, [], 0); } catch (error) { throw this.#fail(error); } @@ -458,7 +461,7 @@ export class ChordStore implements Store { metrics.counter("chord.store.commits").add(1); metrics.counter("chord.store.statements").add(step.statements); try { - fence.adopt(seq, step.created); + fence.adopt(seq, step.created, step.rows); } catch (error) { throw this.#fail(error); } diff --git a/packages/chord/src/store/table-writes.ts b/packages/chord/src/store/table-writes.ts index b517b166b..6540754ef 100644 --- a/packages/chord/src/store/table-writes.ts +++ b/packages/chord/src/store/table-writes.ts @@ -117,6 +117,7 @@ export function stagePut(sql: string, table: string, row: EncodedRow, version: n order: "write", apply: async (step) => { await step.run(sql, ...row.params); + step.rows++; return [write]; }, }; @@ -167,7 +168,11 @@ export function stageDeleteWhere(statement: Statement, table: string, where: Whe function counted(statement: Statement, record: (count: number) => TableWrite): StagedWrite { return { order: "write", - apply: async (step) => [record(await step.runCounted(statement.sql, ...statement.params))], + apply: async (step) => { + const count = await step.runCounted(statement.sql, ...statement.params); + step.rows += count; + return [record(count)]; + }, }; } diff --git a/packages/chord/src/surfaces/client.ts b/packages/chord/src/surfaces/client.ts index 279999c7d..39c089b82 100644 --- a/packages/chord/src/surfaces/client.ts +++ b/packages/chord/src/surfaces/client.ts @@ -5,6 +5,7 @@ // client's code is outdated, which the app answers by starting over (a page reloads). The surface specifics (how a // socket opens, how a script is fetched, what is outdated) are given. +import type { TelemetryContext } from "@earendil-works/pi-telemetry"; import { BACKGROUND_CONTEXT, type Context, withAbortSignal } from "../context/index.ts"; import type { Principal } from "../data/index.ts"; import { createHost, type Host, type HostError, type PluginFacet, type PluginFailure } from "../host/index.ts"; @@ -52,6 +53,11 @@ export type ClientOptions

    = { /** A new set was applied (its scripts loaded or kept old): the page swaps the plugins' stylesheets. */ readonly applied?: (set: ScriptSet) => void; readonly environment: LoopEnvironment; + /** + * Makes the host's telemetry adapter (none: NOOP); `incarnation` names the host's run (it is read at the adapter's + * first span, after the host was made). + */ + readonly telemetry?: (incarnation: () => string) => TelemetryContext; readonly onError: (error: ClientError) => void; }; @@ -61,7 +67,11 @@ export type Client

    = { readonly host: Host

    ; stop(context /** Starts a client: its host loaded with the initial scripts, its link kept, its plugins kept current. */ export async function startClient

    (options: ClientOptions

    ): Promise> { const context = BACKGROUND_CONTEXT; - const host = createHost

    ({ onError: (error) => options.onError({ kind: "host", error }) }); + let made: Host

    | undefined; + // Read at the first span, which starts once the host exists (in its load at the earliest). + const telemetry = options.telemetry?.(() => made!.incarnation); + const host = createHost

    ({ telemetry, onError: (error) => options.onError({ kind: "host", error }) }); + made = host; const entries = options.initial.map((script) => entryOf(script, options.modules)); const { loaded, report } = await LoadedFacets.load(host, options.core, entries, context); if (report.failed.length > 0) options.onError({ kind: "plugins", failed: report.failed }); diff --git a/packages/chord/src/telemetry-ingest/batch.ts b/packages/chord/src/telemetry-ingest/batch.ts new file mode 100644 index 000000000..74de25f0e --- /dev/null +++ b/packages/chord/src/telemetry-ingest/batch.ts @@ -0,0 +1,136 @@ +// What pages and workers send the server's telemetry objects (decisions "Telemetry from browser pages", "The server is +// the one place telemetry is stored"): a batch of finished spans (a worker's with its metric aggregates), its schemas +// (a page's strict: every field bounded, ids printable ASCII, no control characters; a worker's, the server's own +// child, generous), and the two remote objects on the server that take them, `PageTelemetry` and `WorkerTelemetry`. + +import type { AttributeValue, StoredMetric, StoredSpan } from "@earendil-works/pi-telemetry"; +import { arr, hostKind, int, lit, method, num, obj, opt, remote, type Schema, str, union } from "../contracts/index.ts"; + +/** At most this many spans in a page's batch. */ +export const PAGE_BATCH_SPANS = 100; + +/** At most this many spans and metric rows in a worker's batch. */ +export const WORKER_BATCH_ROWS = 1000; + +/** An id: printable ASCII, no spaces (a page's are `browser::`). */ +function id(max: number): Schema { + return str({ min: 1, max, pattern: "^[\\x21-\\x7e]+$" }); +} + +/** Text: no control characters (C0, DEL, C1), at most `max` characters. */ +function text(max: number, min = 0): Schema { + return str({ min, max, pattern: "^[^\\u0000-\\u001f\\u007f-\\u009f]*$" }); +} + +/** A record of attributes: at most `count` keys of 1 to 64 characters of text (other keys refused), values of `value`. */ +function attributes(count: number, value: Schema): Schema> { + return { + type: "object", + maxProperties: count, + patternProperties: { "^[^\\u0000-\\u001f\\u007f-\\u009f]{1,64}$": value }, + additionalProperties: false, + }; +} + +/** A finished span as `span`'s fields allow: ids, a name, times, attributes, events, links and a status. */ +function spanSchema(limits: SpanLimits): Schema { + const record = attributes(limits.attributes, limits.value); + const span = obj({ + id: id(limits.id), + traceId: id(limits.id), + parentId: opt(id(limits.id)), + name: text(limits.name, 1), + startedMs: num({ min: 0 }), + endedMs: num({ min: 0 }), + attributes: record, + events: arr(obj({ name: text(limits.name, 1), timeMs: num({ min: 0 }), attributes: record }), { + max: limits.events, + }), + links: arr(obj({ traceId: id(limits.id), spanId: id(limits.id), attributes: opt(record) }), { + max: limits.links, + }), + status: union([ + obj({ status: lit("ok") }), + obj({ status: lit("error"), error: opt(obj({ name: text(64), message: text(500) })) }), + ]), + }); + // The object schema's static type is StoredSpan's shape: the same fields, optional ones `opt`. + return span as unknown as Schema; +} + +/** The bounds of one span's fields. */ +type SpanLimits = { + readonly id: number; + readonly name: number; + readonly attributes: number; + readonly value: Schema; + readonly events: number; + readonly links: number; +}; + +/** A page's spans: what a recorded click needs, little more (decision "Telemetry from browser pages"). */ +const PAGE_SPAN = spanSchema({ + id: 128, + name: 128, + attributes: 32, + value: union([text(256), num(), { type: "boolean" } as Schema]), + events: 16, + links: 8, +}); + +/** A worker's spans: bounded generously (its spans are Chord's and its plugins'). */ +const WORKER_SPAN = spanSchema({ + id: 256, + name: 1024, + attributes: 256, + value: union([str({ max: 65_536 }), num(), { type: "boolean" } as Schema, arr(union([str(), num()]))]), + events: 1024, + links: 256, +}); + +/** A worker's metric row: one series' aggregate of one 10 s interval. */ +const METRIC = obj({ + intervalMs: int({ min: 0 }), + name: text(256, 1), + kind: union([lit("counter"), lit("upDownCounter"), lit("histogram")]), + unit: opt(text(64)), + attributes: attributes(64, union([str({ max: 4096 }), num(), { type: "boolean" } as Schema])), + count: int({ min: 0 }), + sum: num(), + min: num(), + max: num(), +}) as unknown as Schema; + +/** What `ingest` answers: the spans the store queued and the ones it dropped (refused, over the rate, over its bound). */ +const RECEIPT = obj({ written: int(), dropped: int() }); + +/** A page's batch (spans only: a page sends no metrics). */ +export const PageBatch = obj({ spans: arr(PAGE_SPAN, { max: PAGE_BATCH_SPANS }) }); + +/** A worker's batch: its spans and its metric rows. */ +export const WorkerBatch = obj({ + spans: arr(WORKER_SPAN, { max: WORKER_BATCH_ROWS }), + metrics: arr(METRIC, { max: WORKER_BATCH_ROWS }), +}); + +/** The server's host kind as the app kit names it. */ +const Server = hostKind("server"); + +/** The server's telemetry object for pages: any principal's page sends its batches to it, untrusted. */ +export const PageTelemetry = remote("chord.telemetry.page", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { main: { methods: { ingest: method(PageBatch, RECEIPT) } } }, +}); + +/** + * The server's telemetry object for workers: `worker` for a worker's principal (its batches written as they are), + * `other` for everyone else (nothing to call). + */ +export const WorkerTelemetry = remote("chord.telemetry.worker", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { worker: { methods: { ingest: method(WorkerBatch, RECEIPT) } }, other: {} }, +}); diff --git a/packages/chord/src/telemetry-ingest/facet.ts b/packages/chord/src/telemetry-ingest/facet.ts new file mode 100644 index 000000000..18e75d485 --- /dev/null +++ b/packages/chord/src/telemetry-ingest/facet.ts @@ -0,0 +1,124 @@ +// The server's telemetry ingest (decisions "Telemetry from browser pages", "The server is the one place telemetry is +// stored", "Who a span was for"): a facet serving `PageTelemetry` and `WorkerTelemetry`, whose `ingest(batch)` writes +// what pages and workers recorded to the server's store. A page is untrusted: its spans must carry its own ids +// (`browser::...`), are capped per page and minute, marked `chord.host: browser` and stamped with the +// principal of the link they came over; a worker's are written as they are, stamped with its host. + +import type { SpanAttributes, StoredSpan } from "@earendil-works/pi-telemetry"; +import type { Static } from "../contracts/index.ts"; +import type { Principal } from "../data/index.ts"; +import { defineFacet, type Facet } from "../host/index.ts"; +import { type PageBatch, PageTelemetry, WorkerTelemetry } from "./batch.ts"; +import type { TelemetryBatch } from "./recorder.ts"; + +/** Where ingested batches go: the server's store (`SqliteTelemetry`), which queues them; how many spans it queued. */ +export type TelemetryIngestTarget = { ingest(batch: TelemetryBatch): number }; + +/** What the ingest facet takes. */ +export type TelemetryIngestOptions

    = { + readonly target: TelemetryIngestTarget; + /** The address key of the worker whose link stamps `principal`; `undefined` for any other principal. */ + readonly workerHost: (principal: P) => string | undefined; + /** The attributes a page's spans record for the principal of their link (the server host's `principalAttributes`). */ + readonly principalAttributes: (principal: P) => Readonly; +}; + +/** How many spans one page may send per minute; past it they are dropped (and counted in its receipts). */ +export const PAGE_SPANS_PER_MINUTE = 1200; + +/** How many pages' minute counts one principal's object keeps (the earliest goes first). */ +const PAGES = 1000; + +const MINUTE_MS = 60_000; + +/** The ingest facet: `PageTelemetry` and `WorkerTelemetry` on the server, writing to `options.target`. */ +export function telemetryIngestFacet

    (options: TelemetryIngestOptions

    ): Facet

    { + return defineFacet

    ((host) => { + host.serve( + PageTelemetry, + (_params, principal) => { + const stamp = { ...options.principalAttributes(principal), "chord.host": "browser" }; + const budgets = new PageBudgets(); + return { + variant: "main", + members: { + ingest: async (batch, ctx) => { + const budget = budgets.of(ctx.incarnation, Date.now()); + const accepted = pageSpans(batch, `browser:${ctx.incarnation}:`, stamp, budget); + const written = options.target.ingest({ spans: accepted, metrics: [] }); + return { written, dropped: batch.spans.length - written }; + }, + }, + }; + }, + // A span per batch would be telemetry about telemetry, written back with the next batch. + { untraced: true }, + ); + host.serve( + WorkerTelemetry, + (_params, principal) => { + const worker = options.workerHost(principal); + if (worker === undefined) return { variant: "other", members: {} }; + return { + variant: "worker", + members: { + ingest: async ({ spans, metrics }, _ctx) => { + const stamped = spans.map((span) => ({ + ...span, + attributes: { ...span.attributes, "chord.host": worker }, + })); + const written = options.target.ingest({ spans: stamped, metrics }); + return { written, dropped: spans.length - written }; + }, + }, + }; + }, + { untraced: true }, + ); + }); +} + +/** One page's spans of the current minute. */ +type Budget = { readonly start: number; spans: number }; + +/** The minute budgets of one principal's pages, by incarnation, bounded (the earliest page goes first). */ +class PageBudgets { + readonly #budgets = new Map(); + + /** The budget of the page `incarnation` at `now`: a new minute starts a new one. */ + of(incarnation: string, now: number): Budget { + let budget = this.#budgets.get(incarnation); + if (budget === undefined || now - budget.start >= MINUTE_MS) { + this.#budgets.delete(incarnation); + if (this.#budgets.size >= PAGES) this.#budgets.delete(this.#budgets.keys().next().value!); + budget = { start: now, spans: 0 }; + this.#budgets.set(incarnation, budget); + } + return budget; + } +} + +/** + * The spans of a page's `batch` the store takes: its own (ids, trace and parent under `prefix`, so it never writes + * into another sender's trace or poses as a server span), ending after they start, within its minute's `budget`; each + * marked with `stamp` (`chord.host: browser`, the link's principal), which no attribute of the page's may override. + */ +function pageSpans( + batch: Static, + prefix: string, + stamp: Readonly, + budget: Budget, +): StoredSpan[] { + const accepted: StoredSpan[] = []; + for (const span of batch.spans) { + if (budget.spans >= PAGE_SPANS_PER_MINUTE) break; + const own = + span.id.startsWith(prefix) && + span.traceId.startsWith(prefix) && + (span.parentId === undefined || span.parentId.startsWith(prefix)); + if (!own || span.endedMs < span.startedMs) continue; + budget.spans++; + accepted.push({ ...span, attributes: { ...span.attributes, ...stamp } }); + } + return accepted; +} diff --git a/packages/chord/src/telemetry-ingest/index.ts b/packages/chord/src/telemetry-ingest/index.ts new file mode 100644 index 000000000..6342b6ab9 --- /dev/null +++ b/packages/chord/src/telemetry-ingest/index.ts @@ -0,0 +1,20 @@ +// Telemetry from pages and workers to the server's store (decisions "Telemetry from browser pages", "The server is +// the one place telemetry is stored"): the recorder pages and workers keep their spans in, the batches and the +// server's objects that take them, the facet serving those, and the senders. + +export { + PAGE_BATCH_SPANS, + PageBatch, + PageTelemetry, + WORKER_BATCH_ROWS, + WorkerBatch, + WorkerTelemetry, +} from "./batch.ts"; +export { + PAGE_SPANS_PER_MINUTE, + type TelemetryIngestOptions, + type TelemetryIngestTarget, + telemetryIngestFacet, +} from "./facet.ts"; +export { type TelemetryBatch, TelemetryRecorder } from "./recorder.ts"; +export { sendPageTelemetry, sendWorkerTelemetry, type TelemetrySender } from "./sender.ts"; diff --git a/packages/chord/src/telemetry-ingest/recorder.ts b/packages/chord/src/telemetry-ingest/recorder.ts new file mode 100644 index 000000000..4425b55cd --- /dev/null +++ b/packages/chord/src/telemetry-ingest/recorder.ts @@ -0,0 +1,348 @@ +// Chord's telemetry recorder (§29): an adapter keeping settled spans and metric aggregates (per series per 10 s) in a +// bounded queue (past it dropped and counted), until its owner takes them as a batch: the SQLite store writes its +// batches to its file, a page or a worker sends them to the server's telemetry object (`TelemetryIngest`). Its +// carriers name a span by id and trace id; every recorder of a process reads every other's (one carrier format). + +import { + type InstrumentOptions, + NOOP_TELEMETRY_CONTEXT, + type SpanAttributes, + type SpanLink, + type SpanOptions, + type SpanStatus, + type StoredMetric, + type StoredSpan, + type TelemetryCarrier, + type TelemetryContext, + type TelemetryMetrics, + type TelemetryPropagation, + type TelemetrySpan, + telemetryNow, +} from "@earendil-works/pi-telemetry"; + +/** What a recorder hands its owner at once: settled spans and metric aggregates. */ +export type TelemetryBatch = { readonly spans: StoredSpan[]; readonly metrics: StoredMetric[] }; + +/** A span's identity in carriers and links: its id and its trace's. */ +type Identity = { readonly id: string; readonly traceId: string }; + +type Metric = StoredMetric & { readonly key: string }; + +/** The identities of every recorder's spans and extracted parents in this process: links and carriers read them. */ +const identities = new WeakMap(); + +/** The carrier's keys: a span's id and its trace's (the SQLite store's since slice 9: stored job carriers use them). */ +const SPAN_KEY = "pi-sqlite-span"; +const TRACE_KEY = "pi-sqlite-trace"; + +/** The metric interval. */ +export const INTERVAL_MS = 10_000; + +/** + * Records spans and metrics in memory for its owner, who takes them in batches (`take`, then `release` once they are + * written or given up). Span ids are `:`, `scope` read at the first span (a page's names its host's + * incarnation, made after the recorder). `capacity` bounds the queued spans, a taken batch's included, and separately + * the pending metric series. + */ +export class TelemetryRecorder implements TelemetryContext, TelemetryPropagation { + readonly propagation: TelemetryPropagation = this; + readonly metrics: TelemetryMetrics = { + counter: (name, options) => ({ add: this.#measurement("counter", name, options) }), + upDownCounter: (name, options) => ({ add: this.#measurement("upDownCounter", name, options) }), + histogram: (name, options) => ({ record: this.#measurement("histogram", name, options) }), + }; + droppedSpans = 0; + droppedMeasurements = 0; + readonly #scope: () => string; + #prefix: string | undefined; + readonly #capacity: number; + #sequence = 0; + #spans: StoredSpan[] = []; + #inFlight = 0; + readonly #measurements = new Map(); + #inFlightMeasurements = 0; + #stopped = false; + + constructor(scope: () => string, capacity: number) { + this.#scope = scope; + this.#capacity = capacity; + } + + /** Whether it stopped: later spans are inert, later measurements ignored. */ + get stopped(): boolean { + return this.#stopped; + } + + startSpan(options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise): Promise { + return this.#start(undefined, options, callback); + } + + inject(context: TelemetryContext): TelemetryCarrier | undefined { + const identity = identities.get(context); + return identity ? { [SPAN_KEY]: identity.id, [TRACE_KEY]: identity.traceId } : undefined; + } + + extract(carrier: TelemetryCarrier | undefined): TelemetryContext { + const id = carrier?.[SPAN_KEY]; + const traceId = carrier?.[TRACE_KEY]; + // Carriers come from other processes: only their two strings are read, else the root. + if (typeof id !== "string" || typeof traceId !== "string") return this; + const identity = { id, traceId }; + const parent: TelemetryContext = { + metrics: this.metrics, + propagation: this, + startSpan: (options, callback) => this.#start(identity, options, callback), + }; + identities.set(parent, identity); + return parent; + } + + /** + * Takes the queued spans (at most `limit`, the oldest) and the metric intervals that are over (every one with + * `partial`); the spans keep counting against the capacity until `release`. + */ + take(partial: boolean, limit = Number.POSITIVE_INFINITY): TelemetryBatch { + const spans = this.#spans.length <= limit ? this.#spans : this.#spans.slice(0, limit); + this.#spans = spans === this.#spans ? [] : this.#spans.slice(limit); + this.#inFlight = spans.length; + const metrics: StoredMetric[] = []; + const current = intervalOf(Date.now()); + for (const [key, { key: _series, ...metric }] of this.#measurements) { + if (!partial && metric.intervalMs >= current) continue; + metrics.push(metric); + this.#measurements.delete(key); + } + this.#inFlightMeasurements = metrics.length; + return { spans, metrics }; + } + + /** The batch taken last was written or given up: its capacity is free again. */ + release(): void { + this.#inFlight = 0; + this.#inFlightMeasurements = 0; + } + + /** How many settled spans wait to be taken. */ + get queuedSpans(): number { + return this.#spans.length; + } + + /** Whether spans, or metric intervals that are over (all of them with `partial`), wait to be taken. */ + pending(partial: boolean): boolean { + if (this.#spans.length > 0) return true; + const current = intervalOf(Date.now()); + for (const metric of this.#measurements.values()) if (partial || metric.intervalMs < current) return true; + return false; + } + + /** + * Queues spans and metric rows another process recorded (an ingest), within the capacity; returns how many spans + * it queued (the rest dropped and counted). + */ + ingest(batch: TelemetryBatch): number { + if (this.#stopped) return 0; + let queued = 0; + for (const span of batch.spans) { + if (this.#spans.length + this.#inFlight >= this.#capacity) this.droppedSpans++; + else { + this.#spans.push(span); + queued++; + } + } + for (const metric of batch.metrics) this.#merge(metric); + return queued; + } + + /** Stops recording; with `discard`, what waits is dropped too (a failed store). */ + stop(discard: "keep" | "discard"): void { + this.#stopped = true; + if (discard === "discard") { + this.#spans = []; + this.#measurements.clear(); + } + } + + #inert(options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise): Promise { + return NOOP_TELEMETRY_CONTEXT.startSpan(options, (noop) => + callback({ + ...noop, + metrics: this.metrics, + propagation: this, + startSpan: (options, callback) => this.#inert(options, callback), + }), + ); + } + + async #start( + parent: Identity | undefined, + options: SpanOptions, + callback: (span: TelemetrySpan) => T | Promise, + ): Promise { + if (this.#stopped) return this.#inert(options, callback); + let attributes = copyAttributes(options.attributes); + const links: { traceId: string; spanId: string; attributes: SpanAttributes }[] = []; + for (const link of options.links ?? []) { + const copied = copyLink(link); + if (copied) links.push(copied); + } + this.#prefix ??= this.#scope(); + const id = `${this.#prefix}:${++this.#sequence}`; + const identity = { id, traceId: parent?.traceId ?? id }; + // The start on the clock pi-telemetry's adapters share; the end and events by the monotonic clock from it. + const started = telemetryNow(); + const clock = performance.now(); + const events: { name: string; timeMs: number; attributes: SpanAttributes }[] = []; + let status: SpanStatus = { status: "ok" }; + let explicit = false; + let settled = false; + const record = (action: () => void): void => { + if (!settled && !this.#stopped) action(); + }; + const span: TelemetrySpan = { + metrics: this.metrics, + propagation: this, + startSpan: (options, callback) => + settled ? this.#inert(options, callback) : this.#start(identity, options, callback), + addLink: (link) => + record(() => { + const copied = copyLink(link); + if (copied) links.push(copied); + }), + addEvent: (name, attrs) => + record(() => { + events.push({ + name, + timeMs: microseconds(performance.now() - clock), + attributes: copyAttributes(attrs), + }); + }), + setAttributes: (attrs) => + record(() => { + attributes = { ...attributes, ...copyAttributes(attrs) }; + }), + setStatus: (value) => + record(() => { + status = + value.status === "error" && value.error + ? { status: "error", error: { ...value.error } } + : { status: value.status }; + explicit = true; + }), + }; + identities.set(span, identity); + try { + return await callback(span); + } catch (error) { + if (!explicit) + status = + error instanceof Error + ? { status: "error", error: { name: error.name, message: error.message } } + : { status: "error" }; + throw error; + } finally { + settled = true; + if (!this.#stopped) { + if (this.#spans.length + this.#inFlight >= this.#capacity) this.droppedSpans++; + else + this.#spans.push({ + id, + traceId: identity.traceId, + ...(parent === undefined ? {} : { parentId: parent.id }), + name: options.name, + startedMs: started, + endedMs: microseconds(started + performance.now() - clock), + attributes, + events, + links, + status, + }); + } + } + } + + #measurement( + kind: StoredMetric["kind"], + name: string, + options?: InstrumentOptions, + ): (value: number, attributes?: SpanAttributes) => void { + const unit = options?.unit; + return (value, attributes) => { + if (this.#stopped) return; + this.#merge({ + intervalMs: intervalOf(Date.now()), + name, + kind, + ...(unit === undefined ? {} : { unit }), + attributes: sortedAttributes(copyAttributes(attributes)), + count: 1, + sum: value, + min: value, + max: value, + }); + }; + } + + /** Adds `metric` to its series' pending interval (a new series within the capacity, else dropped and counted). */ + #merge(metric: StoredMetric): void { + if (this.#stopped) return; + const key = seriesKey(metric); + const pendingKey = `${metric.intervalMs}:${key}`; + const previous = this.#measurements.get(pendingKey); + if (!previous && this.#measurements.size + this.#inFlightMeasurements >= this.#capacity) { + this.droppedMeasurements++; + return; + } + this.#measurements.set(pendingKey, { + ...metric, + key, + count: (previous?.count ?? 0) + metric.count, + sum: (previous?.sum ?? 0) + metric.sum, + min: Math.min(previous?.min ?? metric.min, metric.min), + max: Math.max(previous?.max ?? metric.max, metric.max), + }); + } +} + +/** A metric's series: its name, kind, unit and attributes (sorted by key), as JSON. */ +export function seriesKey(metric: StoredMetric): string { + return JSON.stringify([ + metric.name, + metric.kind, + metric.unit ?? null, + JSON.stringify(sortedAttributes(metric.attributes)), + ]); +} + +/** The start of the metric interval `time` falls in. */ +function intervalOf(time: number): number { + return Math.floor(time / INTERVAL_MS) * INTERVAL_MS; +} + +/** `attributes` with their keys in order (a series' attributes as stored). */ +export function sortedAttributes(attributes: SpanAttributes): SpanAttributes { + return Object.fromEntries( + Object.keys(attributes) + .sort() + .map((key) => [key, attributes[key]]), + ); +} + +/** `ms` rounded to the microsecond, as span times are. */ +function microseconds(ms: number): number { + return Math.round(ms * 1000) / 1000; +} + +function copyAttributes(attributes?: SpanAttributes): SpanAttributes { + return Object.fromEntries( + Object.entries(attributes ?? {}) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]), + ); +} + +function copyLink(link: SpanLink): { traceId: string; spanId: string; attributes: SpanAttributes } | undefined { + const identity = identities.get(link.context); + return identity + ? { traceId: identity.traceId, spanId: identity.id, attributes: copyAttributes(link.attributes) } + : undefined; +} diff --git a/packages/chord/src/telemetry-ingest/sender.ts b/packages/chord/src/telemetry-ingest/sender.ts new file mode 100644 index 000000000..244eca388 --- /dev/null +++ b/packages/chord/src/telemetry-ingest/sender.ts @@ -0,0 +1,101 @@ +// How a page and a worker send what their recorder kept (decisions "Telemetry from browser pages", "The server is the +// one place telemetry is stored"): their host holds the server's telemetry object over the link it has, and each send +// takes one batch from the recorder and calls `ingest` with it; a batch that cannot be sent (no link, a refusal) is +// given up, best effort. One send at a time; nothing is sent while the object is not bound. + +import { BACKGROUND_CONTEXT, type Context } from "../context/index.ts"; +import type { Principal } from "../data/index.ts"; +import type { Host } from "../host/index.ts"; +import { PAGE_BATCH_SPANS, PageTelemetry, WORKER_BATCH_ROWS, WorkerTelemetry } from "./batch.ts"; +import type { TelemetryBatch, TelemetryRecorder } from "./recorder.ts"; + +/** A running sender: `send` sends one batch now (with `partial`, the metric intervals not over too), `stop` stops. */ +export type TelemetrySender = { + send(partial: boolean): Promise; + stop(): void; +}; + +/** Sends a page's spans (never its metrics) to the server's `PageTelemetry`, at most `PAGE_BATCH_SPANS` a batch. */ +export function sendPageTelemetry

    ( + host: Host

    , + recorder: TelemetryRecorder, + context: Context, +): TelemetrySender { + const held = host.hold(PageTelemetry, {}, context); + // Spans only: its metrics are taken with them and dropped. + const waiting = (_partial: boolean) => recorder.queuedSpans > 0; + return sender( + recorder, + PAGE_BATCH_SPANS, + waiting, + () => held.release(), + (batch) => { + if (held.state.value.kind !== "live") return undefined; + return held.ingest({ spans: batch.spans }, BACKGROUND_CONTEXT); + }, + ); +} + +/** Sends a worker's spans and metrics to the server's `WorkerTelemetry`, at most `WORKER_BATCH_ROWS` spans a batch. */ +export function sendWorkerTelemetry

    ( + host: Host

    , + recorder: TelemetryRecorder, + context: Context, +): TelemetrySender { + const held = host.hold(WorkerTelemetry, {}, context); + return sender( + recorder, + WORKER_BATCH_ROWS, + (partial) => recorder.pending(partial), + () => held.release(), + (batch) => { + if (held.state.value.kind !== "live" || held.variant !== "worker") return undefined; + // A batch takes at most `WORKER_BATCH_ROWS` metric rows; more series in one interval are dropped. + return held.ingest( + { spans: batch.spans, metrics: batch.metrics.slice(0, WORKER_BATCH_ROWS) }, + BACKGROUND_CONTEXT, + ); + }, + ); +} + +/** + * The send loop over `call`, which sends a batch, or answers `undefined` when it cannot now (the batch then waits); a + * send does nothing while `waiting` says nothing waits. + */ +function sender( + recorder: TelemetryRecorder, + limit: number, + waiting: (partial: boolean) => boolean, + release: () => void, + call: (batch: TelemetryBatch) => Promise | undefined, +): TelemetrySender { + let sending: Promise | undefined; + let stopped = false; + const once = async (partial: boolean): Promise => { + if (!waiting(partial)) return; + const batch = recorder.take(partial, limit); + const sent = call(batch); + if (sent === undefined) { + // Not bound now: the batch is queued again (at the back), within the recorder's bound. + recorder.release(); + recorder.ingest(batch); + return; + } + // Best effort: a refused or lost batch is given up. + await sent.catch(() => {}); + recorder.release(); + }; + return { + send: (partial) => { + if (stopped) return Promise.resolve(); + const next = (sending ?? Promise.resolve()).then(() => once(partial)); + sending = next; + return next; + }, + stop: () => { + stopped = true; + release(); + }, + }; +} diff --git a/packages/chord/src/telemetry-sqlite/index.ts b/packages/chord/src/telemetry-sqlite/index.ts index a589b635a..80a883389 100644 --- a/packages/chord/src/telemetry-sqlite/index.ts +++ b/packages/chord/src/telemetry-sqlite/index.ts @@ -1,24 +1,23 @@ -// The SQLite telemetry store (`@earendil-works/chord/telemetry-sqlite`, decision "Telemetry store", §29): an adapter -// recording settled spans one row each (events and links inline) and metrics per series per 10 s, flushed in a batch -// every second with retention, its queue bounded (dropped and counted); and the `TelemetryStore` reads over it. +// The SQLite telemetry store (`@earendil-works/chord/telemetry-sqlite`, decision "Telemetry store", §29): a recorder +// (`TelemetryRecorder`) whose batches are written every second, settled spans one row each (events and links inline, +// times in microseconds) and metrics per series per 10 s, with retention; spans and metrics other processes recorded +// join the same batches (`ingest`, never overwriting a stored span); and the `TelemetryStore` reads over it. -import { - type InstrumentOptions, - NOOP_TELEMETRY_CONTEXT, - type SpanAttributes, - type SpanLink, - type SpanOptions, - type SpanStatus, - type TelemetryCarrier, - type TelemetryContext, - type TelemetryMetrics, - type TelemetryPropagation, - type TelemetrySpan, - type TelemetryStore, +import type { + StoredMetric, + StoredSpan, + TelemetryContext, + TelemetryMetrics, + TelemetryPropagation, + TelemetryStore, } from "@earendil-works/pi-telemetry"; -import { applySqliteMigrations, type SqliteDatabase, type SqliteValue } from "../sqlite/index.ts"; +import type { SqliteDatabase, SqliteExecutor } from "../sqlite/index.ts"; +import { seriesKey, sortedAttributes, type TelemetryBatch, TelemetryRecorder } from "../telemetry-ingest/recorder.ts"; +import { migrateTelemetryStore } from "./schema.ts"; import { findMetrics, findTraces, getTrace } from "./store.ts"; +export type { TelemetryBatch } from "../telemetry-ingest/recorder.ts"; + export interface SqliteTelemetryOptions { /** Retain seven days by default. Cleanup runs in the flush transaction. */ readonly retentionMs?: number; @@ -32,336 +31,162 @@ export interface SqliteTelemetry extends TelemetryContext, TelemetryStore { readonly propagation: TelemetryPropagation; readonly droppedSpans: number; readonly droppedMeasurements: number; + /** + * Queues finished spans and metric rows another process recorded, written with the next flush (a span whose id is + * stored already is kept as it was); within the queue's bound, the rest dropped and counted. Returns how many + * spans it queued. Callers check what they pass: the store writes it as it is. + */ + ingest(batch: TelemetryBatch): number; /** Persist settled spans and completed ten-second metric intervals. */ flush(): Promise; /** Stop recording and drain, including partial metric intervals. Does not close the database. */ close(): Promise; } -type Identity = { id: string; traceId: string }; -type Link = { trace_id: string; span_id: string; attributes?: SpanAttributes }; -type Metric = { - interval: number; - key: string; - name: string; - kind: "counter" | "upDownCounter" | "histogram"; - unit: string | null; - attributes: string; - count: number; - sum: number; - min: number; - max: number; -}; -const identities = new WeakMap(); -const carrierKey = "pi-sqlite-span"; -const traceKey = "pi-sqlite-trace"; -const intervalMs = 10_000; - -function copyAttributes(attributes?: SpanAttributes): SpanAttributes { - return Object.fromEntries( - Object.entries(attributes ?? {}) - .filter(([, value]) => value !== undefined) - .map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]), - ); +/** `ms` in whole microseconds, as the store keeps span times. */ +function micros(ms: number): number { + return Math.round(ms * 1000); } -function copyLink(link: SpanLink): Link | undefined { - const identity = identities.get(link.context); - return identity - ? { trace_id: identity.traceId, span_id: identity.id, attributes: copyAttributes(link.attributes) } - : undefined; -} - -class Recorder implements SqliteTelemetry, TelemetryPropagation { - readonly propagation: TelemetryPropagation = this; - readonly metrics: TelemetryMetrics = { - counter: (name, options) => ({ add: this.measurement("counter", name, options) }), - upDownCounter: (name, options) => ({ add: this.measurement("upDownCounter", name, options) }), - histogram: (name, options) => ({ record: this.measurement("histogram", name, options) }), - }; - droppedSpans = 0; - droppedMeasurements = 0; - private readonly db: SqliteDatabase; - private readonly retention: number; - private readonly capacity: number; - private readonly scope = crypto.randomUUID(); - private sequence = 0; - private readonly spans: SqliteValue[][] = []; - private readonly measurements = new Map(); - private inFlightMeasurements = 0; - private readonly timer: ReturnType; - private stopped = false; - private failed = false; - private failure: unknown; - private writing?: Promise; - private closing?: Promise; +class Store implements SqliteTelemetry { + readonly #recorder: TelemetryRecorder; + readonly #db: SqliteDatabase; + readonly #retention: number; + readonly #timer: ReturnType; + #failed = false; + #failure: unknown; + #writing?: Promise; + #closing?: Promise; constructor(db: SqliteDatabase, retention: number, capacity: number) { - this.db = db; - this.retention = retention; - this.capacity = capacity; - this.timer = setInterval(() => { - if (!this.writing) void this.flush().catch(() => {}); + this.#db = db; + this.#retention = retention; + const scope = crypto.randomUUID(); + this.#recorder = new TelemetryRecorder(() => scope, capacity); + this.#timer = setInterval(() => { + if (!this.#writing) void this.flush().catch(() => {}); }, 1000); - this.timer.unref?.(); + this.#timer.unref?.(); } - startSpan(options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise): Promise { - return this.start(undefined, options, callback); + get metrics(): TelemetryMetrics { + return this.#recorder.metrics; } - inject(context: TelemetryContext): TelemetryCarrier | undefined { - const identity = identities.get(context); - return identity ? { [carrierKey]: identity.id, [traceKey]: identity.traceId } : undefined; + get propagation(): TelemetryPropagation { + return this.#recorder; } - extract(carrier: TelemetryCarrier | undefined): TelemetryContext { - const id = carrier?.[carrierKey]; - const traceId = carrier?.[traceKey]; - if (typeof id !== "string" || typeof traceId !== "string") return this; - const identity = { id, traceId }; - const parent: TelemetryContext = { - metrics: this.metrics, - propagation: this, - startSpan: (options, callback) => this.start(identity, options, callback), - }; - identities.set(parent, identity); - return parent; + get droppedSpans(): number { + return this.#recorder.droppedSpans; } - private inert(options: SpanOptions, callback: (span: TelemetrySpan) => T | Promise): Promise { - return NOOP_TELEMETRY_CONTEXT.startSpan(options, (noop) => - callback({ - ...noop, - metrics: this.metrics, - propagation: this, - startSpan: (options, callback) => this.inert(options, callback), - }), - ); + get droppedMeasurements(): number { + return this.#recorder.droppedMeasurements; } - private async start( - parent: Identity | undefined, - options: SpanOptions, - callback: (span: TelemetrySpan) => T | Promise, - ): Promise { - if (this.stopped) return this.inert(options, callback); - const name = options.name; - let attributes = copyAttributes(options.attributes); - const links: Link[] = []; - for (const link of options.links ?? []) { - const copied = copyLink(link); - if (copied) links.push(copied); - } - const id = `${this.scope}:${++this.sequence}`; - const identity = { id, traceId: parent?.traceId ?? id }; - const started = Date.now(); - const clock = performance.now(); - const events: { name: string; time_ms: number; attributes: SpanAttributes }[] = []; - let status: SpanStatus = { status: "ok" }; - let explicit = false; - let settled = false; - const record = (action: () => void): void => { - if (!settled && !this.stopped) action(); - }; - const span: TelemetrySpan = { - metrics: this.metrics, - propagation: this, - startSpan: (options, callback) => - settled ? this.inert(options, callback) : this.start(identity, options, callback), - addLink: (link) => - record(() => { - const copied = copyLink(link); - if (copied) links.push(copied); - }), - addEvent: (name, attrs) => - record(() => { - events.push({ name, time_ms: performance.now() - clock, attributes: copyAttributes(attrs) }); - }), - setAttributes: (attrs) => - record(() => { - attributes = { ...attributes, ...copyAttributes(attrs) }; - }), - setStatus: (value) => - record(() => { - status = - value.status === "error" && value.error - ? { status: "error", error: { ...value.error } } - : { status: value.status }; - explicit = true; - }), - }; - identities.set(span, identity); - try { - return await callback(span); - } catch (error) { - if (!explicit) - status = - error instanceof Error - ? { status: "error", error: { name: error.name, message: error.message } } - : { status: "error" }; - throw error; - } finally { - settled = true; - if (!this.stopped) { - if (this.spans.length >= this.capacity) this.droppedSpans++; - else - this.spans.push([ - id, - identity.traceId, - parent?.id ?? null, - name, - started, - started + performance.now() - clock, - JSON.stringify(attributes), - JSON.stringify(events), - JSON.stringify(links), - JSON.stringify(status), - ]); - } - } - } + readonly startSpan: TelemetryContext["startSpan"] = (options, callback) => + this.#recorder.startSpan(options, callback); - private measurement( - kind: Metric["kind"], - name: string, - options?: InstrumentOptions, - ): (value: number, attributes?: SpanAttributes) => void { - const unit = options?.unit ?? null; - return (value, attributes) => { - if (this.stopped) return; - const copied = copyAttributes(attributes); - const encoded = JSON.stringify( - Object.fromEntries( - Object.keys(copied) - .sort() - .map((key) => [key, copied[key]]), - ), - ); - const key = JSON.stringify([name, kind, unit, encoded]); - const interval = Math.floor(Date.now() / intervalMs) * intervalMs; - const pendingKey = `${interval}:${key}`; - const previous = this.measurements.get(pendingKey); - if (!previous && this.measurements.size + this.inFlightMeasurements >= this.capacity) { - this.droppedMeasurements++; - return; - } - this.measurements.set(pendingKey, { - interval, - key, - name, - kind, - unit, - attributes: encoded, - count: (previous?.count ?? 0) + 1, - sum: (previous?.sum ?? 0) + value, - min: Math.min(previous?.min ?? value, value), - max: Math.max(previous?.max ?? value, value), - }); - }; + ingest(batch: TelemetryBatch): number { + return this.#recorder.ingest(batch); } /** Readers use committed rows only; flush first to include newly settled spans. */ - readonly getTrace: TelemetryStore["getTrace"] = (traceId) => getTrace(this.db, traceId); - readonly findTraces: TelemetryStore["findTraces"] = (query) => findTraces(this.db, query); - readonly findMetrics: TelemetryStore["findMetrics"] = (query) => findMetrics(this.db, query); + readonly getTrace: TelemetryStore["getTrace"] = (traceId) => getTrace(this.#db, traceId); + readonly findTraces: TelemetryStore["findTraces"] = (query) => findTraces(this.#db, query); + readonly findMetrics: TelemetryStore["findMetrics"] = (query) => findMetrics(this.#db, query); flush(): Promise { - return this.closing ?? this.drain(false); + return this.#closing ?? this.#drain(false); } close(): Promise { - if (!this.closing) { - this.stopped = true; - clearInterval(this.timer); - this.closing = this.drain(true); + if (!this.#closing) { + this.#recorder.stop("keep"); + clearInterval(this.#timer); + this.#closing = this.#drain(true); } - return this.closing; + return this.#closing; } - private async drain(partial: boolean): Promise { - while (this.writing) await this.writing; - if (this.failed) throw this.failure; - const spans = this.spans.slice(); - const metrics: Metric[] = []; - const completed = Math.floor(Date.now() / intervalMs) * intervalMs; - for (const [key, metric] of this.measurements) { - if (!partial && metric.interval >= completed) continue; - metrics.push(metric); - this.measurements.delete(key); - } - this.inFlightMeasurements = metrics.length; - this.writing = Promise.resolve() + async #drain(partial: boolean): Promise { + while (this.#writing) await this.#writing; + if (this.#failed) throw this.#failure; + const batch = this.#recorder.take(partial); + this.#writing = Promise.resolve() .then(() => - this.db.transaction(async (tx) => { - for (const span of spans) - await tx.run("INSERT INTO telemetry_spans VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ...span); - for (const metric of metrics) - await tx.run( - `INSERT INTO telemetry_metrics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(interval_ms, series_key) DO UPDATE SET count = count + excluded.count, - sum = sum + excluded.sum, min = min(min, excluded.min), max = max(max, excluded.max)`, - metric.interval, - metric.key, - metric.name, - metric.kind, - metric.unit, - metric.attributes, - metric.count, - metric.sum, - metric.min, - metric.max, - ); - const cutoff = Date.now() - this.retention; - await tx.run("DELETE FROM telemetry_spans WHERE ended_ms < ?", cutoff); + this.#db.transaction(async (tx) => { + for (const span of batch.spans) await writeSpan(tx, span); + for (const metric of batch.metrics) await writeMetric(tx, metric); + const cutoff = Date.now() - this.#retention; + await tx.run("DELETE FROM telemetry_spans WHERE ended_us < ?", cutoff * 1000); await tx.run("DELETE FROM telemetry_metrics WHERE interval_ms < ?", cutoff); }), ) - .then(() => { - this.spans.splice(0, spans.length); - }) .catch((error: unknown) => { - this.failed = this.stopped = true; - this.failure = error; - this.spans.length = 0; - this.measurements.clear(); - clearInterval(this.timer); + this.#failed = true; + this.#failure = error; + this.#recorder.stop("discard"); + clearInterval(this.#timer); throw error; }) .finally(() => { - this.writing = undefined; - this.inFlightMeasurements = 0; + this.#writing = undefined; + this.#recorder.release(); }); - await this.writing; + await this.#writing; } } +/** Writes `span`, unless a span of its id is stored already (an ingested span never replaces one). */ +function writeSpan(tx: SqliteExecutor, span: StoredSpan): Promise { + return tx.run( + "INSERT OR IGNORE INTO telemetry_spans VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + span.id, + span.traceId, + span.parentId ?? null, + span.name, + micros(span.startedMs), + micros(span.endedMs), + JSON.stringify(span.attributes), + JSON.stringify(span.events.map(({ name, timeMs, attributes }) => ({ name, time_ms: timeMs, attributes }))), + JSON.stringify( + span.links.map(({ traceId, spanId, attributes }) => ({ trace_id: traceId, span_id: spanId, attributes })), + ), + JSON.stringify(span.status), + ); +} + +/** Adds `metric` to its series' row of its interval. */ +function writeMetric(tx: SqliteExecutor, metric: StoredMetric): Promise { + return tx.run( + `INSERT INTO telemetry_metrics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(interval_ms, series_key) DO UPDATE SET count = count + excluded.count, + sum = sum + excluded.sum, min = min(min, excluded.min), max = max(max, excluded.max)`, + metric.intervalMs, + seriesKey(metric), + metric.name, + metric.kind, + metric.unit ?? null, + JSON.stringify(sortedAttributes(metric.attributes)), + metric.count, + metric.sum, + metric.min, + metric.max, + ); +} + /** Portable, buffered SQLite recorder. Recording never performs SQL; flush runs every second. - * Only settled spans are stored. Metrics aggregate count/sum/min/max per ten-second interval. - * A write failure permanently stops recording and is reported by flush and close. */ + * Only settled spans are stored, their times in microseconds (`telemetryNow`). Metrics aggregate count/sum/min/max per + * ten-second interval. A store of an older version in `db` is migrated (version 2: span times from milliseconds to + * microseconds, rows kept). A write failure permanently stops recording and is reported by flush and close. */ export async function createSqliteTelemetry( db: SqliteDatabase, options: SqliteTelemetryOptions = {}, ): Promise { const retention = options.retentionMs ?? 7 * 24 * 60 * 60 * 1000; const capacity = options.maxQueuedSpans ?? 10_000; - await applySqliteMigrations( - db, - [ - { - version: 1, - statements: [ - `CREATE TABLE telemetry_spans (id TEXT PRIMARY KEY, trace_id TEXT NOT NULL, parent_id TEXT, name TEXT, started_ms REAL, - ended_ms REAL NOT NULL, attributes TEXT, events TEXT, links TEXT, status TEXT)`, - "CREATE INDEX telemetry_spans_ended_ms ON telemetry_spans(ended_ms)", - "CREATE INDEX telemetry_spans_trace_started ON telemetry_spans(trace_id, started_ms)", - "CREATE INDEX telemetry_spans_started_ms ON telemetry_spans(started_ms)", - `CREATE TABLE telemetry_metrics (interval_ms INTEGER, series_key TEXT, name TEXT, kind TEXT, - unit TEXT, attributes TEXT, count INTEGER, sum REAL, min REAL, max REAL, PRIMARY KEY(interval_ms, series_key))`, - ], - }, - ], - { table: "telemetry_schema" }, - ); - return new Recorder(db, retention, capacity); + await migrateTelemetryStore(db); + return new Store(db, retention, capacity); } diff --git a/packages/chord/src/telemetry-sqlite/node.ts b/packages/chord/src/telemetry-sqlite/node.ts new file mode 100644 index 000000000..812f60ecc --- /dev/null +++ b/packages/chord/src/telemetry-sqlite/node.ts @@ -0,0 +1,34 @@ +// The SQLite telemetry store read-only, on Node (`@earendil-works/chord/telemetry-sqlite/node`): a store file that +// recorders of other processes write, opened for its reads (`TelemetryStore`) only. + +import type { TelemetryStore } from "@earendil-works/pi-telemetry"; +import { openNodeSqliteDatabase } from "../sqlite/node.ts"; +import { assertTelemetryStore } from "./schema.ts"; +import { findMetrics, findTraces, getTrace } from "./store.ts"; + +/** A telemetry store opened for reads: the store's reads, and the close of its file. */ +export interface SqliteTelemetryStore extends TelemetryStore { + /** Closes the file; reads after it reject. */ + close(): Promise; +} + +/** + * Opens the telemetry store at `path` read-only (SQLite's read-only mode: it never writes the file, never migrates it, + * never creates it), while recorders of other processes write it or after. Rejects for a file that holds no store or + * a store of another version (a recorder, `createSqliteTelemetry`, migrates an older one). + */ +export async function openTelemetryStore(path: string): Promise { + const db = await openNodeSqliteDatabase(path, { mode: "read-only" }); + try { + await assertTelemetryStore(db); + } catch (error) { + await db.close(); + throw error; + } + return { + getTrace: (traceId) => getTrace(db, traceId), + findTraces: (query) => findTraces(db, query), + findMetrics: (query) => findMetrics(db, query), + close: () => db.close(), + }; +} diff --git a/packages/chord/src/telemetry-sqlite/schema.ts b/packages/chord/src/telemetry-sqlite/schema.ts new file mode 100644 index 000000000..53b226f4c --- /dev/null +++ b/packages/chord/src/telemetry-sqlite/schema.ts @@ -0,0 +1,69 @@ +// The SQLite telemetry store's tables and their versions: the migrations a recorder applies, and the check a reader +// makes that a file holds a store of this version. + +import { applySqliteMigrations, type SqliteDatabase, type SqliteMigration } from "../sqlite/index.ts"; + +/** The table that records the store's version. */ +const VERSION_TABLE = "telemetry_schema"; + +/** + * The store's versions. 1: spans with their start and end in milliseconds (`started_ms`, `ended_ms`, whole + * milliseconds at the start); 2: in whole microseconds (`started_us`, `ended_us`, integers), the rows of 1 kept, + * their times multiplied. + */ +const MIGRATIONS: readonly SqliteMigration[] = [ + { + version: 1, + statements: [ + `CREATE TABLE telemetry_spans (id TEXT PRIMARY KEY, trace_id TEXT NOT NULL, parent_id TEXT, name TEXT, started_ms REAL, + ended_ms REAL NOT NULL, attributes TEXT, events TEXT, links TEXT, status TEXT)`, + "CREATE INDEX telemetry_spans_ended_ms ON telemetry_spans(ended_ms)", + "CREATE INDEX telemetry_spans_trace_started ON telemetry_spans(trace_id, started_ms)", + "CREATE INDEX telemetry_spans_started_ms ON telemetry_spans(started_ms)", + `CREATE TABLE telemetry_metrics (interval_ms INTEGER, series_key TEXT, name TEXT, kind TEXT, + unit TEXT, attributes TEXT, count INTEGER, sum REAL, min REAL, max REAL, PRIMARY KEY(interval_ms, series_key))`, + ], + }, + { + version: 2, + statements: [ + `CREATE TABLE telemetry_spans_us (id TEXT PRIMARY KEY, trace_id TEXT NOT NULL, parent_id TEXT, name TEXT, + started_us INTEGER, ended_us INTEGER NOT NULL, attributes TEXT, events TEXT, links TEXT, status TEXT)`, + `INSERT INTO telemetry_spans_us SELECT id, trace_id, parent_id, name, CAST(round(started_ms * 1000) AS INTEGER), + CAST(round(ended_ms * 1000) AS INTEGER), attributes, events, links, status FROM telemetry_spans`, + "DROP TABLE telemetry_spans", + "ALTER TABLE telemetry_spans_us RENAME TO telemetry_spans", + "CREATE INDEX telemetry_spans_ended_us ON telemetry_spans(ended_us)", + "CREATE INDEX telemetry_spans_trace_started ON telemetry_spans(trace_id, started_us)", + "CREATE INDEX telemetry_spans_started_us ON telemetry_spans(started_us)", + ], + }, +]; + +/** Brings the store in `db` to this version (made when absent; one of an older version migrated, its rows kept). */ +export function migrateTelemetryStore(db: SqliteDatabase): Promise { + return applySqliteMigrations(db, MIGRATIONS, { table: VERSION_TABLE }); +} + +/** + * Throws unless `db` holds a store of this version: a reader cannot migrate (a recorder of this version, opened on the + * file once, does). + */ +export async function assertTelemetryStore(db: SqliteDatabase): Promise { + const expected = MIGRATIONS.at(-1)!.version; + // The file is the trust boundary here: any file may be named, a store or not. + const tables = await db.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + VERSION_TABLE, + ); + if (tables.length === 0) throw new Error("not a telemetry store: no telemetry_schema table"); + const row = await db.get<{ version: number }>(`SELECT version FROM ${VERSION_TABLE} WHERE singleton = 1`); + const version = row?.version ?? 0; + if (version > expected) + throw new Error(`telemetry store version ${version} is newer than this reader's ${expected}`); + if (version < expected) { + throw new Error( + `telemetry store version ${version}, not ${expected}: a recorder (createSqliteTelemetry) migrates it`, + ); + } +} diff --git a/packages/chord/src/telemetry-sqlite/store.ts b/packages/chord/src/telemetry-sqlite/store.ts index 562af3570..ee8dfb0a9 100644 --- a/packages/chord/src/telemetry-sqlite/store.ts +++ b/packages/chord/src/telemetry-sqlite/store.ts @@ -1,4 +1,5 @@ -// The reads of the SQLite telemetry store (`TelemetryStore`): complete traces by query or id, metric rows by query. +// The reads of the SQLite telemetry store (`TelemetryStore`): complete traces by query or id, metric rows by query; span +// times read from microseconds as milliseconds. import type { MetricQuery, @@ -16,8 +17,8 @@ type SpanRow = { trace_id: string; parent_id: string | null; name: string; - started_ms: number; - ended_ms: number; + started_us: number; + ended_us: number; attributes: string; events: string; links: string; @@ -30,8 +31,8 @@ function storedSpan(row: SpanRow): StoredSpan { traceId: row.trace_id, ...(row.parent_id === null ? {} : { parentId: row.parent_id }), name: row.name, - startedMs: row.started_ms, - endedMs: row.ended_ms, + startedMs: row.started_us / 1000, + endedMs: row.ended_us / 1000, attributes: JSON.parse(row.attributes) as SpanAttributes, events: (JSON.parse(row.events) as { name: string; time_ms: number; attributes: SpanAttributes }[]).map( (event) => ({ name: event.name, timeMs: event.time_ms, attributes: event.attributes }), @@ -66,10 +67,11 @@ function attributesSql(attributes: TraceQuery["attributes"], column: string, par } /** - * Spans in start order: by start time, then as each recorder started them (an id is its recorder's scope and a counter: - * by length, then text, the counter compares as a number), so spans of one millisecond keep their order (slice 9). + * Spans in start order: by start time (to the microsecond), then as each recorder started them (an id is its + * recorder's scope and a counter: by length, then text, the counter compares as a number), so spans of one + * microsecond keep their order (slice 9). */ -const SPAN_ORDER = "started_ms, length(id), id"; +const SPAN_ORDER = "started_us, length(id), id"; /** The spans of trace `traceId` in start order; `undefined` when none is stored. */ export async function getTrace(db: SqliteDatabase, traceId: string): Promise { @@ -95,12 +97,12 @@ export async function findTraces( params.push(query.name); } if (query.startMin !== undefined) { - conditions.push("started_ms >= ?"); - params.push(query.startMin); + conditions.push("started_us >= ?"); + params.push(query.startMin * 1000); } if (query.startMax !== undefined) { - conditions.push("started_ms <= ?"); - params.push(query.startMax); + conditions.push("started_us <= ?"); + params.push(query.startMax * 1000); } if (query.errors) conditions.push( @@ -112,14 +114,14 @@ export async function findTraces( ); if (query.cursor !== undefined) { const cursor = JSON.parse(query.cursor) as [number, string]; - conditions.push("(started_ms < ? OR (started_ms = ? AND trace_id > ?))"); + conditions.push("(started_us < ? OR (started_us = ? AND trace_id > ?))"); params.push(cursor[0], cursor[0], cursor[1]); } return db.transaction(async (tx) => { - const heads = await tx.all<{ trace_id: string; started_ms: number }>( + const heads = await tx.all<{ trace_id: string; started_us: number }>( `WITH heads AS ( - SELECT trace_id, min(started_ms) started_ms FROM telemetry_spans GROUP BY trace_id - ) SELECT * FROM heads WHERE ${conditions.join(" AND ") || "1"} ORDER BY started_ms DESC, trace_id LIMIT ?`, + SELECT trace_id, min(started_us) started_us FROM telemetry_spans GROUP BY trace_id + ) SELECT * FROM heads WHERE ${conditions.join(" AND ") || "1"} ORDER BY started_us DESC, trace_id LIMIT ?`, ...params, limit + 1, ); @@ -139,7 +141,7 @@ export async function findTraces( const last = heads.at(-1)!; return { traces: heads.map((head) => ({ traceId: head.trace_id, spans: grouped.get(head.trace_id)! })), - ...(more ? { cursor: JSON.stringify([last.started_ms, last.trace_id]) } : {}), + ...(more ? { cursor: JSON.stringify([last.started_us, last.trace_id]) } : {}), }; }); } diff --git a/packages/chord/src/telemetry.ts b/packages/chord/src/telemetry.ts index 9216e2df5..5945e4418 100644 --- a/packages/chord/src/telemetry.ts +++ b/packages/chord/src/telemetry.ts @@ -20,6 +20,7 @@ import { type TypedSpanStarter, type TypedTelemetryMetrics, } from "@earendil-works/pi-telemetry"; +import type { Address } from "./contracts/index.ts"; import { isChordError } from "./data/index.ts"; /** A span of Chord's schema, its end attributes and events typed. */ @@ -38,6 +39,21 @@ export type ChordCounter ATTRIBUTE_LENGTH ? text.slice(0, ATTRIBUTE_LENGTH) : text; } +/** The `chord.host` attribute of the spans of a host at `address`: its key, `client` without one; capped. */ +export function hostAttribute(address: Address | undefined): string { + return capped(address?.key ?? "client"); +} + /** The longest message a span's error or event carries (decision "Errors in telemetry"). */ export const MAX_MESSAGE = 500; diff --git a/packages/chord/src/web/README.md b/packages/chord/src/web/README.md index 8c82a79f1..e1b4a310b 100644 --- a/packages/chord/src/web/README.md +++ b/packages/chord/src/web/README.md @@ -11,6 +11,7 @@ startPage

    ({ canRetry: async () => true, // false: stop reconnecting (a guest's ended share) onConnection: (state) => setBanner(state), // connecting | connected | waiting render: (host) => createRoot(root).render(), + telemetry: true, // record: spans sent to the server's PageTelemetry }); ``` @@ -23,6 +24,9 @@ startPage

    ({ `navigator.languages`, followed on `languagechange`) is the host's language for `useMessages`, and `lang` and `dir` on the root element (`language.ts`, `followLanguage`); reports reach `onError` as `{ kind: "messages", report }`. - A new set's plugin stylesheets replace the old ones (`syncStylesheets`); an outdated page reloads. +- With `telemetry: true` the page's host records in memory (`TelemetryRecorder`, span ids `browser::`) + and sends finished spans to the server's `PageTelemetry` every 3 s and when the page is hidden; a click is a trace of + its own with `host.root(fn, ctx, { trace: { name } })`. The kit's server serves the object when it has a store. - Wakes (`environment.ts`): visible again, `online`, `pageshow` from the back/forward cache, Chromium's network change. Tests in a real browser: `test/node/web/` (Chromium: `CHROMIUM_BIN`, else `chromium-docker`; skipped without). diff --git a/packages/chord/src/web/page.ts b/packages/chord/src/web/page.ts index 8fd69ff4c..a9cc8b5f8 100644 --- a/packages/chord/src/web/page.ts +++ b/packages/chord/src/web/page.ts @@ -2,7 +2,8 @@ // app's web entry (the base bundle). It takes the plugin scripts the page's combined file registered, loads them with // the app's core facets and the kit's themes and translations, follows the theme and the language, keeps its link to // the server (the connection loop), follows `WebScripts` to apply new plugin scripts in place, reloads the page when -// the server's base bundle changed, and hands the host to the app's render. +// the server's base bundle changed, records its telemetry when asked (sent to the server's `PageTelemetry`), and hands +// the host to the app's render. import { BACKGROUND_CONTEXT } from "../context/index.ts"; import type { Principal } from "../data/index.ts"; @@ -18,6 +19,7 @@ import { translationsFacet, WebScripts, } from "../surfaces/index.ts"; +import { sendPageTelemetry, TelemetryRecorder, type TelemetrySender } from "../telemetry-ingest/index.ts"; import { pageEnvironment } from "./environment.ts"; import { followLanguage } from "./language.ts"; import { type PageBootstrap, PageScripts, syncStylesheets } from "./scripts.ts"; @@ -36,6 +38,12 @@ export type PageOptions

    = { readonly onConnection?: (state: ConnectionState) => void; /** Renders the app with its host, once the plugins are loaded. */ readonly render: (host: Client

    ["host"]) => void; + /** + * Records the page's telemetry (decision "Telemetry from browser pages"): its spans (a click started with + * `host.root(fn, ctx, { trace })`, the calls under it, carriers on its calls) are kept in memory and sent to the + * server's telemetry object every few seconds and when the page is hidden. Off by default. + */ + readonly telemetry?: boolean; /** Plugins left out or kept old, a failed update, the host's errors, messages' reports: the console by default. */ readonly onError?: (error: ClientError) => void; }; @@ -55,6 +63,14 @@ export async function startPage

    (options: PageOptions

    ): P ...(options.core ?? []), ]; const onError = options.onError ?? ((error: ClientError) => console.error("chord page:", error)); + let recorder: TelemetryRecorder | undefined; + const telemetry = + options.telemetry === true + ? (incarnation: () => string) => { + recorder = new TelemetryRecorder(() => `browser:${incarnation()}`, PAGE_QUEUE); + return recorder; + } + : undefined; const client = await startClient

    ({ scripts: WebScripts, base: scripts.base, @@ -74,14 +90,30 @@ export async function startPage

    (options: PageOptions

    ): P ...(options.onConnection === undefined ? {} : { onConnection: options.onConnection }), applied: (set) => syncStylesheets(set, PREFIX), environment: pageEnvironment(), + ...(telemetry === undefined ? {} : { telemetry }), onError, }); + if (recorder !== undefined) sendEvery(sendPageTelemetry(client.host, recorder, BACKGROUND_CONTEXT)); await followThemes(client.host, BACKGROUND_CONTEXT); await followLanguage(client.host, (report) => onError({ kind: "messages", report }), BACKGROUND_CONTEXT); options.render(client.host); return client; } +/** How many finished spans a page keeps while it cannot send (past it new ones are dropped). */ +const PAGE_QUEUE = 1000; + +/** How often a page sends its finished spans. */ +const PAGE_SEND_MS = 3000; + +/** Sends with `sender` every `PAGE_SEND_MS` and at once when the page is hidden (it may not come back). */ +function sendEvery(sender: TelemetrySender): void { + setInterval(() => void sender.send(false), PAGE_SEND_MS); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") void sender.send(true); + }); +} + /** The server's current base bundle hash (`undefined` when it cannot be asked: offline, restarting). */ async function serverBase(): Promise { try { diff --git a/packages/chord/test/documents/documents.test.ts b/packages/chord/test/documents/documents.test.ts index 03dbe683c..9160f7798 100644 --- a/packages/chord/test/documents/documents.test.ts +++ b/packages/chord/test/documents/documents.test.ts @@ -1,9 +1,13 @@ import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry"; import { describe, expect, it, vi } from "vitest"; -import { BACKGROUND_CONTEXT as context, withCancel, withTelemetry } from "../../src/context/index.ts"; +import { type Context, BACKGROUND_CONTEXT as context, withCancel, withTelemetry } from "../../src/context/index.ts"; import { arr, int, local, method, obj, str } from "../../src/contracts/index.ts"; +import type { Tx } from "../../src/documents/index.ts"; import { defineState, type KeyedHandle, type SingletonHandle } from "../../src/documents/index.ts"; -import { delivered, framesOf, loaded, rejection } from "../support.ts"; +import { eq } from "../../src/store/expression.ts"; +import type { TableOf } from "../../src/store/index.ts"; +import { defineTable } from "../../src/store/table.ts"; +import { delivered, framesOf, loaded, memoryStore, rejection } from "../support.ts"; const List = defineState("list", { schema: obj({ items: arr(str()) }), version: 1, initial: () => ({ items: [] }) }); const Drafts = defineState("drafts", { @@ -196,7 +200,7 @@ describe("commits in telemetry (slice 9)", () => { }); const work = telemetry.getSpans().find(({ name }) => name === "work"); expect(work!.events).toEqual([ - { name: "chord.commit", attributes: { documents: 1, ops: 1, duration: expect.any(Number) } }, + { name: "chord.commit", attributes: { documents: 1, ops: 1, rows: 0, duration: expect.any(Number) } }, ]); expect(telemetry.getMetrics().map(({ name, unit }) => [name, unit])).toContainEqual([ "chord.commit.duration", @@ -204,6 +208,39 @@ describe("commits in telemetry (slice 9)", () => { ]); }); + it("counts the table rows a commit's writes changed: a put one, an update or delete the rows it changed", async () => { + const Rows = defineTable("rows", { version: 1, columns: { id: int(), text: str() }, key: ["id"] }); + // A table's owner has empty params. + const Keeper = local("app.keeper", { version: 1, params: {}, members: { touch: method() } }); + const telemetry = new InMemoryTelemetryContext(); + let write: ((fn: (tx: Tx, rows: TableOf) => void, ctx: Context) => Promise) | undefined; + const { host } = await loaded( + (h) => + h.serve(Keeper, (_, self) => { + const rows = self.table(Rows); + write = (fn, ctx) => self.tx((tx) => fn(tx, rows), ctx); + return { touch: async () => null }; + }), + { telemetry, store: await memoryStore() }, + ); + await telemetry.startSpan({ name: "work" }, async (span) => { + const traced = withTelemetry(span, context); + await write!((tx, rows) => { + for (const id of [1, 2, 3]) tx.put(rows, { id, text: "a" }); + }, traced); + await write!((tx, rows) => { + tx.updateWhere(rows, (row) => eq(row.text, "a"), { text: "b" }); + tx.delete(rows, { id: 9 }); + }, traced); + }); + const work = telemetry.getSpans().find(({ name }) => name === "work"); + expect(work!.events.map(({ attributes }) => [attributes.documents, attributes.ops, attributes.rows])).toEqual([ + [0, 0, 3], + [0, 0, 3], + ]); + await host.close(context); + }); + it("times nothing when nothing records", async () => { const { list } = await owner(); const now = vi.spyOn(performance, "now"); diff --git a/packages/chord/test/examples/61-tutorial/main.ts b/packages/chord/test/examples/61-tutorial/main.ts index 63e63d407..126387b8a 100644 --- a/packages/chord/test/examples/61-tutorial/main.ts +++ b/packages/chord/test/examples/61-tutorial/main.ts @@ -1,7 +1,7 @@ // The tutorial's app (`docs/tutorial/book.md`): a shared todo app built with the app kit, one worker per list. Each // numbered step is the tutorial's chapter of that number: the server and the owner's sign-in, a list on its worker, // routes, the page, the terminal, a theme plugin added while the app runs, a second user, languages, an edit applied in -// place, a list exported and imported, and a trace across the page, the server and a worker, read back from the app's +// place, a list exported and imported, and a request's trace across the server and a worker, read back from the app's // telemetry store. The lists plugin's own test is `plugins/lists/worker.test.ts`; `screenshots.ts` shows the page and // the trace viewer in Chromium; `trace.ts` reads a data directory's traces. // Run from packages/chord (with Node 26.5.1 or later the terminal client runs too; `--serve` keeps the app running): @@ -16,7 +16,7 @@ import type { StoredTrace } from "@earendil-works/pi-telemetry"; import { renderTrace } from "@earendil-works/pi-telemetry/render"; import { List } from "@todo/app/v1"; import { WebSocket } from "ws"; -import { BACKGROUND_CONTEXT as context, withTelemetry } from "../../../src/context/index.ts"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { type ChordError, createHost, @@ -249,37 +249,27 @@ await app.importData(archive, List({ id: "groceries" }), context).catch((error: console.log(" importing over groceries:", error.message.replace(base, "…")); }); -// 11. Telemetry. Every process of the app records to the store in the data directory (step 1 opened it for the server, -// `worker.ts` opens it in each list's worker). A page stands in here as a client host of this process with a -// recorder of its own on the same file, signed in with the owner's cookie of step 1. A click is one span of the -// page; the call under it goes through the server's relay into the list's worker, and the carriers on the wire make -// one trace of the three processes' spans. The store's `add` checks the text in a span of its own (`ctx.trace`). -const pageTelemetry = await openTelemetry(telemetryFile(dataDir)); -const page = createHost({ - telemetry: pageTelemetry.store, - onError: (error) => console.error("page:", error.kind), -}); -await page.load([], context); -const pageSocket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie } }); -page.connect(webSocketLink(pageSocket as unknown as WebSocketLike), { principal: { kind: "server" } }, context); -const pageTodos = await page.get(Todos, { list: "groceries" }, context); -const click = (text: string) => - pageTelemetry.store.startSpan({ name: "page.add" }, (span) => pageTodos.add(text, withTelemetry(span, context))); -await click("honey"); -await click(" ").catch(() => "refused"); -pageTodos.release(); -await page.close(context); -await pageTelemetry.close(); -// Each recorder writes what settled once a second: read until the worker's spans of both clicks are there. -const [refusedTrace, honeyTrace] = await written(telemetry.store, "page.add", 2, "todos.validate"); -console.log("11. adding honey from the page:"); +// 11. Telemetry. The server records to the store in the data directory (step 1 opened it) and writes what every list's +// worker sends it. A command-line client adds honey and a blank todo: each request is a `chord.http` trace of the +// server, the call into the list's worker under it, and the store's `add` checks the text in a span of its own +// (`ctx.trace`). A page's click, recorded by the page itself, is `screenshots.ts`'s (it needs Chromium). +for (const text of ["honey", " "]) { + await fetch(`${url}/api/lists/groceries`, { + method: "POST", + headers: { ...cli, "content-type": "application/json" }, + body: JSON.stringify(text), + }); +} +// The worker sends every second, the server writes every second: read until the worker's spans of both are there. +const [refusedTrace, honeyTrace] = await written(telemetry.store, "chord.http", 2, "todos.validate"); +console.log("11. adding honey from the command line:"); printTelemetry(renderTrace(honeyTrace!.spans)); // `trace.ts` reads the same file from a process of its own: the newest traces, then one by its id. const traceScript = fileURLToPath(new URL("./trace.ts", import.meta.url)); const read = async (...args: string[]) => (await promisify(execFile)(process.execPath, [...process.execArgv, traceScript, ...args])).stdout; -console.log(" trace.ts --name page.add:"); -printTelemetry(await read(dataDir, "--name", "page.add")); +console.log(" trace.ts --name chord.http --principal owner:"); +printTelemetry(await read(dataDir, "--name", "chord.http", "--principal", "owner")); console.log(" trace.ts :"); printTelemetry(await read(dataDir, refusedTrace!.traceId)); diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts index 34c4b9127..f0ce6755e 100644 --- a/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts @@ -4,7 +4,7 @@ // texts are in the page's language (`useMessages`), and its catalogs are its contribution to the page's `Translations`. import { BACKGROUND_CONTEXT as context, defineFacet, Translations } from "@todo/app/v1"; -import { Panels, useHold, useMessages, usePageState, useWatch } from "@todo/app/v1/web"; +import { Panels, useHold, useHost, useMessages, usePageState, useWatch } from "@todo/app/v1/web"; import { createElement as h } from "react"; import { Todos } from "./contract.ts"; import { messages } from "./messages.ts"; @@ -12,6 +12,7 @@ import "./lists.css"; function TodoList() { const { t } = useMessages(messages); + const host = useHost(); const list = location.hash.slice(1) || "groceries"; const todos = useHold(Todos, { list }); const items = useWatch(todos?.items) ?? []; @@ -20,8 +21,11 @@ function TodoList() { const [text, setText] = usePageState(`lists.draft.${list}`, ""); const add = (event: { preventDefault(): void }) => { event.preventDefault(); - if (todos === undefined || text.trim() === "") return; - void todos.add(text, context); + // An empty todo too: the list's store refuses it, and the click's trace shows where. + if (todos === undefined) return; + // The click is a trace of its own: a root span of the page, the call to the list's worker under it. + const trace = { name: "page.add", attributes: { chars: text.length } }; + void host.root((ctx) => todos.add(text, ctx), context, { trace }).catch(() => {}); setText(""); }; return h( diff --git a/packages/chord/test/examples/61-tutorial/plugins/traces/clock.test.ts b/packages/chord/test/examples/61-tutorial/plugins/traces/clock.test.ts new file mode 100644 index 000000000..7ad75e33c --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/traces/clock.test.ts @@ -0,0 +1,60 @@ +// The viewer's clock correction: a page's spans moved by the least amount that puts the server's spans under them +// inside them; nothing moved when they fit, or when no server span is under a page span. +// Run from packages/chord: +// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/traces/clock.test.ts +import type { StoredSpan } from "@todo/app/v1/server"; +import { describe, expect, it } from "vitest"; +import { clamped } from "./clock.ts"; + +/** A span `id` on `host` from `startedMs` to `endedMs`, under `parentId`. */ +function span(id: string, host: string, startedMs: number, endedMs: number, parentId?: string): StoredSpan { + return { + id, + traceId: "t", + ...(parentId === undefined ? {} : { parentId }), + name: id, + startedMs, + endedMs, + attributes: { "chord.host": host }, + events: [], + links: [], + status: { status: "ok" }, + }; +} + +/** Each span's times after the correction. */ +function times(spans: readonly StoredSpan[]): [string, number, number][] { + return clamped(spans).map(({ id, startedMs, endedMs }) => [id, startedMs, endedMs]); +} + +describe("clamped", () => { + it("moves a page that runs late back, so its call contains the server's relay", () => { + const spans = [span("click", "browser", 5000, 5010), span("call", "browser", 5001, 5009, "click")]; + spans.push(span("relay", "server", 2002, 2008, "call")); + expect(times(spans)).toEqual([ + ["click", 2001, 2011], + ["call", 2002, 2010], + ["relay", 2002, 2008], + ]); + }); + + it("moves a page that runs early forward by the least amount", () => { + const spans = [span("call", "browser", 100, 110), span("relay", "server", 1101, 1105, "call")]; + expect(times(spans)).toEqual([ + ["call", 1095, 1105], + ["relay", 1101, 1105], + ]); + }); + + it("moves nothing when the server's spans fit, or when no server span is under a page span", () => { + const fitting = [span("call", "browser", 100, 110), span("relay", "server", 101, 109, "call")]; + expect(clamped(fitting)).toBe(fitting); + const alone = [span("click", "browser", 5000, 5010), span("other", "server", 1, 2)]; + expect(clamped(alone)).toBe(alone); + }); + + it("centres a server child longer than its page parent", () => { + const spans = [span("call", "browser", 100, 102), span("relay", "server", 200, 210, "call")]; + expect(times(spans)[0]).toEqual(["call", 204, 206]); + }); +}); diff --git a/packages/chord/test/examples/61-tutorial/plugins/traces/clock.ts b/packages/chord/test/examples/61-tutorial/plugins/traces/clock.ts new file mode 100644 index 000000000..6e3f50a38 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/traces/clock.ts @@ -0,0 +1,42 @@ +// The trace viewer's clocks: a page's spans come with the page's clock, which may be off by seconds against the +// server's; a trace's page spans are moved onto the server's clock by its own spans under them. + +import type { StoredSpan } from "@todo/app/v1/server"; + +/** + * `spans` with the page's moved onto the server's clock: a page's clock may be off by seconds, so its spans are moved + * by the least amount that puts each server span under a page span inside it (`skewOf`); none without such a pair. + */ +export function clamped(spans: readonly StoredSpan[]): readonly StoredSpan[] { + const skew = skewOf(spans); + if (skew === 0) return spans; + return spans.map((span) => + hostOf(span) === "browser" ? { ...span, startedMs: span.startedMs + skew, endedMs: span.endedMs + skew } : span, + ); +} + +/** + * The shift of the page's spans closest to none that puts every server child of a page span inside it: at least each + * child's end past its parent's, at most each child's start past its parent's (centred when no shift fits both: a + * child longer than its parent). + */ +function skewOf(spans: readonly StoredSpan[]): number { + const byId = new Map(spans.map((span) => [span.id, span])); + let least = Number.NEGATIVE_INFINITY; + let most = Number.POSITIVE_INFINITY; + for (const child of spans) { + const parent = child.parentId === undefined ? undefined : byId.get(child.parentId); + if (parent === undefined || hostOf(parent) !== "browser" || hostOf(child) === "browser") continue; + least = Math.max(least, child.endedMs - parent.endedMs); + most = Math.min(most, child.startedMs - parent.startedMs); + } + if (least === Number.NEGATIVE_INFINITY) return 0; + if (least > most) return (least + most) / 2; + return Math.min(Math.max(0, least), most); +} + +/** The host that recorded `span`, as every span of Chord's names it (`chord.host`). */ +export function hostOf(span: StoredSpan): string | undefined { + const host = span.attributes["chord.host"]; + return typeof host === "string" ? host : undefined; +} diff --git a/packages/chord/test/examples/61-tutorial/plugins/traces/server.ts b/packages/chord/test/examples/61-tutorial/plugins/traces/server.ts index 63336c795..f630b448c 100644 --- a/packages/chord/test/examples/61-tutorial/plugins/traces/server.ts +++ b/packages/chord/test/examples/61-tutorial/plugins/traces/server.ts @@ -1,10 +1,12 @@ // The trace viewer's server half: the owner's JSON routes over the app's telemetry store, which the page's viewer at -// `/traces` reads (`src/web/traces.ts`). One object per server opens the store's file in the data directory (a native) -// and contributes the routes while it is live; they read through the store's read interface only (`findTraces`, -// `getTrace`) and never follow a link to another trace. +// `/traces` reads (`src/web/traces.ts`). One object per server opens the store's file in the data directory read-only +// (a native) and contributes the routes while it is live; they read through the store's read interface only +// (`findTraces`, filtered by who the spans were for, and `getTrace`) and never follow a link to another trace. A page's +// spans come with the page's clock: the trace moves them onto the server's (`clamped`). import { type Ctx, defineFacet, forbidden, local } from "@todo/app/v1"; -import { AppInfo, Http, openTelemetry, type StoredSpan, telemetryFile } from "@todo/app/v1/server"; +import { AppInfo, Http, openTelemetryStore, type StoredSpan, telemetryFile } from "@todo/app/v1/server"; +import { clamped, hostOf } from "./clock.ts"; /** The viewer's reader, once per server: empty params, so it lives as long as the server and may contribute. */ const Traces = local("todo.traces", { version: 1, params: {}, members: {} }); @@ -15,23 +17,26 @@ const RECENT = 40; export default defineFacet((host) => { host.serve(Traces, (_params, self) => { const info = self.get(AppInfo, {}); - // The server records to the same file with its own recorder; this one only reads. + // The server records to the same file with its own recorder; this object only reads it. const telemetry = self.open( - async () => openTelemetry(telemetryFile(info.dataDir.value)), - (opened) => opened.close(), + async () => openTelemetryStore(telemetryFile(info.dataDir.value)), + (store) => store.close(), ); self.contribute(Http, { routes: { - "GET /api/traces": async (_request, ctx) => { + "GET /api/traces": async (request, ctx) => { ownerOnly(ctx); - const { traces } = await telemetry.value.store.findTraces({ limit: RECENT }); + // `?principal=user:u1`: the traces with a span for that principal (`principalAttributes`). + const principal = request.query.get("principal"); + const attributes = principal === null ? undefined : { "chord.principal": principal }; + const { traces } = await telemetry.value.findTraces({ limit: RECENT, attributes }); return { kind: "json", value: traces.map(({ traceId, spans }) => summaryOf(traceId, spans)) }; }, "GET /api/traces/:id": async (request, ctx) => { ownerOnly(ctx); - const trace = await telemetry.value.store.getTrace(request.params.id!); + const trace = await telemetry.value.getTrace(request.params.id!); if (trace === undefined) return { kind: "error", status: 404 }; - return { kind: "json", value: { id: trace.traceId, spans: rowsOf(trace.spans) } }; + return { kind: "json", value: { id: trace.traceId, spans: rowsOf(clamped(trace.spans)) } }; }, }, }); @@ -47,17 +52,23 @@ function ownerOnly(ctx: Ctx): void { /** The attributes of a trace's first span the list shows: an object's type and member, an HTTP route. */ const DETAIL = ["type", "member", "route"]; -/** A trace as the list shows it: its first span's name and details, its size, its processes, its length, its failure. */ -function summaryOf(id: string, spans: readonly StoredSpan[]) { +/** + * A trace as the list shows it: its first span's name and details, whom it was for (a user's name, else the principal + * key of its first span that has one), its size, its hosts, its length, its failure. + */ +function summaryOf(id: string, stored: readonly StoredSpan[]) { + const spans = clamped(stored); const start = Math.min(...spans.map((span) => span.startedMs)); // A stored trace has at least one span; its spans come in start order. const first = spans[0]!; + const who = spans.find(({ attributes }) => attributes["chord.principal"] !== undefined)?.attributes; return { id, name: first.name, detail: DETAIL.flatMap((key) => first.attributes[key] ?? []).join(" "), + principal: String(who?.["user.name"] ?? who?.["chord.principal"] ?? ""), spans: spans.length, - processes: new Set(spans.map(processOf)).size, + hosts: new Set(spans.flatMap((span) => hostOf(span) ?? [])).size, durationMs: Math.max(...spans.map((span) => span.endedMs)) - start, error: spans.some((span) => span.status.status === "error"), }; @@ -65,8 +76,9 @@ function summaryOf(id: string, spans: readonly StoredSpan[]) { /** * The trace's spans as the tree shows them, parents before their children (in start order), each with its depth, its - * process, its offset and length from the trace's start, and its attributes, events, links and error as text. A span - * whose parent is not in the trace (another trace's, or one not stored) is a root. + * host, its offset and length from the trace's start, and its attributes, events, links and error as text. A span + * whose parent is not in the trace (another trace's, or one not stored) is a root. A span naming no host (one an app + * started on an adapter itself) is shown on its parent's. */ function rowsOf(spans: readonly StoredSpan[]) { const start = Math.min(...spans.map((span) => span.startedMs)); @@ -80,24 +92,26 @@ function rowsOf(spans: readonly StoredSpan[]) { else siblings.push(span); } const rows: ReturnType[] = []; - const visit = (span: StoredSpan, depth: number): void => { - rows.push(rowOf(span, depth, start)); - for (const child of children.get(span.id) ?? []) visit(child, depth + 1); + const visit = (span: StoredSpan, depth: number, around: string): void => { + const host = hostOf(span) ?? around; + rows.push(rowOf(span, depth, host, start)); + for (const child of children.get(span.id) ?? []) visit(child, depth + 1, host); }; - for (const root of children.get(undefined) ?? []) visit(root, 0); + for (const root of children.get(undefined) ?? []) visit(root, 0, "?"); return rows; } -/** One span of the tree (see `rowsOf`). */ -function rowOf(span: StoredSpan, depth: number, start: number) { +/** One span of the tree (see `rowsOf`), recorded on `host`. */ +function rowOf(span: StoredSpan, depth: number, host: string, start: number) { const { status } = span; return { name: span.name, depth, - process: processOf(span), + host, offsetMs: span.startedMs - start, durationMs: span.endedMs - span.startedMs, - attributes: pairs(span.attributes), + // Its host is shown as its tag. + attributes: pairs(span.attributes).filter((pair) => !pair.startsWith("chord.host=")), events: span.events.map((event) => [event.name, ...pairs(event.attributes)].join(" ")), // A reference to a span of another trace, shown and never followed. links: span.links.map((link) => `trace ${link.traceId} span ${link.spanId}`), @@ -105,11 +119,6 @@ function rowOf(span: StoredSpan, depth: number, start: number) { }; } -/** The recording process of `span`: the SQLite store's span ids are `:`, one recorder per process. */ -function processOf(span: StoredSpan): string { - return span.id.slice(0, span.id.lastIndexOf(":")); -} - /** Attributes as `key=value` texts, the values as JSON, fractions at two decimals (`duration=0.41`). */ function pairs(attributes: StoredSpan["attributes"]): string[] { return Object.entries(attributes).map(([key, value]) => { diff --git a/packages/chord/test/examples/61-tutorial/screenshots.ts b/packages/chord/test/examples/61-tutorial/screenshots.ts index ead0ca851..d6f480cd2 100644 --- a/packages/chord/test/examples/61-tutorial/screenshots.ts +++ b/packages/chord/test/examples/61-tutorial/screenshots.ts @@ -1,18 +1,22 @@ // Screenshots of the tutorial's page (`docs/tutorial/screens/`), taken in headless Chromium: the page after the owner // signed in with the owner link, the page in the Harbor theme added while it runs, the page after an edit of the // lists plugin's web facet applied in place, the page after the owner chose French, with the list's notice, and the -// owner's trace viewer showing a todo added from a page. +// page before and after the owner adds honey (the page records its clicks), the trace viewer showing that click's +// trace, then the traces for Ada. // Run from packages/chord, with Chromium (`CHROMIUM_BIN`, else `chromium-docker` on the `PATH`): // node --conditions=source --experimental-strip-types test/examples/61-tutorial/screenshots.ts docs/tutorial/screens + +import { execFile } from "node:child_process"; import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { WebSocket } from "ws"; -import { BACKGROUND_CONTEXT as context, withTelemetry } from "../../../src/context/index.ts"; -import { createHost, LocaleChoice, type WebSocketLike, webSocketLink } from "../../../src/index.ts"; +import { promisify } from "node:util"; +import type { StoredTrace } from "@earendil-works/pi-telemetry"; +import { renderTrace } from "@earendil-works/pi-telemetry/render"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { LocaleChoice } from "../../../src/index.ts"; import { Browser, CHROMIUM } from "../../node/web/browser.ts"; import { Todos } from "./plugins/lists/contract.ts"; -import type { TodoPrincipal } from "./src/principals.ts"; import { openTelemetry, telemetryFile } from "./src/telemetry.ts"; import { startTodo, todoDirectories } from "./start.ts"; @@ -115,36 +119,42 @@ try { console.log(`after choosing French: lang="${lang}", ${JSON.stringify(french)} still typed`); console.log("the page did not reload:", await browser.evaluate("window.marked === true")); - // 5. Honey added from a page that records, and a blank todo refused (main.ts's step 11: a client host with a recorder - // of its own, here signed in with the owner token), then the owner opens the trace viewer on honey's trace. - const pageTelemetry = await openTelemetry(telemetryFile(dataDir)); - const page = createHost({ telemetry: pageTelemetry.store, onError: () => {} }); - await page.load([], context); - const token = (await readFile(app.ownerTokenFile, "utf8")).trim(); - const origin = new URL(app.ownerLink()).origin; - const socket = new WebSocket(`${origin.replace(/^http/, "ws")}/chord`, { - headers: { authorization: `Bearer ${token}` }, - }); - page.connect(webSocketLink(socket as unknown as WebSocketLike), { principal: { kind: "server" } }, context); - const todos = await page.get(Todos, { list: "groceries" }, context); - const click = (text: string) => - pageTelemetry.store.startSpan({ name: "page.add" }, (span) => todos.add(text, withTelemetry(span, context))); - await click("honey"); - await click("").catch(() => "refused"); - todos.release(); - await page.close(context); - await pageTelemetry.close(); - // Each recorder writes what settled once a second: read until the worker's span of honey's add is there. - const honey = async () => { + // 5. The page records (`startPage({ telemetry: true })`): the owner adds honey, then clicks again with nothing typed; + // each click is a trace of the page's, the call under it through the server's relay into the list's worker. The + // page sends its spans every few seconds, the worker every second, the server writes every second: read until the + // worker's span of each click is there, print honey's trace, read the store with `trace.ts`, then open the viewer. + await browser.evaluate("document.querySelector('.lists-panel input').select()"); + await browser.type("honey"); + await shoot("click-before.png"); + const submit = "document.querySelector('.lists-panel form').requestSubmit()"; + await browser.evaluate(submit); + await browser.wait( + "[...document.querySelectorAll('.lists-panel li')].some((item) => item.textContent.startsWith('honey'))", + ); + await browser.evaluate("document.querySelector('.lists-panel input').blur()"); + await shoot("click-after.png"); + await browser.evaluate(submit); + const clicks = async () => { await telemetry.store.flush(); - const [, trace] = (await telemetry.store.findTraces({ name: "page.add", limit: 2 })).traces; - return trace?.spans.some(({ name }) => name === "todos.validate") ? trace : undefined; + const { traces } = await telemetry.store.findTraces({ name: "page.add", limit: 2 }); + return traces.filter(({ spans }) => spans.some(({ name }) => name === "todos.validate")); }; - let trace = await honey(); - while (trace === undefined) { + let traces = await clicks(); + while (traces.length < 2) { await new Promise((done) => setTimeout(done, 100)); - trace = await honey(); + traces = await clicks(); } + const [refused, trace] = traces as [StoredTrace, StoredTrace]; + console.log("adding honey from the page:"); + printTelemetry(renderTrace(trace.spans)); + const traceScript = fileURLToPath(new URL("./trace.ts", import.meta.url)); + const read = async (...args: string[]) => + (await promisify(execFile)(process.execPath, [...process.execArgv, traceScript, ...args])).stdout; + console.log("trace.ts --name page.add --principal owner:"); + printTelemetry(await read(dataDir, "--name", "page.add", "--principal", "owner")); + console.log("trace.ts :"); + printTelemetry(await read(dataDir, refused.traceId)); + const origin = new URL(app.ownerLink()).origin; await browser.call("Emulation.setDeviceMetricsOverride", { width: 900, height: 640, @@ -158,9 +168,33 @@ try { "new Set([...document.querySelectorAll('.traces-tree .traces-host')].map((tag) => tag.textContent)).size", ); console.log(`the viewer shows page.add: 6 spans on ${hosts} hosts`); + + // 6. The traces for Ada (`user:u1`, the app's `principalAttributes` names her): her todo of the start, a call of the + // server's root acting as her, recorded by the list's worker with her name. + await browser.evaluate("document.querySelector('.traces-filter').focus()"); + await browser.type("user:u1"); + await browser.wait("document.querySelector('.traces nav a small')?.textContent.includes('ada')"); + await browser.evaluate("document.querySelector('.traces nav a').click()"); + await browser.wait("document.querySelectorAll('.traces-tree li').length > 0"); + await shoot("traces-ada.png"); + const listed = await browser.evaluate( + "[...document.querySelectorAll('.traces nav a')].map((a) => a.querySelector('span').textContent + ' for ' + a.querySelectorAll('small')[0].textContent.split(' · ').at(-1))", + ); + console.log(`the viewer for user:u1 lists ${[...new Set(listed)].join(", ")}`); } finally { await browser.close(); await app.close(context); await telemetry.close(); await rm(base, { recursive: true, force: true }); } + +/** Prints telemetry indented, as the same text at every run: span ids, lengths of time and byte counts masked. */ +function printTelemetry(text: string): void { + const steady = text + .replaceAll(/browser:[0-9a-f-]{36}:\d+|[0-9a-f-]{36}:\d+/g, "") + .replaceAll(/ \(\+?[\d.]+ms\)/g, "") + .replaceAll(/duration=[\d.]+/g, "duration=") + .replaceAll(/ [\d.]+ ms/g, " ") + .replaceAll(/\d+ bytes in \d+ messages/g, " bytes in messages"); + for (const line of steady.trim().split("\n")) console.log(` ${line}`); +} diff --git a/packages/chord/test/examples/61-tutorial/src/api/v1/server.ts b/packages/chord/test/examples/61-tutorial/src/api/v1/server.ts index 43ec8e61c..c683695c0 100644 --- a/packages/chord/test/examples/61-tutorial/src/api/v1/server.ts +++ b/packages/chord/test/examples/61-tutorial/src/api/v1/server.ts @@ -1,6 +1,6 @@ // The todo app's API v1 for server facets (`@todo/app/v1/server`): the HTTP registry plugins add routes to, the kit's -// pick-a-name login as a facet a one-line plugin exports, and the app's telemetry store (where it is, how a server -// facet opens it to read) with the kit's `AppInfo`, which names the data directory. +// pick-a-name login as a facet a one-line plugin exports, and the app's telemetry store (where it is, and Chord's +// read-only store a server facet opens it with) with the kit's `AppInfo`, which names the data directory. import { ephemeralLoginFacet } from "../../../../../../src/node/app/index.ts"; import type { TodoPrincipal } from "../../principals.ts"; @@ -8,7 +8,8 @@ import type { TodoPrincipal } from "../../principals.ts"; export type { StoredSpan, StoredTrace } from "@earendil-works/pi-telemetry"; export { AppInfo } from "../../../../../../src/node/app/index.ts"; export { Http } from "../../../../../../src/node/http/index.ts"; -export { openTelemetry, type TodoTelemetry, telemetryFile } from "../../telemetry.ts"; +export { openTelemetryStore } from "../../../../../../src/telemetry-sqlite/node.ts"; +export { telemetryFile } from "../../telemetry.ts"; /** The kit's ephemeral login: `POST /_chord/login { name }` signs in as `{ kind: "user", id, name }`. */ export const login = ephemeralLoginFacet(); diff --git a/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts b/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts index c29d217e3..76237b36c 100644 --- a/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts +++ b/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts @@ -4,7 +4,7 @@ import type { ComponentType } from "react"; import { defineRegistry } from "../../../../../../src/index.ts"; -export { useHold, useMessages, usePageState, useWatch } from "../../../../../../src/react/index.ts"; +export { useHold, useHost, useMessages, usePageState, useWatch } from "../../../../../../src/react/index.ts"; /** A panel of the page: its name (a key, never shown) and its React component, which shows its own title. */ export type Panel = { readonly name: string; readonly Component: ComponentType }; diff --git a/packages/chord/test/examples/61-tutorial/src/principals.ts b/packages/chord/test/examples/61-tutorial/src/principals.ts index e3844887e..2f39af99f 100644 --- a/packages/chord/test/examples/61-tutorial/src/principals.ts +++ b/packages/chord/test/examples/61-tutorial/src/principals.ts @@ -6,3 +6,23 @@ export type TodoPrincipal = | { readonly kind: "worker"; readonly list: string } | { readonly kind: "owner" } | { readonly kind: "user"; readonly id: string; readonly name: string }; + +/** The names the telemetry records, by principal: a principal crossing a link is one object per link, made once. */ +const recorded = new WeakMap>>(); + +/** + * Who a span was for, as the app's telemetry records it (the hosts' `principalAttributes`): a short principal key, and + * a user's name, which the owner's trace viewer shows. Cheap and synchronous, as Chord calls it per recorded span; the + * cache is the hook's own. + */ +export function principalAttributes(principal: TodoPrincipal): Readonly> { + let attributes = recorded.get(principal); + if (attributes === undefined) { + attributes = + principal.kind === "user" + ? { "chord.principal": `user:${principal.id}`, "user.name": principal.name } + : { "chord.principal": principal.kind === "worker" ? `worker:${principal.list}` : principal.kind }; + recorded.set(principal, attributes); + } + return attributes; +} diff --git a/packages/chord/test/examples/61-tutorial/src/telemetry.ts b/packages/chord/test/examples/61-tutorial/src/telemetry.ts index e4e75e7c8..01b72c49a 100644 --- a/packages/chord/test/examples/61-tutorial/src/telemetry.ts +++ b/packages/chord/test/examples/61-tutorial/src/telemetry.ts @@ -1,6 +1,6 @@ -// The todo app's telemetry store: one SQLite file in the data directory, `telemetry.sqlite`. The server and every -// list's worker record to it, each process with a recorder of its own on the shared file; readers (the trace viewer, -// `trace.ts`) open it the same way and read through the store's read interface. +// The todo app's telemetry store: one SQLite file in the data directory, `telemetry.sqlite`. The server is its only +// writer: its own spans, and those every list's worker and every recording page send it; readers (the trace viewer, +// `trace.ts`) open it read-only with Chord's `openTelemetryStore` and read through the store's read interface. import { mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; diff --git a/packages/chord/test/examples/61-tutorial/src/web/main.ts b/packages/chord/test/examples/61-tutorial/src/web/main.ts index 433fb3c6f..64fdf1816 100644 --- a/packages/chord/test/examples/61-tutorial/src/web/main.ts +++ b/packages/chord/test/examples/61-tutorial/src/web/main.ts @@ -33,5 +33,7 @@ void startPage({ connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`), modules: { "@todo/app/v1": v1, "@todo/app/v1/web": v1web, react: React, "react/jsx-runtime": jsx }, core: [{ name: "@todo/web", facet: core }], + // The page records its clicks and the calls under them, and sends them to the server every few seconds. + telemetry: true, render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))), }); diff --git a/packages/chord/test/examples/61-tutorial/src/web/style.css b/packages/chord/test/examples/61-tutorial/src/web/style.css index 60f3aa857..48454ca26 100644 --- a/packages/chord/test/examples/61-tutorial/src/web/style.css +++ b/packages/chord/test/examples/61-tutorial/src/web/style.css @@ -4,12 +4,13 @@ main { max-width: 26rem; margin: 1.5rem auto; padding: 0 1rem; } h1 { color: var(--accent); font-size: 1.4rem; margin: 0 0 1rem; } h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } -/* The trace viewer (`/traces`): the list beside the chosen trace's tree, a colour per process. */ +/* The trace viewer (`/traces`): the list (filtered by principal) beside the chosen trace's tree, a colour per host. */ main.wide { max-width: 60rem; } .traces { display: grid; grid-template-columns: 13rem 1fr; gap: 1rem; align-items: start; } .traces nav a { display: block; padding: 0.3rem 0.4rem; color: var(--fg); text-decoration: none; border-left: 2px solid transparent; } .traces nav a.traces-chosen { border-left-color: var(--accent); background: color-mix(in srgb, var(--fg) 8%, transparent); } .traces nav small { display: block; color: var(--muted); font-size: 0.75rem; } +.traces-filter { width: 100%; box-sizing: border-box; margin-bottom: 0.5rem; background: transparent; color: var(--fg); border: 1px solid var(--muted); border-radius: 3px; padding: 0.2rem 0.4rem; } .traces-tree { list-style: none; margin: 0; padding: 0; font-size: 0.8rem; } .traces-tree li { padding: 0.25rem 0; border-bottom: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); } .traces-span { display: flex; gap: 0.5rem; align-items: baseline; } diff --git a/packages/chord/test/examples/61-tutorial/src/web/traces.ts b/packages/chord/test/examples/61-tutorial/src/web/traces.ts index 6a8ab0db7..96e376ad1 100644 --- a/packages/chord/test/examples/61-tutorial/src/web/traces.ts +++ b/packages/chord/test/examples/61-tutorial/src/web/traces.ts @@ -1,7 +1,8 @@ -// The owner's trace viewer, the page at `/traces`: the newest traces of the app's telemetry store and the one chosen -// (its id in the URL's fragment) as a tree: each span with its host, its length as a bar on the trace's time line, its -// attributes and events, its error marked, and its links as references to other traces, never followed. It reads the -// traces plugin's JSON routes (`plugins/traces/server.ts`), which answer the owner only. +// The owner's trace viewer, the page at `/traces`: the newest traces of the app's telemetry store (all, or those for +// one principal: `user:u1`, `owner`), each with whom it was for, and the one chosen +// (its id in the URL's fragment) as a tree: each span with its host (`chord.host`), its length as a bar on the trace's +// time line, its attributes and events, its error marked, and its links as references to other traces, never +// followed. It reads the traces plugin's JSON routes (`plugins/traces/server.ts`), which answer the owner only. import { createElement as h, useEffect, useState } from "react"; @@ -10,8 +11,9 @@ type Summary = { readonly id: string; readonly name: string; readonly detail: string; + readonly principal: string; readonly spans: number; - readonly processes: number; + readonly hosts: number; readonly durationMs: number; readonly error: boolean; }; @@ -20,7 +22,7 @@ type Summary = { type Row = { readonly name: string; readonly depth: number; - readonly process: string; + readonly host: string; readonly offsetMs: number; readonly durationMs: number; readonly attributes: readonly string[]; @@ -35,14 +37,18 @@ type Answer = { readonly kind: "ok"; readonly value: T } | { readonly kind: " /** The viewer: the list, and the chosen trace's tree. */ export function TraceViewer() { const [list, setList] = useState>(); + const [principal, setPrincipal] = useState(""); const [chosen, setChosen] = useState(fragment); const [trace, setTrace] = useState>(); useEffect(() => { - void read("/api/traces").then(setList); const follow = () => setChosen(fragment()); addEventListener("hashchange", follow); return () => removeEventListener("hashchange", follow); }, []); + useEffect(() => { + const query = principal === "" ? "" : `?principal=${encodeURIComponent(principal)}`; + void read(`/api/traces${query}`).then(setList); + }, [principal]); useEffect(() => { setTrace(undefined); if (chosen === "") return; @@ -56,6 +62,12 @@ export function TraceViewer() { "nav", null, h("h2", null, "Traces"), + h("input", { + className: "traces-filter", + placeholder: "principal", + value: principal, + onChange: (event: { target: { value: string } }) => setPrincipal(event.target.value), + }), ...(list?.value ?? []).map((summary) => h( "a", @@ -65,11 +77,11 @@ export function TraceViewer() { className: summary.id === chosen ? "traces-chosen" : undefined, }, h("span", { className: summary.error ? "traces-error" : undefined }, summary.name), - h("small", null, summary.detail), + h("small", null, [summary.detail, summary.principal].filter((part) => part !== "").join(" · ")), h( "small", null, - `${count(summary.spans, "span")} · ${count(summary.processes, "host")} · ${ms(summary.durationMs)}`, + `${count(summary.spans, "span")} · ${count(summary.hosts, "host")} · ${ms(summary.durationMs)}`, ), ), ), @@ -78,19 +90,19 @@ export function TraceViewer() { ); } -/** One trace as a tree: a colour per host (the process that recorded the span), lengths on the trace's time line. */ +/** One trace as a tree: a colour per host (the host that recorded the span), lengths on the trace's time line. */ function Tree({ rows }: { readonly rows: readonly Row[] }) { const total = Math.max(...rows.map((row) => row.offsetMs + row.durationMs)); - const hosts = [...new Set(rows.map((row) => row.process))]; + const hosts = [...new Set(rows.map((row) => row.host))]; return h( "ol", { className: "traces-tree" }, - ...rows.map((row, index) => h(Span, { key: index, row, host: hosts.indexOf(row.process), total })), + ...rows.map((row, index) => h(Span, { key: index, row, colour: hosts.indexOf(row.host), total })), ); } /** One span: its host, name and length, its bar, then its attributes, events, links and error. */ -function Span({ row, host, total }: { readonly row: Row; readonly host: number; readonly total: number }) { +function Span({ row, colour, total }: { readonly row: Row; readonly colour: number; readonly total: number }) { const indent = { paddingLeft: `${row.depth * 1.1}rem` }; // Inline styles are set through the DOM by React, which the page's CSP allows (it refuses style attributes in HTML). const bar = { @@ -103,14 +115,14 @@ function Span({ row, host, total }: { readonly row: Row; readonly host: number; h( "div", { className: "traces-span", style: indent }, - h("span", { className: `traces-host traces-host-${host % 4}` }, `host ${host + 1}`), + h("span", { className: `traces-host traces-host-${colour % 4}` }, row.host), h("b", null, row.name), h("span", { className: "traces-ms" }, ms(row.durationMs)), ), h( "div", { className: "traces-line" }, - h("span", { className: `traces-bar traces-host-${host % 4}`, style: bar }), + h("span", { className: `traces-bar traces-host-${colour % 4}`, style: bar }), ), h( "div", diff --git a/packages/chord/test/examples/61-tutorial/start.ts b/packages/chord/test/examples/61-tutorial/start.ts index 610070b5d..03315817d 100644 --- a/packages/chord/test/examples/61-tutorial/start.ts +++ b/packages/chord/test/examples/61-tutorial/start.ts @@ -1,7 +1,8 @@ // The todo app's start: what the app decides itself, given to the kit's `startServer`: its name and data directory, // its API, its facet kinds, its principals, its one worker kind (a list), its page and its terminal client, and its -// telemetry store (one SQLite file in the data directory, shared by the server and the workers). The kit does the rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls -// it with the data directory from its command line and prints the owner link. +// telemetry store (one SQLite file in the data directory, which the server writes for every process). The kit does the +// rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls it with +// the data directory from its command line and prints the owner link. import { cp, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -12,8 +13,8 @@ import * as v1server from "@todo/app/v1/server"; import type { Context } from "../../../src/context/index.ts"; import { type App, startServer } from "../../../src/node/app/index.ts"; import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts"; -import type { TodoPrincipal } from "./src/principals.ts"; -import { type TodoTelemetry, telemetryFile } from "./src/telemetry.ts"; +import { principalAttributes, type TodoPrincipal } from "./src/principals.ts"; +import type { TodoTelemetry } from "./src/telemetry.ts"; /** The app's API: its name, its versions (newest first), and this process's instances of its modules. */ export const API = { @@ -54,14 +55,15 @@ export function startTodo( api: API, kinds: KINDS, principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) }, - // The server's spans and metrics; each list's worker opens the same file (`worker.ts`). + // The store the server writes: its own spans and metrics, and what the workers and recording pages send. telemetry: telemetry.store, + // Who a span was for: a short key and a user's name (each worker's `startWorker` gets the same hook). + principalAttributes, workers: [ { kind: v1.List, entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], - env: { TODO_TELEMETRY: telemetryFile(dataDir) }, start: () => ({}), // Who may reach a list's worker: everyone signed in, and the server itself. authorize: (principal) => principal.kind !== "worker", diff --git a/packages/chord/test/examples/61-tutorial/trace.ts b/packages/chord/test/examples/61-tutorial/trace.ts index 087c1755f..effee74a8 100644 --- a/packages/chord/test/examples/61-tutorial/trace.ts +++ b/packages/chord/test/examples/61-tutorial/trace.ts @@ -1,25 +1,34 @@ // The todo app's trace reader: a script reading the telemetry store of a data directory (`telemetry.sqlite`) through // the store's read interface, while the app runs or after. Without a trace id it prints the newest traces (those whose -// first span is `--name`, when given) and the bytes on the links; with one, that trace as the renderer prints it. +// first span is `--name`, and with a span for `--principal`, when given) and the bytes on the links; with one, that +// trace as the renderer prints it. // Run from packages/chord: -// node --conditions=source --experimental-strip-types test/examples/61-tutorial/trace.ts [--name ] +// node --conditions=source --experimental-strip-types test/examples/61-tutorial/trace.ts [--name ] [--principal ] // node --conditions=source --experimental-strip-types test/examples/61-tutorial/trace.ts import { parseArgs } from "node:util"; import { renderTrace } from "@earendil-works/pi-telemetry/render"; -import { openTelemetry, telemetryFile } from "./src/telemetry.ts"; +import { openTelemetryStore } from "../../../src/telemetry-sqlite/node.ts"; +import { telemetryFile } from "./src/telemetry.ts"; -const { values, positionals } = parseArgs({ allowPositionals: true, options: { name: { type: "string" } } }); +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { name: { type: "string" }, principal: { type: "string" } }, +}); const [dataDir, id] = positionals; -if (dataDir === undefined) throw new Error("usage: trace.ts [--name ] []"); -// A recorder of this process on the shared file, which records nothing: the reads are what the script uses. -const telemetry = await openTelemetry(telemetryFile(dataDir)); +if (dataDir === undefined) { + throw new Error("usage: trace.ts [--name ] [--principal ] []"); +} +// The shared file, read-only: the app's processes may be writing it meanwhile. +const store = await openTelemetryStore(telemetryFile(dataDir)); try { if (id !== undefined) { - const trace = await telemetry.store.getTrace(id); + const trace = await store.getTrace(id); console.log(trace === undefined ? `no trace ${id}` : renderTrace(trace.spans)); } else { - // Complete traces, newest first; a trace's first span (its root, by start time) names it. - const { traces } = await telemetry.store.findTraces({ name: values.name, limit: 10 }); + // Complete traces, newest first; a trace's first span (its root, by start time) names it, and the app's + // `principalAttributes` hook named whom each span was for. + const attributes = values.principal === undefined ? undefined : { "chord.principal": values.principal }; + const { traces } = await store.findTraces({ name: values.name, attributes, limit: 10 }); for (const { traceId, spans } of traces) { const start = Math.min(...spans.map((span) => span.startedMs)); const length = Math.max(...spans.map((span) => span.endedMs)) - start; @@ -28,12 +37,12 @@ try { } // Metrics are stored per series and 10 s: a series is one set of attributes, here the direction. for (const direction of ["sent", "received"]) { - const rows = await telemetry.store.findMetrics({ name: "chord.link.bytes", attributes: { direction } }); + const rows = await store.findMetrics({ name: "chord.link.bytes", attributes: { direction } }); const bytes = rows.reduce((sum, row) => sum + row.sum, 0); const messages = rows.reduce((sum, row) => sum + row.count, 0); console.log(`chord.link.bytes ${direction}: ${bytes} bytes in ${messages} messages`); } } } finally { - await telemetry.close(); + await store.close(); } diff --git a/packages/chord/test/examples/61-tutorial/worker.ts b/packages/chord/test/examples/61-tutorial/worker.ts index 496dc3852..976625ee4 100644 --- a/packages/chord/test/examples/61-tutorial/worker.ts +++ b/packages/chord/test/examples/61-tutorial/worker.ts @@ -1,10 +1,9 @@ // The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects -// to the server and loads the plugins' worker facets. The worker records to the app's telemetry store, the file its -// server named in the environment: its methods called through the server continue the caller's trace. +// to the server and loads the plugins' worker facets. The worker records its spans in memory and sends them to the +// server, which stores them: its methods called through the server continue the caller's trace. import { startWorker } from "../../../src/node/app/index.ts"; -import { openTelemetry } from "./src/telemetry.ts"; +import { principalAttributes } from "./src/principals.ts"; import { API, KINDS } from "./start.ts"; -const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!); -startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) }); +startWorker({ api: API, kinds: KINDS, principalAttributes, onError: (error) => console.error("list:", error) }); diff --git a/packages/chord/test/examples/62-telemetry/main.ts b/packages/chord/test/examples/62-telemetry/main.ts index ad2f16025..4248997aa 100644 --- a/packages/chord/test/examples/62-telemetry/main.ts +++ b/packages/chord/test/examples/62-telemetry/main.ts @@ -1,5 +1,5 @@ -// Telemetry across hosts (slice 9): a kit app (`start.ts`) whose server and tally workers record to one SQLite -// telemetry store in the data directory. The owner's client host calls a tally's counter: the call goes from the client +// Telemetry across hosts (slice 9): a kit app (`start.ts`) whose server records to a SQLite telemetry store in the +// data directory, and whose tally workers send what they record to the server. The owner's client host calls a tally's counter: the call goes from the client // over the server's relay into the worker's method, which checks its argument in a span of its own (`ctx.trace`) and // commits through a local store object. Each host starts its spans with its own adapter; the carriers on the wire make // them one trace. The example prints that trace as the store renders it, and a metric the server recorded. @@ -18,7 +18,7 @@ import { openTelemetry, startTally, telemetryFile } from "./start.ts"; const dataDir = join(await mkdtemp(join(tmpdir(), "tally-")), "data"); -// 1. The server records to the store in its data directory; each tally's worker opens the same file. +// 1. The server records to the store in its data directory; each tally's worker sends its spans to the server. await mkdir(dataDir, { mode: 0o700 }); const telemetry = await openTelemetry(telemetryFile(dataDir)); const app = await startTally(dataDir, telemetry, context); @@ -42,11 +42,17 @@ const total = await telemetry.startSpan({ name: "tally.click" }, async (span) => }); console.log("3. the tally's total:", total); -// 4. Each process flushes its settled spans every second: wait for the worker's, flush the server's, then read the -// trace back from the store (the newest trace whose first span is the click). -await new Promise((resolve) => setTimeout(resolve, 1_500)); -await telemetry.flush(); -const { traces } = await telemetry.findTraces({ name: "tally.click", limit: 1 }); +// 4. A worker sends its settled spans every second, the server writes every second: read until the worker's method is +// in the click's trace (the newest trace whose first span is the click). +const clicked = async () => { + await telemetry.flush(); + return (await telemetry.findTraces({ name: "tally.click", limit: 1 })).traces; +}; +let traces = await clicked(); +while (!traces[0]?.spans.some(({ name }) => name === "tally.check")) { + await new Promise((resolve) => setTimeout(resolve, 100)); + traces = await clicked(); +} console.log("4. the trace across the client, the server and the worker (links name the root span they point to):"); // The roots a trace links to (the worker's start, an object's activation) are traces of their own: named here. const names = new Map(); diff --git a/packages/chord/test/examples/62-telemetry/start.ts b/packages/chord/test/examples/62-telemetry/start.ts index 1c41f73a5..6028a9ded 100644 --- a/packages/chord/test/examples/62-telemetry/start.ts +++ b/packages/chord/test/examples/62-telemetry/start.ts @@ -1,6 +1,5 @@ -// The tally app's start: `startServer` with the app's telemetry adapter, a SQLite telemetry store in the data -// directory. Each process records to the store it is given: here every process opens the same file (`telemetry.sqlite` -// in the data directory; its workers get the path in their environment), so one trace reads tab → server → worker. +// The tally app's start: `startServer` with the app's telemetry store, a SQLite file in the data directory. The server +// is its only writer: each worker sends what it records to the server, so one trace reads tab → server → worker. import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -22,7 +21,7 @@ export function telemetryFile(dataDir: string): string { return join(dataDir, "telemetry.sqlite"); } -/** Opens the SQLite telemetry store at `path` (each process its own recorder on the shared file). */ +/** Opens the SQLite telemetry store at `path` (the server's). */ export async function openTelemetry(path: string): Promise { return createSqliteTelemetry(await openNodeSqliteDatabase(path)); } @@ -48,7 +47,6 @@ export function startTally( kind: v1.Tally, entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], - env: { TALLY_TELEMETRY: telemetryFile(dataDir) }, start: () => ({}), // The owner reaches a tally through the server, which relays. authorize: (principal) => principal.kind === "owner" || principal.kind === "server", diff --git a/packages/chord/test/examples/62-telemetry/worker.ts b/packages/chord/test/examples/62-telemetry/worker.ts index c00f2b6e2..94138ffff 100644 --- a/packages/chord/test/examples/62-telemetry/worker.ts +++ b/packages/chord/test/examples/62-telemetry/worker.ts @@ -1,9 +1,8 @@ -// The tally app's worker entry: one process per tally, recording to the app's telemetry store (the file its server -// named in the environment). Its methods called through the server continue the caller's trace: the carrier travels on -// the wire. +// The tally app's worker entry: one process per tally. It records in memory and sends its spans and metrics to the +// server, which stores them (the kit does it when the server has a telemetry store). Its methods called through the +// server continue the caller's trace: the carrier travels on the wire. import { startWorker } from "../../../src/node/app/index.ts"; -import { API, KINDS, openTelemetry } from "./start.ts"; +import { API, KINDS } from "./start.ts"; -const telemetry = await openTelemetry(process.env.TALLY_TELEMETRY!); -startWorker({ api: API, kinds: KINDS, telemetry, onError: (error) => console.error("worker:", error) }); +startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("worker:", error) }); diff --git a/packages/chord/test/host/ctx.test.ts b/packages/chord/test/host/ctx.test.ts index d9307f326..2d700c499 100644 --- a/packages/chord/test/host/ctx.test.ts +++ b/packages/chord/test/host/ctx.test.ts @@ -259,7 +259,7 @@ describe("ctx.trace (C11, slice 9)", () => { const spans = telemetry.getSpans(); const byName = (name: string) => spans.find((span) => span.name === name)!; expect(byName("app.step").parentId).toBe(byName("chord.method").id); - expect(byName("app.step").attributes).toEqual({ n: 1 }); + expect(byName("app.step").attributes).toEqual({ "chord.host": "client", n: 1 }); expect(byName("app.deeper").parentId).toBe(byName("app.step").id); expect(seen.inner).not.toBe(seen.outer); expect(seen.inner!.principal).toBe(seen.outer!.principal); diff --git a/packages/chord/test/host/facet.test.ts b/packages/chord/test/host/facet.test.ts index 539ffd77b..1031f852a 100644 --- a/packages/chord/test/host/facet.test.ts +++ b/packages/chord/test/host/facet.test.ts @@ -1,7 +1,15 @@ +import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry"; import { describe, expect, expectTypeOf, it } from "vitest"; -import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts"; -import { int, local } from "../../src/contracts/index.ts"; -import { defineFacet, type Facet, type FacetHost, type FacetSelf, type Host } from "../../src/host/index.ts"; +import { BACKGROUND_CONTEXT as context, withTelemetry } from "../../src/context/index.ts"; +import { int, local, method } from "../../src/contracts/index.ts"; +import { + createHost, + defineFacet, + type Facet, + type FacetHost, + type FacetSelf, + type Host, +} from "../../src/host/index.ts"; import { defineRegistry } from "../../src/registries/index.ts"; import { constant } from "../../src/state/index.ts"; import { loaded } from "../support.ts"; @@ -63,3 +71,29 @@ describe("facets", () => { expectTypeOf>().not.toExtend>(); }); }); + +describe("serve options", () => { + it("starts no method span for a type served `untraced`", async () => { + const Quiet = local("app.quiet", { version: 1, params: {}, members: { ping: method() } }); + const Loud = local("app.loud", { version: 1, params: {}, members: { ping: method() } }); + const telemetry = new InMemoryTelemetryContext(); + const host = createHost({ telemetry, onError: () => {} }); + const facet = defineFacet((h) => { + h.serve(Quiet, () => ({ ping: async () => null }), { untraced: true }); + h.serve(Loud, () => ({ ping: async () => null })); + }); + await host.load([{ name: "@test/app", facet }], context); + const [quiet, loud] = [await host.get(Quiet, {}, context), await host.get(Loud, {}, context)]; + await telemetry.startSpan({ name: "work" }, async (span) => { + const traced = withTelemetry(span, context); + await quiet.ping(traced); + await loud.ping(traced); + }); + expect( + telemetry + .getSpans() + .filter(({ name }) => name === "chord.method") + .map(({ attributes }) => attributes.type), + ).toEqual(["app.loud"]); + }); +}); diff --git a/packages/chord/test/host/host.test.ts b/packages/chord/test/host/host.test.ts index 010712aa9..6c968aaf5 100644 --- a/packages/chord/test/host/host.test.ts +++ b/packages/chord/test/host/host.test.ts @@ -1,4 +1,4 @@ -import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry"; +import { InMemoryTelemetryContext, type SpanAttributes } from "@earendil-works/pi-telemetry"; import { describe, expect, it, vi } from "vitest"; import { BACKGROUND_CONTEXT as context, withAbortSignal, withCancel, withTelemetry } from "../../src/context/index.ts"; import { evolve, hostKind, int, local, method, obj, remote, str } from "../../src/contracts/index.ts"; @@ -1674,6 +1674,50 @@ describe("reload: an abort while staging (fix round 1)", () => { }); }); +describe("who a span was for (principalAttributes)", () => { + const Echo = local("app.echo", { version: 1, params: {}, members: { echo: method(str(), str()) } }); + + /** A host acting as `{ kind: "user", id: "u1", name: "Ada" }` serving `Echo`, recording to `telemetry`. */ + async function host( + telemetry: InMemoryTelemetryContext, + principalAttributes?: (p: { kind: string }) => Readonly, + ) { + const made = createHost({ + principal: { kind: "user", id: "u1", name: "Ada" }, + telemetry, + ...(principalAttributes === undefined ? {} : { principalAttributes }), + onError: () => {}, + }); + await made.load( + [{ name: "@test/echo", facet: defineFacet((h) => h.serve(Echo, () => ({ echo: async (text) => text }))) }], + context, + ); + const echo = await made.get(Echo, {}, context); + await telemetry.startSpan({ name: "work" }, (span) => echo.echo("hi", withTelemetry(span, context))); + return telemetry.getSpans().find(({ name }) => name === "chord.method")!.attributes; + } + + it("records a method's principal key as `chord.principal` by default", async () => { + expect(await host(new InMemoryTelemetryContext())).toMatchObject({ + "chord.principal": '{"id":"u1","kind":"user","name":"Ada"}', + }); + }); + + it("records the hook's attributes in its place, the hook called per recorded span only", async () => { + const hook = vi.fn((principal: { kind: string }) => ({ "chord.principal": principal.kind, "user.name": "Ada" })); + const attributes = await host(new InMemoryTelemetryContext(), hook); + expect(attributes).toMatchObject({ "chord.principal": "user", "user.name": "Ada" }); + expect(hook).toHaveBeenCalledTimes(1); + const silent = createHost({ principal: { kind: "user" }, principalAttributes: hook, onError: () => {} }); + await silent.load( + [{ name: "@test/echo", facet: defineFacet((h) => h.serve(Echo, () => ({ echo: async (text) => text }))) }], + context, + ); + await (await silent.get(Echo, {}, context)).echo("hi", context); + expect(hook).toHaveBeenCalledTimes(1); + }); +}); + describe("the host's spans (slice 9)", () => { it("traces load under its caller's span: plugins given and left out; host-lifetime activations link to it", async () => { const telemetry = new InMemoryTelemetryContext(); @@ -1694,7 +1738,7 @@ describe("the host's spans (slice 9)", () => { const spans = telemetry.getSpans(); const load = spans.find(({ name }) => name === "chord.load")!; expect(load.parentId).toBe(spans.find(({ name }) => name === "start")!.id); - expect(load.attributes).toEqual({ plugins: 2, failed: 1 }); + expect(load.attributes).toEqual({ "chord.host": "client", plugins: 2, failed: 1 }); const activation = spans.find(({ name }) => name === "chord.activate")!; expect(activation.links.map(({ context: carrier }) => carrier)).toEqual([load.context]); }); @@ -1708,7 +1752,15 @@ describe("the host's spans (slice 9)", () => { host.reload([{ name: "@test/app", facet: facet("b") }], withTelemetry(span, context)), ); const reload = telemetry.getSpans().find(({ name }) => name === "chord.reload")!; - expect(reload.attributes).toEqual({ plugins: 1, added: 0, changed: 1, removed: 0, failed: 0, fates: 1 }); + expect(reload.attributes).toEqual({ + "chord.host": "client", + plugins: 1, + added: 0, + changed: 1, + removed: 0, + failed: 0, + fates: 1, + }); expect(reload.events).toEqual([ { name: "chord.reload.fate", attributes: { object: "app.info", fate: "swapped" } }, ]); @@ -1741,7 +1793,7 @@ describe("the host's spans (slice 9)", () => { host.accept(a, { principal: { kind: "guest", session: "S1" } }, context); await telemetry.startSpan({ name: "admin" }, (span) => host.revoke("S1", withTelemetry(span, context))); const revoke = telemetry.getSpans().find(({ name }) => name === "chord.revoke")!; - expect(revoke.attributes).toEqual({ count: 1 }); + expect(revoke.attributes).toEqual({ "chord.host": "server", count: 1 }); host.revoke("S1", context); expect(telemetry.getSpans().filter(({ name }) => name === "chord.revoke")).toHaveLength(1); }); diff --git a/packages/chord/test/host/native.test.ts b/packages/chord/test/host/native.test.ts index 08cee0b09..09aa909ea 100644 --- a/packages/chord/test/host/native.test.ts +++ b/packages/chord/test/host/native.test.ts @@ -279,7 +279,10 @@ describe("spans (slice 9)", () => { expect(opens[1]!.status).toEqual({ status: "error", error: { name: "open_failed", message: "no socket" } }); expect(spans.find(({ name }) => name === "app.connect")!.parentId).toBe(opens[0]!.id); const close = spans.find(({ name }) => name === "chord.close")!; - expect([close.parentId, close.attributes]).toEqual([null, { type: "app.res", native: 1, reason: "end" }]); + expect([close.parentId, close.attributes]).toEqual([ + null, + { "chord.host": "client", type: "app.res", native: 1, reason: "end" }, + ]); await host.close(context); }); diff --git a/packages/chord/test/host/root.test.ts b/packages/chord/test/host/root.test.ts index 34ecbf77a..24a9d117c 100644 --- a/packages/chord/test/host/root.test.ts +++ b/packages/chord/test/host/root.test.ts @@ -1,4 +1,5 @@ import { getEventListeners } from "node:events"; +import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry"; import { describe, expect, it } from "vitest"; import { BACKGROUND_CONTEXT as context, withAbortSignal, withCancel } from "../../src/context/index.ts"; import { hostKind, int, local, method, remote, str } from "../../src/contracts/index.ts"; @@ -206,3 +207,43 @@ describe("host.root", () => { await host.close(context); }); }); + +describe("host.root's trace", () => { + it("runs fn in a new root span of the host's adapter, with its host and attributes; a throw fails it", async () => { + const telemetry = new InMemoryTelemetryContext(); + const host = createHost({ telemetry, onError: () => {} }); + await host.load([], context); + const seen = await host.root(async (ctx) => ctx.telemetry, context, { + trace: { name: "page.add", attributes: { n: 1 } }, + }); + const error = await host + .root( + async () => { + throw new Error("refused"); + }, + context, + { trace: { name: "page.add" } }, + ) + .catch((thrown: unknown) => thrown); + expect(error).toBeInstanceOf(Error); + const spans = telemetry.getSpans(); + expect(spans.map(({ name, parentId, attributes, status }) => [name, parentId, attributes, status])).toEqual([ + ["page.add", null, { "chord.host": "client", n: 1 }, { status: "ok" }], + [ + "page.add", + null, + { "chord.host": "client" }, + { status: "error", error: { name: "trace_failed", message: "refused" } }, + ], + ]); + expect(telemetry.inject(seen)).toEqual(spans[0]!.context); + }); + + it("starts nothing without an adapter that records", async () => { + const host = createHost({ onError: () => {} }); + await host.load([], context); + expect( + await host.root(async (ctx) => ctx.telemetry === context.telemetry, context, { trace: { name: "x" } }), + ).toBe(true); + }); +}); diff --git a/packages/chord/test/host/route.test.ts b/packages/chord/test/host/route.test.ts index 653b290e4..87fcbd7f7 100644 --- a/packages/chord/test/host/route.test.ts +++ b/packages/chord/test/host/route.test.ts @@ -512,7 +512,12 @@ describe("resolve spans (slice 9)", () => { const resolve = spans.find(({ name }) => name === "chord.resolve")!; const relay = spans.find(({ name }) => name === "chord.relay")!; expect(resolve.parentId).toBeNull(); - expect(resolve.attributes).toEqual({ kind: "session", address: "session|S1", outcome: "failed" }); + expect(resolve.attributes).toEqual({ + "chord.host": "server", + kind: "session", + address: "session|S1", + outcome: "failed", + }); expect(resolve.status).toEqual({ status: "error", error: { name: "resolve_failed", message: "no worker" } }); expect(resolve.links.map(({ context: carrier }) => carrier)).toEqual([relay.context]); expect(relay.links.map(({ context: carrier }) => carrier)).toEqual([resolve.context]); diff --git a/packages/chord/test/host/routines.test.ts b/packages/chord/test/host/routines.test.ts index 9cde37825..c9bf05c63 100644 --- a/packages/chord/test/host/routines.test.ts +++ b/packages/chord/test/host/routines.test.ts @@ -1179,7 +1179,14 @@ describe("routine spans (slice 9)", () => { const live = routines.find(({ attributes }) => attributes.routine === "onLive")!; expect([live.parentId, live.attributes]).toEqual([ null, - { type: "app.store", object: "app.store", routine: "onLive", position: 1, outcome: "ok" }, + { + "chord.host": "client", + type: "app.store", + object: "app.store", + routine: "onLive", + position: 1, + outcome: "ok", + }, ]); expect(live.events.map(({ name }) => name)).toEqual(["chord.commit"]); const ons = routines.filter(({ attributes }) => attributes.routine === "on"); diff --git a/packages/chord/test/jobs/job.test.ts b/packages/chord/test/jobs/job.test.ts index ddcbfadb2..d8aa5047a 100644 --- a/packages/chord/test/jobs/job.test.ts +++ b/packages/chord/test/jobs/job.test.ts @@ -1419,8 +1419,22 @@ describe("phase spans (slice 9)", () => { const [intent, ran, finish] = spans.filter(({ name }) => name === "chord.job"); expect([intent!.parentId, ran!.parentId, finish!.parentId]).toEqual([request.id, intent!.id, intent!.id]); expect([ran!.attributes, finish!.attributes]).toEqual([ - { job: "app.echo", phase: "run", object: "app.store", "job.id": run.id, outcome: "ok" }, - { job: "app.echo", phase: "finish", object: "app.store", "job.id": run.id, outcome: "ok" }, + { + "chord.host": "client", + job: "app.echo", + phase: "run", + object: "app.store", + "job.id": run.id, + outcome: "ok", + }, + { + "chord.host": "client", + job: "app.echo", + phase: "finish", + object: "app.store", + "job.id": run.id, + outcome: "ok", + }, ]); expect(finish!.events.map(({ name, attributes }) => [name, attributes])).toEqual([ ["chord.retry", { backoff: 1_000 }], diff --git a/packages/chord/test/jobs/persisted.test.ts b/packages/chord/test/jobs/persisted.test.ts index 101a88391..ee24bc7b6 100644 --- a/packages/chord/test/jobs/persisted.test.ts +++ b/packages/chord/test/jobs/persisted.test.ts @@ -304,7 +304,14 @@ describe("trace (slice 9)", () => { const phases = spans.filter(({ name }) => name === "chord.job"); const [intent] = phases; expect([intent!.attributes, intent!.parentId]).toEqual([ - { job: "notes.import", phase: "intent", object: "notes.store", outcome: "ok", "job.id": run.id }, + { + "chord.host": "client", + job: "notes.import", + phase: "intent", + object: "notes.store", + outcome: "ok", + "job.id": run.id, + }, request.id, ]); // The run cut off by the crash (never settled), the rerun and the finish: one trace, children of the intent. @@ -435,8 +442,8 @@ describe("a seal during the reservation (review regressions)", () => { first = false; const holding = { admit: () => fence.admit(), - adopt: (seq: number, ids: readonly number[]) => { - fence.adopt(seq, ids); + adopt: (seq: number, ids: readonly number[], rows: number) => { + fence.adopt(seq, ids, rows); void jobs.store.take(() => {}).then(held.resolve); }, }; diff --git a/packages/chord/test/node/fixtures/web/telemetry.ts b/packages/chord/test/node/fixtures/web/telemetry.ts new file mode 100644 index 000000000..a7390d8f8 --- /dev/null +++ b/packages/chord/test/node/fixtures/web/telemetry.ts @@ -0,0 +1,53 @@ +// The web entry of the page telemetry test (the base bundle): `startPage` recording (`telemetry: true`), and a button +// whose click writes a project's note on its worker in a root span of the page (`host.root`'s `trace`), so the trace +// runs page → server → worker. + +import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts"; +import * as chord from "../../../../src/index.ts"; +import { startPage } from "../../../../src/web/index.ts"; +import * as webApi from "./api.ts"; + +const { hostKind, method, remote, str, unknown, int } = chord; + +/** The notes plugin's contract (`NOTES_PLUGIN` of the app tests), declared again: contracts are data. */ +const Notes = remote("test.notes", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => hostKind("project", { id: str() })({ id }), + variants: { + main: { + state: { text: str(), start: unknown() }, + methods: { + write: method(str()), + visit: method(str(), int()), + attach: method(str(), str()), + file: method(str(), str()), + }, + }, + }, +}); + +/** What the test reads from the page. */ +const probe = globalThis as unknown as { written?: number }; + +void startPage({ + connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`), + modules: { "@test/app/v1": chord, "@test/app/v1/web": webApi }, + telemetry: true, + render: (host) => { + const notes = host.hold(Notes, { id: "p1" }, BACKGROUND_CONTEXT); + const button = document.createElement("button"); + button.id = "write"; + button.textContent = "write"; + button.addEventListener("click", () => { + void host + .root((ctx) => notes.write("from the page", ctx), BACKGROUND_CONTEXT, { trace: { name: "page.write" } }) + .then(() => { + probe.written = (probe.written ?? 0) + 1; + }); + }); + const panels = document.createElement("div"); + panels.id = "panels"; + document.getElementById("root")!.append(panels, button); + }, +}); diff --git a/packages/chord/test/node/http/http.test.ts b/packages/chord/test/node/http/http.test.ts index 9c1c6bb08..1336a1a8e 100644 --- a/packages/chord/test/node/http/http.test.ts +++ b/packages/chord/test/node/http/http.test.ts @@ -992,8 +992,8 @@ describe("request spans (slice 9)", () => { await server.close(); const requests = telemetry.getSpans().filter(({ name }) => name === "chord.http"); expect(requests.map(({ attributes, parentId, status }) => [attributes, parentId, status.status])).toEqual([ - [{ method: "GET", route: "GET /items/:id", status: 200 }, null, "ok"], - [{ method: "GET", route: "GET /broken", status: 500 }, null, "error"], + [{ "chord.host": "client", method: "GET", route: "GET /items/:id", status: 200 }, null, "ok"], + [{ "chord.host": "client", method: "GET", route: "GET /broken", status: 500 }, null, "error"], ]); expect(JSON.stringify(telemetry.getSpans())).not.toContain("secret-id"); const lookup = telemetry.getSpans().find(({ name }) => name === "app.lookup")!; diff --git a/packages/chord/test/node/web/page.test.ts b/packages/chord/test/node/web/page.test.ts index 468af5a3a..3c95b326a 100644 --- a/packages/chord/test/node/web/page.test.ts +++ b/packages/chord/test/node/web/page.test.ts @@ -4,9 +4,14 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderTrace } from "@earendil-works/pi-telemetry/render"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; -import { Browser, CHROMIUM } from "./browser.ts"; +import { openNodeSqliteDatabase } from "../../../src/sqlite/node.ts"; +import { createSqliteTelemetry } from "../../../src/telemetry-sqlite/index.ts"; +import { NOTES_PLUGIN, tempDir } from "../app/support.ts"; +import { Browser, CHROMIUM, poll } from "./browser.ts"; import { freePort, panelPlugin, signIn, versionedEntry, webApp } from "./support.ts"; describe.skipIf(CHROMIUM === undefined)("a page", () => { @@ -80,4 +85,47 @@ describe.skipIf(CHROMIUM === undefined)("a page", () => { await second.app.close(context); } }, 120_000); + + it("records a click as the root of one trace through the server into the worker, stored by the server", async () => { + const telemetry = await createSqliteTelemetry(await openNodeSqliteDatabase(join(await tempDir(), "t.sqlite"))); + const entry = fileURLToPath(new URL("../fixtures/web/telemetry.ts", import.meta.url)); + const { app } = await webApp(entry, await freePort(), { + plugins: { notes: NOTES_PLUGIN }, + withWorkers: true, + telemetry, + }); + try { + await signIn(browser, app.ownerLink()); + await browser.evaluate("document.getElementById('write').click()"); + await browser.wait("globalThis.written === 1"); + // The page sends every few seconds, the worker every second, the server writes every second. + let spans: Awaited>["traces"][number]["spans"] = []; + await poll( + async () => { + await telemetry.flush(); + spans = (await telemetry.findTraces({ name: "page.write" })).traces[0]?.spans ?? []; + return spans.some(({ attributes }) => attributes.type === "test.store"); + }, + 30_000, + "the click's trace", + ); + const tree = renderTrace( + spans.map((span) => ({ ...span, attributes: { host: span.attributes["chord.host"] ?? "?" } })), + ).replaceAll(/ \(\+?[\d.]+ms\)/g, ""); + expect(tree.split("\n").filter((line) => !line.includes("·"))).toEqual([ + 'page.write host="browser"', + ' chord.call host="browser"', + ' chord.relay host="server"', + ' chord.method host="project|p1"', + ' chord.method host="project|p1"', + ]); + const [click] = spans; + expect(click!.id).toMatch(/^browser:[0-9a-f-]{36}:\d+$/); + expect(click!.attributes["chord.principal"]).toBe('{"kind":"owner"}'); + expect(new Set(spans.map(({ traceId }) => traceId))).toEqual(new Set([click!.id])); + } finally { + await app.close(context); + await telemetry.close(); + } + }, 120_000); }); diff --git a/packages/chord/test/node/web/support.ts b/packages/chord/test/node/web/support.ts index 0ae6ede63..939a9b1b7 100644 --- a/packages/chord/test/node/web/support.ts +++ b/packages/chord/test/node/web/support.ts @@ -6,8 +6,9 @@ import { writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import type { AppOptions } from "../../../src/node/app/index.ts"; import { pageScripts } from "../../../src/node/surfaces/index.ts"; -import { tempDir, testApp } from "../app/support.ts"; +import { type TestPrincipal, tempDir, testApp } from "../app/support.ts"; import type { Browser } from "./browser.ts"; /** The fixture's web entry. */ @@ -58,6 +59,8 @@ export function webApp( readonly dataDir?: string; readonly root?: string; readonly plugins?: Record>; + readonly withWorkers?: boolean; + readonly telemetry?: AppOptions["telemetry"]; } = {}, ) { return testApp({ diff --git a/packages/chord/test/remote/provider.test.ts b/packages/chord/test/remote/provider.test.ts index dbb529b5c..64dd630f5 100644 --- a/packages/chord/test/remote/provider.test.ts +++ b/packages/chord/test/remote/provider.test.ts @@ -1050,10 +1050,12 @@ describe("telemetry at the provider (slice 9)", () => { ); expect(methods.map(({ parentId }) => parentId)).toEqual([caller.id, null, null]); expect(methods[0]!.attributes).toEqual({ + "chord.host": "server", type: "app.notes", member: "add", object: "app.notes(S1)", "principal.kind": "owner", + "chord.principal": '{"kind":"owner"}', outcome: "ok", }); }); diff --git a/packages/chord/test/remote/remote-object.test.ts b/packages/chord/test/remote/remote-object.test.ts index 8c18e6792..da62c7e4e 100644 --- a/packages/chord/test/remote/remote-object.test.ts +++ b/packages/chord/test/remote/remote-object.test.ts @@ -870,6 +870,7 @@ describe("consumer spans (slice 9)", () => { expect(held.trace).toEqual(hold.context); expect(call.trace).toEqual(chordCall.context); expect(hold.attributes).toEqual({ + "chord.host": "client", type: "app.notes", object: "app.notes(S1)", "address.kind": "server", diff --git a/packages/chord/test/sqlite/node.test.ts b/packages/chord/test/sqlite/node.test.ts index 8666b3f5d..21523defa 100644 --- a/packages/chord/test/sqlite/node.test.ts +++ b/packages/chord/test/sqlite/node.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { describe, expect, it, vi } from "vitest"; import type { SqliteExecutor } from "../../src/sqlite/index.ts"; @@ -199,6 +202,37 @@ describe("Node SQLite driver", () => { }); }); +describe("read-only connections", () => { + it("read a file another connection writes, in deferred transactions, and never write it", async () => { + const dir = mkdtempSync(join(tmpdir(), "chord-sqlite-")); + const file = join(dir, "data.sqlite"); + try { + const writer = await openNodeSqliteDatabase(file); + await writer.exec("CREATE TABLE probe (value INTEGER)"); + await writer.run("INSERT INTO probe VALUES (1)"); + const reader = await openNodeSqliteDatabase(file, { mode: "read-only" }); + const exec = vi.spyOn(DatabaseSync.prototype, "exec"); + // One snapshot per transaction: a write committed meanwhile shows in the next one. + expect( + await reader.transaction(async (tx) => { + const before = await tx.all("SELECT value FROM probe"); + await writer.run("INSERT INTO probe VALUES (2)"); + return [before, await tx.all("SELECT value FROM probe")]; + }), + ).toEqual([[{ value: 1 }], [{ value: 1 }]]); + expect(exec.mock.calls.map(([sql]) => sql)).toEqual(["BEGIN", "COMMIT"]); + expect(await reader.all("SELECT value FROM probe")).toEqual([{ value: 1 }, { value: 2 }]); + await expect(reader.run("INSERT INTO probe VALUES (3)")).rejects.toThrow(/readonly/); + exec.mockRestore(); + await reader.close(); + await writer.close(); + await expect(openNodeSqliteDatabase(join(dir, "missing.sqlite"), { mode: "read-only" })).rejects.toThrow(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("the outcome of a transaction (slice 6b D4)", () => { /** A facade on `:memory:` whose next `statement` throws (and a `ROLLBACK` still rolls back). */ function faulty(statement?: "BEGIN IMMEDIATE" | "COMMIT" | "ROLLBACK") { diff --git a/packages/chord/test/telemetry-ingest/facet.test.ts b/packages/chord/test/telemetry-ingest/facet.test.ts new file mode 100644 index 000000000..60085bdbb --- /dev/null +++ b/packages/chord/test/telemetry-ingest/facet.test.ts @@ -0,0 +1,189 @@ +// Tests of the server's telemetry ingest (decisions "Telemetry from browser pages", "The server is the one place +// telemetry is stored", "Who a span was for"): a page's batch is checked by its schema (size, every field, no control +// characters), its spans must carry the page's own ids, are capped per page and minute, marked `chord.host: browser` +// and stamped with the link's principal; a worker's batch is written as it is, stamped with its host; nobody else +// gets the worker's `ingest`; the calls record no span of their own. + +import { InMemoryTelemetryContext, type StoredSpan } from "@earendil-works/pi-telemetry"; +import { describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts"; +import { hostKind } from "../../src/contracts/index.ts"; +import { isChordError, type Principal } from "../../src/data/index.ts"; +import { createHost, type Host } from "../../src/host/index.ts"; +import { memoryLinkPair } from "../../src/link/index.ts"; +import { + PAGE_BATCH_SPANS, + PAGE_SPANS_PER_MINUTE, + PageTelemetry, + type TelemetryBatch, + telemetryIngestFacet, + WorkerTelemetry, +} from "../../src/telemetry-ingest/index.ts"; +import { testCodec, transported } from "../support.ts"; + +const Server = hostKind("server"); + +/** A server serving the ingest facet (its batches collected), and a client of `principal` linked to it. */ +async function ingesting(principal: Principal, workers: Readonly> = {}) { + const batches: TelemetryBatch[] = []; + const telemetry = new InMemoryTelemetryContext(); + const server = createHost({ address: Server(), principal: { kind: "server" }, telemetry, onError: () => {} }); + const facet = telemetryIngestFacet({ + target: { + ingest: (batch) => { + batches.push(batch); + return batch.spans.length; + }, + }, + workerHost: (who) => workers[JSON.stringify(who)], + principalAttributes: (who) => ({ "chord.principal": JSON.stringify(who), "user.name": "Ada" }), + }); + await server.load([{ name: "chord.telemetry", facet }], context); + const client = createHost({ onError: () => {} }); + await client.load([], context); + const [a, b] = memoryLinkPair(); + server.accept(a, { principal, codec: testCodec() }, context); + client.connect(b, { principal: { kind: "server" }, codec: testCodec() }, context); + await transported(); + return { server, client, batches, telemetry }; +} + +/** A page span of `host` numbered `n` (its own ids), with `override`. */ +function span(host: Host, n: number, override: Partial = {}): StoredSpan { + const id = `browser:${host.incarnation}:${n}`; + return { + id, + traceId: `browser:${host.incarnation}:1`, + ...(n === 1 ? {} : { parentId: `browser:${host.incarnation}:1` }), + name: "page.click", + startedMs: 1000, + endedMs: 1002, + attributes: {}, + events: [], + links: [], + status: { status: "ok" }, + ...override, + }; +} + +/** The page's `ingest` of `spans` over `client`'s link. */ +async function ingest(client: Host, spans: StoredSpan[]) { + const page = await client.get(PageTelemetry, {}, context); + return page.ingest({ spans }, context); +} + +describe("a page's batches", () => { + it("writes its own spans marked `chord.host: browser` with its link's principal, no attribute of its overriding", async () => { + const { client, batches } = await ingesting({ kind: "owner" }); + const spans = [ + span(client, 1, { attributes: { "chord.host": "server", "chord.principal": "root" } }), + span(client, 2), + ]; + expect(await ingest(client, spans)).toEqual({ written: 2, dropped: 0 }); + expect(batches[0]!.spans.map(({ attributes }) => attributes)).toEqual([ + { "chord.host": "browser", "chord.principal": '{"kind":"owner"}', "user.name": "Ada" }, + { "chord.host": "browser", "chord.principal": '{"kind":"owner"}', "user.name": "Ada" }, + ]); + expect(batches[0]!.metrics).toEqual([]); + }); + + it("drops spans posing as another sender's or the server's: ids, traces and parents not under its own prefix", async () => { + const { client, batches } = await ingesting({ kind: "owner" }); + const server = "0193b2c4-1111-7000-8000-000000000000:7"; + const spans = [ + span(client, 2), + span(client, 3, { id: server }), + span(client, 4, { traceId: server }), + span(client, 5, { parentId: server }), + span(client, 6, { id: "browser:someone-else:6" }), + span(client, 7, { startedMs: 2000, endedMs: 1000 }), + ]; + expect(await ingest(client, spans)).toEqual({ written: 1, dropped: 5 }); + expect(batches[0]!.spans.map(({ id }) => id)).toEqual([spans[0]!.id]); + }); + + it.each([ + [ + "more spans than a batch takes", + (host: Host) => Array.from({ length: PAGE_BATCH_SPANS + 1 }, (_, n) => span(host, n + 2)), + ], + ["a name with a control character", (host: Host) => [span(host, 1, { name: "\u001b]0;PWNED\u0007" })]], + ["an id with a space", (host: Host) => [span(host, 1, { id: `browser:${host.incarnation}: 1` })]], + ["a 129-character name", (host: Host) => [span(host, 1, { name: "n".repeat(129) })]], + ["a 257-character attribute", (host: Host) => [span(host, 1, { attributes: { text: "t".repeat(257) } })]], + ["an attribute key with a control character", (host: Host) => [span(host, 1, { attributes: { "a\u0000": 1 } })]], + [ + "33 attributes", + (host: Host) => [ + span(host, 1, { attributes: Object.fromEntries(Array.from({ length: 33 }, (_, n) => [`k${n}`, n])) }), + ], + ], + ["an array attribute", (host: Host) => [span(host, 1, { attributes: { tags: ["a"] } })]], + [ + "17 events", + (host: Host) => [ + span(host, 1, { events: Array.from({ length: 17 }, () => ({ name: "e", timeMs: 0, attributes: {} })) }), + ], + ], + [ + "a 501-character error message", + (host: Host) => [ + span(host, 1, { status: { status: "error", error: { name: "x", message: "m".repeat(501) } } }), + ], + ], + ])("refuses a batch with %s as invalid arguments, writing nothing", async (_, spans) => { + const { client, batches } = await ingesting({ kind: "owner" }); + const error = await ingest(client, spans(client)).catch((thrown: unknown) => thrown); + expect(isChordError(error, "invalid")).toBe(true); + expect(batches).toEqual([]); + }); + + it("caps a page's spans per minute: past the budget they are dropped and counted", async () => { + const { client, batches } = await ingesting({ kind: "owner" }); + let written = 0; + let dropped = 0; + for (let batch = 0; batch * PAGE_BATCH_SPANS < PAGE_SPANS_PER_MINUTE + PAGE_BATCH_SPANS; batch++) { + const spans = Array.from({ length: PAGE_BATCH_SPANS }, (_, n) => + span(client, batch * PAGE_BATCH_SPANS + n + 2), + ); + const receipt = await ingest(client, spans); + written += receipt.written; + dropped += receipt.dropped; + } + expect([written, dropped]).toEqual([PAGE_SPANS_PER_MINUTE, PAGE_BATCH_SPANS]); + expect(batches.flatMap(({ spans }) => spans)).toHaveLength(PAGE_SPANS_PER_MINUTE); + }); + + it("records no span for an ingest call", async () => { + const { client, telemetry } = await ingesting({ kind: "owner" }); + await ingest(client, [span(client, 1)]); + expect(telemetry.getSpans().map(({ name }) => name)).not.toContain("chord.method"); + }); +}); + +describe("a worker's batches", () => { + it("writes a worker's spans and metrics as they are, stamped with its host; anyone else has nothing to call", async () => { + const worker = { kind: "worker", key: "project|p1" }; + const { client, batches } = await ingesting(worker, { [JSON.stringify(worker)]: "project|p1" }); + const held = await client.get(WorkerTelemetry, {}, context); + if (held.variant !== "worker") throw new Error("expected the worker variant"); + const own = { ...span(client, 1), id: "w:1", traceId: "w:1", attributes: { tags: ["a", "b"] } }; + const metric = { + intervalMs: 10_000, + name: "chord.link.bytes", + kind: "counter", + attributes: { direction: "sent" }, + count: 2, + sum: 10, + min: 4, + max: 6, + } as const; + expect(await held.ingest({ spans: [own], metrics: [metric] }, context)).toEqual({ written: 1, dropped: 0 }); + expect(batches[0]).toEqual({ + spans: [{ ...own, attributes: { tags: ["a", "b"], "chord.host": "project|p1" } }], + metrics: [metric], + }); + const { client: page } = await ingesting({ kind: "owner" }); + expect((await page.get(WorkerTelemetry, {}, context)).variant).toBe("other"); + }); +}); diff --git a/packages/chord/test/telemetry-ingest/recorder.test.ts b/packages/chord/test/telemetry-ingest/recorder.test.ts new file mode 100644 index 000000000..88a4f3a1b --- /dev/null +++ b/packages/chord/test/telemetry-ingest/recorder.test.ts @@ -0,0 +1,113 @@ +// Tests of Chord's telemetry recorder: settled spans and metric aggregates kept for its owner, taken as batches; ids +// under a scope read at the first span; the queue bounded with a taken batch counted until released; ingested spans +// and metrics join the queue; a stop makes later spans inert. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TelemetryRecorder } from "../../src/telemetry-ingest/index.ts"; + +afterEach(() => vi.restoreAllMocks()); + +describe("recording", () => { + it("keeps settled spans only, its ids under the scope it reads at its first span, carriers its own", async () => { + let scope = "early"; + const recorder = new TelemetryRecorder(() => scope, 100); + scope = "browser:inc"; + const gate = Promise.withResolvers(); + const open = recorder.startSpan({ name: "open" }, () => gate.promise); + let carrier: Record | undefined; + await recorder.startSpan({ name: "root" }, (span) => { + carrier = recorder.inject(span); + return span.startSpan({ name: "child" }, () => {}); + }); + const { spans } = recorder.take(false); + expect(spans.map(({ id, parentId, name }) => [id, parentId, name])).toEqual([ + ["browser:inc:3", "browser:inc:2", "child"], + ["browser:inc:2", undefined, "root"], + ]); + expect(carrier).toEqual({ "pi-sqlite-span": "browser:inc:2", "pi-sqlite-trace": "browser:inc:2" }); + gate.resolve(); + await open; + expect(recorder.take(false).spans.map(({ name }) => name)).toEqual(["open"]); + }); + + it("aggregates metrics per series and 10 s, taking only intervals that are over unless partial", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(20_001); + const recorder = new TelemetryRecorder(() => "s", 100); + recorder.metrics.counter("hits").add(2, { b: 1, a: true }); + recorder.metrics.counter("hits").add(3, { a: true, b: 1 }); + expect(recorder.take(false).metrics).toEqual([]); + now.mockReturnValue(30_001); + expect(recorder.take(false).metrics).toEqual([ + { + intervalMs: 20_000, + name: "hits", + kind: "counter", + attributes: { a: true, b: 1 }, + count: 2, + sum: 5, + min: 2, + max: 3, + }, + ]); + recorder.metrics.counter("hits").add(1); + expect(recorder.take(true).metrics.map(({ intervalMs, sum }) => [intervalMs, sum])).toEqual([[30_000, 1]]); + }); +}); + +describe("the queue", () => { + it("counts a taken batch against its bound until released, dropping and counting past it", async () => { + const recorder = new TelemetryRecorder(() => "s", 2); + await recorder.startSpan({ name: "a" }, () => {}); + await recorder.startSpan({ name: "b" }, () => {}); + const taken = recorder.take(false, 1); + expect(taken.spans.map(({ name }) => name)).toEqual(["a"]); + await recorder.startSpan({ name: "c" }, () => {}); + expect(recorder.droppedSpans).toBe(1); + recorder.release(); + await recorder.startSpan({ name: "d" }, () => {}); + expect(recorder.take(false).spans.map(({ name }) => name)).toEqual(["b", "d"]); + }); + + it("queues ingested spans and merges ingested metric rows within its bounds", () => { + const recorder = new TelemetryRecorder(() => "s", 1); + const span = { + id: "w:1", + traceId: "w:1", + name: "x", + startedMs: 1, + endedMs: 2, + attributes: {}, + events: [], + links: [], + status: { status: "ok" }, + } as const; + const metric = { + intervalMs: 0, + name: "m", + kind: "counter", + attributes: {}, + count: 1, + sum: 2, + min: 2, + max: 2, + } as const; + expect(recorder.ingest({ spans: [span, { ...span, id: "w:2" }], metrics: [metric, metric] })).toBe(1); + expect(recorder.droppedSpans).toBe(1); + const batch = recorder.take(true); + expect(batch.spans.map(({ id }) => id)).toEqual(["w:1"]); + expect(batch.metrics).toEqual([{ ...metric, count: 2, sum: 4 }]); + }); + + it("records nothing once stopped; a discarding stop drops what waits", async () => { + const recorder = new TelemetryRecorder(() => "s", 10); + await recorder.startSpan({ name: "kept" }, () => {}); + recorder.stop("keep"); + await recorder.startSpan({ name: "late" }, () => {}); + recorder.metrics.counter("late").add(1); + expect(recorder.take(true)).toEqual({ spans: [expect.objectContaining({ name: "kept" })], metrics: [] }); + const discarding = new TelemetryRecorder(() => "s", 10); + await discarding.startSpan({ name: "gone" }, () => {}); + discarding.stop("discard"); + expect(discarding.take(true)).toEqual({ spans: [], metrics: [] }); + }); +}); diff --git a/packages/chord/test/telemetry-ingest/sender.test.ts b/packages/chord/test/telemetry-ingest/sender.test.ts new file mode 100644 index 000000000..f65929ca4 --- /dev/null +++ b/packages/chord/test/telemetry-ingest/sender.test.ts @@ -0,0 +1,98 @@ +// Tests of the senders: a page's sends its spans (never its metrics) to the server's `PageTelemetry`, a worker's its +// spans and metrics to `WorkerTelemetry`; nothing is sent (or lost) while the object is not bound, a refused batch is +// given up. + +import { describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts"; +import { hostKind } from "../../src/contracts/index.ts"; +import type { Principal } from "../../src/data/index.ts"; +import { createHost } from "../../src/host/index.ts"; +import { memoryLinkPair } from "../../src/link/index.ts"; +import { + sendPageTelemetry, + sendWorkerTelemetry, + type TelemetryBatch, + TelemetryRecorder, + telemetryIngestFacet, +} from "../../src/telemetry-ingest/index.ts"; +import { testCodec, transported } from "../support.ts"; + +const Server = hostKind("server"); + +/** A server with the ingest facet (its batches collected) and a client of `principal` recording to `recorder`. */ +async function sending(principal: Principal, scope: (incarnation: string) => string) { + const batches: TelemetryBatch[] = []; + const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); + const facet = telemetryIngestFacet({ + target: { + ingest: (batch) => { + batches.push(batch); + return batch.spans.length; + }, + }, + workerHost: (who) => (who.kind === "worker" ? "project|p1" : undefined), + principalAttributes: () => ({}), + }); + await server.load([{ name: "chord.telemetry", facet }], context); + let incarnation = ""; + const recorder = new TelemetryRecorder(() => scope(incarnation), 100); + const client = createHost({ telemetry: recorder, onError: () => {} }); + incarnation = client.incarnation; + await client.load([], context); + const connect = () => { + const [a, b] = memoryLinkPair(); + server.accept(a, { principal, codec: testCodec() }, context); + client.connect(b, { principal: { kind: "server" }, codec: testCodec() }, context); + return a; + }; + return { batches, recorder, client, connect }; +} + +describe("a page's sender", () => { + it("sends its spans without metrics once bound; until then they wait in the recorder", async () => { + const { batches, recorder, client, connect } = await sending({ kind: "owner" }, (inc) => `browser:${inc}`); + const sender = sendPageTelemetry(client, recorder, context); + await recorder.startSpan({ name: "page.click" }, () => {}); + recorder.metrics.counter("clicks").add(1); + await sender.send(true); + expect(batches).toEqual([]); + connect(); + await transported(); + await sender.send(true); + expect(batches.map(({ spans, metrics }) => [spans.map(({ name }) => name), metrics])).toEqual([ + [["page.click"], []], + ]); + expect(recorder.queuedSpans).toBe(0); + // Nothing waits: no call. + await sender.send(true); + expect(batches).toHaveLength(1); + sender.stop(); + }); + + it("gives a refused batch up", async () => { + // Ids the server refuses: not under the page's prefix. + const { batches, recorder, client, connect } = await sending({ kind: "owner" }, () => "elsewhere"); + connect(); + const sender = sendPageTelemetry(client, recorder, context); + await transported(); + await recorder.startSpan({ name: "page.click" }, () => {}); + await sender.send(false); + expect(batches.map(({ spans }) => spans)).toEqual([[]]); + expect(recorder.queuedSpans).toBe(0); + }); +}); + +describe("a worker's sender", () => { + it("sends spans and metrics to the worker variant", async () => { + const { batches, recorder, client, connect } = await sending({ kind: "worker" }, () => "w"); + connect(); + const sender = sendWorkerTelemetry(client, recorder, context); + await transported(); + await recorder.startSpan({ name: "worker.job" }, () => {}); + recorder.metrics.counter("runs").add(2); + await sender.send(true); + const [batch] = batches; + expect(batch!.spans.map(({ name }) => name)).toEqual(["worker.job"]); + expect(batch!.metrics.find(({ name }) => name === "runs")?.sum).toBe(2); + }); +}); diff --git a/packages/chord/test/telemetry-sqlite-node.test.ts b/packages/chord/test/telemetry-sqlite-node.test.ts new file mode 100644 index 000000000..4c3cdd20e --- /dev/null +++ b/packages/chord/test/telemetry-sqlite-node.test.ts @@ -0,0 +1,72 @@ +// Tests of the read-only SQLite telemetry store on Node (`openTelemetryStore`): it reads what recorders of the file +// write, while they write, never writes the file, and refuses a file without a store or with one of another version. + +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { openNodeSqliteDatabase } from "../src/sqlite/node.ts"; +import { createSqliteTelemetry } from "../src/telemetry-sqlite/index.ts"; +import { openTelemetryStore } from "../src/telemetry-sqlite/node.ts"; + +let dir = ""; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "chord-telemetry-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +/** A recorder on `file` of its own database. */ +async function recorder(file: string) { + const db = await openNodeSqliteDatabase(file); + const telemetry = await createSqliteTelemetry(db); + return { + telemetry, + close: async () => { + await telemetry.close(); + await db.close(); + }, + }; +} + +describe("the read-only store", () => { + it("reads the traces and metrics recorders wrote, while one still records, and never writes the file", async () => { + const file = join(dir, "telemetry.sqlite"); + const writing = await recorder(file); + await writing.telemetry.startSpan({ name: "first" }, (span) => span.startSpan({ name: "child" }, () => {})); + writing.telemetry.metrics.counter("hits").add(2); + await writing.telemetry.flush(); + const store = await openTelemetryStore(file); + const { traces } = await store.findTraces({ name: "first" }); + expect(traces.map(({ spans }) => spans.map(({ name }) => name))).toEqual([["first", "child"]]); + expect(await store.getTrace(traces[0]!.traceId)).toEqual(traces[0]); + // The recorder goes on: the reader sees each flush. + await writing.telemetry.startSpan({ name: "second" }, () => {}); + await writing.close(); + expect((await store.findTraces()).traces.map(({ spans }) => spans[0]!.name)).toEqual(["second", "first"]); + expect((await store.findMetrics({ name: "hits" })).map(({ sum }) => sum)).toEqual([2]); + const before = readFileSync(file); + await store.findTraces({ limit: 100 }); + await store.close(); + expect(readFileSync(file).equals(before)).toBe(true); + await expect(store.findTraces()).rejects.toThrow(); + }); + + it("refuses a missing file, a file without a store and a store of another version", async () => { + await expect(openTelemetryStore(join(dir, "missing.sqlite"))).rejects.toThrow(); + const other = join(dir, "other.sqlite"); + const db = await openNodeSqliteDatabase(other); + await db.exec("CREATE TABLE notes (id INTEGER)"); + await db.close(); + await expect(openTelemetryStore(other)).rejects.toThrow("not a telemetry store"); + const old = join(dir, "old.sqlite"); + await (await recorder(old)).close(); + const downgraded = await openNodeSqliteDatabase(old); + await downgraded.run("UPDATE telemetry_schema SET version = 1"); + await downgraded.close(); + await expect(openTelemetryStore(old)).rejects.toThrow("telemetry store version 1, not 2"); + const newer = await openNodeSqliteDatabase(old); + await newer.run("UPDATE telemetry_schema SET version = 3"); + await newer.close(); + await expect(openTelemetryStore(old)).rejects.toThrow("telemetry store version 3 is newer than this reader's 2"); + }); +}); diff --git a/packages/chord/test/telemetry-sqlite.test.ts b/packages/chord/test/telemetry-sqlite.test.ts index cd1589a35..30ae15034 100644 --- a/packages/chord/test/telemetry-sqlite.test.ts +++ b/packages/chord/test/telemetry-sqlite.test.ts @@ -16,8 +16,8 @@ type SpanRow = { trace_id: string; parent_id: string | null; name: string; - started_ms: number; - ended_ms: number; + started_us: number; + ended_us: number; attributes: string; events: string; links: string; @@ -109,6 +109,89 @@ describe("SQLite telemetry buffering and aggregation", () => { ]); }); + it("stores span times in whole microseconds of telemetryNow, read back as milliseconds", async () => { + vi.spyOn(Date, "now").mockReturnValue(20_001); + const clock = vi.spyOn(performance, "now").mockReturnValue(500); + await using f = await fixture(); + await f.telemetry.startSpan({ name: "root" }, async (span) => { + clock.mockReturnValue(500.125); + await span.startSpan({ name: "child" }, () => { + clock.mockReturnValue(500.5); + span.addEvent("half", {}); + }); + }); + await f.telemetry.flush(); + expect( + await f.db.all("SELECT name, started_us, ended_us, events FROM telemetry_spans ORDER BY started_us"), + ).toEqual([ + { + name: "root", + started_us: 20_001_000, + ended_us: 20_001_500, + events: '[{"name":"half","time_ms":0.5,"attributes":{}}]', + }, + { name: "child", started_us: 20_001_125, ended_us: 20_001_500, events: "[]" }, + ]); + const { traces } = await f.telemetry.findTraces({ startMin: 20_001, startMax: 20_001 }); + expect(traces[0]!.spans.map(({ startedMs, endedMs }) => [startedMs, endedMs])).toEqual([ + [20_001, 20_001.5], + [20_001.125, 20_001.5], + ]); + }); + + it("migrates a store of version 1 (milliseconds) to microseconds, its rows kept", async () => { + const db = await openNodeSqliteDatabase(":memory:"); + try { + // Version 1 as it shipped: its tables and one span. + await db.exec(`CREATE TABLE telemetry_schema (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + version INTEGER NOT NULL CHECK (version >= 0)) STRICT`); + await db.run("INSERT INTO telemetry_schema VALUES (1, 1)"); + await db.exec(`CREATE TABLE telemetry_spans (id TEXT PRIMARY KEY, trace_id TEXT NOT NULL, parent_id TEXT, + name TEXT, started_ms REAL, ended_ms REAL NOT NULL, attributes TEXT, events TEXT, links TEXT, status TEXT)`); + await db.exec("CREATE INDEX telemetry_spans_started_ms ON telemetry_spans(started_ms)"); + await db.exec(`CREATE TABLE telemetry_metrics (interval_ms INTEGER, series_key TEXT, name TEXT, kind TEXT, + unit TEXT, attributes TEXT, count INTEGER, sum REAL, min REAL, max REAL, PRIMARY KEY(interval_ms, series_key))`); + await db.run( + "INSERT INTO telemetry_spans VALUES ('old:1', 'old:1', NULL, 'old', 1000, 1002.5, '{}', '[]', '[]', '{\"status\":\"ok\"}')", + ); + const telemetry = await createSqliteTelemetry(db); + expect(await db.get("SELECT version FROM telemetry_schema")).toEqual({ version: 2 }); + expect(await db.all("SELECT started_us, ended_us FROM telemetry_spans")).toEqual([ + { started_us: 1_000_000, ended_us: 1_002_500 }, + ]); + expect( + (await telemetry.getTrace("old:1"))?.spans.map(({ startedMs, endedMs }) => [startedMs, endedMs]), + ).toEqual([[1000, 1002.5]]); + await telemetry.close(); + } finally { + await db.close(); + } + }); + + it("writes ingested spans and metric rows with its own, never replacing a stored span", async () => { + vi.spyOn(Date, "now").mockReturnValue(20_001); + await using f = await fixture(); + await f.telemetry.startSpan({ name: "own" }, () => {}); + await f.telemetry.flush(); + const [own] = (await f.telemetry.findTraces({ name: "own" })).traces[0]!.spans; + const span = { ...own!, id: "w:1", traceId: "w:1", name: "worker", startedMs: 1000.5, endedMs: 1001.25 }; + const metric = { + intervalMs: 10_000, + name: "hits", + kind: "counter", + attributes: { b: 1, a: 2 }, + count: 2, + sum: 6, + min: 2, + max: 4, + } as const; + expect(f.telemetry.ingest({ spans: [span, { ...own!, name: "impostor" }], metrics: [metric] })).toBe(2); + await f.telemetry.flush(); + expect((await f.telemetry.getTrace("w:1"))?.spans).toEqual([span]); + expect((await f.telemetry.getTrace(own!.traceId))?.spans.map(({ name }) => name)).toEqual(["own"]); + expect(await f.telemetry.findMetrics({ name: "hits" })).toEqual([{ ...metric, attributes: { a: 2, b: 1 } }]); + }); + it("does not execute SQL while recording and stores only settled spans", async () => { await using f = await fixture(); const transaction = vi.spyOn(f.db, "transaction"); @@ -129,7 +212,7 @@ describe("SQLite telemetry buffering and aggregation", () => { active.addEvent("late"); await f.telemetry.flush(); const row = await f.db.get("SELECT * FROM telemetry_spans"); - expect(row?.ended_ms).toBeGreaterThanOrEqual(row?.started_ms ?? Infinity); + expect(row?.ended_us).toBeGreaterThanOrEqual(row?.started_us ?? Infinity); expect(JSON.parse(row?.events ?? "[]")).toEqual([{ name: "begin", time_ms: expect.any(Number), attributes: {} }]); }); diff --git a/packages/chord/test/telemetry.test.ts b/packages/chord/test/telemetry.test.ts index edc9d1deb..6d31daeea 100644 --- a/packages/chord/test/telemetry.test.ts +++ b/packages/chord/test/telemetry.test.ts @@ -126,7 +126,7 @@ describe("carriers", () => { describe("open spans", () => { it("ends once, with how its work settled: ok, a cancel (status ok), or an error with its category", async () => { const telemetry = new InMemoryTelemetryContext(); - const attributes = { type: "t", member: "m", object: "o", "principal.kind": "none" }; + const attributes = { "chord.host": "client", type: "t", member: "m", object: "o", "principal.kind": "none" }; const ok = new OpenSpan(telemetry, "chord.method", attributes); ok.endWhen(Promise.resolve(1), "method_failed", undefined); const aborted = new OpenSpan(telemetry, "chord.method", attributes); @@ -225,6 +225,32 @@ describe("across hosts", () => { await telemetry.close(); }); + it("names the host that recorded each span: its address key, `client` without an address", async () => { + const { telemetry, tab } = await store(); + await telemetry.startSpan({ name: "test" }, async (span) => { + const context = withTelemetry(span, BACKGROUND_CONTEXT); + const notes = await tab.get(PlacedNotes, { session: "S1" }, context); + if (notes.variant !== "editor") throw new Error("expected the editor"); + await notes.add({ text: "a" }, context); + }); + await transported(); + await telemetry.flush(); + const { traces } = await telemetry.findTraces({ limit: 100 }); + const spans = traces.flatMap((trace) => trace.spans).filter(({ name }) => name !== "test"); + const hosts = new Map>(); + for (const { name, attributes } of spans) + hosts.set(name, (hosts.get(name) ?? new Set()).add(attributes["chord.host"])); + expect(Object.fromEntries([...hosts].map(([name, of]) => [name, [...of].sort()]))).toEqual({ + "chord.activate": ["session|S1"], + "chord.call": ["client"], + "chord.hold": ["client"], + "chord.method": ["session|S1"], + "chord.relay": ["server"], + "chord.resolve": ["server"], + }); + await telemetry.close(); + }); + it("holds through the relay under the tab's span; the worker's start and its activations link to their triggers", async () => { const { telemetry, tab } = await store(); await telemetry.startSpan({ name: "test" }, async (span) => { diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index fa95b46aa..824b50113 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `telemetryNow()`, the span clock: wall-clock milliseconds at microsecond resolution from `performance.now()` anchored to `Date.now()`, anchored anew when the two part by more than 10 ms. The in-memory adapter stamps span starts with it (span times rounded to the microsecond), and `renderTrace` prints lengths of time to the microsecond. + ### Fixed - `renderTrace` escapes control characters (C0, C1, DEL) as `\uXXXX` in span and event names, trace and span ids of links, attribute keys and error names and messages, so ids taken from another process's carrier never reach a terminal or viewer raw. diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index 6432d095a..b0f8b3270 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -29,12 +29,16 @@ export interface SpanOptions { export type SpanStatus = { status: "ok" } | { status: "error"; error?: { name: string; message: string } }; -/** A settled span snapshot, independent of the recording adapter. Event times are relative to span start. */ +/** + * A settled span snapshot, independent of the recording adapter. Times are milliseconds at microsecond resolution; + * event times are relative to span start. + */ export interface StoredSpan { readonly id: string; readonly traceId: string; readonly parentId?: string; readonly name: string; + /** Wall-clock milliseconds since the epoch (`telemetryNow`). */ readonly startedMs: number; readonly endedMs: number; readonly attributes: SpanAttributes; @@ -462,4 +466,4 @@ export type { RecordedTelemetryMeasurement, RecordedTelemetrySpan, } from "./memory.ts"; -export { InMemoryTelemetryContext } from "./memory.ts"; +export { InMemoryTelemetryContext, telemetryNow } from "./memory.ts"; diff --git a/packages/telemetry/src/memory.ts b/packages/telemetry/src/memory.ts index b59cb1639..0ce7e6699 100644 --- a/packages/telemetry/src/memory.ts +++ b/packages/telemetry/src/memory.ts @@ -94,6 +94,37 @@ function carrierId(carrier: Readonly): string { return `${carrier["pi-memory-scope"]}:${carrier["pi-memory-span"]}`; } +// The clock of span times: wall-clock milliseconds at microsecond resolution, from the monotonic high-resolution +// clock anchored to the wall clock (`telemetryNow`, also Chord's SQLite store's). + +/** + * How far the anchored clock may part from `Date.now()` before it is anchored anew: more than `Date.now()`'s own + * truncation to whole milliseconds, less than any step worth following (a wall clock set, a sleep the monotonic clock + * did not count). + */ +const TOLERANCE_MS = 10; + +/** The wall-clock time of `performance.now()`'s zero; `performance.timeOrigin` until the first step is followed. */ +let anchor = performance.timeOrigin; + +/** + * Now as milliseconds since the epoch, rounded to the microsecond: `performance.now()` (monotonic, sub-millisecond) + * plus an anchor to the wall clock. While the two agree within 10 ms, times in one process keep their order and + * spacing to the microsecond; past that the clock follows `Date.now()` again, at whole milliseconds. Every adapter + * of this package and Chord's SQLite store stamp span starts with it, so their times agree. + */ +export function telemetryNow(): number { + const clock = performance.now(); + const wall = Date.now(); + if (Math.abs(anchor + clock - wall) > TOLERANCE_MS) anchor = wall - clock; + return microseconds(anchor + clock); +} + +/** `ms` rounded to the microsecond, as span times are. */ +function microseconds(ms: number): number { + return Math.round(ms * 1000) / 1000; +} + // Identity survives settlement without exposing backend IDs on the generic context contract. const contextCarriers = new WeakMap(); @@ -141,7 +172,7 @@ function createSpan( } const id = state.nextSpanId++; return { - startedMs: Date.now(), + startedMs: telemetryNow(), clock: performance.now(), id, parentId: parent?.localId ?? null, @@ -212,7 +243,7 @@ async function startInMemorySpan( if (recordedSpan.settled) return; recordedSpan.events.push({ name, - timeMs: performance.now() - recordedSpan.clock, + timeMs: microseconds(performance.now() - recordedSpan.clock), attributes: copyAttributes(attributes), }); }, @@ -240,7 +271,7 @@ async function startInMemorySpan( throw error; } finally { recordedSpan.settled = true; - recordedSpan.endedMs = recordedSpan.startedMs + performance.now() - recordedSpan.clock; + recordedSpan.endedMs = microseconds(recordedSpan.startedMs + performance.now() - recordedSpan.clock); recordedSpan.endSequence = state.nextEndSequence++; } } diff --git a/packages/telemetry/src/render.ts b/packages/telemetry/src/render.ts index a99f6ff90..c83cbb2c8 100644 --- a/packages/telemetry/src/render.ts +++ b/packages/telemetry/src/render.ts @@ -9,6 +9,11 @@ function escaped(text: string): string { return text.replace(CONTROL, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`); } +/** `ms` rounded to the microsecond, as the adapters record span times. */ +function microseconds(ms: number): number { + return Math.round(ms * 1000) / 1000; +} + function formatAttributes(attributes: Readonly = {}): string { return Object.entries(attributes) .map(([key, value]) => ` ${escaped(key)}=${JSON.stringify(value)}`) @@ -16,8 +21,9 @@ function formatAttributes(attributes: Readonly = {}): string { } /** - * Render stored spans as a chronological forest, treating absent parents as roots. Control characters in ids, names, - * attribute keys and error messages are escaped (attribute values are JSON). + * Render stored spans as a chronological forest, treating absent parents as roots. Lengths of time are milliseconds to + * the microsecond. Control characters in ids, names, attribute keys and error messages are escaped (attribute values + * are JSON). */ export function renderTrace(spans: readonly StoredSpan[]): string { const byId = new Map(spans.map((span) => [span.id, span])); @@ -46,10 +52,12 @@ export function renderTrace(spans: readonly StoredSpan[]): string { } } lines.push( - `${indent}${escaped(span.name)}${formatAttributes(span.attributes)} (${span.endedMs - span.startedMs}ms)${error}`, + `${indent}${escaped(span.name)}${formatAttributes(span.attributes)} (${microseconds(span.endedMs - span.startedMs)}ms)${error}`, ); for (const event of span.events) { - lines.push(`${indent} · ${escaped(event.name)}${formatAttributes(event.attributes)} (+${event.timeMs}ms)`); + lines.push( + `${indent} · ${escaped(event.name)}${formatAttributes(event.attributes)} (+${microseconds(event.timeMs)}ms)`, + ); } for (const link of span.links) { const ids = `trace ${escaped(link.traceId)} span ${escaped(link.spanId)}`; diff --git a/packages/telemetry/test/render.test.ts b/packages/telemetry/test/render.test.ts index 82afb383a..5be9a60b8 100644 --- a/packages/telemetry/test/render.test.ts +++ b/packages/telemetry/test/render.test.ts @@ -141,6 +141,15 @@ describe("renderTrace", () => { expect(rendered.endsWith(`${" ".repeat(1999)}1999 (10ms)`)).toBe(true); }); + it("renders lengths of time to the microsecond, as the adapters record them", () => { + const root = span("root", { + startedMs: 1_793_789_452_816.123, + endedMs: 1_793_789_452_816.431, + events: [{ name: "half", timeMs: 0.15400000002, attributes: {} }], + }); + expect(renderTrace([root])).toBe("root (0.308ms)\n · half (+0.154ms)"); + }); + it("renders an empty input as an empty string", () => { expect(renderTrace([])).toBe(""); }); diff --git a/packages/telemetry/test/store.test.ts b/packages/telemetry/test/store.test.ts index d59d45cad..b1b381e59 100644 --- a/packages/telemetry/test/store.test.ts +++ b/packages/telemetry/test/store.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { InMemoryTelemetryContext, type TelemetrySpan } from "../src/index.ts"; +import { InMemoryTelemetryContext, type TelemetrySpan, telemetryNow } from "../src/index.ts"; import { telemetryStoreConformance } from "./store-conformance.ts"; afterEach(() => vi.restoreAllMocks()); @@ -42,6 +42,23 @@ describe("in-memory store snapshots", () => { expect(telemetry.getSpans()[0]).not.toHaveProperty("startedMs"); }); + it("stamps starts with telemetryNow: spans of one millisecond apart to the microsecond, in their order", async () => { + vi.spyOn(Date, "now").mockReturnValue(1000); + const clock = vi.spyOn(performance, "now").mockReturnValue(100); + const telemetry = new InMemoryTelemetryContext(); + await telemetry.startSpan({ name: "first" }, () => { + clock.mockReturnValue(100.25); + }); + await telemetry.startSpan({ name: "second" }, () => { + clock.mockReturnValue(100.5); + }); + const spans = (await telemetry.findTraces()).traces.flatMap((trace) => trace.spans); + expect(spans.map(({ name, startedMs, endedMs }) => [name, startedMs, endedMs])).toEqual([ + ["second", 1000.25, 1000.5], + ["first", 1000, 1000.25], + ]); + }); + it("maps raw metric measurements to count-one rows without exposing private timing in getMetrics", async () => { vi.spyOn(Date, "now").mockReturnValue(20_001); const telemetry = new InMemoryTelemetryContext(); @@ -80,3 +97,32 @@ describe("in-memory store snapshots", () => { expect(fresh.status).toEqual({ status: "error", error: { name: "Expected", message: "original" } }); }); }); + +describe("telemetryNow", () => { + it("counts sub-millisecond steps of the monotonic clock while the wall clock stays in its millisecond", () => { + vi.spyOn(Date, "now").mockReturnValue(50_000); + const clock = vi.spyOn(performance, "now").mockReturnValue(1000.25); + const first = telemetryNow(); + clock.mockReturnValue(1000.2504); + expect(telemetryNow() - first).toBeCloseTo(0, 6); + clock.mockReturnValue(1000.7512); + expect(telemetryNow() - first).toBeCloseTo(0.501, 6); + expect(Number.isInteger(telemetryNow() * 1000)).toBe(true); + }); + + it("follows a step of the wall clock (set, or a sleep the monotonic clock did not count) past 10 ms", () => { + // Far from the anchor any earlier call left: anchored anew, at the wall clock's millisecond. + const wall = vi.spyOn(Date, "now").mockReturnValue(80_000); + const clock = vi.spyOn(performance, "now").mockReturnValue(1000); + expect(telemetryNow()).toBe(80_000); + clock.mockReturnValue(1003.5); + wall.mockReturnValue(80_005); + // Within 10 ms the anchored clock keeps its own spacing. + expect(telemetryNow()).toBe(80_003.5); + clock.mockReturnValue(1004); + wall.mockReturnValue(3_650_004); + expect(telemetryNow()).toBe(3_650_004); + clock.mockReturnValue(1004.125); + expect(telemetryNow()).toBe(3_650_004.125); + }); +});