Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions examples/otel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions internal/otel/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
}
}
Expand Down
40 changes: 40 additions & 0 deletions internal/otel/otel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down