From 0efe50cb6dde31b19fb5931c96db0e7562b788e2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 4 Nov 2026 10:40:17 +0000 Subject: [PATCH] docs(chord): tutorial chapter on telemetry, with a small trace viewer --- packages/chord/docs/tutorial/book.md | 434 ++++++++++++++++-- packages/chord/docs/tutorial/index.html | 431 +++++++++++++++-- .../chord/docs/tutorial/screens/traces.png | Bin 0 -> 81083 bytes .../chord/test/examples/61-tutorial/main.ts | 80 +++- .../61-tutorial/plugins/lists/worker.ts | 9 +- .../61-tutorial/plugins/traces/package.json | 5 + .../61-tutorial/plugins/traces/server.ts | 119 +++++ .../test/examples/61-tutorial/screenshots.ts | 58 ++- .../examples/61-tutorial/src/api/v1/server.ts | 8 +- .../examples/61-tutorial/src/telemetry.ts | 35 ++ .../test/examples/61-tutorial/src/web/main.ts | 6 +- .../examples/61-tutorial/src/web/style.css | 21 + .../examples/61-tutorial/src/web/traces.ts | 147 ++++++ .../chord/test/examples/61-tutorial/start.ts | 23 +- .../chord/test/examples/61-tutorial/trace.ts | 39 ++ .../examples/61-tutorial/tutorial.test.ts | 5 +- .../chord/test/examples/61-tutorial/worker.ts | 7 +- 17 files changed, 1344 insertions(+), 83 deletions(-) create mode 100644 packages/chord/docs/tutorial/screens/traces.png create mode 100644 packages/chord/test/examples/61-tutorial/plugins/traces/package.json create mode 100644 packages/chord/test/examples/61-tutorial/plugins/traces/server.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/telemetry.ts create mode 100644 packages/chord/test/examples/61-tutorial/src/web/traces.ts create mode 100644 packages/chord/test/examples/61-tutorial/trace.ts diff --git a/packages/chord/docs/tutorial/book.md b/packages/chord/docs/tutorial/book.md index f2e4b2f66..941541e05 100644 --- a/packages/chord/docs/tutorial/book.md +++ b/packages/chord/docs/tutorial/book.md @@ -2,9 +2,9 @@ title: Together: build a shared todo app with Chord cover: Build It / Together kicker: A tutorial · Chord v2 and its app kit · 2026-11-03 -subtitle: One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages and an edit applied while they are connected +subtitle: One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages, an edit applied while they are connected, and one click traced across its processes stats: - - 10 | steps, one runnable example + - 11 | steps, one runnable example - 4 | host kinds one plugin runs on - 1 | worker process per list - 0 | restarts for a new theme, a language or an edit @@ -41,7 +41,7 @@ node --conditions=source --experimental-strip-types test/examples/61-tutorial/ma With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one, that step says so and the rest runs. -## Nine things to know +## Ten things to know :::findings 1. **An app is its API, its plugins and a start script.** `startServer` makes the data directory, the store, the @@ -61,8 +61,11 @@ 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. **Five 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. +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. +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. ::: ## Words used throughout @@ -76,7 +79,8 @@ are the member sets different principals get. A **factory** declares an object's (values computed from others), **natives** (things opened and closed, such as a file or a database cursor). A **reference** is what asking for an object returns. A **facet** is a plugin's code for one host kind; a **registry** is an extension point plugins **contribute** to. A **message** is a text with a key, a **catalog** a plugin's messages in one language, a **locale** a language tag (`de`, `pt-BR`). A **rollout** applies -new plugin code to the hosts in order. +new plugin code to the hosts in order. A **span** is one timed piece of work a host recorded, a **trace** the spans of +one request across hosts, joined by the **carriers** calls carry. # One Server {#server lede="The API, the principals and a start script: the smallest app the kit runs, and who may come in."} @@ -212,7 +216,12 @@ export const KINDS = { ```ts -export function startTodo(dataDir: string, root: string, context: Context): Promise> { +export function startTodo( + dataDir: string, + root: string, + telemetry: TodoTelemetry, + context: Context, +): Promise> { return startServer( { name: "todo", @@ -222,17 +231,21 @@ export function startTodo(dataDir: string, root: string, context: Context): Prom 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`). + telemetry: telemetry.store, 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", }, ], - web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page }, + // The page, and the owner's trace viewer: the same page, which shows the viewer at `/traces`. + web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page, paths: ["/", "/traces"] }, tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) }, onError: (error) => console.error("todo:", error.kind, error.error), }, @@ -241,18 +254,22 @@ export function startTodo(dataDir: string, root: string, context: Context): Prom } ``` -`workers` is chapter II, `web` chapter IV, `tui` chapter V. `listen` asks for a free port on loopback, as the example -does; a real app picks a port. The worker entry is three lines: +`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): ```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. +// 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. import { startWorker } from "../../../src/node/app/index.ts"; +import { openTelemetry } from "./src/telemetry.ts"; import { API, KINDS } from "./start.ts"; -startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) }); +const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!); +startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) }); ``` ## Running it @@ -262,7 +279,8 @@ link: ```ts -const app = await startTodo(dataDir, root, context); +const telemetry = await openTelemetry(telemetryFile(dataDir)); +const app = await startTodo(dataDir, root, telemetry, context); const link = new URL(app.ownerLink()); const url = link.origin; console.log("1. owner link:", `http://127.0.0.1:${link.pathname}#`); @@ -281,14 +299,16 @@ console.log(" GET / signed in:", (await fetch(url, { headers: { cookie } })).s ```text 1. owner link: http://127.0.0.1:/_chord/owner# - data directory: logs, owner-token, plugins, server, workers + data directory: logs, owner-token, plugins, server, telemetry.sqlite, telemetry.sqlite-shm, telemetry.sqlite-wal, workers GET / signed out: 401 the owner signs in: 200 chord-session=… GET / signed in: 200 ``` `startServer` made the data directory: `server/` (its store, the cookie secret, its files), `workers/` (one directory -per worker), `plugins/` (the user's plugin root, beside the app's built-in one), `logs/` and `owner-token`. The page +per worker), `plugins/` (the user's plugin root, beside the app's built-in one), `logs/` and `owner-token`; +`telemetry.sqlite` (with SQLite's `-shm` and `-wal` files) is the app's own, opened before the server starts +(chapter XI). The page answered 401: HTTP **denies by default**, before any plugin code runs, unless a route says `public: true`. The **owner link** (`/_chord/owner#`) is how the owner gets in: a single-use token, valid ten minutes, made at @@ -376,6 +396,7 @@ 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", @@ -383,7 +404,8 @@ it on to its parent or to a route (design §3, "Host kinds and addresses"). The ], ``` -`authorize` runs for every request routed to a list before anything starts; the first request for an address starts +`env` names the app's telemetry store for the worker (chapter XI). `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. @@ -463,8 +485,11 @@ else in memory, not even the next todo's number, which it reads in the transacti ```ts add: async ({ text, by }, ctx) => { - // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. - if (text === "") throw invalid("args", "a todo needs text"); + // A span of its own under the method's: a trace shows the check, and why it refused. + await ctx.trace("todos.validate", { chars: text.length }, async () => { + // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. + if (text === "") throw invalid("args", "a todo needs text"); + }); await self.tx(async (tx) => { // The number after the newest todo's: the store's transactions run one after another. const { items: last } = await tx.page(items, NEWEST, 1); @@ -475,7 +500,7 @@ else in memory, not even the next todo's number, which it reads in the transacti A todo without text is refused with `invalid("args", …)`, from the API: the error crosses every link as it is, so a caller on a page or in a terminal gets `{ code: "invalid", reason: "args" }`, as for arguments that fail the -contract's schema. +contract's schema. The check runs in a span of its own (`ctx.trace`), which chapter XI shows in a trace. ## The view per principal @@ -663,6 +688,8 @@ const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) = function Page() { const panels = useRegistry(v1web.Panels) ?? []; + // 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)); return h( "main", null, @@ -679,8 +706,9 @@ void startPage({ }); ``` -`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 path `/traces` is the owner's trace viewer (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 @@ -1415,6 +1443,339 @@ 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."} + +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 +piece of work with attributes), **events** on spans, and **metrics** (counters and histograms). Each host has one +**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 + +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: + + +```ts +/** The telemetry store's file in the data directory `dataDir`. */ +export function telemetryFile(dataDir: string): string { + return join(dataDir, "telemetry.sqlite"); +} + +/** Opens the telemetry store at `file` for this process (its directory made, private, when missing). */ +export async function openTelemetry(file: string): Promise { + // Spans carry who called what: the data directory is private before the store's first file appears in it. + await mkdir(dirname(file), { recursive: true, mode: 0o700 }); + const db = await openNodeSqliteDatabase(file); + const store = await createSqliteTelemetry(db); + return { + store, + close: async () => { + await store.close(); + await db.close(); + }, + }; +} +``` + +`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: + + +```ts + // The server's spans and metrics; each list's worker opens the same file (`worker.ts`). + 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) }, +``` + + +```ts +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: + + +```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"); +``` + +`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): + + +```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 +``` + +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. + +:::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" +page -> page: page.add, chord.call +page => server: call add, carrier of chord.call +server -> server: chord.relay +server => worker: call #0, carrier of chord.relay +worker -> worker: chord.method ×2, todos.validate, commit +worker --> server: reply +server --> page: reply +::: + +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). + +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 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. + +## A span of your own + +The store's `add` checks the text in a span of its own: + + +```ts + await ctx.trace("todos.validate", { chars: text.length }, async () => { + // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. + 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. + +## 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: + + +```ts + if (id !== undefined) { + const trace = await telemetry.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 }); + 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; + const failed = spans.some((span) => span.status.status === "error") ? " error" : ""; + console.log(`${traceId} ${spans[0]!.name} ${spans.length} spans ${length.toFixed(2)} ms${failed}`); + } + // 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 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): + + +```text + trace.ts --name page.add: + 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 +``` + +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. + +## 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: + + +```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. + const telemetry = self.open( + async () => openTelemetry(telemetryFile(info.dataDir.value)), + (opened) => opened.close(), + ); + self.contribute(Http, { + routes: { + "GET /api/traces": async (_request, ctx) => { + ownerOnly(ctx); + const { traces } = await telemetry.value.store.findTraces({ limit: RECENT }); + 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!); + if (trace === undefined) return { kind: "error", status: 404 }; + return { kind: "json", value: { id: trace.traceId, spans: rowsOf(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: + + +```ts +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: + + +```ts +function processOf(span: StoredSpan): string { + return span.id.slice(0, span.id.lastIndexOf(":")); +} +``` + +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: + + +```ts + // 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: + + +```ts + 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: + + +```ts + await browser.open(`${origin}/traces#${encodeURIComponent(trace.traceId)}`); + await browser.wait("document.querySelectorAll('.traces-tree li').length === 6"); + await shoot("traces.png"); +``` + + +```text +wrote docs/tutorial/screens/traces.png +the viewer shows page.add: 6 spans on 3 hosts +``` + +:::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. +![The trace viewer](screens/traces.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. + # Testing One Plugin {#testing lede="testPlugin builds a plugin as production does and loads it alone on a host of its own."} The steps above test the whole app. Most rules are checked faster on one plugin. `testPlugin` builds the plugin as @@ -1618,13 +1979,15 @@ p2 with the owner's Reload button or `npm run reload -- scratchpad`; a new plugi reload. The trap of chapter II applies with p2's names: a server method that must reach a session's worker as the server runs in a root of the server's own (`serverOf(self)().root`), not through its caller's `ctx.get`. -# Traps and Open Questions {.epilogue lede="The five traps once more, and what in Chord v2 was hard to explain while writing this."} +# Traps and Open Questions {.epilogue lede="The six traps once more, and what in Chord v2 was hard to explain while writing this."} **The traps.** Load before use: a host binds nothing before `load`; a client with nothing to load calls `load([])`. A reference acts as whoever made it: `ctx.get` as the caller, `host.get` and a local object's `self.get` as the host. Await what you start: a write started with a context that ends is cancelled and reported `transaction_cancelled`. A watch starts at the current value: read `handle.value`, then `start`; the listener gets changes only. The plain `t` -has no language of its own: components and screens render with `useMessages(t)` and `scope.messages(t)`. +has no language of its own: components and screens render with `useMessages(t)` and `scope.messages(t)`. A trace +starts where a span starts: work under a context without a recording span records nothing, and its provider's span is +a root of its own. **Found while writing the example, and fixed in Chord since.** Integer table keys were typed `string` (a leftover of the switch from TypeBox), so `tx.update(items, { id }, ...)` with a number failed `tsc`; the store now uses it. A @@ -1634,6 +1997,15 @@ 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. + # The App's Files {.appendix} | File | Kind | What it is | @@ -1641,17 +2013,21 @@ plugin (now `self.query`, a live query Chord runs, chapters II and IX). | `src/principals.ts` | base | the principal type | | `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` | +| `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/tui.ts` | base, tui | `Screens` | -| `start.ts`, `worker.ts` | base | `startServer`, `startWorker` | -| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels | +| `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` | +| `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 | | `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 | +| `trace.ts` | example | a data directory's newest traces, one trace, a metric | # Reference {.appendix} @@ -1675,4 +2051,8 @@ plugin (now `self.query`, a live query Chord runs, chapters II and IX). | `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 | -| `testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)` | XI | one plugin alone | +| `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 | +| `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 782e64b66..7a1569a3b 100644 --- a/packages/chord/docs/tutorial/index.html +++ b/packages/chord/docs/tutorial/index.html @@ -111,8 +111,8 @@ table{max-width:100%}
A tutorial · Chord v2 and its app kit · 2026-11-03

