Telemetry
Play telemetry events, the compact trace format and its decoder, and the ingestion that lands a serve on its first event.
The telemetry module models what the player reports about a played deal: a closed vocabulary of event types, and a compact trace of every game fact with its timing. The API ingests batches sent by the player, attaches them to the client session, and lands each referenced serve on the owner's quota. The decoder rebuilds a replay from the stored chunks. Rollups and analytics over these events live in Usage analytics.
Import from @workspace/training/domain/telemetry and @workspace/training/application/telemetry. The signed client session a batch is attached to comes from the SessionTokenService port, documented on the Ports page.
Domain
Event types and payloads
Every boundary validates against these unions; unknown values are rejected, never stored.
| Export | Definition |
|---|---|
TELEMETRY_EVENT_TYPES | ["deal-loaded", "play-entered", "play-finished", "result", "duration", "trace-chunk"] |
TelemetryEventType | Union of the above. |
TelemetryEventPayloads | Per-type payload shapes, keyed by the event vocabulary. |
TelemetryEventPayload | Union of every payload. |
Every payload may carry the structural references serveId?: string and code?: string, never free text.
| Type | Payload | Fields |
|---|---|---|
deal-loaded | DealLoadedPayload | base only |
play-entered | PlayEnteredPayload | auctionErrors?: number, bids the learner got wrong over the whole auction. Absent when the auction was never theirs to bid: a zero there would read as a flawless auction instead of an unmeasured one. |
play-finished | PlayFinishedPayload | tricks: number |
result | ResultPayload | stars: number |
duration | DurationPayload | durationMs: number |
trace-chunk | TraceChunkPayload | seq, part, steps (below) |
Prop
Type
TraceStep is a tuple [token, deltaMs] or [token, deltaMs, 1]: a closed-vocabulary token, the milliseconds since the previous step of the run (the first step counts from the run start), and 1 when the engine played the seat instead of the learner (bids and cards only). Seats, bid indexes and completed tricks are derivable, so never sent.
LegacyTraceStep and LegacyTraceChunkPayload are the verbose format shipped before the compact one. They are still accepted at ingestion while stale player bundles age out, never emitted, and carry seats and trick outcomes inline.
Trace tokens
One string per game fact, whose shape alone names the fact kind.
| Fact | Token | Example |
|---|---|---|
| bid placed | the bid | 1NT, Pass, X |
| card played | the card | SA, HT |
| hint | TRACE_HINT_TOKEN | hint |
| play error | error:<PlayErrorKind> | error:follow-suit |
| bid rejected | bid-error:<AuctionErrorKind>:<bid> | bid-error:wrong-bid:1NT, the bid refused in teaching mode |
| claim accepted | claim:<Partnership>:<NS tricks>-<EW tricks> | claim:NS:9-4, the claiming side and the final trick count of both partnerships |
| state replaced | <StateReplacedReason>:<plays> | undo:12, where plays counts the bids and cards left on the table once the state is rebuilt |
| Function | Signature | Description |
|---|---|---|
parseTraceToken | (token: string) => ParsedTraceToken | null | Null when the token is outside the closed vocabulary. |
playErrorToken | (error: PlayErrorKind) => string | |
bidRejectedToken | (error: AuctionErrorKind, bid: Bid) => string | |
playClaimedToken | (side: Partnership, tricks: PartnershipRecord<number>) => string | |
stateReplacedToken | (reason: StateReplacedReason, plays: number) => string | |
isTracePlayToken | (token: string) => boolean | Bid and card tokens, the only ones that may carry the auto flag. |
isTraceMarkerToken | (token: string) => boolean | Hint, play-error, bid-rejected, play-claimed and state-replaced tokens. |
ParsedTraceToken is { kind: "bid-placed"; bid }, { kind: "card-played"; card }, { kind: "hint" }, { kind: "play-error"; error }, { kind: "bid-rejected"; error; bid }, { kind: "play-claimed"; side; tricks } or { kind: "state-replaced"; reason; plays }.
parseTraceToken("1NT"); // => { kind: "bid-placed", bid: "1NT" }
parseTraceToken("SA"); // => { kind: "card-played", card: "SA" }
parseTraceToken(TRACE_HINT_TOKEN); // => { kind: "hint" }
parseTraceToken(playErrorToken("follow-suit")); // => { kind: "play-error", error: "follow-suit" }
parseTraceToken(bidRejectedToken("wrong-bid", "1NT")); // => { kind: "bid-rejected", error: "wrong-bid", bid: "1NT" }
parseTraceToken(playClaimedToken("NS", { NS: 9, EW: 4 })); // => { kind: "play-claimed", side: "NS", tricks: { NS: 9, EW: 4 } }
parseTraceToken(stateReplacedToken("undo", 12)); // => { kind: "state-replaced", reason: "undo", plays: 12 }
parseTraceToken("whatever"); // => null: outside the closed vocabulary
isTracePlayToken("Pass"); // => true: only bids and cards may carry the `auto` flag
isTraceMarkerToken("undo:3"); // => trueDecoding a trace
The decoder is the replay's input format. It rebuilds what the wire leaves out (seats, bid indexes, completed tricks and their winners) from the token sequence and the deal, with the same rules the player's engine applies.
Prop
Type
| Function | Signature | Description |
|---|---|---|
splitTraceRuns | (chunks: readonly TraceChunkPayload[]) => TraceChunkPayload[][] | Splits a serve's chunks into runs (a replay of the serve continues its seq): ordered by seq, a new run opens at every part: 0. |
decodeTraceRun | (chunks: readonly TraceChunkPayload[], context: TraceDecodeContext) => DecodedTraceStep[] | Chunks of one run, in any order; part must run 0..n-1 without a gap since a lost chunk leaves every later seat unknowable. Offsets accumulate across chunks. Undone plays stay in the timeline past the cursor so a redo restores them; a new play overwrites the undone future. Throws TraceDecodeError on a missing part, an unknown token, a card with no opening leader, or a state-replaced past the traced plays. |
DecodedTraceStep is { offsetMs } plus one of: bid-placed (player, bid, index, auto), card-played (player, card, auto), trick-completed (winner, trickNumber), state-replaced (reason, plays), play-error (error), bid-rejected (player, bid, error, seated like the bid it refused without moving the table), play-claimed (side, tricks per partnership), hint. TraceDecodeError extends Error and is not an AppError.
The contract is derived from the traced auction when there is one, else taken from context.contract. Trump precedence follows the play phase: the contract's trump, then the deal's. The recorded lead prevails; otherwise the declarer's left-hand opponent leads.
const auctionPart: TraceChunkPayload = {
seq: 1,
part: 0,
steps: [["1NT", 2000], ["Pass", 800, 1], ["Pass", 300], ["Pass", 300, 1]],
};
const playPart: TraceChunkPayload = { seq: 2, part: 1, steps: [["SA", 1500], ["S2", 400, 1]] };
const chunks = [playPart, auctionPart];
const steps = decodeTraceRun(chunks, { opener: "N" });
// Parts are reordered, deltas accumulate across them, seats and tricks are recomputed:
// steps[0] => { kind: "bid-placed", player: "N", bid: "1NT", index: 0, auto: false, offsetMs: 2000 }
// steps[1] => { kind: "bid-placed", player: "E", bid: "Pass", index: 1, auto: true, offsetMs: 2800 }
// steps[4] => { kind: "card-played", player: "E", card: "SA", auto: false, offsetMs: 4900 }
try {
decodeTraceRun([playPart], { opener: "N" });
} catch (error) {
error instanceof TraceDecodeError; // => true: "trace part 0 is missing"
}
// A serve replayed twice carries several runs; `part: 0` opens each one.
splitTraceRuns([
{ seq: 1, part: 0, steps: [] },
{ seq: 2, part: 1, steps: [] },
{ seq: 3, part: 0, steps: [] },
]).length; // => 2Application
TelemetryIngestService
createTelemetryIngestService(deps: TelemetryIngestServiceDeps): TelemetryIngestService
Stores a validated batch under its session and lands every distinct serve the batch references. Every event type counts: the first one received is the serve's landing. A debit that throws never fails the batch; the serve stays unconsumed so a later event retries it.
Prop
Type
| Method | Signature | Description |
|---|---|---|
ingest | (input: TelemetryIngestInput) => Promise<TelemetryIngestResult> | Inserts every event with sessionId, then calls DiffusionDebitService.debitForServe once per distinct serveId, in first-seen order. Events without a serve id (presets, demo) are stored but land nothing. |
Prop
Type
Prop
Type
Prop
Type
ServeDebit is { serveId; outcome: ServeDebitOutcome } where ServeDebitOutcome is the DiffusionDebitOutcome of Entitlements plus "failed": the landing transaction threw and rolled back.
export async function landBatch(deps: TelemetryIngestServiceDeps, sessionId: string) {
const telemetry = createTelemetryIngestService(deps);
const batch: TelemetryIngestInput = {
sessionId,
events: [
{ type: "deal-loaded", serveId: "serve-1", payload: {}, occurredAt: new Date() },
{
type: "play-finished",
serveId: "serve-1",
payload: { tricks: 9 },
occurredAt: new Date(),
},
{ type: "result", serveId: null, payload: { stars: 3 }, occurredAt: new Date() },
],
};
const result = await telemetry.ingest(batch);
result.accepted; // => 3: every event is stored, with or without a serve
result.debits; // => [{ serveId: "serve-1", outcome }]: one debit per distinct serve
return result;
}TelemetryRetentionService
createTelemetryRetentionService(deps: TelemetryRetentionServiceDeps): TelemetryRetentionService
Deletes the raw events past their retention span. An event is identified when it carries the signed-in player's account, copied from its serve at ingestion; every other event, those without a serve included, is anonymous. Both spans outlast every reader of raw events, which looks back a few days at most, so they only bound what a later replay or re-rollup can still see. The API runs it nightly after the rollup, with both spans overridable by environment.
Prop
Type
| Method | Signature | Description |
|---|---|---|
purge | (now: Date) => Promise<number> | Resolves both cutoffs from now, then deletes in batches of batchSize until one comes back short. Returns how many events went. |
Ports
TelemetryEventsRepository
The append-only raw event log, on the canonical surface of Queries: writeMany and a bounded delete. It has no read side; rollups and usage read the table through their own ports.
Prop
Type
| Method | Signature | Description |
|---|---|---|
writeMany | (events: PersistableTelemetryEventWrite[]) => Promise<void> | One insert statement for the batch. serveId is best-effort: an unknown serve never rejects the event. Each event carries its serve's signed-in player, null when anonymous. |
delete | (criteria: TelemetryEventCriteria, opts: { limit: number }) => Promise<{ deletedCount: number }> | Deletes at most limit matching events. The limit is required: a purge never runs as one unbounded statement. |
TelemetryEventCriteria.pastRetention(cutoffs) matches the events received before anonymousBefore that carry no account, and those received before identifiedBefore whatever they carry. createFakeTelemetryEventsRepository() from @workspace/training/testing is its in-memory fake.
SessionTokenService (cross-cutting): mints, verifies and renews the signed anonymous client session token whose sid becomes the batch's sessionId. It throws SessionTokenVerificationError from the package root. See Ports.
Errors
The service throws nothing of its own: a debit failure is reported through onDebitError and surfaces as the failed outcome. TraceDecodeError (domain) is a plain Error thrown by decodeTraceRun.
Events
Inbound webhook ingestion: verify, persist, enqueue, the worker lifecycle, admin replay, and the in-memory repository for tests.
Usage analytics
Serves, daily play rollups, the rollup boundary between rolled-up days and live telemetry, perceived difficulty, and the usage stats and analytics read by the teacher app.