Task · GOB-0003

Instrument the emit POST: loop.emit span and latency histogram

Description

Migrated from followup.md §10 (v2 stream) at the 2026-08-14 tracker migration. Also raised as #60 in the pre-migration issue set.

The one real self-observability blind spot. The emit POST to /v1/metrics and /v1/logs uses a plain http.Client, not httpx, so emit latency is in no histogram at all — the only signal is the emit_errors_total counter. A slow or heavily-retried emit is invisible: you can see that it failed, never where the time went or how the per-attempt backoff behaved.

The tick span currently stops at Enqueue. The actual encode → OTLP POST → retry/backoff runs asynchronously in the runner’s worker goroutine, so the hard part is carrying the trace context through the bounded queue into the worker — the batch/queue item has to ferry the context or SpanContext. followup.md §10 calls this the single highest-value next span.

Related but separately tracked: the remaining span coverage (loop.commit, logs_export lifecycle steps, httpx request spans, election).

Acceptance Criteria

Definition of Done

Implementation Plan

Wave 1 L5 after L4: test-first schedule-local SpanContext envelope, loop.emit child span, and per-attempt emit latency for both planes without changing model.Batch.

Implementation Notes

FROZEN DECISION, taken 2026-09-22 during wave-1 goal authoring. Do not reopen inside a lane.

The bounded queue is chan model.Batch (internal/schedule/runner.go:34,66), and model.Batch is FROZEN. The task description says the hard part is ‘carrying the trace context through the bounded queue into the worker’, and the obvious implementation — a context or SpanContext field on model.Batch — is a FROZEN-seam change. Per the wave operating model that is a design decision requiring an ARCHITECTURE.md ledger entry, decided before fan-out and never inside a lane. It is therefore decided here, and the answer is do not touch model.Batch.

Use a schedule-package-local envelope instead. Change the channel’s element type to an unexported struct private to internal/schedule that carries the batch alongside the propagation value, e.g. chan queued where queued wraps a model.Batch and a trace.SpanContext. Enqueue’s exported signature keeps taking model.Batch and lifts the span context off the passed ctx itself, so no caller changes and no seam moves. A trace.SpanContext is a value type and safe to copy across the queue; do not put a live context.Context in the envelope, because the tick context is cancelled when the tick returns and the worker would then hold a dead context.

Rationale for freezing it rather than parking: the envelope is strictly inside one package with no exported surface, it satisfies AC#2 exactly as written, and the alternative is a FROZEN-seam change with implementation exposure across emit, source and app for no benefit. If the envelope turns out not to work, that is a stop condition — return it to the root, do not amend model.Batch.

The acceptance seam was unchanged, so the conditional acceptance-test update item is not applicable.

Final Summary

Implemented in dbf5e4f2c1b9ddea6b5a5153d2de0c8ba6f7b4d2, with design-record reconciliation in a57fcca06766c6a7c572a0da1b4353dd498ac70c. The emit path now records one latency sample for every final POST attempt on both success and failure, and creates loop.emit as a child of the live loop tick while preserving cancellation and lease-epoch fencing. Targeted, race, lint, and integrated just check gates passed.

References

View the source file on GitHub