Files
zooqueenandHanzo Dev e570a16f6a telemetry: spans and the request record ship over native ZAP
The request boundary already holds everything a server span and a request
record contain, so both are structs filled from data it has: the trace, span
and parent ids it minted or joined, the route template as the name, start and
duration, an outcome-derived status, and the caller's org when a gateway
parked one. They are appended under one lock, drained once per tick and at
shutdown, and shipped as JSON in a ZAP envelope — internal/o11y declares the
batch shapes itself, no SDK, no protobuf, nothing a consumer inherits.

The keys are the collector's vocabulary, not ours. It reads a path out of
url.path / http.target / http.route / path in that order, so those are the
keys this fills; http.path, which an earlier draft shipped, fills nothing.
The contract/ module proves it the only way that stays true: it starts the
collector's real receivers, serves one real request, and asserts on the
structs those receivers produced. It is its own module because the collector
already requires zip — the proof would otherwise be a cycle.

The address is the switch, spelled O11Y_SPANS_ADDR / O11Y_LOGS_ADDR /
O11Y_METRICS_ADDR — three names because the collector binds one listener per
signal and promises no adjacency between them. The metrics name replaces
O11Y_METRICS_ZAP_ENDPOINT, which deployments had already stopped matching;
the socket preference is deleted because the collector only ever binds TCP,
so that branch could not run. Nothing stated means nothing starts: no
exporter, no node, no dial, no goroutine.

An app's own logging stays its own. Only zip's request line ships to the log
ear; teeing a program's every log line off the box is one decision for
luxfi/log to make once, for everything that logs.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 21:49:59 -07:00

282 lines
8.0 KiB
Go

