From 2de29bec8cf294b423c9cd53b4b7a3a1dc8cf745 Mon Sep 17 00:00:00 2001 From: Adarsh Prashar Date: Sat, 6 Jun 2026 18:51:10 +0530 Subject: [PATCH] feat(otel): support OTEL_EXPORTER_OTLP_HEADERS for authenticated export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exporter could only reach an unauthenticated OTLP endpoint — there was no way to send an auth token, so a backend that requires one (Honeycomb's x-honeycomb-team, a Bearer token, Grafana Cloud) couldn't receive spans. The OTel example even documented Honeycomb "via the std OTEL env header," which didn't actually work. Read the standard OTEL_EXPORTER_OTLP_HEADERS (and the traces-specific OTEL_EXPORTER_OTLP_TRACES_HEADERS, which takes precedence) as a comma-separated key=value list and attach them to every export over grpc and http. Values carry secrets, so they're never logged. Tested the parse (whitespace, value-with-=, malformed pairs, precedence) and asserted the header actually reaches the endpoint on the wire. --- CHANGELOG.md | 6 ++++ examples/otel/README.md | 16 +++++++++ internal/config/config.go | 37 +++++++++++++++++++++ internal/config/config_test.go | 60 ++++++++++++++++++++++++++++++++++ internal/otel/otel.go | 6 ++++ internal/otel/otel_test.go | 40 +++++++++++++++++++++++ 6 files changed, 165 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 274be01..242801f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md). spend over time and per run, token burn, budget halts by reason, tool-call outcomes, and p95 latency by model. Built from the spans you already emit (Tempo TraceQL metrics — no extra instrumentation); `docker compose up`, no import step. +- **Authenticated OTLP export.** Set the standard `OTEL_EXPORTER_OTLP_HEADERS` (or the + traces-specific `OTEL_EXPORTER_OTLP_TRACES_HEADERS`) — a comma-separated list of + `key=value` pairs — to send an auth header on every span export, so RiskKernel can + feed a backend that requires one (Honeycomb's `x-honeycomb-team`, a `Bearer` token, + Grafana Cloud). Header values carry secrets and are never logged. See + [`examples/otel`](examples/otel#other-backends). ## [0.3.0] - 2026-06-06 diff --git a/examples/otel/README.md b/examples/otel/README.md index a16c6ea..67c962f 100644 --- a/examples/otel/README.md +++ b/examples/otel/README.md @@ -57,6 +57,22 @@ Same spans, just change the endpoint: Use `OTEL_EXPORTER_OTLP_PROTOCOL=http` (and endpoint `http://host:4318`) if your backend prefers OTLP/HTTP. +**Authenticated backends.** For any endpoint that needs an auth token — Honeycomb, +Grafana Cloud, a hosted collector — set the standard `OTEL_EXPORTER_OTLP_HEADERS` +as a comma-separated list of `key=value` pairs: + +```bash +# Honeycomb +export OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io +export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=$HONEYCOMB_API_KEY" + +# Any Bearer-token endpoint +export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer $TOKEN" +``` + +Header values carry secrets and are never logged. The traces-specific +`OTEL_EXPORTER_OTLP_TRACES_HEADERS` takes precedence when both are set. + ## Building cost/usage dashboards **Want it ready-made?** [`grafana/`](grafana/) ships a provisioned Grafana + Tempo diff --git a/internal/config/config.go b/internal/config/config.go index 75df8df..1aceb84 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -133,6 +133,12 @@ type OTelConfig struct { // ServiceName tags exported spans. Read from OTEL_SERVICE_NAME (default // "riskkernel"). ServiceName string + // Headers are sent on every OTLP export request, used to authenticate to a + // backend that requires it (e.g. `authorization=Bearer …`, or Honeycomb's + // `x-honeycomb-team`). Read from OTEL_EXPORTER_OTLP_TRACES_HEADERS, then + // OTEL_EXPORTER_OTLP_HEADERS, as a comma-separated list of key=value pairs. + // Carries secrets — never logged. + Headers map[string]string } // BudgetConfig holds raw budget values (no governor dependency here so config @@ -248,12 +254,43 @@ func loadOTel() OTelConfig { } insecure := strings.EqualFold(os.Getenv("OTEL_EXPORTER_OTLP_INSECURE"), "true") || strings.HasPrefix(endpoint, "http://") + headers := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS") + if headers == "" { + headers = os.Getenv("OTEL_EXPORTER_OTLP_HEADERS") + } return OTelConfig{ Endpoint: endpoint, Protocol: getenvDefault("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc"), Insecure: insecure, ServiceName: getenvDefault("OTEL_SERVICE_NAME", "riskkernel"), + Headers: parseOTLPHeaders(headers), + } +} + +// parseOTLPHeaders parses the OTEL_EXPORTER_OTLP_*_HEADERS format: a comma-separated +// list of key=value pairs (e.g. "authorization=Bearer abc,x-tenant=42"). Whitespace +// around each key and value is trimmed; a value may itself contain '=' (only the +// first is the separator). Values are taken literally (no percent-decoding), which +// is what real tokens and API keys need. Returns nil for an empty or all-malformed +// string so callers can treat nil as "no headers". +func parseOTLPHeaders(s string) map[string]string { + s = strings.TrimSpace(s) + if s == "" { + return nil } + out := make(map[string]string) + for _, pair := range strings.Split(s, ",") { + k, v, ok := strings.Cut(pair, "=") + k = strings.TrimSpace(k) + if !ok || k == "" { + continue + } + out[k] = strings.TrimSpace(v) + } + if len(out) == 0 { + return nil + } + return out } // loadBudget reads the optional default-budget env vars. When none is set the diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b7fb6c8..e54d64a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -148,6 +148,64 @@ func TestLoad_DotEnvDoesNotOverrideRealEnv(t *testing.T) { } } +func TestParseOTLPHeaders(t *testing.T) { + cases := []struct { + in string + want map[string]string + }{ + {"", nil}, + {" ", nil}, + {"noequals", nil}, + {"authorization=Bearer abc", map[string]string{"authorization": "Bearer abc"}}, + {"a=1,b=2", map[string]string{"a": "1", "b": "2"}}, + {" a = 1 , b = 2 ", map[string]string{"a": "1", "b": "2"}}, + {"x-honeycomb-team=key123", map[string]string{"x-honeycomb-team": "key123"}}, + {"token=a=b=c", map[string]string{"token": "a=b=c"}}, // value may contain '=' + {"=nokey,a=1", map[string]string{"a": "1"}}, // skip empty key + {"bad,a=1", map[string]string{"a": "1"}}, // skip malformed pair + } + for _, c := range cases { + got := parseOTLPHeaders(c.in) + if len(got) != len(c.want) { + t.Errorf("parseOTLPHeaders(%q) = %v, want %v", c.in, got, c.want) + continue + } + for k, v := range c.want { + if got[k] != v { + t.Errorf("parseOTLPHeaders(%q)[%q] = %q, want %q", c.in, k, got[k], v) + } + } + } +} + +func TestLoad_OTLPHeaders(t *testing.T) { + withCleanEnv(t) + chdirTemp(t) + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://api.honeycomb.io") + t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "x-honeycomb-team=secret-key") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OTel.Headers["x-honeycomb-team"] != "secret-key" { + t.Errorf("headers = %v", cfg.OTel.Headers) + } + + // The traces-specific var takes precedence over the general one. + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "authorization=Bearer t1") + cfg, err = Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OTel.Headers["authorization"] != "Bearer t1" { + t.Errorf("traces headers should win: %v", cfg.OTel.Headers) + } + if _, ok := cfg.OTel.Headers["x-honeycomb-team"]; ok { + t.Errorf("general headers should be replaced, not merged: %v", cfg.OTel.Headers) + } +} + // --- helpers --- // withCleanEnv clears the env vars Load reads so tests are hermetic. t.Setenv @@ -158,6 +216,8 @@ func withCleanEnv(t *testing.T) { "RISKKERNEL_DEFAULT_PROVIDER", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "RISKKERNEL_DEFAULT_TOKENS", "RISKKERNEL_DEFAULT_DOLLARS", "RISKKERNEL_DEFAULT_LOOPS", "RISKKERNEL_DEFAULT_SECONDS", + "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_HEADERS", } { t.Setenv(k, "") os.Unsetenv(k) diff --git a/internal/otel/otel.go b/internal/otel/otel.go index 77d0f80..60aea80 100644 --- a/internal/otel/otel.go +++ b/internal/otel/otel.go @@ -116,12 +116,18 @@ func newExporter(ctx context.Context, cfg config.OTelConfig) (*otlptrace.Exporte if cfg.Insecure { opts = append(opts, otlptracehttp.WithInsecure()) } + if len(cfg.Headers) > 0 { + opts = append(opts, otlptracehttp.WithHeaders(cfg.Headers)) + } return otlptracehttp.New(ctx, opts...) default: // grpc opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpointURL(cfg.Endpoint)} if cfg.Insecure { opts = append(opts, otlptracegrpc.WithInsecure()) } + if len(cfg.Headers) > 0 { + opts = append(opts, otlptracegrpc.WithHeaders(cfg.Headers)) + } return otlptracegrpc.New(ctx, opts...) } } diff --git a/internal/otel/otel_test.go b/internal/otel/otel_test.go index fb3766b..8fc5b4c 100644 --- a/internal/otel/otel_test.go +++ b/internal/otel/otel_test.go @@ -3,10 +3,17 @@ package otel import ( "context" "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync" "testing" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/trace/tracetest" + + "github.com/prashar32/riskkernel/internal/config" ) // newRecording builds a Tracer backed by an in-memory span recorder. @@ -120,6 +127,39 @@ func TestRecordToolCall_Attributes(t *testing.T) { } } +func TestExport_SendsConfiguredHeaders(t *testing.T) { + // Prove the auth header reaches the OTLP endpoint on the wire — this is what + // lets RiskKernel export to a backend that requires authentication (Honeycomb, + // Grafana Cloud, a hosted dashboard). + var mu sync.Mutex + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotAuth = r.Header.Get("Authorization") + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + tr, err := New(context.Background(), config.OTelConfig{ + Endpoint: srv.URL, Protocol: "http", Insecure: true, ServiceName: "test", + Headers: map[string]string{"authorization": "Bearer xyz"}, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("New: %v", err) + } + tr.RecordCall(context.Background(), Call{RunID: "r1", Operation: "chat", RequestModel: "m", PromptTokens: 1, OutputTokens: 1}) + if err := tr.Shutdown(context.Background()); err != nil { // flushes the batch + t.Fatalf("Shutdown: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if gotAuth != "Bearer xyz" { + t.Fatalf("Authorization header on the wire = %q, want %q", gotAuth, "Bearer xyz") + } +} + func TestDisabled_NoOp(t *testing.T) { d := Disabled() if d.Enabled() {