Telemetry
- Providers
- internal/telemetry/telemetry.go
- internal/telemetry/logging.go
- internal/telemetry/metrics.go
- internal/telemetry/keys.go
Relevant source files · 4
Astrolabe runs a long, LLM-heavy pipeline: it walks a source graph, drives an agent through multi-round drafting, and verifies the output. The internal/telemetry package is the observability layer that makes that pipeline legible. It wires the binary into OpenTelemetry so that every LLM request, tool call, and pipeline stage can be exported as traces, metrics, and logs — over OTLP (gRPC or HTTP/protobuf) or to stdout — configured entirely through the standard OTEL_* environment variables. When nothing is configured, the package deliberately does nothing: Setup returns no-op providers and the pipeline runs with zero telemetry overhead.
Attribute and metric names follow the OpenTelemetry GenAI semantic conventions (gen_ai.*). Because those conventions are still moving upstream, every name lives in this package as a string constant — a rename upstream is a one-line change rather than a semconv dependency to chase (keys.go).
How it works
Everything starts at Setup, which cmd/astrolabe calls once during startup. It parses the environment into a config (which exporter kind and endpoint each of the three signals should use, plus protocol, insecure flag, and headers via parseConfig), builds a resource tagged with service.name, service.version, OS, and architecture, and then wires the three signals one by one (Setup):
- Traces —
tracesProviderreturns a no-op tracer when disabled, astdouttraceexporter for debugging, or an OTLP exporter over gRPC/HTTP. - Metrics —
setupMetricsattaches a periodic reader (15 s interval) to aMeterProviderand installs it globally viaotel.SetMeterProvider. - Logs —
setupLogscreates an OTLP-backedLoggerProvider, installs it globally, and — the important link — hands back anotelsloghandler asProviders.SlogHandlerso the ordinarylog/slogoutput also flows into OTLP (setupLogs).
The result is bundled into Providers, whose fields are always non-nil; a disabled signal is simply a no-op provider (Providers). Shutdown runs the collected closers in order (flushing metrics and logs before shutting down), and Enabled reports whether anything real is wired up — useful for skipping expensive attribute assembly when nobody is listening (Shutdown).
The zero-config path is a first-class design choice. resolveExporter decides per signal: an explicit OTEL_TRACES_EXPORTER wins; if unset, OTLP is used only when an endpoint is configured, otherwise none (resolveExporter). So a plain astrolabe run touches no exporters at all.
Logging: one logger, two sinks
Logging is kept deliberately simple. NewLogger always writes logfmt text to the console, and, when the logs signal is enabled, fans out to the OTel bridge through a slog.NewMultiHandler — both sinks share the same level (NewLogger). The package adds a trace level below debug (LevelTrace = -8) for full model replies, reasoning, and tool results, with a ReplaceAttr that renders it as "TRACE" on the console (ParseLevel). Because the OTel bridge would otherwise accept everything its provider enables, a small minLevel wrapper filters it by the configured level. Records logged with a context carry the active span's trace ID, so logs join up with traces in the backend.
Metrics: GenAI client conventions
Metrics are minimal and focused on the LLM boundary. The package owns two instruments and two record helpers: RecordOperationDuration samples gen_ai.client.operation.duration (adding error.type on failure), and RecordTokenUsage samples gen_ai.client.token.usage per token type, skipping non-positive counts (RecordOperationDuration, RecordTokenUsage). The LLM client layer and the agent record against these as calls happen; see LLM Client Layer and Agent Core for where the samples originate. errorType walks the wrap chain to produce a stable class — timeout for deadline exceeded, else error.
Attribute vocabulary
The largest file, keys.go, is the shared vocabulary. It defines three families:
| Family | Examples | Purpose |
|---|---|---|
| GenAI attributes | gen_ai.operation.name, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.tool.call.id |
Span/metric attributes matching the GenAI semantic conventions |
| Claude-compatible flat keys | span.type, input_tokens, stop_reason, tool_name, success, attempt |
Same shape Claude Code puts on its agent spans, so dashboards built for those traces work unchanged (keys.go) |
| astrolabe pipeline keys | astrolabe.page.slug, astrolabe.redraft, astrolabe.verification.failures, astrolabe.graph.nodes |
Pipeline-level observability: which page, which round, verification outcome |
Content capture is opt-in and off by default — prompts carry source code, so nothing lands on spans unless OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true (CaptureContent). Truncate bounds attribute strings for both spans and logs.
Endpoint handling
The OTLP option builders normalize endpoints per transport: for gRPC, endpointParts splits a URL into host:port and derives the insecure flag from an http:// scheme (defaulting to localhost:4317); for HTTP, the endpoint is passed through with insecure inferred the same way. Headers parsed from OTEL_EXPORTER_OTLP_HEADERS (k=v,k2=v2) are applied to every exporter.
Relationships
flowchart TB
main["cmd/astrolabe/main.go"] -->|"Setup, Shutdown"| Providers["Providers"]
Providers --> setupMetrics["Providers::setupMetrics"]
Providers --> setupLogs["Providers::setupLogs"]
setupLogs --> SlogHandler["Providers.SlogHandler"]
SlogHandler --> NewLogger["NewLogger"]
agent["internal/agent.Agent"] -->|"spans + metrics"| keys["keys.go constants"]
llm["internal/llm.Client"] -->|"RecordOperationDuration / RecordTokenUsage"| metrics["metrics.go instruments"]
tools["internal/agent/tools"] -->|"spans"| keysThe package sits at the edge of the pipeline: CLI & Pipeline Orchestration initializes it, while Agent Core, Agent Tools & Budget, and LLM Client Layer emit spans and metric samples using the vocabulary defined here.
Configuration
| Variable | Effect |
|---|---|
OTEL_TRACES_EXPORTER / OTEL_METRICS_EXPORTER / OTEL_LOGS_EXPORTER |
otlp, stdout, or none; unset means OTLP if an endpoint exists, else none |
OTEL_EXPORTER_OTLP_PROTOCOL |
grpc (default) or http/protobuf; anything else is an error for enabled signals |
OTEL_EXPORTER_OTLP_ENDPOINT (and per-signal _TRACES_/_METRICS_/_LOGS_ variants) |
Collector endpoint; signal-specific value wins |
OTEL_EXPORTER_OTLP_INSECURE |
true disables TLS (also implied by http:// endpoints) |
OTEL_EXPORTER_OTLP_HEADERS |
Comma-separated k=v headers sent with every export |
OTEL_SERVICE_NAME |
Overrides the service.name resource attribute |
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT |
true opts into putting message content on spans |
Reference
| Symbol | Kind | What it does | Location |
|---|---|---|---|
Setup |
function | Parses OTEL_* env, builds resource, wires traces/metrics/logs, installs globals |
internal/telemetry/telemetry.go#L146-L184 |
Providers |
struct | Bundles tracer/meter/logger providers plus the slog bridge handler | internal/telemetry/telemetry.go#L118-L125 |
Providers::Shutdown |
method | Flushes and shuts down all configured exporters | internal/telemetry/telemetry.go#L128-L136 |
Providers::Enabled |
method | True when any signal is wired to a real exporter | internal/telemetry/telemetry.go#L139 |
Providers::setupMetrics |
method | Periodic-reader meter provider over stdout or OTLP | internal/telemetry/telemetry.go#L216-L251 |
Providers::setupLogs |
method | OTLP log provider + otelslog handler bridge |
internal/telemetry/telemetry.go#L253-L288 |
ParseLevel |
function | Parses `trace | debug |
NewLogger |
function | Console logfmt logger fanned out to the OTel bridge | internal/telemetry/logging.go#L36-L52 |
RecordOperationDuration |
function | Samples gen_ai.client.operation.duration with error.type on failure |
internal/telemetry/metrics.go#L37-L43 |
RecordTokenUsage |
function | Samples gen_ai.client.token.usage per token type |
internal/telemetry/metrics.go#L47-L56 |
CaptureContent |
function | Reports whether GenAI message content may go on spans | internal/telemetry/keys.go#L118-L120 |
Truncate |
function | Bounds a string for span attributes and logs | internal/telemetry/keys.go#L123-L128 |
Generated by astrolabe from commit ecc180b3 (tag 0.4.0).