Bridge Training

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.

ExportDefinition
TELEMETRY_EVENT_TYPES["deal-loaded", "play-entered", "play-finished", "result", "duration", "trace-chunk"]
TelemetryEventTypeUnion of the above.
TelemetryEventPayloadsPer-type payload shapes, keyed by the event vocabulary.
TelemetryEventPayloadUnion of every payload.

Every payload may carry the structural references serveId?: string and code?: string, never free text.

TypePayloadFields
deal-loadedDealLoadedPayloadbase only
play-enteredPlayEnteredPayloadauctionErrors?: 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-finishedPlayFinishedPayloadtricks: number
resultResultPayloadstars: number
durationDurationPayloaddurationMs: number
trace-chunkTraceChunkPayloadseq, 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.

FactTokenExample
bid placedthe bid1NT, Pass, X
card playedthe cardSA, HT
hintTRACE_HINT_TOKENhint
play errorerror:<PlayErrorKind>error:follow-suit
bid rejectedbid-error:<AuctionErrorKind>:<bid>bid-error:wrong-bid:1NT, the bid refused in teaching mode
claim acceptedclaim:<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
FunctionSignatureDescription
parseTraceToken(token: string) => ParsedTraceToken | nullNull 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) => booleanBid and card tokens, the only ones that may carry the auto flag.
isTraceMarkerToken(token: string) => booleanHint, 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 }.

telemetry.ts
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"); // => true

Decoding 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

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

telemetry.ts
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; // => 2

Application

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

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

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

MethodSignatureDescription
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

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