Files
zip/telemetry_internal_test.go
T
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

119 lines
4.2 KiB
Go

package zip
import (
"testing"
luxlog "github.com/luxfi/log"
)
// The address is the switch, so these tests are about one question: what does
// this app export, and what does it cost when the answer is nothing.
// TestEnvironmentNames pins the spelling a deployment sets. A manifest and a
// program have to agree on it letter for letter, and a rename here is invisible
// at runtime — an unread variable and an absent one look identical.
func TestEnvironmentNames(t *testing.T) {
for _, c := range []struct{ got, want string }{
{envSpans, "O11Y_SPANS_ADDR"},
{envLogs, "O11Y_LOGS_ADDR"},
{envMetrics, "O11Y_METRICS_ADDR"},
} {
if c.got != c.want {
t.Errorf("environment name = %q, want %q", c.got, c.want)
}
}
}
// TestResolveReadsTheStatedAddressThenTheEnvironment covers the whole rule: a
// field first, the environment second, nothing otherwise.
func TestResolveReadsTheStatedAddressThenTheEnvironment(t *testing.T) {
t.Run("nothing stated anywhere", func(t *testing.T) {
t.Setenv(envSpans, "")
t.Setenv(envLogs, "")
t.Setenv(envMetrics, "")
got := resolve(Config{})
if got.any() {
t.Errorf("resolved %+v with nothing configured; an unstated signal is not exported", got)
}
})
t.Run("the environment states them", func(t *testing.T) {
t.Setenv(envSpans, "127.0.0.1:4317")
t.Setenv(envLogs, "127.0.0.1:4318")
t.Setenv(envMetrics, "127.0.0.1:4319")
got := resolve(Config{})
want := addresses{spans: "127.0.0.1:4317", logs: "127.0.0.1:4318", metrics: "127.0.0.1:4319"}
if got != want {
t.Errorf("resolved %+v, want %+v", got, want)
}
})
t.Run("the program's own field wins", func(t *testing.T) {
t.Setenv(envSpans, "127.0.0.1:4317")
got := resolve(Config{Telemetry: Telemetry{Spans: "collector:4317"}})
if got.spans != "collector:4317" {
t.Errorf("spans = %q, want the address the program stated", got.spans)
}
})
t.Run("blank is not an address", func(t *testing.T) {
t.Setenv(envSpans, " ")
if got := resolve(Config{Telemetry: Telemetry{Spans: " "}}); got.any() {
t.Errorf("resolved %+v from whitespace; that is a variable somebody meant to unset", got)
}
})
}
// TestNoAddressStartsNothing is the cost of being off, stated where it can be
// measured: export returns before it registers the hook that its goroutine,
// its exporters and its connections all hang from. No hook is therefore no
// goroutine, no node, no port and no dial.
func TestNoAddressStartsNothing(t *testing.T) {
t.Setenv(envSpans, "")
t.Setenv(envLogs, "")
t.Setenv(envMetrics, "")
quiet := New(Config{AppName: "quiet", Logger: luxlog.NewNoOpLogger(), DisableStartupMessage: true})
before := len(quiet.hooks)
quiet.export()
if len(quiet.hooks) != before {
t.Fatalf("an app with nowhere to report registered %d shutdown hooks", len(quiet.hooks)-before)
}
// The same app with one address stated does start, so the test above is
// measuring the switch rather than a broken export.
loud := New(Config{
AppName: "loud",
Logger: luxlog.NewNoOpLogger(),
DisableStartupMessage: true,
Telemetry: Telemetry{Spans: "127.0.0.1:1"},
})
loud.export()
if len(loud.hooks) == 0 {
t.Fatal("an app with a span address registered no shutdown hook, so nothing drains it")
}
if err := loud.Shutdown(); err != nil {
t.Fatalf("shutdown: %v", err)
}
}
// TestOffKeepsNothing proves the buffer follows the address: an app that cannot
// ship must not accumulate spans and records for a drain that will never come.
func TestOffKeepsNothing(t *testing.T) {
t.Setenv(envSpans, "")
t.Setenv(envLogs, "")
t.Setenv(envMetrics, "")
if newTelemetry(Config{}).exports() {
t.Error("an app with no span or log address collects for a shipment that cannot happen")
}
if !newTelemetry(Config{Telemetry: Telemetry{Logs: "127.0.0.1:4318"}}).exports() {
t.Error("an app with a log address does not collect, so its flush has nothing to send")
}
// The metric address alone does not make the boundary collect: the numbers
// come from the registry at flush time, not from a buffer.
if newTelemetry(Config{Telemetry: Telemetry{Metrics: "127.0.0.1:4319"}}).exports() {
t.Error("a metrics-only app buffers spans and records nothing will drain")
}
}