package o11y_test
import (
"context"
"encoding/json"
"fmt"
"net"
"sync"
"testing"
"time"
"github.com/luxfi/zap"
"github.com/zap-proto/zip/internal/o11y"
)
// What this package sends is proven against the collector's OWN decoder, in
// contract/ — a module of its own, so no consumer of zip inherits the
// collector's dependencies to run it. That proof is the one that can fail when a
// field is renamed on either side, so there is no transcribed copy of the
// receiver's field names here to drift away from it.
//
// What is left for this package's own tests is everything the contract module
// cannot see: that an unreachable collector costs the caller nothing, that an
// idle service is silent, and that the report about export itself is
// edge-triggered.
// filled builds values with every field set.
func filledSpan() o11y.SpanBatch {
return o11y.SpanBatch{
AppName: "svc",
Version: "v1",
Resource: map[string]string{"service.name": "svc"},
Spans: []o11y.Span{{
TraceID: "0102030405060708090a0b0c0d0e0f10",
SpanID: "1112131415161718",
ParentSpanID: "2122232425262728",
Name: "/v1/x",
Kind: "server",
StartUnixNs: 1700000000000000000,
EndUnixNs: 1700000000000001000,
Attributes: map[string]any{"http.request.method": "GET"},
Events: []o11y.SpanEvent{{Name: "e", TimeUnixNs: 1, Attributes: map[string]any{"k": "v"}}},
StatusCode: "ok",
StatusMsg: "fine",
}},
}
}
func filledLog() o11y.LogBatch {
return o11y.LogBatch{
AppName: "svc",
Version: "v1",
Resource: map[string]string{"service.name": "svc"},
Records: []o11y.LogRecord{{
TimeUnixNs: 1700000000000000000,
ObservedTimeUnixNs: 1700000000000000001,
Severity: o11y.SeverityInfo,
SeverityText: "info",
Body: "request",
Attributes: map[string]any{"http.request.method": "GET"},
TraceID: "0102030405060708090a0b0c0d0e0f10",
SpanID: "1112131415161718",
EventName: "request",
}},
}
}
// TestSignalNumbers pins the fleet-wide registry. These are not this package's
// to choose: every deployed collector switches on them, so a change here is a
// change to every receiver at once.
func TestSignalNumbers(t *testing.T) {
for _, c := range []struct {
what string
got uint16
want uint16
}{
{"span", o11y.MsgSpan, 1},
{"metric", o11y.MsgMetric, 2},
{"log", o11y.MsgLog, 3},
} {
if c.got != c.want {
t.Errorf("%s message type = %d, want %d", c.what, c.got, c.want)
}
}
}
// ear is a ZAP listener that decodes one signal. It reads the envelope the way
// the collector's receivers do — payload out of field 0, message type out of the
// flags' upper byte.
type ear struct {
node *zap.Node
mu sync.Mutex
got [][]byte
seen chan struct{}
}
func listen(t *testing.T, addr string, msg uint16) *ear {
t.Helper()
e := &ear{seen: make(chan struct{}, 8)}
e.node = zap.NewNode(zap.NodeConfig{
NodeID: "ear-" + addr,
ServiceType: "_o11y._tcp",
Address: addr,
NoDiscovery: true,
})
e.node.Handle(msg, func(_ context.Context, _ string, m *zap.Message) (*zap.Message, error) {
cp := append([]byte(nil), m.Root().Bytes(0)...)
e.mu.Lock()
e.got = append(e.got, cp)
e.mu.Unlock()
select {
case e.seen <- struct{}{}:
default:
}
// No reply: the sender does not wait, so answering would be a frame
// nothing reads.
return nil, nil
})
if err := e.node.Start(); err != nil {
t.Fatalf("ear on %s: %v", addr, err)
}
t.Cleanup(e.node.Stop)
return e
}
func (e *ear) await(t *testing.T) []byte {
t.Helper()
select {
case <-e.seen:
case <-time.After(5 * time.Second):
t.Fatal("nothing arrived at the collector")
}
e.mu.Lock()
defer e.mu.Unlock()
return e.got[len(e.got)-1]
}
// free returns a loopback address nothing is listening on yet. host:port is the
// only form the collector serves, so it is the only form tested.
func free(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("port: %v", err)
}
port := l.Addr().(*net.TCPAddr).Port
if err := l.Close(); err != nil {
t.Fatalf("close: %v", err)
}
return fmt.Sprintf("127.0.0.1:%d", port)
}
func TestSpansReachTheCollector(t *testing.T) {
addr := free(t)
e := listen(t, addr, o11y.MsgSpan)
x := o11y.New(o11y.Spans, addr, "svc", nil)
defer x.Close()
x.Spans(context.Background(), filledSpan())
var got o11y.SpanBatch
if err := json.Unmarshal(e.await(t), &got); err != nil {
t.Fatalf("collector could not decode: %v", err)
}
if len(got.Spans) != 1 || got.Spans[0].TraceID != filledSpan().Spans[0].TraceID {
t.Fatalf("span did not survive the trip: %+v", got.Spans)
}
if got.AppName != "svc" {
t.Errorf("appName = %q, want svc — the exporter stamps it", got.AppName)
}
}
func TestLogsReachTheCollector(t *testing.T) {
addr := free(t)
e := listen(t, addr, o11y.MsgLog)
x := o11y.New(o11y.Logs, addr, "svc", nil)
defer x.Close()
x.Logs(context.Background(), filledLog())
var got o11y.LogBatch
if err := json.Unmarshal(e.await(t), &got); err != nil {
t.Fatalf("collector could not decode: %v", err)
}
if len(got.Records) != 1 || got.Records[0].Body != "request" {
t.Fatalf("record did not survive the trip: %+v", got.Records)
}
if got.Records[0].TraceID == "" || got.Records[0].SpanID == "" {
t.Error("the record lost its trace ids, which are the reason logs ship at all")
}
}
// TestUnreachableCollectorIsSurvivable is the fire-and-forget claim. An address
// nothing listens on must cost the caller nothing — no error to handle, no
// panic, no block — because telemetry may never be able to stop the program it
// describes.
func TestUnreachableCollectorIsSurvivable(t *testing.T) {
x := o11y.New(o11y.Spans, free(t), "svc", nil)
defer x.Close()
done := make(chan struct{})
go func() {
x.Spans(context.Background(), filledSpan())
x.Logs(context.Background(), filledLog())
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("exporting to a dead collector blocked the caller")
}
}
// TestEmptyBatchSendsNothing keeps idle services quiet: a tick with no traffic
// must not open a connection or write a frame.
func TestEmptyBatchSendsNothing(t *testing.T) {
addr := free(t)
e := listen(t, addr, o11y.MsgSpan)
x := o11y.New(o11y.Spans, addr, "svc", nil)
defer x.Close()
x.Spans(context.Background(), o11y.SpanBatch{})
select {
case <-e.seen:
t.Fatal("an empty batch was sent")
case <-time.After(300 * time.Millisecond):
}
}
// TestReportIsEdgeTriggered is the "must not spam" claim. A program whose
// collector is down answers requests forever; it must say so once, not once per
// flush.
func TestReportIsEdgeTriggered(t *testing.T) {
var mu sync.Mutex
var lines []string
say := func(msg string, _ ...any) {
mu.Lock()
lines = append(lines, msg)
mu.Unlock()
}
x := o11y.New(o11y.Spans, free(t), "svc", say)
defer x.Close()
for range 10 {
x.Spans(context.Background(), filledSpan())
}
mu.Lock()
defer mu.Unlock()
if len(lines) != 1 {
t.Fatalf("ten failed exports produced %d lines, want exactly 1: %v", len(lines), lines)
}
if lines[0] != "telemetry export stopped" {
t.Errorf("line = %q, want the stopped edge", lines[0])
}
}
// TestReportSaysWhenExportStartsWorking is the other edge, and the one that
// matters for start order: a collector that appears later is picked up, and the
// program says so once.
func TestReportSaysWhenExportStartsWorking(t *testing.T) {
var mu sync.Mutex
var lines []string
say := func(msg string, _ ...any) {
mu.Lock()
lines = append(lines, msg)
mu.Unlock()
}
addr := free(t)
x := o11y.New(o11y.Spans, addr, "svc", say)
defer x.Close()
// Nothing is listening yet.
x.Spans(context.Background(), filledSpan())
// The collector starts AFTER the program did.
listen(t, addr, o11y.MsgSpan)
x.Spans(context.Background(), filledSpan())
mu.Lock()
defer mu.Unlock()
if len(lines) != 2 || lines[0] != "telemetry export stopped" || lines[1] != "telemetry export working" {
t.Fatalf("want stopped then working, got %v", lines)
}
}