docs(chord): tutorial chapter on telemetry, with a small trace viewer

This commit is contained in:
Mario Zechner 2026-11-04 10:40:17 +00:00
parent 0f4d5e960a
commit 0efe50cb6d
17 changed files with 1344 additions and 83 deletions

View file

@ -2,9 +2,9 @@
title: Together: build a shared todo app with Chord title: Together: build a shared todo app with Chord
cover: Build It / Together cover: Build It / Together
kicker: A tutorial · Chord v2 and its app kit · 2026-11-03 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: stats:
- 10 | steps, one runnable example - 11 | steps, one runnable example
- 4 | host kinds one plugin runs on - 4 | host kinds one plugin runs on
- 1 | worker process per list - 1 | worker process per list
- 0 | restarts for a new theme, a language or an edit - 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, 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. that step says so and the rest runs.
## Nine things to know ## Ten things to know
:::findings :::findings
1. **An app is its API, its plugins and a start script.** `startServer` makes the data directory, the store, the 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. 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 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. 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 9. **One click is one trace.** Every process records spans to the telemetry store it is given (here one SQLite file
current value; the plain `t` has no language of its own. 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 ## 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 (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 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 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."} # 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 = {
<!-- quote: start.ts --> <!-- quote: start.ts -->
```ts ```ts
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>( return startServer<TodoPrincipal>(
{ {
name: "todo", name: "todo",
@ -222,17 +231,21 @@ export function startTodo(dataDir: string, root: string, context: Context): Prom
api: API, api: API,
kinds: KINDS, kinds: KINDS,
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) }, 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: [ workers: [
{ {
kind: v1.List, kind: v1.List,
entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
env: { TODO_TELEMETRY: telemetryFile(dataDir) },
start: () => ({}), start: () => ({}),
// Who may reach a list's worker: everyone signed in, and the server itself. // Who may reach a list's worker: everyone signed in, and the server itself.
authorize: (principal) => principal.kind !== "worker", 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)) }, tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) },
onError: (error) => console.error("todo:", error.kind, error.error), 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 `workers` is chapter II, `web` chapter IV (its second path, `/traces`, chapter XI), `tui` chapter V, `telemetry`
does; a real app picks a port. The worker entry is three lines: 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):
<!-- quote: worker.ts --> <!-- quote: worker.ts -->
```ts ```ts
// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects // 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 { startWorker } from "../../../src/node/app/index.ts";
import { openTelemetry } from "./src/telemetry.ts";
import { API, KINDS } from "./start.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 ## Running it
@ -262,7 +279,8 @@ link:
<!-- quote: main.ts --> <!-- quote: main.ts -->
```ts ```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 link = new URL(app.ownerLink());
const url = link.origin; const url = link.origin;
console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`); console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`);
@ -281,14 +299,16 @@ console.log(" GET / signed in:", (await fetch(url, { headers: { cookie } })).s
<!-- output: main --> <!-- output: main -->
```text ```text
1. owner link: http://127.0.0.1:<port>/_chord/owner#<token> 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 GET / signed out: 401
the owner signs in: 200 chord-session=… the owner signs in: 200 chord-session=…
GET / signed in: 200 GET / signed in: 200
``` ```
`startServer` made the data directory: `server/` (its store, the cookie secret, its files), `workers/` (one directory `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`. 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 The **owner link** (`/_chord/owner#<token>`) 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, kind: v1.List,
entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
env: { TODO_TELEMETRY: telemetryFile(dataDir) },
start: () => ({}), start: () => ({}),
// Who may reach a list's worker: everyone signed in, and the server itself. // Who may reach a list's worker: everyone signed in, and the server itself.
authorize: (principal) => principal.kind !== "worker", 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 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. 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
<!-- quote: plugins/lists/worker.ts --> <!-- quote: plugins/lists/worker.ts -->
```ts ```ts
add: async ({ text, by }, ctx) => { add: async ({ text, by }, ctx) => {
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. // A span of its own under the method's: a trace shows the check, and why it refused.
if (text === "") throw invalid("args", "a todo needs text"); 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) => { await self.tx(async (tx) => {
// The number after the newest todo's: the store's transactions run one after another. // The number after the newest todo's: the store's transactions run one after another.
const { items: last } = await tx.page(items, NEWEST, 1); 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 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 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 ## The view per principal
@ -663,6 +688,8 @@ const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) =
function Page() { function Page() {
const panels = useRegistry(v1web.Panels) ?? []; 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( return h(
"main", "main",
null, null,
@ -679,8 +706,9 @@ void startPage({
}); });
``` ```
`startPage` makes the page's host, connects it to `/chord` (reconnecting after drops), loads the current plugin The path `/traces` is the owner's trace viewer (chapter XI). `startPage` makes the page's host, connects it to `/chord`
scripts with the entry's core facets, and keeps them current. `HostProvider` gives the React tree that host. (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 ## 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 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. 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:
<!-- quote: src/telemetry.ts -->
```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<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:
<!-- quote: start.ts -->
```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:
<!-- quote: start.ts -->
```ts
env: { TODO_TELEMETRY: telemetryFile(dataDir) },
```
<!-- quote: worker.ts -->
```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:
<!-- quote: main.ts -->
```ts
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):
<!-- output: main -->
```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=<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.
:::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:
<!-- quote: plugins/lists/worker.ts -->
```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:
<!-- quote: trace.ts -->
```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):
<!-- output: main -->
```text
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:
<!-- quote: plugins/traces/server.ts -->
```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:
<!-- quote: plugins/traces/server.ts -->
```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:
<!-- quote: plugins/traces/server.ts -->
```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:
<!-- quote: src/web/main.ts -->
```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:
<!-- quote: src/web/traces.ts -->
```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:
<!-- quote: screenshots.ts -->
```ts
await browser.open(`${origin}/traces#${encodeURIComponent(trace.traceId)}`);
await browser.wait("document.querySelectorAll('.traces-tree li').length === 6");
await shoot("traces.png");
```
<!-- output: screenshots -->
```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."} # 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 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 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`. 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 **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. 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 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` 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 **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 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 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). 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.
# The App's Files {.appendix} # The App's Files {.appendix}
| File | Kind | What it is | | 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 | | `src/principals.ts` | base | the principal type |
| `package.json` | base | the API as the workspace package `@todo/app`, its `exports` | | `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/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/web.ts` | base, web | `Panels`, `useHold`, `useMessages`, `usePageState`, `useWatch` |
| `src/api/v1/tui.ts` | base, tui | `Screens` | | `src/api/v1/tui.ts` | base, tui | `Screens` |
| `start.ts`, `worker.ts` | base | `startServer`, `startWorker` | | `start.ts`, `worker.ts` | base | `startServer`, `startWorker`, each with the telemetry store |
| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels | | `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 | | `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/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/french/` | plugin | French for the lists plugin, for pages and terminals |
| `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login | | `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 | | `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals |
| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks | | `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks |
| `trace.ts` | example | a data directory's newest traces, one trace, a metric |
# Reference {.appendix} # Reference {.appendix}
@ -1675,4 +2051,8 @@ plugin (now `self.query`, a live query Chord runs, chapters II and IX).
| `MessagesOf<typeof t>`, `LocaleChoice` | VIII | a translation plugin, the user's language | | `MessagesOf<typeof t>`, `LocaleChoice` | VIII | a translation plugin, the user's language |
| `app.plugins.state`, `app.applyChanges(ctx)` | VI, IX | marks and rollouts | | `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 | | `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 |

View file

@ -111,8 +111,8 @@ table{max-width:100%}
<section class="cover"> <section class="cover">
<div class="kicker">A tutorial · Chord v2 and its app kit · 2026-11-03</div> <div class="kicker">A tutorial · Chord v2 and its app kit · 2026-11-03</div>
<h1>Build It<br>Together</h1> <h1>Build It<br>Together</h1>
<p class="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</p> <p class="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</p>
<div class="cover-stats"><div class="stat"><b>10</b><span>steps, one runnable example</span></div><div class="stat"><b>4</b><span>host kinds one plugin runs on</span></div><div class="stat"><b>1</b><span>worker process per list</span></div><div class="stat"><b>0</b><span>restarts for a new theme, a language or an edit</span></div></div> <div class="cover-stats"><div class="stat"><b>11</b><span>steps, one runnable example</span></div><div class="stat"><b>4</b><span>host kinds one plugin runs on</span></div><div class="stat"><b>1</b><span>worker process per list</span></div><div class="stat"><b>0</b><span>restarts for a new theme, a language or an edit</span></div></div>
<div class="cover-note"><p>The app is <code>packages/chord/test/examples/61-tutorial/</code>, runnable with the line in its <code>main.ts</code>. Every code block <div class="cover-note"><p>The app is <code>packages/chord/test/examples/61-tutorial/</code>, runnable with the line in its <code>main.ts</code>. Every code block
below is quoted from its files, and every console output from what its steps print: below is quoted from its files, and every console output from what its steps print:
<code>test/examples/61-tutorial/tutorial.test.ts</code> fails when a quote or an output drifts. The design is <code>test/examples/61-tutorial/tutorial.test.ts</code> 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.</p>
<li><a href="#languages">VIII. Languages</a></li> <li><a href="#languages">VIII. Languages</a></li>
<li><a href="#reload">IX. Changing Code</a></li> <li><a href="#reload">IX. Changing Code</a></li>
<li><a href="#export">X. Moving a List</a></li> <li><a href="#export">X. Moving a List</a></li>
<li><a href="#testing">XI. Testing One Plugin</a></li> <li><a href="#telemetry">XI. Seeing What Happened</a></li>
<li><a href="#p2">XII. Writing a p2 Plugin</a></li> <li><a href="#testing">XII. Testing One Plugin</a></li>
<li><a href="#p2">XIII. Writing a p2 Plugin</a></li>
<li><a href="#traps-and-open-questions">Traps and Open Questions</a></li> <li><a href="#traps-and-open-questions">Traps and Open Questions</a></li>
<li><a href="#the-app-s-files">Appendix A: The App&#39;s Files</a></li> <li><a href="#the-app-s-files">Appendix A: The App&#39;s Files</a></li>
<li><a href="#reference">Appendix B: Reference</a></li> <li><a href="#reference">Appendix B: Reference</a></li>
@ -156,7 +157,7 @@ states the rule behind it. The app is one directory, <code>test/examples/61-tuto
<pre><code>node --conditions=source --experimental-strip-types test/examples/<span class="tk-n">61</span>-tutorial/main.ts</code></pre> <pre><code>node --conditions=source --experimental-strip-types test/examples/<span class="tk-n">61</span>-tutorial/main.ts</code></pre>
<p>With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one, <p>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.</p> that step says so and the rest runs.</p>
<h3 id="nine-things-to-know">Nine things to know</h3> <h3 id="ten-things-to-know">Ten things to know</h3>
<ol class="findings"> <ol class="findings">
<li><strong>An app is its API, its plugins and a start script.</strong> <code>startServer</code> makes the data directory, the store, the <li><strong>An app is its API, its plugins and a start script.</strong> <code>startServer</code> makes the data directory, the store, the
server host, HTTP with sign-in, the workers and the plugin roots; <code>startWorker</code> does a worker&#39;s half.</li> server host, HTTP with sign-in, the workers and the plugin roots; <code>startWorker</code> does a worker&#39;s half.</li>
@ -175,8 +176,11 @@ plugins contribute, one plugin (or the entry) composes.</li>
server, the workers, the pages and the terminals, in place.</li> server, the workers, the pages and the terminals, in place.</li>
<li><strong>Text is rendered per viewer.</strong> A plugin&#39;s messages are typed code, a translation is a plugin, each user picks a <li><strong>Text is rendered per viewer.</strong> A plugin&#39;s messages are typed code, a translation is a plugin, each user picks a
language, and servers store keys, never sentences.</li> language, and servers store keys, never sentences.</li>
<li><strong>Five traps:</strong> load before use; a reference acts as whoever made it; await what you start; a watch starts at the <li><strong>One click is one trace.</strong> Every process records spans to the telemetry store it is given (here one SQLite file
current value; the plain <code>t</code> has no language of its own.</li> in the data directory); carriers in the calls&#39; headers join the page&#39;s call, the server&#39;s relay and the worker&#39;s
method into one trace, which <code>TelemetryStore</code> reads back. <code>ctx.trace</code> adds spans of your own.</li>
<li><strong>Six traps:</strong> load before use; a reference acts as whoever made it; await what you start; a watch starts at the
current value; the plain <code>t</code> has no language of its own; a trace starts where a span starts.</li>
</ol> </ol>
<h3 id="words-used-throughout">Words used throughout</h3> <h3 id="words-used-throughout">Words used throughout</h3>
<p>A <strong>host</strong> is one participant of the app, with an <strong>address</strong> if others can reach it (the server, <code>List({ id })</code>) or <p>A <strong>host</strong> is one participant of the app, with an <strong>address</strong> if others can reach it (the server, <code>List({ id })</code>) or
@ -188,7 +192,8 @@ are the member sets different principals get. A <strong>factory</strong> declare
(values computed from others), <strong>natives</strong> (things opened and closed, such as a file or a database cursor). A <strong>reference</strong> is what asking for an object returns. A <strong>facet</strong> is a (values computed from others), <strong>natives</strong> (things opened and closed, such as a file or a database cursor). A <strong>reference</strong> is what asking for an object returns. A <strong>facet</strong> is a
plugin&#39;s code for one host kind; a <strong>registry</strong> is an extension point plugins <strong>contribute</strong> to. A <strong>message</strong> is a plugin&#39;s code for one host kind; a <strong>registry</strong> is an extension point plugins <strong>contribute</strong> to. A <strong>message</strong> is a
text with a key, a <strong>catalog</strong> a plugin&#39;s messages in one language, a <strong>locale</strong> a language tag (<code>de</code>, <code>pt-BR</code>). A <strong>rollout</strong> applies text with a key, a <strong>catalog</strong> a plugin&#39;s messages in one language, a <strong>locale</strong> a language tag (<code>de</code>, <code>pt-BR</code>). A <strong>rollout</strong> applies
new plugin code to the hosts in order.</p> new plugin code to the hosts in order. A <strong>span</strong> is one timed piece of work a host recorded, a <strong>trace</strong> the spans of
one request across hosts, joined by the <strong>carriers</strong> calls carry.</p>
</section> </section>
<section class="chapter" id="server"><header class="opener"><div class="numeral">I</div><h2>One Server</h2><p class="lede">The API, the principals and a start script: the smallest app the kit runs, and who may come in.</p></header> <section class="chapter" id="server"><header class="opener"><div class="numeral">I</div><h2>One Server</h2><p class="lede">The API, the principals and a start script: the smallest app the kit runs, and who may come in.</p></header>
@ -295,7 +300,12 @@ facet kinds (which files a plugin may have), and the principals:</p>
tui: { platform: <span class="tk-s">"node"</span> }, tui: { platform: <span class="tk-s">"node"</span> },
} <span class="tk-k">as</span> <span class="tk-k">const</span>;</code></pre> } <span class="tk-k">as</span> <span class="tk-k">const</span>;</code></pre>
<!-- quote: start.ts --> <!-- quote: start.ts -->
<pre><code><span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">startTodo</span>(dataDir: string, root: string, context: <span class="tk-t">Context</span>): <span class="tk-t">Promise</span>&lt;<span class="tk-t">App</span>&lt;<span class="tk-t">TodoPrincipal</span>&gt;&gt; { <pre><code><span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">startTodo</span>(
dataDir: string,
root: string,
telemetry: <span class="tk-t">TodoTelemetry</span>,
context: <span class="tk-t">Context</span>,
): <span class="tk-t">Promise</span>&lt;<span class="tk-t">App</span>&lt;<span class="tk-t">TodoPrincipal</span>&gt;&gt; {
<span class="tk-k">return</span> <span class="tk-f">startServer</span>&lt;<span class="tk-t">TodoPrincipal</span>&gt;( <span class="tk-k">return</span> <span class="tk-f">startServer</span>&lt;<span class="tk-t">TodoPrincipal</span>&gt;(
{ {
name: <span class="tk-s">"todo"</span>, name: <span class="tk-s">"todo"</span>,
@ -305,38 +315,47 @@ facet kinds (which files a plugin may have), and the principals:</p>
api: API, api: API,
kinds: KINDS, kinds: KINDS,
principal: { server: { kind: <span class="tk-s">"server"</span> }, worker: (address) =&gt; ({ kind: <span class="tk-s">"worker"</span>, list: address.key }) }, principal: { server: { kind: <span class="tk-s">"server"</span> }, worker: (address) =&gt; ({ kind: <span class="tk-s">"worker"</span>, list: address.key }) },
<span class="tk-c">// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).</span>
telemetry: telemetry.store,
workers: [ workers: [
{ {
kind: v1.<span class="tk-t">List</span>, kind: v1.<span class="tk-t">List</span>,
entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./worker.ts"</span>, <span class="tk-k">import</span>.meta.url)), entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./worker.ts"</span>, <span class="tk-k">import</span>.meta.url)),
execArgv: [<span class="tk-s">"--conditions=source"</span>, <span class="tk-s">"--experimental-strip-types"</span>, <span class="tk-s">"--no-warnings"</span>], execArgv: [<span class="tk-s">"--conditions=source"</span>, <span class="tk-s">"--experimental-strip-types"</span>, <span class="tk-s">"--no-warnings"</span>],
env: { TODO_TELEMETRY: <span class="tk-f">telemetryFile</span>(dataDir) },
start: () =&gt; ({}), start: () =&gt; ({}),
<span class="tk-c">// Who may reach a list's worker: everyone signed in, and the server itself.</span> <span class="tk-c">// Who may reach a list's worker: everyone signed in, and the server itself.</span>
authorize: (principal) =&gt; principal.kind !== <span class="tk-s">"worker"</span>, authorize: (principal) =&gt; principal.kind !== <span class="tk-s">"worker"</span>,
}, },
], ],
web: { entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./src/web/main.ts"</span>, <span class="tk-k">import</span>.meta.url)), page }, <span class="tk-c">// The page, and the owner's trace viewer: the same page, which shows the viewer at `/traces`.</span>
web: { entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./src/web/main.ts"</span>, <span class="tk-k">import</span>.meta.url)), page, paths: [<span class="tk-s">"/"</span>, <span class="tk-s">"/traces"</span>] },
tui: { entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./src/tui/main.ts"</span>, <span class="tk-k">import</span>.meta.url)) }, tui: { entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./src/tui/main.ts"</span>, <span class="tk-k">import</span>.meta.url)) },
onError: (error) =&gt; console.<span class="tk-f">error</span>(<span class="tk-s">"todo:"</span>, error.kind, error.error), onError: (error) =&gt; console.<span class="tk-f">error</span>(<span class="tk-s">"todo:"</span>, error.kind, error.error),
}, },
context, context,
); );
}</code></pre> }</code></pre>
<p><code>workers</code> is chapter II, <code>web</code> chapter IV, <code>tui</code> chapter V. <code>listen</code> asks for a free port on loopback, as the example <p><code>workers</code> is chapter II, <code>web</code> chapter IV (its second path, <code>/traces</code>, chapter XI), <code>tui</code> chapter V, <code>telemetry</code>
does; a real app picks a port. The worker entry is three lines:</p> chapter XI. <code>listen</code> 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&#39;s too):</p>
<!-- quote: worker.ts --> <!-- quote: worker.ts -->
<pre><code><span class="tk-c">// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects</span> <pre><code><span class="tk-c">// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects</span>
<span class="tk-c">// to the server and loads the plugins' worker facets.</span> <span class="tk-c">// to the server and loads the plugins' worker facets. The worker records to the app's telemetry store, the file its</span>
<span class="tk-c">// server named in the environment: its methods called through the server continue the caller's trace.</span>
<span class="tk-k">import</span> { startWorker } <span class="tk-k">from</span> <span class="tk-s">"../../../src/node/app/index.ts"</span>; <span class="tk-k">import</span> { startWorker } <span class="tk-k">from</span> <span class="tk-s">"../../../src/node/app/index.ts"</span>;
<span class="tk-k">import</span> { openTelemetry } <span class="tk-k">from</span> <span class="tk-s">"./src/telemetry.ts"</span>;
<span class="tk-k">import</span> { API, KINDS } <span class="tk-k">from</span> <span class="tk-s">"./start.ts"</span>; <span class="tk-k">import</span> { API, KINDS } <span class="tk-k">from</span> <span class="tk-s">"./start.ts"</span>;
<span class="tk-f">startWorker</span>({ api: API, kinds: KINDS, onError: (error) =&gt; console.<span class="tk-f">error</span>(<span class="tk-s">"list:"</span>, error) });</code></pre> <span class="tk-k">const</span> telemetry = <span class="tk-k">await</span> <span class="tk-f">openTelemetry</span>(process.env.TODO_TELEMETRY!);
<span class="tk-f">startWorker</span>({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) =&gt; console.<span class="tk-f">error</span>(<span class="tk-s">"list:"</span>, error) });</code></pre>
<h3 id="running-it">Running it</h3> <h3 id="running-it">Running it</h3>
<p>Step 1 of <code>main.ts</code> starts the app, tries the page without signing in, then does what a browser does with the owner <p>Step 1 of <code>main.ts</code> starts the app, tries the page without signing in, then does what a browser does with the owner
link:</p> link:</p>
<!-- quote: main.ts --> <!-- quote: main.ts -->
<pre><code><span class="tk-k">const</span> app = <span class="tk-k">await</span> <span class="tk-f">startTodo</span>(dataDir, root, context); <pre><code><span class="tk-k">const</span> telemetry = <span class="tk-k">await</span> <span class="tk-f">openTelemetry</span>(<span class="tk-f">telemetryFile</span>(dataDir));
<span class="tk-k">const</span> app = <span class="tk-k">await</span> <span class="tk-f">startTodo</span>(dataDir, root, telemetry, context);
<span class="tk-k">const</span> link = <span class="tk-k">new</span> <span class="tk-f">URL</span>(app.<span class="tk-f">ownerLink</span>()); <span class="tk-k">const</span> link = <span class="tk-k">new</span> <span class="tk-f">URL</span>(app.<span class="tk-f">ownerLink</span>());
<span class="tk-k">const</span> url = link.origin; <span class="tk-k">const</span> url = link.origin;
console.<span class="tk-f">log</span>(<span class="tk-s">"1. owner link:"</span>, <span class="tk-s">`http://127.0.0.1:&lt;port&gt;${link.pathname}#&lt;token&gt;`</span>); console.<span class="tk-f">log</span>(<span class="tk-s">"1. owner link:"</span>, <span class="tk-s">`http://127.0.0.1:&lt;port&gt;${link.pathname}#&lt;token&gt;`</span>);
@ -352,12 +371,14 @@ console.<span class="tk-f">log</span>(<span class="tk-s">" the owner signs in:
console.<span class="tk-f">log</span>(<span class="tk-s">" GET / signed in:"</span>, (<span class="tk-k">await</span> <span class="tk-f">fetch</span>(url, { headers: { cookie } })).status);</code></pre> console.<span class="tk-f">log</span>(<span class="tk-s">" GET / signed in:"</span>, (<span class="tk-k">await</span> <span class="tk-f">fetch</span>(url, { headers: { cookie } })).status);</code></pre>
<!-- output: main --> <!-- output: main -->
<pre class="plain"><code>1. owner link: http://127.0.0.1:&lt;port&gt;/_chord/owner#&lt;token&gt; <pre class="plain"><code>1. owner link: http://127.0.0.1:&lt;port&gt;/_chord/owner#&lt;token&gt;
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 GET / signed out: 401
the owner signs in: 200 chord-session=… the owner signs in: 200 chord-session=…
GET / signed in: 200</code></pre> GET / signed in: 200</code></pre>
<p><code>startServer</code> made the data directory: <code>server/</code> (its store, the cookie secret, its files), <code>workers/</code> (one directory <p><code>startServer</code> made the data directory: <code>server/</code> (its store, the cookie secret, its files), <code>workers/</code> (one directory
per worker), <code>plugins/</code> (the user&#39;s plugin root, beside the app&#39;s built-in one), <code>logs/</code> and <code>owner-token</code>. The page per worker), <code>plugins/</code> (the user&#39;s plugin root, beside the app&#39;s built-in one), <code>logs/</code> and <code>owner-token</code>;
<code>telemetry.sqlite</code> (with SQLite&#39;s <code>-shm</code> and <code>-wal</code> files) is the app&#39;s own, opened before the server starts
(chapter XI). The page
answered 401: HTTP <strong>denies by default</strong>, before any plugin code runs, unless a route says <code>public: true</code>.</p> answered 401: HTTP <strong>denies by default</strong>, before any plugin code runs, unless a route says <code>public: true</code>.</p>
<p>The <strong>owner link</strong> (<code>/_chord/owner#&lt;token&gt;</code>) is how the owner gets in: a single-use token, valid ten minutes, made at <p>The <strong>owner link</strong> (<code>/_chord/owner#&lt;token&gt;</code>) is how the owner gets in: a single-use token, valid ten minutes, made at
each start. It sits in the URL&#39;s fragment, which browsers never send, so it never reaches a log; the link&#39;s page posts each start. It sits in the URL&#39;s fragment, which browsers never send, so it never reaches a log; the link&#39;s page posts
@ -432,12 +453,14 @@ it on to its parent or to a route (design §3, &quot;Host kinds and addresses&qu
kind: v1.<span class="tk-t">List</span>, kind: v1.<span class="tk-t">List</span>,
entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./worker.ts"</span>, <span class="tk-k">import</span>.meta.url)), entry: <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./worker.ts"</span>, <span class="tk-k">import</span>.meta.url)),
execArgv: [<span class="tk-s">"--conditions=source"</span>, <span class="tk-s">"--experimental-strip-types"</span>, <span class="tk-s">"--no-warnings"</span>], execArgv: [<span class="tk-s">"--conditions=source"</span>, <span class="tk-s">"--experimental-strip-types"</span>, <span class="tk-s">"--no-warnings"</span>],
env: { TODO_TELEMETRY: <span class="tk-f">telemetryFile</span>(dataDir) },
start: () =&gt; ({}), start: () =&gt; ({}),
<span class="tk-c">// Who may reach a list's worker: everyone signed in, and the server itself.</span> <span class="tk-c">// Who may reach a list's worker: everyone signed in, and the server itself.</span>
authorize: (principal) =&gt; principal.kind !== <span class="tk-s">"worker"</span>, authorize: (principal) =&gt; principal.kind !== <span class="tk-s">"worker"</span>,
}, },
],</code></pre> ],</code></pre>
<p><code>authorize</code> runs for every request routed to a list before anything starts; the first request for an address starts <p><code>env</code> names the app&#39;s telemetry store for the worker (chapter XI). <code>authorize</code> 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 <code>idleMs</code>) stops. <strong>Variants</strong> are the member sets a its worker, and a worker idle for five minutes (the default <code>idleMs</code>) stops. <strong>Variants</strong> are the member sets a
contract offers: the owner&#39;s <code>Todos</code> has <code>clear</code>, a member&#39;s does not. The factory picks one per principal.</p> contract offers: the owner&#39;s <code>Todos</code> has <code>clear</code>, a member&#39;s does not. The factory picks one per principal.</p>
<h3 id="the-worker-facet">The worker facet</h3> <h3 id="the-worker-facet">The worker facet</h3>
@ -499,8 +522,11 @@ when <code>fn</code> returns, or not at all if it throws. The live query follows
else in memory, not even the next todo&#39;s number, which it reads in the transaction:</p> else in memory, not even the next todo&#39;s number, which it reads in the transaction:</p>
<!-- quote: plugins/lists/worker.ts --> <!-- quote: plugins/lists/worker.ts -->
<pre><code> add: <span class="tk-k">async</span> ({ text, by }, ctx) =&gt; { <pre><code> add: <span class="tk-k">async</span> ({ text, by }, ctx) =&gt; {
<span class="tk-c">// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.</span> <span class="tk-c">// A span of its own under the method's: a trace shows the check, and why it refused.</span>
<span class="tk-k">if</span> (text === <span class="tk-s">""</span>) <span class="tk-k">throw</span> <span class="tk-f">invalid</span>(<span class="tk-s">"args"</span>, <span class="tk-s">"a todo needs text"</span>); <span class="tk-k">await</span> ctx.<span class="tk-f">trace</span>(<span class="tk-s">"todos.validate"</span>, { chars: text.length }, <span class="tk-k">async</span> () =&gt; {
<span class="tk-c">// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.</span>
<span class="tk-k">if</span> (text === <span class="tk-s">""</span>) <span class="tk-k">throw</span> <span class="tk-f">invalid</span>(<span class="tk-s">"args"</span>, <span class="tk-s">"a todo needs text"</span>);
});
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) =&gt; { <span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) =&gt; {
<span class="tk-c">// The number after the newest todo's: the store's transactions run one after another.</span> <span class="tk-c">// The number after the newest todo's: the store's transactions run one after another.</span>
<span class="tk-k">const</span> { items: last } = <span class="tk-k">await</span> tx.<span class="tk-f">page</span>(items, NEWEST, <span class="tk-n">1</span>); <span class="tk-k">const</span> { items: last } = <span class="tk-k">await</span> tx.<span class="tk-f">page</span>(items, NEWEST, <span class="tk-n">1</span>);
@ -509,7 +535,7 @@ else in memory, not even the next todo&#39;s number, which it reads in the trans
<span class="tk-k">return</span> <span class="tk-k">null</span>;</code></pre> <span class="tk-k">return</span> <span class="tk-k">null</span>;</code></pre>
<p>A todo without text is refused with <code>invalid(&quot;args&quot;, …)</code>, from the API: the error crosses every link as it is, so a <p>A todo without text is refused with <code>invalid(&quot;args&quot;, …)</code>, from the API: the error crosses every link as it is, so a
caller on a page or in a terminal gets <code>{ code: &quot;invalid&quot;, reason: &quot;args&quot; }</code>, as for arguments that fail the caller on a page or in a terminal gets <code>{ code: &quot;invalid&quot;, reason: &quot;args&quot; }</code>, as for arguments that fail the
contract&#39;s schema.</p> contract&#39;s schema. The check runs in a span of its own (<code>ctx.trace</code>), which chapter XI shows in a trace.</p>
<h3 id="the-view-per-principal">The view per principal</h3> <h3 id="the-view-per-principal">The view per principal</h3>
<p><code>Todos</code> is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:</p> <p><code>Todos</code> is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:</p>
<!-- quote: plugins/lists/worker.ts --> <!-- quote: plugins/lists/worker.ts -->
@ -655,6 +681,8 @@ for them:</p>
<span class="tk-k">function</span> <span class="tk-t">Page</span>() { <span class="tk-k">function</span> <span class="tk-t">Page</span>() {
<span class="tk-k">const</span> panels = <span class="tk-f">useRegistry</span>(v1web.<span class="tk-t">Panels</span>) ?? []; <span class="tk-k">const</span> panels = <span class="tk-f">useRegistry</span>(v1web.<span class="tk-t">Panels</span>) ?? [];
<span class="tk-c">// The page's one other path (`start.ts` serves the page at `/` and `/traces`).</span>
<span class="tk-k">if</span> (location.pathname === <span class="tk-s">"/traces"</span>) <span class="tk-k">return</span> <span class="tk-f">h</span>(<span class="tk-s">"main"</span>, { className: <span class="tk-s">"wide"</span> }, <span class="tk-f">h</span>(<span class="tk-s">"h1"</span>, <span class="tk-k">null</span>, <span class="tk-s">"Todo"</span>), <span class="tk-f">h</span>(<span class="tk-t">TraceViewer</span>));
<span class="tk-k">return</span> <span class="tk-f">h</span>( <span class="tk-k">return</span> <span class="tk-f">h</span>(
<span class="tk-s">"main"</span>, <span class="tk-s">"main"</span>,
<span class="tk-k">null</span>, <span class="tk-k">null</span>,
@ -669,8 +697,9 @@ for them:</p>
core: [{ name: <span class="tk-s">"@todo/web"</span>, facet: core }], core: [{ name: <span class="tk-s">"@todo/web"</span>, facet: core }],
render: (host) =&gt; <span class="tk-f">createRoot</span>(document.<span class="tk-f">getElementById</span>(<span class="tk-s">"root"</span>)!).<span class="tk-f">render</span>(<span class="tk-f">h</span>(<span class="tk-t">HostProvider</span>, { host }, <span class="tk-f">h</span>(<span class="tk-t">Page</span>))), render: (host) =&gt; <span class="tk-f">createRoot</span>(document.<span class="tk-f">getElementById</span>(<span class="tk-s">"root"</span>)!).<span class="tk-f">render</span>(<span class="tk-f">h</span>(<span class="tk-t">HostProvider</span>, { host }, <span class="tk-f">h</span>(<span class="tk-t">Page</span>))),
});</code></pre> });</code></pre>
<p><code>startPage</code> makes the page&#39;s host, connects it to <code>/chord</code> (reconnecting after drops), loads the current plugin <p>The path <code>/traces</code> is the owner&#39;s trace viewer (chapter XI). <code>startPage</code> makes the page&#39;s host, connects it to <code>/chord</code>
scripts with the entry&#39;s core facets, and keeps them current. <code>HostProvider</code> gives the React tree that host.</p> (reconnecting after drops), loads the current plugin scripts with the entry&#39;s core facets, and keeps them current.
<code>HostProvider</code> gives the React tree that host.</p>
<h3 id="the-web-facet">The web facet</h3> <h3 id="the-web-facet">The web facet</h3>
<p><code>plugins/lists/web.ts</code> contributes a panel. Its component holds the list&#39;s <code>Todos</code> while it is mounted and re-renders <p><code>plugins/lists/web.ts</code> contributes a panel. Its component holds the list&#39;s <code>Todos</code> while it is mounted and re-renders
when its items change:</p> when its items change:</p>
@ -1251,7 +1280,249 @@ replacing a list is a delete, then an import (decision &quot;App kit naming and
an index of the app&#39;s, if it had one, is the app&#39;s.</p> an index of the app&#39;s, if it had one, is the app&#39;s.</p>
</section> </section>
<section class="chapter" id="testing"><header class="opener"><div class="numeral">XI</div><h2>Testing One Plugin</h2><p class="lede">testPlugin builds a plugin as production does and loads it alone on a host of its own.</p></header> <section class="chapter" id="telemetry"><header class="opener"><div class="numeral">XI</div><h2>Seeing What Happened</h2><p class="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.</p></header>
<p class="dropcap">Ada says that adding a todo from her phone feels slow some evenings. Her todo crosses three processes: her page, the
server&#39;s relay and the list&#39;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 <strong>telemetry</strong> through pi-telemetry: <strong>spans</strong> (a named, timed
piece of work with attributes), <strong>events</strong> on spans, and <strong>metrics</strong> (counters and histograms). Each host has one
<strong>adapter</strong>, given to <code>createHost({ telemetry })</code> and NOOP by default, which decides where they go. A <strong>carrier</strong> in
each call&#39;s header names the caller&#39;s span, so the spans of one request, recorded by different processes, form one
<strong>trace</strong> (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.</p>
<h3 id="recording">Recording</h3>
<p>The app keeps its telemetry in one SQLite file in the data directory, through Chord&#39;s store
(<code>@earendil-works/chord/telemetry-sqlite</code>). The app&#39;s base code opens it, for any process that asks:</p>
<!-- quote: src/telemetry.ts -->
<pre><code><span class="tk-c">/** The telemetry store's file in the data directory `dataDir`. */</span>
<span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">telemetryFile</span>(dataDir: string): string {
<span class="tk-k">return</span> <span class="tk-f">join</span>(dataDir, <span class="tk-s">"telemetry.sqlite"</span>);
}
<span class="tk-c">/** Opens the telemetry store at `file` for this process (its directory made, private, when missing). */</span>
<span class="tk-k">export</span> <span class="tk-k">async</span> <span class="tk-k">function</span> <span class="tk-f">openTelemetry</span>(file: string): <span class="tk-t">Promise</span>&lt;<span class="tk-t">TodoTelemetry</span>&gt; {
<span class="tk-c">// Spans carry who called what: the data directory is private before the store's first file appears in it.</span>
<span class="tk-k">await</span> <span class="tk-f">mkdir</span>(<span class="tk-f">dirname</span>(file), { recursive: <span class="tk-k">true</span>, mode: 0o700 });
<span class="tk-k">const</span> db = <span class="tk-k">await</span> <span class="tk-f">openNodeSqliteDatabase</span>(file);
<span class="tk-k">const</span> store = <span class="tk-k">await</span> <span class="tk-f">createSqliteTelemetry</span>(db);
<span class="tk-k">return</span> {
store,
close: <span class="tk-k">async</span> () =&gt; {
<span class="tk-k">await</span> store.<span class="tk-f">close</span>();
<span class="tk-k">await</span> db.<span class="tk-f">close</span>();
},
};
}</code></pre>
<p><code>createSqliteTelemetry</code> returns a <strong>recorder</strong>: 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&#39;s <strong>read interface</strong>, <code>TelemetryStore</code> (below).</p>
<p>Step 1 opened the store before the server (chapter I), and <code>startTodo</code> gives it to <code>startServer</code>, which passes it to the
server&#39;s host and its HTTP component:</p>
<!-- quote: start.ts -->
<pre><code> <span class="tk-c">// The server's spans and metrics; each list's worker opens the same file (`worker.ts`).</span>
telemetry: telemetry.store,</code></pre>
<p>The kit carries no spans between processes: each process records to the adapter it was given (slice 9, §5). So each
list&#39;s worker opens the same file with a recorder of its own; the server names the file in the worker&#39;s environment:</p>
<!-- quote: start.ts -->
<pre><code> env: { TODO_TELEMETRY: <span class="tk-f">telemetryFile</span>(dataDir) },</code></pre>
<!-- quote: worker.ts -->
<pre><code><span class="tk-k">const</span> telemetry = <span class="tk-k">await</span> <span class="tk-f">openTelemetry</span>(process.env.TODO_TELEMETRY!);
<span class="tk-f">startWorker</span>({ api: API, kinds: KINDS, telemetry: telemetry.store, onError: (error) =&gt; console.<span class="tk-f">error</span>(<span class="tk-s">"list:"</span>, error) });</code></pre>
<p>One file for every process is the app&#39;s choice. SQLite in WAL mode lets several processes write it, each waiting for
the others&#39; 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.</p>
<h3 id="one-trace-across-three-hosts">One trace across three hosts</h3>
<p>A page in a browser cannot record yet: <code>startPage</code> takes no telemetry adapter. Step 11 makes the page itself, as Ada&#39;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&#39;s cookie from step 1. A click is one span of the page, and the call runs under it:</p>
<!-- quote: main.ts -->
<pre><code><span class="tk-k">const</span> pageTelemetry = <span class="tk-k">await</span> <span class="tk-f">openTelemetry</span>(<span class="tk-f">telemetryFile</span>(dataDir));
<span class="tk-k">const</span> page = <span class="tk-f">createHost</span>&lt;<span class="tk-t">TodoPrincipal</span>&gt;({
telemetry: pageTelemetry.store,
onError: (error) =&gt; console.<span class="tk-f">error</span>(<span class="tk-s">"page:"</span>, error.kind),
});
<span class="tk-k">await</span> page.<span class="tk-f">load</span>([], context);
<span class="tk-k">const</span> pageSocket = <span class="tk-k">new</span> <span class="tk-t">WebSocket</span>(<span class="tk-s">`${url.replace(/^http/, "ws")}/chord`</span>, { headers: { cookie } });
page.<span class="tk-f">connect</span>(<span class="tk-f">webSocketLink</span>(pageSocket <span class="tk-k">as</span> <span class="tk-k">unknown</span> <span class="tk-k">as</span> <span class="tk-t">WebSocketLike</span>), { principal: { kind: <span class="tk-s">"server"</span> } }, context);
<span class="tk-k">const</span> pageTodos = <span class="tk-k">await</span> page.<span class="tk-f">get</span>(<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> }, context);
<span class="tk-k">const</span> click = (text: string) =&gt;
pageTelemetry.store.<span class="tk-f">startSpan</span>({ name: <span class="tk-s">"page.add"</span> }, (span) =&gt; pageTodos.<span class="tk-f">add</span>(text, <span class="tk-f">withTelemetry</span>(span, context)));
<span class="tk-k">await</span> <span class="tk-f">click</span>(<span class="tk-s">"honey"</span>);
<span class="tk-k">await</span> <span class="tk-f">click</span>(<span class="tk-s">" "</span>).<span class="tk-k">catch</span>(() =&gt; <span class="tk-s">"refused"</span>);</code></pre>
<p><code>startSpan</code> runs the callback in a new root span; <code>withTelemetry(span, context)</code> makes it the context&#39;s span, so the
call of <code>add</code> starts its <code>chord.call</code> under it and puts that span&#39;s carrier in the call&#39;s header. Each recorder writes
once a second, so the step reads the store until the worker&#39;s spans are there, takes the older of the two traces whose
first span is <code>page.add</code> and prints it with pi-telemetry&#39;s <code>renderTrace</code> (main.ts masks the lengths of time, which
differ at every run):</p>
<!-- output: main -->
<pre class="plain"><code>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=&lt;ms&gt;
todos.validate chars=5</code></pre>
<p>Three processes recorded these six spans. The page recorded <code>page.add</code> and <code>chord.call</code>, the consumer&#39;s call until its
reply. The server recorded <code>chord.relay</code>: it forwarded the call to the list&#39;s worker without knowing member names
(<code>#0</code> is the method&#39;s index). The worker recorded the rest: <code>chord.method</code> for the owner&#39;s <code>Todos</code> view, then the
view&#39;s call of the store, which is a method span too, by <code>worker</code>, 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&#39;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.</p>
<figure class="fig anim"><svg viewBox="0 0 720 326" width="100%" role="img" aria-label="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." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f5m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f5a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f5b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="130.7" y1="52" x2="130.7" y2="318" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="45.7" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="130.7" y="27" font-size="11" fill="#2a2723" text-anchor="middle">page</text><text x="130.7" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">recorder 1</text><line x1="360" y1="52" x2="360" y2="318" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="275" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="360" y="27" font-size="11" fill="#2a2723" text-anchor="middle">server</text><text x="360" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">recorder 2</text><line x1="589.3" y1="52" x2="589.3" y2="318" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="504.3" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="589.3" y="27" font-size="11" fill="#2a2723" text-anchor="middle">list|groceries</text><text x="589.3" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">recorder 3</text><g class="step edge" style="--i:1"><path d="M130.7 72 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/><text x="172.7" y="82" font-size="10" fill="#2a2723" text-anchor="start">page.add, chord.call</text></g><g class="step edge" style="--i:2"><line x1="130.7" y1="118" x2="357" y2="118" stroke="#b4472f" marker-end="url(#f5a)"/><text x="245.3" y="112" font-size="10" fill="#b4472f" text-anchor="middle">call add, carrier of chord.call</text></g><g class="step edge" style="--i:3"><path d="M360 144 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/><text x="402" y="154" font-size="10" fill="#2a2723" text-anchor="start">chord.relay</text></g><g class="step edge" style="--i:4"><line x1="360" y1="190" x2="586.3" y2="190" stroke="#b4472f" marker-end="url(#f5a)"/><text x="474.7" y="184" font-size="10" fill="#b4472f" text-anchor="middle">call #0, carrier of chord.relay</text></g><g class="step edge" style="--i:5"><path d="M589.3 216 h-34 v14 h32" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/><text x="547.3" y="226" font-size="10" fill="#2a2723" text-anchor="end">chord.method ×2, todos.validate, commit</text></g><g class="step edge" style="--i:6"><line x1="589.3" y1="262" x2="363" y2="262" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f5m)"/><text x="474.7" y="256" font-size="10" fill="#2a2723" text-anchor="middle">reply</text></g><g class="step edge" style="--i:7"><line x1="360" y1="294" x2="133.7" y2="294" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f5m)"/><text x="245.3" y="288" font-size="10" fill="#2a2723" text-anchor="middle">reply</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 5.</span> One click, one trace: each process starts its spans with its own recorder, and the carrier in each call&#39;s header names the parent span on the other side.</figcaption></figure>
<p>The carrier is a field of the <code>call</code> and <code>hold</code> 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).</p>
<p>The trace holds the click and nothing else. Work that serves many requests is its own trace, <strong>linked</strong> to the span that
first needed it: a worker&#39;s start (<code>chord.resolve</code>), an object&#39;s activation (<code>chord.activate</code>). A link is a reference to
a span of another trace; the renderer prints it and never follows it.</p>
<h3 id="trap-a-trace-starts-where-a-span-starts">Trap: a trace starts where a span starts</h3>
<p>The page took its reference to <code>Todos</code> before the click, under <code>BACKGROUND_CONTEXT</code>, and that hold is in no trace of
the page: <strong>a context without a recording span records nothing.</strong> A host gives such work no span of its own, so its
calls start no <code>chord.call</code> and send no carrier, and the provider&#39;s <code>chord.method</code> becomes a root of its own trace
(the server&#39;s relay of the page&#39;s hold is one: <code>chord.relay</code> with <code>member=&quot;hold&quot;</code>). So were the server&#39;s calls in step 2:
on the worker each is a root <code>chord.method</code>. Start a span where the work begins, a click handler or a job, and pass it
down with <code>withTelemetry</code>. The kit&#39;s HTTP component does this for every request: a <code>chord.http</code> span (method, route
pattern, status), with the route&#39;s <code>ctx</code> under it.</p>
<h3 id="a-span-of-your-own">A span of your own</h3>
<p>The store&#39;s <code>add</code> checks the text in a span of its own:</p>
<!-- quote: plugins/lists/worker.ts -->
<pre><code> <span class="tk-k">await</span> ctx.<span class="tk-f">trace</span>(<span class="tk-s">"todos.validate"</span>, { chars: text.length }, <span class="tk-k">async</span> () =&gt; {
<span class="tk-c">// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.</span>
<span class="tk-k">if</span> (text === <span class="tk-s">""</span>) <span class="tk-k">throw</span> <span class="tk-f">invalid</span>(<span class="tk-s">"args"</span>, <span class="tk-s">"a todo needs text"</span>);
});</code></pre>
<p><code>ctx.trace(name, attributes, fn)</code> starts a child of the method&#39;s span and gives <code>fn</code> a child <code>ctx</code> with the same
principal and <code>get</code>, so calls made inside are parented under it. It is the last line of the trace above:
<code>todos.validate chars=5</code>. Its attributes are small, flat values; the todo&#39;s text is data and stays out of telemetry,
its length is enough. When <code>fn</code> throws, the span fails and the error passes through as it was: the caller still gets
<code>invalid/args</code>. A check this small does not need a span of its own; a lookup, a model request or a file read does.</p>
<h3 id="reading">Reading</h3>
<p>The store is read through <code>TelemetryStore</code>, pi-telemetry&#39;s read interface modeled on Jaeger&#39;s: <code>findTraces</code> (complete
traces, newest first, filtered by their first span&#39;s name, attributes, errors or a time window, paged by a cursor),
<code>getTrace(id)</code> and <code>findMetrics</code> (a metric&#39;s rows, one per series and 10 seconds, with count, sum, min and max).
<code>trace.ts</code> is a script for a data directory, the app running or not:</p>
<!-- quote: trace.ts -->
<pre><code> <span class="tk-k">if</span> (id !== <span class="tk-k">undefined</span>) {
<span class="tk-k">const</span> trace = <span class="tk-k">await</span> telemetry.store.<span class="tk-f">getTrace</span>(id);
console.<span class="tk-f">log</span>(trace === <span class="tk-k">undefined</span> ? <span class="tk-s">`no trace ${id}`</span> : <span class="tk-f">renderTrace</span>(trace.spans));
} <span class="tk-k">else</span> {
<span class="tk-c">// Complete traces, newest first; a trace's first span (its root, by start time) names it.</span>
<span class="tk-k">const</span> { traces } = <span class="tk-k">await</span> telemetry.store.<span class="tk-f">findTraces</span>({ name: values.name, limit: <span class="tk-n">10</span> });
<span class="tk-k">for</span> (<span class="tk-k">const</span> { traceId, spans } <span class="tk-k">of</span> traces) {
<span class="tk-k">const</span> start = <span class="tk-t">Math</span>.<span class="tk-f">min</span>(...spans.<span class="tk-f">map</span>((span) =&gt; span.startedMs));
<span class="tk-k">const</span> length = <span class="tk-t">Math</span>.<span class="tk-f">max</span>(...spans.<span class="tk-f">map</span>((span) =&gt; span.endedMs)) - start;
<span class="tk-k">const</span> failed = spans.<span class="tk-f">some</span>((span) =&gt; span.status.status === <span class="tk-s">"error"</span>) ? <span class="tk-s">" error"</span> : <span class="tk-s">""</span>;
console.<span class="tk-f">log</span>(<span class="tk-s">`${traceId} ${spans[0]!.name} ${spans.length} spans ${length.toFixed(2)} ms${failed}`</span>);
}
<span class="tk-c">// Metrics are stored per series and 10 s: a series is one set of attributes, here the direction.</span>
<span class="tk-k">for</span> (<span class="tk-k">const</span> direction <span class="tk-k">of</span> [<span class="tk-s">"sent"</span>, <span class="tk-s">"received"</span>]) {
<span class="tk-k">const</span> rows = <span class="tk-k">await</span> telemetry.store.<span class="tk-f">findMetrics</span>({ name: <span class="tk-s">"chord.link.bytes"</span>, attributes: { direction } });
<span class="tk-k">const</span> bytes = rows.<span class="tk-f">reduce</span>((sum, row) =&gt; sum + row.sum, <span class="tk-n">0</span>);
<span class="tk-k">const</span> messages = rows.<span class="tk-f">reduce</span>((sum, row) =&gt; sum + row.count, <span class="tk-n">0</span>);
console.<span class="tk-f">log</span>(<span class="tk-s">`chord.link.bytes ${direction}: ${bytes} bytes in ${messages} messages`</span>);
}
}</code></pre>
<p>Step 11 runs it twice from a process of its own: the newest <code>page.add</code> traces, then the refused one by its id (ids,
lengths and counts masked):</p>
<!-- output: main -->
<pre class="plain"><code> trace.ts &lt;data directory&gt; --name page.add:
&lt;id&gt; page.add 6 spans &lt;ms&gt; error
&lt;id&gt; page.add 6 spans &lt;ms&gt;
chord.link.bytes sent: &lt;n&gt; bytes in &lt;n&gt; messages
chord.link.bytes received: &lt;n&gt; bytes in &lt;n&gt; messages
trace.ts &lt;data directory&gt; &lt;the refused one's id&gt;:
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</code></pre>
<p>A trace&#39;s id is its root span&#39;s. The refused add failed on every span of its path, each with a fixed category
(<code>call_failed</code>, <code>relay_failed</code>, <code>method_failed</code>, <code>trace_failed</code>) and the message cut at 500 characters; a cancelled call
is not an error. It has no commit: nothing was written. <code>chord.link.bytes</code> counts the encoded messages of every host&#39;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 (<code>direction</code>), so its series stay few. A running recorder writes a metric&#39;s current 10 seconds only
once they are over, or when it closes: <code>trace.ts</code> sees the page&#39;s bytes, whose recorder closed, and the server&#39;s and
workers&#39; 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.</p>
<h3 id="a-trace-viewer">A trace viewer</h3>
<p>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, <code>plugins/traces/</code>. Its server facet serves one host-lifetime object, which opens the store&#39;s file as a
native and contributes the routes while it is live:</p>
<!-- quote: plugins/traces/server.ts -->
<pre><code> host.<span class="tk-f">serve</span>(<span class="tk-t">Traces</span>, (_params, self) =&gt; {
<span class="tk-k">const</span> info = self.<span class="tk-f">get</span>(<span class="tk-t">AppInfo</span>, {});
<span class="tk-c">// The server records to the same file with its own recorder; this one only reads.</span>
<span class="tk-k">const</span> telemetry = self.<span class="tk-f">open</span>(
<span class="tk-k">async</span> () =&gt; <span class="tk-f">openTelemetry</span>(<span class="tk-f">telemetryFile</span>(info.dataDir.value)),
(opened) =&gt; opened.<span class="tk-f">close</span>(),
);
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
routes: {
<span class="tk-s">"GET /api/traces"</span>: <span class="tk-k">async</span> (_request, ctx) =&gt; {
<span class="tk-f">ownerOnly</span>(ctx);
<span class="tk-k">const</span> { traces } = <span class="tk-k">await</span> telemetry.value.store.<span class="tk-f">findTraces</span>({ limit: RECENT });
<span class="tk-k">return</span> { kind: <span class="tk-s">"json"</span>, value: traces.<span class="tk-f">map</span>(({ traceId, spans }) =&gt; <span class="tk-f">summaryOf</span>(traceId, spans)) };
},
<span class="tk-s">"GET /api/traces/:id"</span>: <span class="tk-k">async</span> (request, ctx) =&gt; {
<span class="tk-f">ownerOnly</span>(ctx);
<span class="tk-k">const</span> trace = <span class="tk-k">await</span> telemetry.value.store.<span class="tk-f">getTrace</span>(request.params.id!);
<span class="tk-k">if</span> (trace === <span class="tk-k">undefined</span>) <span class="tk-k">return</span> { kind: <span class="tk-s">"error"</span>, status: <span class="tk-n">404</span> };
<span class="tk-k">return</span> { kind: <span class="tk-s">"json"</span>, value: { id: trace.traceId, spans: <span class="tk-f">rowsOf</span>(trace.spans) } };
},
},
});
<span class="tk-k">return</span> {};
});</code></pre>
<p>The kit gives a plugin the server&#39;s process values as objects: <code>AppInfo</code> names the data directory, and the API&#39;s server
module re-exports it with the app&#39;s <code>openTelemetry</code>. <code>self.open</code> declares a native, opened once <code>AppInfo</code> 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 (<code>local</code>, empty params; decision R21). Spans name who called what, so
only the owner reads them:</p>
<!-- quote: plugins/traces/server.ts -->
<pre><code><span class="tk-k">function</span> <span class="tk-f">ownerOnly</span>(ctx: <span class="tk-t">Ctx</span>): <span class="tk-k">void</span> {
<span class="tk-k">if</span> (ctx.principal?.kind !== <span class="tk-s">"owner"</span>) <span class="tk-k">throw</span> <span class="tk-f">forbidden</span>(<span class="tk-s">"the traces are the owner's"</span>);
}</code></pre>
<p>A route&#39;s principal is <code>undefined</code> only on a public route, which these are not; a signed-in member gets 403. <code>rowsOf</code>
orders a trace&#39;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&#39;s span ids begin with the id of the recorder, one per process, and the viewer
calls each recorder a host:</p>
<!-- quote: plugins/traces/server.ts -->
<pre><code><span class="tk-k">function</span> <span class="tk-f">processOf</span>(span: <span class="tk-t">StoredSpan</span>): string {
<span class="tk-k">return</span> span.id.<span class="tk-f">slice</span>(<span class="tk-n">0</span>, span.id.<span class="tk-f">lastIndexOf</span>(<span class="tk-s">":"</span>));
}</code></pre>
<p>That is a detail of this store, not of the read interface (see the epilogue). The page is the app&#39;s own page at a
second path: <code>start.ts</code> serves it at <code>/</code> and <code>/traces</code> (chapter I), and the web entry picks the viewer by the path:</p>
<!-- quote: src/web/main.ts -->
<pre><code> <span class="tk-c">// The page's one other path (`start.ts` serves the page at `/` and `/traces`).</span>
<span class="tk-k">if</span> (location.pathname === <span class="tk-s">"/traces"</span>) <span class="tk-k">return</span> <span class="tk-f">h</span>(<span class="tk-s">"main"</span>, { className: <span class="tk-s">"wide"</span> }, <span class="tk-f">h</span>(<span class="tk-s">"h1"</span>, <span class="tk-k">null</span>, <span class="tk-s">"Todo"</span>), <span class="tk-f">h</span>(<span class="tk-t">TraceViewer</span>));</code></pre>
<p><code>TraceViewer</code> (<code>src/web/traces.ts</code>) fetches the list and the trace chosen in the URL&#39;s fragment, and draws each span
with its host, its length as a bar on the trace&#39;s time line, its attributes and events, a failed span in the theme&#39;s
error colour, and each link as the ids it refers to, never fetched. The bars are positioned with inline styles, which
the page&#39;s CSP allows only because React sets them through the DOM:</p>
<!-- quote: src/web/traces.ts -->
<pre><code> <span class="tk-k">const</span> bar = {
left: <span class="tk-s">`${(row.offsetMs / total) * 100}%`</span>,
width: <span class="tk-s">`${Math.max((row.durationMs / total) * 100, 0.5)}%`</span>,
};</code></pre>
<p><code>screenshots.ts</code> adds honey from a recording page and refuses a blank todo, as step 11 does, then opens the viewer on
honey&#39;s trace:</p>
<!-- quote: screenshots.ts -->
<pre><code> <span class="tk-k">await</span> browser.<span class="tk-f">open</span>(<span class="tk-s">`${origin}/traces#${encodeURIComponent(trace.traceId)}`</span>);
<span class="tk-k">await</span> browser.<span class="tk-f">wait</span>(<span class="tk-s">"document.querySelectorAll('.traces-tree li').length === 6"</span>);
<span class="tk-k">await</span> <span class="tk-f">shoot</span>(<span class="tk-s">"traces.png"</span>);</code></pre>
<!-- output: screenshots -->
<pre class="plain"><code>wrote docs/tutorial/screens/traces.png
the viewer shows page.add: 6 spans on 3 hosts</code></pre>
<figure class="fig wide"><p><img src="screens/traces.png" alt="The trace viewer"></p>
<figcaption><span class="fignum">Figure 6.</span> The owner&#39;s trace viewer at <code>/traces</code>, in the Harbor theme: the newest traces (the refused add marked in red, the rest the app&#39;s own work: relays, activations, HTTP requests), and honey&#39;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.</figcaption></figure>
<p>The list is mostly the app&#39;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 (<code>findTraces({ name })</code>) or by error (<code>errors: true</code>), 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.</p>
</section>
<section class="chapter" id="testing"><header class="opener"><div class="numeral">XII</div><h2>Testing One Plugin</h2><p class="lede">testPlugin builds a plugin as production does and loads it alone on a host of its own.</p></header>
<p class="dropcap">The steps above test the whole app. Most rules are checked faster on one plugin. <code>testPlugin</code> builds the plugin as <p class="dropcap">The steps above test the whole app. Most rules are checked faster on one plugin. <code>testPlugin</code> builds the plugin as
production builds it (the same esbuild call, the API&#39;s imports checked), evaluates it with the app&#39;s real API module and production builds it (the same esbuild call, the API&#39;s imports checked), evaluates it with the app&#39;s real API module and
loads it on a new host with an in-memory store, here at a list&#39;s address so it may serve <code>Todos</code>:</p> loads it on a new host with an in-memory store, here at a list&#39;s address so it may serve <code>Todos</code>:</p>
@ -1306,7 +1577,7 @@ the refusal of chapter II:</p>
<span class="tk-f">expect</span>(refused).<span class="tk-f">toMatchObject</span>({ code: <span class="tk-s">"invalid"</span>, reason: <span class="tk-s">"args"</span> });</code></pre> <span class="tk-f">expect</span>(refused).<span class="tk-f">toMatchObject</span>({ code: <span class="tk-s">"invalid"</span>, reason: <span class="tk-s">"args"</span> });</code></pre>
</section> </section>
<section class="chapter" id="p2"><header class="opener"><div class="numeral">XII</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header> <section class="chapter" id="p2"><header class="opener"><div class="numeral">XIII</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header>
<p class="dropcap">p2, the coding agent&#39;s server with its web page and terminal, runs on Chord v2 but not on the app kit. It is older than <p class="dropcap">p2, the coding agent&#39;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, 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&#39;s components directly: <code>createHost</code> with p2&#39;s principals, its own page and terminal bundles. It uses Chord&#39;s components directly: <code>createHost</code> with p2&#39;s principals,
@ -1449,12 +1720,14 @@ reload. The trap of chapter II applies with p2&#39;s names: a server method that
server runs in a root of the server&#39;s own (<code>serverOf(self)().root</code>), not through its caller&#39;s <code>ctx.get</code>.</p> server runs in a root of the server&#39;s own (<code>serverOf(self)().root</code>), not through its caller&#39;s <code>ctx.get</code>.</p>
</section> </section>
<section class="chapter epilogue" id="traps-and-open-questions"><header class="opener"><div class="numeral">∴</div><h2>Traps and Open Questions</h2><p class="lede">The five traps once more, and what in Chord v2 was hard to explain while writing this.</p></header> <section class="chapter epilogue" id="traps-and-open-questions"><header class="opener"><div class="numeral">∴</div><h2>Traps and Open Questions</h2><p class="lede">The six traps once more, and what in Chord v2 was hard to explain while writing this.</p></header>
<p class="dropcap"><strong>The traps.</strong> Load before use: a host binds nothing before <code>load</code>; a client with nothing to load calls <code>load([])</code>. A <p class="dropcap"><strong>The traps.</strong> Load before use: a host binds nothing before <code>load</code>; a client with nothing to load calls <code>load([])</code>. A
reference acts as whoever made it: <code>ctx.get</code> as the caller, <code>host.get</code> and a local object&#39;s <code>self.get</code> as the host. reference acts as whoever made it: <code>ctx.get</code> as the caller, <code>host.get</code> and a local object&#39;s <code>self.get</code> as the host.
Await what you start: a write started with a context that ends is cancelled and reported <code>transaction_cancelled</code>. A Await what you start: a write started with a context that ends is cancelled and reported <code>transaction_cancelled</code>. A
watch starts at the current value: read <code>handle.value</code>, then <code>start</code>; the listener gets changes only. The plain <code>t</code> watch starts at the current value: read <code>handle.value</code>, then <code>start</code>; the listener gets changes only. The plain <code>t</code>
has no language of its own: components and screens render with <code>useMessages(t)</code> and <code>scope.messages(t)</code>.</p> has no language of its own: components and screens render with <code>useMessages(t)</code> and <code>scope.messages(t)</code>. A trace
starts where a span starts: work under a context without a recording span records nothing, and its provider&#39;s span is
a root of its own.</p>
<p><strong>Found while writing the example, and fixed in Chord since.</strong> Integer table keys were typed <code>string</code> (a leftover <p><strong>Found while writing the example, and fixed in Chord since.</strong> Integer table keys were typed <code>string</code> (a leftover
of the switch from TypeBox), so <code>tx.update(items, { id }, ...)</code> with a number failed <code>tsc</code>; the store now uses it. A of the switch from TypeBox), so <code>tx.update(items, { id }, ...)</code> with a number failed <code>tsc</code>; the store now uses it. A
reloaded web plugin lost its own React state (now <code>usePageState</code>, reloaded web plugin lost its own React state (now <code>usePageState</code>,
@ -1462,6 +1735,14 @@ chapter IX), a plugin&#39;s <code>contract.ts</code> could not be imported outsi
chapter I), plugins could not throw Chord&#39;s <code>invalid</code> (Chord exports <code>invalid</code> and <code>forbidden</code>, chapter II), and a chapter I), plugins could not throw Chord&#39;s <code>invalid</code> (Chord exports <code>invalid</code> and <code>forbidden</code>, chapter II), and a
live window over a table needed a one-time read in <code>self.open</code>, which made the store restart at every reload of its live window over a table needed a one-time read in <code>self.open</code>, which made the store restart at every reload of its
plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX).</p> plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX).</p>
<p><strong>Found while writing the telemetry chapter, still open.</strong> A page cannot record: <code>startPage</code> takes no telemetry
adapter, so chapter XI&#39;s page is a client host in Node, and a browser&#39;s click starts its trace at the server&#39;s relay.
<code>chord.commit</code> counts documents and their ops only, so a commit that wrote table rows reports <code>documents=0 ops=0</code>.
Spans name no host, and the viewer tells processes apart by the SQLite store&#39;s id format (<code>&lt;recorder&gt;:&lt;n&gt;</code>), which
the read interface does not promise. The store keeps a span&#39;s start in whole milliseconds (<code>Date.now()</code>) and its
length in fractions (<code>performance.now()</code>), so on a trace of a few milliseconds the viewer&#39;s bars shift by up to one. And
reading needs a recorder: <code>chord/telemetry-sqlite</code> exports no read-only
store, so a script or a viewer opens a recorder that records nothing.</p>
</section> </section>
<section class="chapter appendix" id="the-app-s-files"><header class="opener"><div class="numeral">A</div><h2>The App&#39;s Files</h2></header> <section class="chapter appendix" id="the-app-s-files"><header class="opener"><div class="numeral">A</div><h2>The App&#39;s Files</h2></header>
@ -1491,7 +1772,7 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
<tr> <tr>
<td><code>src/api/v1/server.ts</code></td> <td><code>src/api/v1/server.ts</code></td>
<td>base, server</td> <td>base, server</td>
<td><code>Http</code>, the ephemeral <code>login</code></td> <td><code>Http</code>, the ephemeral <code>login</code>, <code>AppInfo</code>, <code>openTelemetry</code></td>
</tr> </tr>
<tr> <tr>
<td><code>src/api/v1/web.ts</code></td> <td><code>src/api/v1/web.ts</code></td>
@ -1506,12 +1787,22 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
<tr> <tr>
<td><code>start.ts</code>, <code>worker.ts</code></td> <td><code>start.ts</code>, <code>worker.ts</code></td>
<td>base</td> <td>base</td>
<td><code>startServer</code>, <code>startWorker</code></td> <td><code>startServer</code>, <code>startWorker</code>, each with the telemetry store</td>
</tr>
<tr>
<td><code>src/telemetry.ts</code></td>
<td>base</td>
<td>the telemetry store&#39;s file in the data directory, <code>openTelemetry</code></td>
</tr> </tr>
<tr> <tr>
<td><code>src/web/main.ts</code>, <code>src/web/style.css</code></td> <td><code>src/web/main.ts</code>, <code>src/web/style.css</code></td>
<td>base, web entry</td> <td>base, web entry</td>
<td><code>startPage</code>, the panels</td> <td><code>startPage</code>, the panels, the viewer at <code>/traces</code></td>
</tr>
<tr>
<td><code>src/web/traces.ts</code></td>
<td>base, web entry</td>
<td>the owner&#39;s trace viewer</td>
</tr> </tr>
<tr> <tr>
<td><code>src/tui/main.ts</code></td> <td><code>src/tui/main.ts</code></td>
@ -1534,6 +1825,11 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
<td>the kit&#39;s ephemeral login</td> <td>the kit&#39;s ephemeral login</td>
</tr> </tr>
<tr> <tr>
<td><code>plugins/traces/</code></td>
<td>plugin</td>
<td>the trace viewer&#39;s routes over the telemetry store (server), the owner&#39;s only</td>
</tr>
<tr>
<td><code>later/harbor/</code></td> <td><code>later/harbor/</code></td>
<td>plugin, added in step 6</td> <td>plugin, added in step 6</td>
<td>the Harbor theme for pages and terminals</td> <td>the Harbor theme for pages and terminals</td>
@ -1543,6 +1839,11 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
<td>example</td> <td>example</td>
<td>the steps, the screenshots, the checks</td> <td>the steps, the screenshots, the checks</td>
</tr> </tr>
<tr>
<td><code>trace.ts</code></td>
<td>example</td>
<td>a data directory&#39;s newest traces, one trace, a metric</td>
</tr>
</tbody></table> </tbody></table>
</section> </section>
@ -1646,8 +1947,28 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
<td>moving a worker&#39;s data</td> <td>moving a worker&#39;s data</td>
</tr> </tr>
<tr> <tr>
<td><code>testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)</code></td> <td><code>createSqliteTelemetry(db)</code>, <code>startServer({ telemetry })</code>, <code>startWorker({ telemetry })</code>, <code>createHost({ telemetry })</code></td>
<td>XI</td> <td>XI</td>
<td>recording to the telemetry store</td>
</tr>
<tr>
<td><code>adapter.startSpan({ name }, fn)</code>, <code>withTelemetry(span, ctx)</code>, <code>ctx.trace(name, attributes, fn)</code></td>
<td>XI</td>
<td>spans of your own</td>
</tr>
<tr>
<td><code>store.findTraces({ name, attributes, errors, startMin, startMax, limit, cursor })</code>, <code>store.getTrace(id)</code>, <code>store.findMetrics({ name, attributes })</code>, <code>renderTrace(spans)</code></td>
<td>XI</td>
<td>reading traces and metrics</td>
</tr>
<tr>
<td><code>AppInfo</code>, <code>self.open(open, close)</code></td>
<td>XI</td>
<td>the data directory, a native (the viewer&#39;s store)</td>
</tr>
<tr>
<td><code>testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)</code></td>
<td>XII</td>
<td>one plugin alone</td> <td>one plugin alone</td>
</tr> </tr>
</tbody></table> </tbody></table>
@ -1655,5 +1976,51 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX
<p class="colophon">Together · first edition · 2026-11-03 · Chord v2 slices 1-13, the app kit, example 61</p> <p class="colophon">Together · first edition · 2026-11-03 · Chord v2 slices 1-13, the app kit, example 61</p>
</div></div> </div></div>
<script>(function activate(root) {
const replay = (f) => {
f.classList.remove("play");
void f.getBoundingClientRect();
f.classList.add("play");
};
const io = new IntersectionObserver(
(es) => {
for (const e of es)
if (e.isIntersecting) {
e.target.classList.add("play");
io.unobserve(e.target);
}
},
{ threshold: 0.3 },
);
root.querySelectorAll(".anim:not(.armed)").forEach((f) => {
f.classList.add("armed");
io.observe(f);
const b = f.querySelector(".replay");
if (b) b.onclick = () => replay(f);
});
root.querySelectorAll(".steps:not(.js)").forEach((s) => {
s.classList.add("js");
const ps = [...s.querySelectorAll(":scope>.step-panel")];
let i = 0;
const c = s.querySelector(".count"),
p = s.querySelector(".prev"),
n = s.querySelector(".next");
const show = () => {
ps.forEach((x, j) => x.classList.toggle("on", j === i));
c.textContent = `${i + 1} / ${ps.length}`;
p.disabled = i === 0;
n.disabled = i === ps.length - 1;
ps[i].querySelectorAll(".anim").forEach(replay);
};
p.onclick = () => {
i--;
show();
};
n.onclick = () => {
i++;
show();
};
show();
});
})(document)</script>
</body></html> </body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View file

@ -1,17 +1,22 @@
// The tutorial's app (`docs/tutorial/book.md`): a shared todo app built with the app kit, one worker per list. Each // The tutorial's app (`docs/tutorial/book.md`): a shared todo app built with the app kit, one worker per list. Each
// numbered step is the tutorial's chapter of that number: the server and the owner's sign-in, a list on its worker, // numbered step is the tutorial's chapter of that number: the server and the owner's sign-in, a list on its worker,
// routes, the page, the terminal, a theme plugin added while the app runs, a second user, languages, an edit applied in // routes, the page, the terminal, a theme plugin added while the app runs, a second user, languages, an edit applied in
// place, and a list exported and imported. The lists plugin's own test is `plugins/lists/worker.test.ts`; // place, a list exported and imported, and a trace across the page, the server and a worker, read back from the app's
// `screenshots.ts` shows the page in Chromium. // telemetry store. The lists plugin's own test is `plugins/lists/worker.test.ts`; `screenshots.ts` shows the page and
// the trace viewer in Chromium; `trace.ts` reads a data directory's traces.
// Run from packages/chord (with Node 26.5.1 or later the terminal client runs too; `--serve` keeps the app running): // Run from packages/chord (with Node 26.5.1 or later the terminal client runs too; `--serve` keeps the app running):
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts // node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts
import { execFile } from "node:child_process";
import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import type { StoredTrace } from "@earendil-works/pi-telemetry";
import { renderTrace } from "@earendil-works/pi-telemetry/render";
import { List } from "@todo/app/v1"; import { List } from "@todo/app/v1";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { BACKGROUND_CONTEXT as context, withTelemetry } from "../../../src/context/index.ts";
import { import {
type ChordError, type ChordError,
createHost, createHost,
@ -24,6 +29,7 @@ import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../.
import { type Item, Todos } from "./plugins/lists/contract.ts"; import { type Item, Todos } from "./plugins/lists/contract.ts";
import { messages } from "./plugins/lists/messages.ts"; import { messages } from "./plugins/lists/messages.ts";
import type { TodoPrincipal } from "./src/principals.ts"; import type { TodoPrincipal } from "./src/principals.ts";
import { openTelemetry, type TodoTelemetry, telemetryFile } from "./src/telemetry.ts";
import { startTodo, todoDirectories } from "./start.ts"; import { startTodo, todoDirectories } from "./start.ts";
const { base, dataDir, root } = await todoDirectories(); const { base, dataDir, root } = await todoDirectories();
@ -31,7 +37,8 @@ const { base, dataDir, root } = await todoDirectories();
// 1. Start the app: the kit makes the data directory, the server's store and host, HTTP and the owner link. Nobody is // 1. Start the app: the kit makes the data directory, the server's store and host, HTTP and the owner link. Nobody is
// signed in yet, so the page answers 401. The owner opens the link: its page posts the token from the URL's fragment // signed in yet, so the page answers 401. The owner opens the link: its page posts the token from the URL's fragment
// and gets a session cookie. // and gets a session cookie.
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 link = new URL(app.ownerLink());
const url = link.origin; const url = link.origin;
console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`); console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`);
@ -242,16 +249,52 @@ await app.importData(archive, List({ id: "groceries" }), context).catch((error:
console.log(" importing over groceries:", error.message.replace(base, "…")); 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<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");
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 <data directory> --name page.add:");
printTelemetry(await read(dataDir, "--name", "page.add"));
console.log(" trace.ts <data directory> <the refused one's id>:");
printTelemetry(await read(dataDir, refusedTrace!.traceId));
if (process.argv.includes("--serve")) { 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(`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 { } else {
await terminal?.stop(); await terminal?.stop();
groceries.release(); groceries.release();
adas.release(); adas.release();
await ada.close(context); await ada.close(context);
await app.close(context); await app.close(context);
await telemetry.close();
await rm(base, { recursive: true, force: true }); await rm(base, { recursive: true, force: true });
console.log("11. closed"); console.log("12. closed");
} }
/** Todos as one line: `[x] milk (owner), [ ] eggs (owner)`. */ /** 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, ""); .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<readonly StoredTrace[]> {
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, "<id>")
.replaceAll(/ \(\+?[\d.]+ms\)/g, "")
.replaceAll(/duration=[\d.]+/g, "duration=<ms>")
.replaceAll(/ [\d.]+ ms/g, " <ms>")
.replaceAll(/\d+ bytes in \d+ messages/g, "<n> bytes in <n> messages");
for (const line of steady.trim().split("\n")) console.log(` ${line}`);
}
/** Whether this Node can run the terminal's sandbox. */ /** Whether this Node can run the terminal's sandbox. */
function sandboxAvailable(): boolean { function sandboxAvailable(): boolean {
try { try {

View file

@ -3,7 +3,7 @@
// before the store goes live and again after every commit that writes the table. Each principal's `Todos` view adds // 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`), // 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 // 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 { import {
arr, arr,
@ -69,8 +69,11 @@ export default defineFacet((host) => {
items: self.derive([newest], ([rows]) => [...rows].reverse()), items: self.derive([newest], ([rows]) => [...rows].reverse()),
notice: self.derive([notices], ([value]) => value.notice), notice: self.derive([notices], ([value]) => value.notice),
add: async ({ text, by }, ctx) => { add: async ({ text, by }, ctx) => {
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. // A span of its own under the method's: a trace shows the check, and why it refused.
if (text === "") throw invalid("args", "a todo needs text"); 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) => { await self.tx(async (tx) => {
// The number after the newest todo's: the store's transactions run one after another. // The number after the newest todo's: the store's transactions run one after another.
const { items: last } = await tx.page(items, NEWEST, 1); const { items: last } = await tx.page(items, NEWEST, 1);

View file

@ -0,0 +1,5 @@
{
"name": "@todo/traces",
"private": true,
"type": "module"
}

View file

@ -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<string | undefined, StoredSpan[]>();
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<typeof rowOf>[] = [];
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 `<recorder>:<n>`, 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}`;
});
}

View file

@ -1,22 +1,27 @@
// Screenshots of the tutorial's page (`docs/tutorial/screens/`), taken in headless Chromium: the page after the owner // 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 // 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`): // 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 // 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 { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { WebSocket } from "ws";
import { LocaleChoice } from "../../../src/index.ts"; 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 { Browser, CHROMIUM } from "../../node/web/browser.ts";
import { Todos } from "./plugins/lists/contract.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"; import { startTodo, todoDirectories } from "./start.ts";
const out = resolve(process.argv[2] ?? "screens"); const out = resolve(process.argv[2] ?? "screens");
if (CHROMIUM === undefined) throw new Error("no Chromium: set CHROMIUM_BIN"); if (CHROMIUM === undefined) throw new Error("no Chromium: set CHROMIUM_BIN");
await mkdir(out, { recursive: true }); await mkdir(out, { recursive: true });
const { base, dataDir, root } = await todoDirectories(); 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. // 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<typeof app.host.root>[2], texts: readonly string[]): Promise<void> => { const as = async (principal: Parameters<typeof app.host.root>[2], texts: readonly string[]): Promise<void> => {
@ -109,8 +114,53 @@ try {
const lang = await browser.evaluate<string>("document.documentElement.lang"); const lang = await browser.evaluate<string>("document.documentElement.lang");
console.log(`after choosing French: lang="${lang}", ${JSON.stringify(french)} still typed`); console.log(`after choosing French: lang="${lang}", ${JSON.stringify(french)} still typed`);
console.log("the page did not reload:", await browser.evaluate<boolean>("window.marked === true")); console.log("the page did not reload:", await browser.evaluate<boolean>("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<TodoPrincipal>({ 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<number>(
"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 { } finally {
await browser.close(); await browser.close();
await app.close(context); await app.close(context);
await telemetry.close();
await rm(base, { recursive: true, force: true }); await rm(base, { recursive: true, force: true });
} }

View file

@ -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 // The todo app's API v1 for server facets (`@todo/app/v1/server`): the HTTP registry plugins add routes to, the kit's
// kit's pick-a-name login as a facet a one-line plugin exports. // 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 { ephemeralLoginFacet } from "../../../../../../src/node/app/index.ts";
import type { TodoPrincipal } from "../../principals.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 { 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 }`. */ /** The kit's ephemeral login: `POST /_chord/login { name }` signs in as `{ kind: "user", id, name }`. */
export const login = ephemeralLoginFacet<TodoPrincipal>(); export const login = ephemeralLoginFacet<TodoPrincipal>();

View file

@ -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<void>;
};
/** 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();
},
};
}

View file

@ -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 // 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 v1 from "@todo/app/v1";
import * as v1web from "@todo/app/v1/web"; 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 { HostProvider, useRegistry } from "../../../../../src/react/index.ts";
import { startPage } from "../../../../../src/web/index.ts"; import { startPage } from "../../../../../src/web/index.ts";
import "./style.css"; import "./style.css";
import { TraceViewer } from "./traces.ts";
/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */ /** 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) })); const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) => table.map(({ value }) => value) }));
function Page() { function Page() {
const panels = useRegistry(v1web.Panels) ?? []; 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( return h(
"main", "main",
null, null,

View file

@ -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; } main { max-width: 26rem; margin: 1.5rem auto; padding: 0 1rem; }
h1 { color: var(--accent); font-size: 1.4rem; margin: 0 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); } 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); }

View file

@ -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<T> = { 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<Answer<readonly Summary[]>>();
const [chosen, setChosen] = useState(fragment);
const [trace, setTrace] = useState<Answer<{ readonly spans: readonly Row[] }>>();
useEffect(() => {
void read<readonly Summary[]>("/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<T>(path: string): Promise<Answer<T>> {
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`;
}

View file

@ -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, // 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 // its API, its facet kinds, its principals, its one worker kind (a list), its page and its terminal client, and its
// does the rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls // 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. // it with the data directory from its command line and prints the owner link.
import { cp, mkdtemp } from "node:fs/promises"; 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 App, startServer } from "../../../src/node/app/index.ts";
import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts"; import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts";
import type { TodoPrincipal } from "./src/principals.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. */ /** The app's API: its name, its versions (newest first), and this process's instances of its modules. */
export const API = { export const API = {
@ -34,8 +35,16 @@ function page(request: { readonly nonce: string }, assets: PageAssets): string {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Todo</title>${pageScripts(assets, request.nonce)}</head><body><div id="root"></div></body></html>`; return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Todo</title>${pageScripts(assets, request.nonce)}</head><body><div id="root"></div></body></html>`;
} }
/** 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<App<TodoPrincipal>> { * 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<App<TodoPrincipal>> {
return startServer<TodoPrincipal>( return startServer<TodoPrincipal>(
{ {
name: "todo", name: "todo",
@ -45,17 +54,21 @@ export function startTodo(dataDir: string, root: string, context: Context): Prom
api: API, api: API,
kinds: KINDS, kinds: KINDS,
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) }, 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: [ workers: [
{ {
kind: v1.List, kind: v1.List,
entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
env: { TODO_TELEMETRY: telemetryFile(dataDir) },
start: () => ({}), start: () => ({}),
// Who may reach a list's worker: everyone signed in, and the server itself. // Who may reach a list's worker: everyone signed in, and the server itself.
authorize: (principal) => principal.kind !== "worker", 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)) }, tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) },
onError: (error) => console.error("todo:", error.kind, error.error), onError: (error) => console.error("todo:", error.kind, error.error),
}, },

View file

@ -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 <data directory> [--name <span>]
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/trace.ts <data directory> <trace id>
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 <data directory> [--name <span>] [<trace id>]");
// 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();
}

View file

@ -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 // The tutorial's checks (`docs/tutorial/book.md`): every TypeScript, JSON and CSS block of the book is quoted from this
// example's files (`<!-- quote: path -->` above it) and fails here when the file no longer has those lines; the // example's files (`<!-- quote: path -->` 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 // example's steps run (`main.ts`, which runs `trace.ts` on its data directory, and `screenshots.ts` with Chromium), and
// (`<!-- output: main -->`, `<!-- output: screenshots -->`) is among the lines they print, in order. The terminal's // every console output the book shows (`<!-- output: main -->`, `<!-- output: screenshots -->`) 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 (`<!-- output: tui -->`) is checked when a Node for its sandbox is there (`CHORD_TUI_NODE`, else `node26`). // output (`<!-- output: tui -->`) is checked when a Node for its sandbox is there (`CHORD_TUI_NODE`, else `node26`).
// The p2 chapter's blocks (`<!-- p2: path -->`) quote p2's sources, which live outside this repository. // The p2 chapter's blocks (`<!-- p2: path -->`) quote p2's sources, which live outside this repository.
// Run from packages/chord: // Run from packages/chord:

View file

@ -1,7 +1,10 @@
// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects // 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 { startWorker } from "../../../src/node/app/index.ts";
import { openTelemetry } from "./src/telemetry.ts";
import { API, KINDS } from "./start.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) });