Files
llmgw/internal/obs/trace.go
Ilker ManapandClaude Opus 5.5 e725adc2f0 Tracing beyond the router: reconcile passes, placement and a replica's start traced down to the node
A reconcile pass that does something is a trace, with placement (where it
went, or every candidate turned down), scale-up/down, drain, stop, eviction,
preemption, reaping and restarts beneath it. A replica's start is a span open
from placement until it serves; the start command carries its context in a
new ControlMessage.trace field, and the agent continues the trace through
the weights fetch, engine launch, rendezvous and readiness wait. Stops,
drains, adoption and engine installs are traced on the node, model jobs are
a trace with a span per step linked to the replica's start, and agent.yaml
takes the same tracing block as server.yaml. Off, every span is the no-op one
and commands carry nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:03:17 +02:00

144 lines
5.2 KiB
Go

package obs
import (
"context"
"fmt"
"net/url"
"os"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)
// Tracing is where traces go, over OTLP/HTTP.
//
// In server.yaml: one span per inference request, with admission, scheduling
// and each attempt at an engine beneath it, and the trace carried on to the
// engine in a traceparent header — the request's X-Request-Id is on the span,
// so a trace and `llmgw ctl request` name the same request. A control plane
// also traces every reconcile pass that does something, each replica from
// its placement until it serves, and each job `ctl model serve` and `ctl
// model update` start.
//
// In agent.yaml: the node's part of starting a replica — fetching the
// weights, launching the engine, waiting for it to answer — inside the
// control plane's trace of that start. Point both at the same collector.
type Tracing struct {
// Endpoint is the collector, for example http://otel.internal:4318.
// Empty falls back to OTEL_EXPORTER_OTLP_ENDPOINT, and to no tracing.
Endpoint string `koanf:"endpoint"`
// SampleRatio is the share of traces kept, 0 to 1, of those nobody
// upstream already decided. Zero takes 1. A request with a sampled
// traceparent of its own is kept, and an agent keeps every start the
// control plane kept, whatever their own ratio says.
SampleRatio float64 `koanf:"sample_ratio"`
}
// TracingOptions says where traces go.
type TracingOptions struct {
Endpoint string // http(s)://host:4318; empty reads OTEL_EXPORTER_OTLP_ENDPOINT
SampleRatio float64 // of traces the caller did not already decide; zero takes 1
Service string
Version string
// Instance tells apart the processes of one service: the node, for an
// agent. Empty leaves it out.
Instance string
}
// SetupTracing installs the global tracer provider and the W3C propagator,
// and returns what flushes it on the way out. With no endpoint it installs
// nothing, and every span is the no-op one: tracing that is off costs a
// function call per span.
func SetupTracing(ctx context.Context, o TracingOptions) (func(context.Context) error, error) {
noop := func(context.Context) error { return nil }
endpoint := o.Endpoint
if endpoint == "" {
endpoint = os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
}
if endpoint == "" {
return noop, nil
}
u, err := url.Parse(endpoint)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return noop, fmt.Errorf("tracing endpoint %q is not an http(s) address", endpoint)
}
opts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(u.Host)}
if u.Scheme == "http" {
opts = append(opts, otlptracehttp.WithInsecure())
}
if u.Path != "" && u.Path != "/" {
opts = append(opts, otlptracehttp.WithURLPath(u.Path))
}
exp, err := otlptracehttp.New(ctx, opts...)
if err != nil {
return noop, err
}
ratio := o.SampleRatio
if ratio <= 0 || ratio > 1 {
ratio = 1
}
attrs := []attribute.KeyValue{
attribute.String("service.name", o.Service),
attribute.String("service.version", o.Version),
}
if o.Instance != "" {
attrs = append(attrs, attribute.String("service.instance.id", o.Instance))
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(5*time.Second)),
sdktrace.WithResource(resource.NewSchemaless(attrs...)),
// A caller that sent a sampled traceparent has already decided; the
// ratio is for requests that arrive without one.
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
return tp.Shutdown, nil
}
// Tracer is the named tracer of whatever provider is installed when it is
// called. Looked up per use rather than kept, so a provider installed after a
// component was built — as tests do — is the one its spans go to. With none
// installed it is the no-op tracer, and a span costs a function call.
func Tracer(name string) trace.Tracer { return otel.Tracer(name) }
// End closes a span, marking it failed when err is set.
func End(span trace.Span, err error) {
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
span.End()
}
// Inject is the trace context of ctx as a message carries it: traceparent
// and, when there is one, tracestate. Nil when ctx holds no span, which is
// every context while tracing is off, so a message then carries nothing.
func Inject(ctx context.Context) map[string]string {
if !trace.SpanContextFromContext(ctx).IsValid() {
return nil
}
m := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(ctx, m)
if len(m) == 0 {
return nil
}
return m
}
// Extract is ctx continuing the trace a message carried, as Inject wrote it.
// A message that carried none leaves ctx as it was.
func Extract(ctx context.Context, carried map[string]string) context.Context {
if len(carried) == 0 {
return ctx
}
return otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(carried))
}