Build It
Together

-

One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages and an edit applied while they are connected

-
10steps, one runnable example
4host kinds one plugin runs on
1worker process per list
0restarts for a new theme, a language or an edit
+

One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages, an edit applied while they are connected, and one click traced across its processes

+
11steps, one runnable example
4host kinds one plugin runs on
1worker process per list
0restarts for a new theme, a language or an edit

The app is packages/chord/test/examples/61-tutorial/, runnable with the line in its main.ts. Every code block below is quoted from its files, and every console output from what its steps print: test/examples/61-tutorial/tutorial.test.ts fails when a quote or an output drifts. The design is @@ -132,8 +132,9 @@ chapter is about p2, whose code lives outside this repository.

  • VIII. Languages
  • IX. Changing Code
  • X. Moving a List
  • -
  • XI. Testing One Plugin
  • -
  • XII. Writing a p2 Plugin
  • +
  • XI. Seeing What Happened
  • +
  • XII. Testing One Plugin
  • +
  • XIII. Writing a p2 Plugin
  • Traps and Open Questions
  • Appendix A: The App's Files
  • Appendix B: Reference
  • @@ -156,7 +157,7 @@ states the rule behind it. The app is one directory, test/examples/61-tuto
    node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts

    With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one, that step says so and the rest runs.

    -

    Nine things to know

    +

    Ten things to know

    1. An app is its API, its plugins and a start script. startServer makes the data directory, the store, the server host, HTTP with sign-in, the workers and the plugin roots; startWorker does a worker's half.
    2. @@ -175,8 +176,11 @@ plugins contribute, one plugin (or the entry) composes. server, the workers, the pages and the terminals, in place.
    3. 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.
    4. -
    5. Five 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.
    6. +
    7. 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.
    8. +
    9. 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.

    Words used throughout

    A host is one participant of the app, with an address if others can reach it (the server, List({ id })) or @@ -188,7 +192,8 @@ are the member sets different principals get. A factory declare (values computed from others), natives (things opened and closed, such as a file or a database cursor). A reference is what asking for an object returns. A facet is a plugin's code for one host kind; a registry is an extension point plugins contribute to. A message is a text with a key, a catalog a plugin's messages in one language, a locale a language tag (de, pt-BR). A rollout applies -new plugin code to the hosts in order.

    +new plugin code to the hosts in order. A span is one timed piece of work a host recorded, a trace the spans of +one request across hosts, joined by the carriers calls carry.

    I

    One Server

    The API, the principals and a start script: the smallest app the kit runs, and who may come in.

    @@ -295,7 +300,12 @@ facet kinds (which files a plugin may have), and the principals:

    tui: { platform: "node" }, } as const;
    -
    export function startTodo(dataDir: string, root: string, context: Context): Promise<App<TodoPrincipal>> {
    +
    export function startTodo(
    +	dataDir: string,
    +	root: string,
    +	telemetry: TodoTelemetry,
    +	context: Context,
    +): Promise<App<TodoPrincipal>> {
     	return startServer<TodoPrincipal>(
     		{
     			name: "todo",
    @@ -305,38 +315,47 @@ 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`). + telemetry: telemetry.store, 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", }, ], - web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page }, + // The page, and the owner's trace viewer: the same page, which shows the viewer at `/traces`. + web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page, paths: ["/", "/traces"] }, tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) }, onError: (error) => console.error("todo:", error.kind, error.error), }, context, ); }
    -

    workers is chapter II, web chapter IV, tui chapter V. listen asks for a free port on loopback, as the example -does; a real app picks a port. The worker entry is three lines:

    +

    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):

    // 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.
    +// 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.
     
     import { startWorker } from "../../../src/node/app/index.ts";
    +import { openTelemetry } from "./src/telemetry.ts";
     import { API, KINDS } from "./start.ts";
     
    -startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) });
    +const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!); +startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, 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:

    -
    const app = await startTodo(dataDir, root, context);
    +
    const telemetry = await openTelemetry(telemetryFile(dataDir));
    +const app = await startTodo(dataDir, root, telemetry, context);
     const link = new URL(app.ownerLink());
     const url = link.origin;
     console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`);
    @@ -352,12 +371,14 @@ console.log("   the owner signs in:
     console.log("   GET / signed in:", (await fetch(url, { headers: { cookie } })).status);
    1. owner link: http://127.0.0.1:<port>/_chord/owner#<token>
    -   data directory: logs, owner-token, plugins, server, workers
    +   data directory: logs, owner-token, plugins, server, telemetry.sqlite, telemetry.sqlite-shm, telemetry.sqlite-wal, workers
        GET / signed out: 401
        the owner signs in: 200 chord-session=…
        GET / signed in: 200

    startServer made the data directory: server/ (its store, the cookie secret, its files), workers/ (one directory -per worker), plugins/ (the user's plugin root, beside the app's built-in one), logs/ and owner-token. The page +per worker), plugins/ (the user's plugin root, beside the app's built-in one), logs/ and owner-token; +telemetry.sqlite (with SQLite's -shm and -wal files) is the app's own, opened before the server starts +(chapter XI). The page answered 401: HTTP denies by default, before any plugin code runs, unless a route says public: true.

    The owner link (/_chord/owner#<token>) is how the owner gets in: a single-use token, valid ten minutes, made at each start. It sits in the URL's fragment, which browsers never send, so it never reaches a log; the link's page posts @@ -432,12 +453,14 @@ 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", }, ],

    -

    authorize runs for every request routed to a list before anything starts; the first request for an address starts +

    env names the app's telemetry store for the worker (chapter XI). 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.

    The worker facet

    @@ -499,8 +522,11 @@ when fn returns, or not at all if it throws. The live query follows else in memory, not even the next todo's number, which it reads in the transaction:

    			add: async ({ text, by }, ctx) => {
    -				// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
    -				if (text === "") throw invalid("args", "a todo needs text");
    +				// A span of its own under the method's: a trace shows the check, and why it refused.
    +				await ctx.trace("todos.validate", { chars: text.length }, async () => {
    +					// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
    +					if (text === "") throw invalid("args", "a todo needs text");
    +				});
     				await self.tx(async (tx) => {
     					// The number after the newest todo's: the store's transactions run one after another.
     					const { items: last } = await tx.page(items, NEWEST, 1);
    @@ -509,7 +535,7 @@ else in memory, not even the next todo's number, which it reads in the trans
     				return null;

    A todo without text is refused with invalid("args", …), from the API: the error crosses every link as it is, so a caller on a page or in a terminal gets { code: "invalid", reason: "args" }, as for arguments that fail the -contract's schema.

    +contract's schema. The check runs in a span of its own (ctx.trace), which chapter XI shows in a trace.

    The view per principal

    Todos is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:

    @@ -655,6 +681,8 @@ for them:

    function Page() { const panels = useRegistry(v1web.Panels) ?? []; + // 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)); return h( "main", null, @@ -669,8 +697,9 @@ for them:

    core: [{ name: "@todo/web", facet: core }], render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))), }); -

    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 path /traces is the owner's trace viewer (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

    plugins/lists/web.ts contributes a panel. Its component holds the list's Todos while it is mounted and re-renders when its items change:

    @@ -1251,7 +1280,249 @@ 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

    Testing One Plugin

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

    +
    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.

    +

    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 +piece of work with attributes), events on spans, and metrics (counters and histograms). Each host has one +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

    +

    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:

    + +
    /** The telemetry store's file in the data directory `dataDir`. */
    +export function telemetryFile(dataDir: string): string {
    +	return join(dataDir, "telemetry.sqlite");
    +}
    +
    +/** Opens the telemetry store at `file` for this process (its directory made, private, when missing). */
    +export async function openTelemetry(file: string): Promise<TodoTelemetry> {
    +	// Spans carry who called what: the data directory is private before the store's first file appears in it.
    +	await mkdir(dirname(file), { recursive: true, mode: 0o700 });
    +	const db = await openNodeSqliteDatabase(file);
    +	const store = await createSqliteTelemetry(db);
    +	return {
    +		store,
    +		close: async () => {
    +			await store.close();
    +			await db.close();
    +		},
    +	};
    +}
    +

    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:

    + +
    			// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).
    +			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) },
    + +
    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.
    +

    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).

    +

    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 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.

    +

    A span of your own

    +

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

    + +
    				await ctx.trace("todos.validate", { chars: text.length }, async () => {
    +					// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
    +					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.

    +

    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:

    + +
    	if (id !== undefined) {
    +		const trace = await telemetry.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 });
    +		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;
    +			const failed = spans.some((span) => span.status.status === "error") ? "  error" : "";
    +			console.log(`${traceId}  ${spans[0]!.name}  ${spans.length} spans  ${length.toFixed(2)} ms${failed}`);
    +		}
    +		// 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 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:
    +   <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
    +

    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.

    +

    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:

    + +
    	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.
    +		const telemetry = self.open(
    +			async () => openTelemetry(telemetryFile(info.dataDir.value)),
    +			(opened) => opened.close(),
    +		);
    +		self.contribute(Http, {
    +			routes: {
    +				"GET /api/traces": async (_request, ctx) => {
    +					ownerOnly(ctx);
    +					const { traces } = await telemetry.value.store.findTraces({ limit: RECENT });
    +					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!);
    +					if (trace === undefined) return { kind: "error", status: 404 };
    +					return { kind: "json", value: { id: trace.traceId, spans: rowsOf(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:

    + +
    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(":"));
    +}
    +

    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'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:

    + +
    	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:

    + +
    	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 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.
    +

    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.

    +
    + +
    XII

    Testing One Plugin

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

    The steps above test the whole app. Most rules are checked faster on one plugin. testPlugin builds the plugin as production builds it (the same esbuild call, the API's imports checked), evaluates it with the app's real API module and loads it on a new host with an in-memory store, here at a list's address so it may serve Todos:

    @@ -1306,7 +1577,7 @@ the refusal of chapter II:

    expect(refused).toMatchObject({ code: "invalid", reason: "args" });
    -
    XII

    Writing a p2 Plugin

    What is different in p2, and one small plugin: a tool for the agent and a chip for the people.

    +
    XIII

    Writing a p2 Plugin

    What is different in p2, and one small plugin: a tool for the agent and a chip for the people.

    p2, the coding agent's server with its web page and terminal, runs on Chord v2 but not on the app kit. It is older than the kit and keeps its own decisions: its own data directory and SQLite files, its own sign-in, devices and sharing, its own page and terminal bundles. It uses Chord's components directly: createHost with p2's principals, @@ -1449,12 +1720,14 @@ reload. The trap of chapter II applies with p2's names: a server method that server runs in a root of the server's own (serverOf(self)().root), not through its caller's ctx.get.

    -
    ∴

    Traps and Open Questions

    The five traps once more, and what in Chord v2 was hard to explain while writing this.

    +
    ∴

    Traps and Open Questions

    The six traps once more, and what in Chord v2 was hard to explain while writing this.

    The traps. Load before use: a host binds nothing before load; a client with nothing to load calls load([]). A reference acts as whoever made it: ctx.get as the caller, host.get and a local object's self.get as the host. Await what you start: a write started with a context that ends is cancelled and reported transaction_cancelled. A watch starts at the current value: read handle.value, then start; the listener gets changes only. The plain t -has no language of its own: components and screens render with useMessages(t) and scope.messages(t).

    +has no language of its own: components and screens render with useMessages(t) and scope.messages(t). A trace +starts where a span starts: work under a context without a recording span records nothing, and its provider's span is +a root of its own.

    Found while writing the example, and fixed in Chord since. Integer table keys were typed string (a leftover of the switch from TypeBox), so tx.update(items, { id }, ...) with a number failed tsc; the store now uses it. A reloaded web plugin lost its own React state (now usePageState, @@ -1462,6 +1735,14 @@ 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.

    A

    The App's Files

    @@ -1491,7 +1772,7 @@ plugin (now self.query, a live query Chord runs, chapters II and IX src/api/v1/server.ts base, server -Http, the ephemeral login +Http, the ephemeral login, AppInfo, openTelemetry src/api/v1/web.ts @@ -1506,12 +1787,22 @@ plugin (now self.query, a live query Chord runs, chapters II and IX start.ts, worker.ts base -startServer, startWorker +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 +startPage, the panels, the viewer at /traces + + +src/web/traces.ts +base, web entry +the owner's trace viewer src/tui/main.ts @@ -1534,6 +1825,11 @@ plugin (now self.query, a live query Chord runs, chapters II and IX the kit's ephemeral login +plugins/traces/ +plugin +the trace viewer's routes over the telemetry store (server), the owner's only + + later/harbor/ plugin, added in step 6 the Harbor theme for pages and terminals @@ -1543,6 +1839,11 @@ plugin (now self.query, a live query Chord runs, chapters II and IX example the steps, the screenshots, the checks + +trace.ts +example +a data directory's newest traces, one trace, a metric +
    @@ -1646,8 +1947,28 @@ plugin (now self.query, a live query Chord runs, chapters II and IX moving a worker's data -testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx) +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 + + +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 @@ -1655,5 +1976,51 @@ plugin (now self.query, a live query Chord runs, chapters II and IX

    Together · first edition · 2026-11-03 · Chord v2 slices 1-13, the app kit, example 61

    - + diff --git a/packages/chord/docs/tutorial/screens/traces.png b/packages/chord/docs/tutorial/screens/traces.png new file mode 100644 index 0000000000000000000000000000000000000000..567509d9f3a5740bce1c2ddbd10a78bf6661d870 GIT binary patch literal 81083 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.pathname}#`); @@ -242,16 +249,52 @@ 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:"); +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 :"); +printTelemetry(await read(dataDir, refusedTrace!.traceId)); + if (process.argv.includes("--serve")) { console.log(`Open ${app.ownerLink()} (Ctrl-C to stop); terminal: 57-tui's terminal.ts with ${app.ownerTokenFile}`); + console.log(`Traces: ${url}/traces, or trace.ts ${dataDir}`); } else { await terminal?.stop(); groceries.release(); adas.release(); await ada.close(context); await app.close(context); + await telemetry.close(); await rm(base, { recursive: true, force: true }); - console.log("11. closed"); + console.log("12. closed"); } /** Todos as one line: `[x] milk (owner), [ ] eggs (owner)`. */ @@ -283,6 +326,33 @@ function screen(written: readonly string[]): string { .replace(/\x1b\[[0-9;]*m/g, ""); } +/** The newest `count` traces whose first span is `name`, once each has a span named `last` (the server's flushed). */ +async function written( + store: TodoTelemetry["store"], + name: string, + count: number, + last: string, +): Promise { + for (;;) { + await store.flush(); + const { traces } = await store.findTraces({ name, limit: count }); + const complete = traces.filter(({ spans }) => spans.some((span) => span.name === last)); + if (complete.length === count) return traces; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +/** 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(/[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}`); +} + /** Whether this Node can run the terminal's sandbox. */ function sandboxAvailable(): boolean { try { diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts index b098a12df..6cb12734e 100644 --- a/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts @@ -3,7 +3,7 @@ // before the store goes live and again after every commit that writes the table. Each principal's `Todos` view adds // todos as that principal, and gives the owner `clear`, which leaves a notice: a message as data (`messages.message`), // never a sentence, so each viewer reads it in its own language. The store refuses a todo without text with Chord's -// `invalid/args`. +// `invalid/args`, in a span of its own (`ctx.trace`) that the app's traces show. import { arr, @@ -69,8 +69,11 @@ export default defineFacet((host) => { items: self.derive([newest], ([rows]) => [...rows].reverse()), notice: self.derive([notices], ([value]) => value.notice), add: async ({ text, by }, ctx) => { - // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. - if (text === "") throw invalid("args", "a todo needs text"); + // A span of its own under the method's: a trace shows the check, and why it refused. + await ctx.trace("todos.validate", { chars: text.length }, async () => { + // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. + if (text === "") throw invalid("args", "a todo needs text"); + }); await self.tx(async (tx) => { // The number after the newest todo's: the store's transactions run one after another. const { items: last } = await tx.page(items, NEWEST, 1); diff --git a/packages/chord/test/examples/61-tutorial/plugins/traces/package.json b/packages/chord/test/examples/61-tutorial/plugins/traces/package.json new file mode 100644 index 000000000..8c6cffd31 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/traces/package.json @@ -0,0 +1,5 @@ +{ + "name": "@todo/traces", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/examples/61-tutorial/plugins/traces/server.ts b/packages/chord/test/examples/61-tutorial/plugins/traces/server.ts new file mode 100644 index 000000000..63336c795 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/traces/server.ts @@ -0,0 +1,119 @@ +// 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. + +import { type Ctx, defineFacet, forbidden, local } from "@todo/app/v1"; +import { AppInfo, Http, openTelemetry, type StoredSpan, telemetryFile } from "@todo/app/v1/server"; + +/** 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: {} }); + +/** How many traces the list shows, newest first. */ +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. + const telemetry = self.open( + async () => openTelemetry(telemetryFile(info.dataDir.value)), + (opened) => opened.close(), + ); + self.contribute(Http, { + routes: { + "GET /api/traces": async (_request, ctx) => { + ownerOnly(ctx); + const { traces } = await telemetry.value.store.findTraces({ limit: RECENT }); + 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!); + if (trace === undefined) return { kind: "error", status: 404 }; + return { kind: "json", value: { id: trace.traceId, spans: rowsOf(trace.spans) } }; + }, + }, + }); + return {}; + }); +}); + +/** Refuses everyone but the owner: spans name who called what, and the route answers 403. */ +function ownerOnly(ctx: Ctx): void { + if (ctx.principal?.kind !== "owner") throw forbidden("the traces are the owner's"); +} + +/** 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[]) { + 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]!; + return { + id, + name: first.name, + detail: DETAIL.flatMap((key) => first.attributes[key] ?? []).join(" "), + spans: spans.length, + processes: new Set(spans.map(processOf)).size, + durationMs: Math.max(...spans.map((span) => span.endedMs)) - start, + error: spans.some((span) => span.status.status === "error"), + }; +} + +/** + * 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. + */ +function rowsOf(spans: readonly StoredSpan[]) { + const start = Math.min(...spans.map((span) => span.startedMs)); + const ids = new Set(spans.map((span) => span.id)); + // The store gives a trace's spans in start order, so each parent's children stay in it. + const children = new Map(); + for (const span of spans) { + const parent = span.parentId !== undefined && ids.has(span.parentId) ? span.parentId : undefined; + const siblings = children.get(parent); + if (siblings === undefined) children.set(parent, [span]); + 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); + }; + 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) { + const { status } = span; + return { + name: span.name, + depth, + process: processOf(span), + offsetMs: span.startedMs - start, + durationMs: span.endedMs - span.startedMs, + attributes: pairs(span.attributes), + 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}`), + error: status.status === "error" ? (status.error?.message ?? "failed") : null, + }; +} + +/** 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]) => { + const shown = typeof value === "number" && !Number.isInteger(value) ? value.toFixed(2) : JSON.stringify(value); + return `${key}=${shown}`; + }); +} diff --git a/packages/chord/test/examples/61-tutorial/screenshots.ts b/packages/chord/test/examples/61-tutorial/screenshots.ts index 9eae3dde3..ead0ca851 100644 --- a/packages/chord/test/examples/61-tutorial/screenshots.ts +++ b/packages/chord/test/examples/61-tutorial/screenshots.ts @@ -1,22 +1,27 @@ // 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, and the page after the owner chose French, with the list's notice. +// 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. // 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 { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; -import { LocaleChoice } from "../../../src/index.ts"; +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 { 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"; const out = resolve(process.argv[2] ?? "screens"); if (CHROMIUM === undefined) throw new Error("no Chromium: set CHROMIUM_BIN"); await mkdir(out, { recursive: true }); const { base, dataDir, root } = await todoDirectories(); -const app = await startTodo(dataDir, root, context); +const telemetry = await openTelemetry(telemetryFile(dataDir)); +const app = await startTodo(dataDir, root, telemetry, context); // The list as the tutorial leaves it after chapter 7: the owner's todos, one by the server, one by Ada. const as = async (principal: Parameters[2], texts: readonly string[]): Promise => { @@ -109,8 +114,53 @@ try { const lang = await browser.evaluate("document.documentElement.lang"); 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 () => { + 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; + }; + let trace = await honey(); + while (trace === undefined) { + await new Promise((done) => setTimeout(done, 100)); + trace = await honey(); + } + await browser.call("Emulation.setDeviceMetricsOverride", { + width: 900, + height: 640, + deviceScaleFactor: 1, + mobile: false, + }); + await browser.open(`${origin}/traces#${encodeURIComponent(trace.traceId)}`); + await browser.wait("document.querySelectorAll('.traces-tree li').length === 6"); + await shoot("traces.png"); + const hosts = await browser.evaluate( + "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`); } finally { await browser.close(); await app.close(context); + await telemetry.close(); await rm(base, { recursive: true, force: true }); } 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 ce3b171d9..43ec8e61c 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,10 +1,14 @@ -// The todo app's API v1 for server facets (`@todo/app/v1/server`): the HTTP registry plugins add routes to, and the -// kit's pick-a-name login as a facet a one-line plugin exports. +// 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. import { ephemeralLoginFacet } from "../../../../../../src/node/app/index.ts"; 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"; /** 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/telemetry.ts b/packages/chord/test/examples/61-tutorial/src/telemetry.ts new file mode 100644 index 000000000..e4e75e7c8 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/telemetry.ts @@ -0,0 +1,35 @@ +// 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. + +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { openNodeSqliteDatabase } from "../../../../src/sqlite/node.ts"; +import { createSqliteTelemetry, type SqliteTelemetry } from "../../../../src/telemetry-sqlite/index.ts"; + +/** The telemetry store as one process opened it: its recorder (an adapter and the reads) and its close. */ +export type TodoTelemetry = { + readonly store: SqliteTelemetry; + /** Flushes what the recorder still holds and closes the file. */ + close(): Promise; +}; + +/** The telemetry store's file in the data directory `dataDir`. */ +export function telemetryFile(dataDir: string): string { + return join(dataDir, "telemetry.sqlite"); +} + +/** Opens the telemetry store at `file` for this process (its directory made, private, when missing). */ +export async function openTelemetry(file: string): Promise { + // Spans carry who called what: the data directory is private before the store's first file appears in it. + await mkdir(dirname(file), { recursive: true, mode: 0o700 }); + const db = await openNodeSqliteDatabase(file); + const store = await createSqliteTelemetry(db); + return { + store, + close: async () => { + await store.close(); + await db.close(); + }, + }; +} 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 5e4af0f97..433fb3c6f 100644 --- a/packages/chord/test/examples/61-tutorial/src/web/main.ts +++ b/packages/chord/test/examples/61-tutorial/src/web/main.ts @@ -1,5 +1,6 @@ // The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and -// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute. +// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute; at +// `/traces`, the owner's trace viewer instead. import * as v1 from "@todo/app/v1"; import * as v1web from "@todo/app/v1/web"; @@ -11,12 +12,15 @@ import { defineFacet } from "../../../../../src/index.ts"; import { HostProvider, useRegistry } from "../../../../../src/react/index.ts"; import { startPage } from "../../../../../src/web/index.ts"; import "./style.css"; +import { TraceViewer } from "./traces.ts"; /** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */ const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) => table.map(({ value }) => value) })); function Page() { const panels = useRegistry(v1web.Panels) ?? []; + // 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)); return h( "main", null, 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 d7cc22856..60f3aa857 100644 --- a/packages/chord/test/examples/61-tutorial/src/web/style.css +++ b/packages/chord/test/examples/61-tutorial/src/web/style.css @@ -3,3 +3,24 @@ body { margin: 0; font: 15px system-ui, sans-serif; background: var(--bg); color 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. */ +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-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; } +.traces-ms { margin-left: auto; color: var(--muted); } +.traces-host { font-size: 0.7rem; padding: 0 0.3rem; border-radius: 3px; color: var(--bg); } +.traces-line { position: relative; height: 4px; margin: 0.2rem 0; } +.traces-bar { position: absolute; top: 0; height: 4px; border-radius: 2px; } +.traces-details { color: var(--muted); font: 0.72rem ui-monospace, monospace; overflow-wrap: anywhere; } +.traces-error, .traces-failed b { color: var(--error); } +.traces-link { font-style: italic; } +.traces-host-0 { background: var(--accent); } +.traces-host-1 { background: var(--success); } +.traces-host-2 { background: var(--warning); } +.traces-host-3 { background: var(--muted); } diff --git a/packages/chord/test/examples/61-tutorial/src/web/traces.ts b/packages/chord/test/examples/61-tutorial/src/web/traces.ts new file mode 100644 index 000000000..6a8ab0db7 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/src/web/traces.ts @@ -0,0 +1,147 @@ +// 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. + +import { createElement as h, useEffect, useState } from "react"; + +/** A trace in the list (`GET /api/traces`). */ +type Summary = { + readonly id: string; + readonly name: string; + readonly detail: string; + readonly spans: number; + readonly processes: number; + readonly durationMs: number; + readonly error: boolean; +}; + +/** A span of the tree (`GET /api/traces/:id`), parents before their children. */ +type Row = { + readonly name: string; + readonly depth: number; + readonly process: string; + readonly offsetMs: number; + readonly durationMs: number; + readonly attributes: readonly string[]; + readonly events: readonly string[]; + readonly links: readonly string[]; + readonly error: string | null; +}; + +/** What a route answered: its JSON, or its status when it refused. */ +type Answer = { readonly kind: "ok"; readonly value: T } | { readonly kind: "refused"; readonly status: number }; + +/** The viewer: the list, and the chosen trace's tree. */ +export function TraceViewer() { + const [list, setList] = 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(() => { + setTrace(undefined); + if (chosen === "") return; + void read<{ readonly spans: readonly Row[] }>(`/api/traces/${encodeURIComponent(chosen)}`).then(setTrace); + }, [chosen]); + if (list?.kind === "refused") return h("p", null, `The traces are the owner's (${list.status}).`); + return h( + "div", + { className: "traces" }, + h( + "nav", + null, + h("h2", null, "Traces"), + ...(list?.value ?? []).map((summary) => + h( + "a", + { + key: summary.id, + href: `#${encodeURIComponent(summary.id)}`, + 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, + `${count(summary.spans, "span")} · ${count(summary.processes, "host")} · ${ms(summary.durationMs)}`, + ), + ), + ), + ), + trace?.kind === "ok" ? h(Tree, { rows: trace.value.spans }) : null, + ); +} + +/** One trace as a tree: a colour per host (the process 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))]; + return h( + "ol", + { className: "traces-tree" }, + ...rows.map((row, index) => h(Span, { key: index, row, host: hosts.indexOf(row.process), 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 }) { + 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 = { + left: `${(row.offsetMs / total) * 100}%`, + width: `${Math.max((row.durationMs / total) * 100, 0.5)}%`, + }; + return h( + "li", + { className: row.error === null ? undefined : "traces-failed" }, + h( + "div", + { className: "traces-span", style: indent }, + h("span", { className: `traces-host traces-host-${host % 4}` }, `host ${host + 1}`), + 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( + "div", + { className: "traces-details", style: indent }, + row.attributes.join(" "), + ...row.events.map((event) => h("div", { key: event }, `· ${event}`)), + ...row.links.map((link) => h("div", { key: link, className: "traces-link" }, `→ link to ${link}`)), + row.error === null ? null : h("div", { className: "traces-error" }, `error: ${row.error}`), + ), + ); +} + +/** Reads one of the viewer's routes. */ +async function read(path: string): Promise> { + const response = await fetch(path, { credentials: "same-origin" }); + if (!response.ok) return { kind: "refused", status: response.status }; + // The traces plugin's JSON from the page's own server: the shape its routes write. + return { kind: "ok", value: (await response.json()) as T }; +} + +/** The chosen trace's id: the URL's fragment. */ +function fragment(): string { + return decodeURIComponent(location.hash.slice(1)); +} + +/** `n` things: `1 span`, `6 spans`. */ +function count(n: number, thing: string): string { + return `${n} ${thing}${n === 1 ? "" : "s"}`; +} + +/** A length in milliseconds, as the viewer shows it. */ +function ms(value: number): string { + return value < 10 ? `${value.toFixed(2)} ms` : `${Math.round(value)} ms`; +} diff --git a/packages/chord/test/examples/61-tutorial/start.ts b/packages/chord/test/examples/61-tutorial/start.ts index c13b0f739..610070b5d 100644 --- a/packages/chord/test/examples/61-tutorial/start.ts +++ b/packages/chord/test/examples/61-tutorial/start.ts @@ -1,6 +1,6 @@ // 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. The kit -// does the rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls +// 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. import { cp, mkdtemp } from "node:fs/promises"; @@ -13,6 +13,7 @@ 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"; /** The app's API: its name, its versions (newest first), and this process's instances of its modules. */ export const API = { @@ -34,8 +35,16 @@ function page(request: { readonly nonce: string }, assets: PageAssets): string { return `Todo${pageScripts(assets, request.nonce)}
    `; } -/** Starts the todo app on a free loopback port, with its data in `dataDir` and its built-in plugins in `root`. */ -export function startTodo(dataDir: string, root: string, context: Context): Promise> { +/** + * Starts the todo app on a free loopback port, with its data in `dataDir`, its built-in plugins in `root`, recording to + * `telemetry` (the store in `dataDir`). + */ +export function startTodo( + dataDir: string, + root: string, + telemetry: TodoTelemetry, + context: Context, +): Promise> { return startServer( { name: "todo", @@ -45,17 +54,21 @@ export function startTodo(dataDir: string, root: string, context: Context): Prom 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`). + telemetry: telemetry.store, 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", }, ], - web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page }, + // The page, and the owner's trace viewer: the same page, which shows the viewer at `/traces`. + web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page, paths: ["/", "/traces"] }, tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) }, onError: (error) => console.error("todo:", error.kind, error.error), }, diff --git a/packages/chord/test/examples/61-tutorial/trace.ts b/packages/chord/test/examples/61-tutorial/trace.ts new file mode 100644 index 000000000..087c1755f --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/trace.ts @@ -0,0 +1,39 @@ +// 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. +// 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 +import { parseArgs } from "node:util"; +import { renderTrace } from "@earendil-works/pi-telemetry/render"; +import { openTelemetry, telemetryFile } from "./src/telemetry.ts"; + +const { values, positionals } = parseArgs({ allowPositionals: true, options: { name: { 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)); +try { + if (id !== undefined) { + const trace = await telemetry.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 }); + 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; + const failed = spans.some((span) => span.status.status === "error") ? " error" : ""; + console.log(`${traceId} ${spans[0]!.name} ${spans.length} spans ${length.toFixed(2)} ms${failed}`); + } + // 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 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(); +} diff --git a/packages/chord/test/examples/61-tutorial/tutorial.test.ts b/packages/chord/test/examples/61-tutorial/tutorial.test.ts index bdd8c3077..a102536bc 100644 --- a/packages/chord/test/examples/61-tutorial/tutorial.test.ts +++ b/packages/chord/test/examples/61-tutorial/tutorial.test.ts @@ -1,7 +1,8 @@ // The tutorial's checks (`docs/tutorial/book.md`): every TypeScript, JSON and CSS block of the book is quoted from this // example's files (`` above it) and fails here when the file no longer has those lines; the -// example's steps run (`main.ts`, and `screenshots.ts` with Chromium), and every console output the book shows -// (``, ``) is among the lines they print, in order. The terminal's +// example's steps run (`main.ts`, which runs `trace.ts` on its data directory, and `screenshots.ts` with Chromium), and +// every console output the book shows (``, ``) is among the lines they +// print, in order: the telemetry chapter's traces, `trace.ts`'s reads and the viewer's screenshot too. The terminal's // output (``) is checked when a Node for its sandbox is there (`CHORD_TUI_NODE`, else `node26`). // The p2 chapter's blocks (``) quote p2's sources, which live outside this repository. // Run from packages/chord: diff --git a/packages/chord/test/examples/61-tutorial/worker.ts b/packages/chord/test/examples/61-tutorial/worker.ts index 74b186b05..496dc3852 100644 --- a/packages/chord/test/examples/61-tutorial/worker.ts +++ b/packages/chord/test/examples/61-tutorial/worker.ts @@ -1,7 +1,10 @@ // 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. +// 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. import { startWorker } from "../../../src/node/app/index.ts"; +import { openTelemetry } from "./src/telemetry.ts"; import { API, KINDS } from "./start.ts"; -startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) }); +const telemetry = await openTelemetry(process.env.TODO_TELEMETRY!); +startWorker({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) => console.error("list:", error) });