Skip to content

[REG-12] W0.7 - LLM span conventions - model, tokens, cost, latency - #170

Merged
singret merged 1 commit into
mainfrom
reg-12-llm-span-conventions
Sep 1, 2026
Merged

[REG-12] W0.7 - LLM span conventions - model, tokens, cost, latency#170
singret merged 1 commit into
mainfrom
reg-12-llm-span-conventions

Conversation

@singret

@singret singret commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes REG-12

Summary

Every LLM completion now produces a span using OTel's GenAI semantic conventions, so a bad answer, a cost spike, or a latency regression can all be traced back to the exact call that caused it.

What changed

  • internal/integrations/llm/instrumented.go (new): instrumentedClient wraps any provider Client with a span per Complete() call. Uses semconv v1.34.0 specifically - the last version in this module's vendored set that still names the attribute gen_ai.system (later revisions rename it to gen_ai.provider.name; GenAI semconv is marked "Development" stability upstream and the names churn between versions - v1.34.0 is the one matching what REG-12 asks for).
  • internal/integrations/llm/pricing.go (new): a small OSS per-model USD price table for the span's estimated cost. This is a separate computation from whatever Pro's CostTracker.RecordUsage charges internally (different repo, no shared code) - but both are fed the same Model/PromptTokens/CompletionTokens for a call, so the two agree numerically without needing to share an implementation.
  • internal/integrations/llm/callmeta.go (new): CallMeta (agent name + incident ID) travels via context rather than as Complete() parameters, so tagging a call never requires changing the Client interface or touching all three provider implementations.
  • internal/observability/genai.go (new): GenAIPromptRecordingEnabled(), gated on OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT - the real upstream OTel GenAI instrumentation convention's own env var for this - mirroring SQLStatementRecordingEnabled's shape and rationale exactly.
  • Wired into llm.New() for all three providers (openai, anthropic, ollama) and into all six ai_service.go call sites that reach a Client, each tagged with its own agent name ("Incident Summarizer", "Post-Mortem Drafter", ...) and incident ID where one is in scope.

Acceptance criteria

  • Span per completion: gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.operation.name
  • prompt_hash (sha256 of rendered system+user messages) as a span attribute
  • Estimated cost in USD as a span attribute, from a per-model price table
  • incident.id and gen_ai.agent.name where applicable (the real GenAI semconv key for "named agent/persona", used instead of inventing a custom agent.name)
  • Applies to all three providers, not just OpenAI
  • Prompt/completion text NOT recorded by default; opt-in via OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true for local debugging only
  • Already wired into CostTracker.RecordUsage at the handler level (pre-existing, using the same Model/Usage values this PR's spans use) - verified unaffected

Found and flagged, not fixed here

buildAnswerQuestionPrompt dereferences current *models.Incident unconditionally even though AnswerQuestion's own signature documents it as optional (nil-able). This panics if ever called with current == nil. It's dormant today - the sole caller in slack_event_handler.go already guards incident == nil before calling AnswerQuestion - but is a real defensive gap for the next caller. Out of scope for this ticket (span conventions, not this bug); flagging rather than silently fixing or silently leaving undocumented.

Verification

  • go build ./..., go vet ./..., gofmt -l clean on all touched files
  • Full test suite green, plus -shuffle=on on all touched packages
  • golangci-lint run ./...: 0 issues
  • New code at 100% coverage (internal/integrations/llm package: 90.8% overall)
  • Live-verified against a real Jaeger collector, through the real llm.New() path, for all three providers: correct system/model/token counts/cost/agent.name/incident.id/prompt_hash tags, zero span events (no PII) by default

Every LLM completion now produces a span carrying enough metadata to
answer which prompt produced a bad answer, what an incident's AI usage
cost, and why p99 latency was bad last week.

- New internal/integrations/llm/instrumented.go: instrumentedClient
  wraps any provider Client with a span per Complete() call, using
  OTel's GenAI semantic conventions (gen_ai.system, gen_ai.request.model,
  gen_ai.operation.name, gen_ai.usage.input_tokens/output_tokens,
  gen_ai.agent.name). Pinned to semconv v1.34.0 specifically - the last
  version in this module's vendored set that still carries gen_ai.system
  under that name (later revisions rename it to gen_ai.provider.name;
  GenAI semconv is marked "Development" stability upstream and churns
  between versions).
- New internal/integrations/llm/pricing.go: a small per-model USD price
  table for tagging estimated cost directly on the span. Independent of
  Pro's CostTracker.RecordUsage (different repo, no shared code path)
  but fed the same Model/PromptTokens/CompletionTokens, so the two
  agree numerically without sharing implementation.
- New internal/integrations/llm/callmeta.go: CallMeta carries AgentName
  and IncidentID through context rather than as Complete() parameters,
  so tagging a call doesn't require changing the Client interface or
  any of its three provider implementations.
- Prompt/completion text is never recorded by default - only a sha256
  prompt_hash goes on the span. Opt-in via
  OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true (the upstream
  OTel GenAI instrumentation convention's own env var for this), which
  adds span events (never attributes) carrying the actual text, for
  local debugging only.
- Wired into llm.New() for all three providers (openai, anthropic,
  ollama) and into all six ai_service.go call sites that reach a
  Client, each tagged with its own agent name and incident ID where one
  is in scope.
- Live-verified against a real Jaeger collector through the real
  llm.New() path for all three providers: correct system/model/tokens/
  cost/agent/incident tags, zero span events (no PII) by default.

Found and flagged (not fixed here - unrelated to span conventions):
buildAnswerQuestionPrompt panics on a nil *models.Incident even though
AnswerQuestion's own signature documents current as optional. Dormant
today (the only caller already guards incident == nil first) but worth
its own fix.
@mintlify

mintlify Bot commented Sep 1, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
fluidify 🟢 Ready View Preview Sep 1, 2026, 9:54 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@singret
singret merged commit 430a93c into main Sep 1, 2026
5 checks passed
@singret
singret deleted the reg-12-llm-span-conventions branch September 1, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant