Skip to content

[SC-17274] Emit Atryum activity as OpenTelemetry traces - #144

Open
juanmleng wants to merge 6 commits into
mainfrom
juan/sc-17274/emit-atryum-traces-in-otel-format
Open

[SC-17274] Emit Atryum activity as OpenTelemetry traces#144
juanmleng wants to merge 6 commits into
mainfrom
juan/sc-17274/emit-atryum-traces-in-otel-format

Conversation

@juanmleng

@juanmleng juanmleng commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Pull Request Description

What and why?

Atryum now reports its activity — every agent tool-call it governs and every AI-judge decision — as OpenTelemetry (OTEL) traces. OTEL is the industry-standard observability format, so this activity can flow into any compatible tool (Langfuse, Datadog, LangSmith, and others) with no vendor-specific code in Atryum.

Why it matters: there was previously no straightforward way to see, in an observability tool, what Atryum's governance layer was doing — which tool calls agents attempted, how the AI judge ruled, and what each judgment cost in time and tokens.

Before: governance activity was not exported anywhere.
After: each governed invocation produces a single trace showing:

  • the tool call and its outcome — approved, denied, or sent for human approval;
  • the AI-judge decision rendered as a generation: the model used, input/output token counts, and latency;
  • the exact prompt the judge saw and the verdict it returned.

This works the same whether the judge runs inside Atryum (standalone / bring-your-own-key) or is hosted by the ValidMind backend — both paths render identically. Sending traces to a new backend is pure configuration (endpoint + credentials in atryum.toml); the same traces can fan out to several backends at once.

Configuration

Everything lives in one [otel] section of atryum.toml — no environment
variables, no code changes. Off by default. Providers are not hardcoded: any
OTLP backend works, and Langfuse/Datadog below are just examples.

Turn it on (or off)

[otel]
enabled = true   # omit the section or set false = no traces, fully no-op

Add a provider — one [[otel.exporters]] block per destination. Each is an
OTLP endpoint plus how to authenticate:

[[otel.exporters]]                                       # Basic-auth key pair (e.g. Langfuse)
name = "langfuse"                                        # a label, for logs only
endpoint = "https://<host>/api/public/otel/v1/traces"    # full URL, must end in /v1/traces
public_key = "pk-lf-…"
secret_key = "sk-lf-…"

[[otel.exporters]]                                       # API key in a header (e.g. Datadog)
name = "datadog"
endpoint = "https://otlp.<dd-site>/v1/traces"
headers = { "DD-API-KEY" = "<key>" }

Rule of thumb: auth goes in headers; public_key/secret_key is just a
shortcut for Basic-auth backends. List several blocks to fan the same traces to
several tools at once. Restart Atryum — the startup log prints otel tracing enabled.

Keep sensitive text out (optional)

[otel]
capture_content = false   # default true

When off, model / tokens / latency / verdict still export, but the judge's prompt
and answer text (which contain your charter/policy and the agent's tool arguments)
do not.

That's the whole surface: enabled, one or more [[otel.exporters]], and capture_content.

How it connects

Every governance decision Atryum makes is now emitted as a standard OpenTelemetry (OTEL) trace, so the same data can flow to any observability tool — we proved it reaching Langfuse and Datadog at the same
time, with no code change to add the second.

Screenshot 2026-07-22 at 13 07 53

The shape of one trace

A single permission check produces one nested trace. Each layer is a "span":
Screenshot 2026-07-22 at 13 11 42

Datadog

Screenshot 2026-07-22 at 13 19 52 Screenshot 2026-07-22 at 13 20 55

Langfuse

Screenshot 2026-07-22 at 13 13 34 Screenshot 2026-07-22 at 13 13 49

How to test

  1. In atryum.toml, enable [otel] and add an exporter pointing at any OTLP-capable backend (endpoint + credentials).
  2. Restart Atryum — the startup log confirms otel tracing enabled.
  3. Drive a few invocations through the UI or the invocations API.
  4. In your observability tool, filter for service:atryum: each invocation appears as a trace, and any AI-judged call shows a chat {model} generation with model, tokens, latency, and (unless content capture is disabled) the prompt and verdict.

Verified locally end-to-end against a real OTLP backend for both judge paths, including token counts, latency, and input/output.

What needs special review?

  • internal/invocation/service.go — the VM-path generation span is synthesized after the fact and backdated by the reported latency so its duration is the real LLM call time; confirm the timestamps and nesting under atryum.ai_evaluation.
  • The content-capture gate ([otel].capture_content, default on) — confirm it fully suppresses prompt/completion when off while still emitting model/tokens/latency/verdict.

Dependencies, breaking changes, and deployment notes

  • Paired with a ValidMind backend change https://github.com/validmind/backend/pull/3390 that returns the judge model, token usage, latency, and messages from the evaluation endpoint, so backend-hosted judgments render fully. Atryum degrades gracefully without it (model-less span, no crash).
  • No breaking changes. OTEL export is off unless [otel] is enabled; when disabled the tracer is a no-op.
  • Deployment note: enabling export sends span data (and, unless capture_content = false, judge prompt/response text) to the configured backend.

Data impact

  • Yes — this change is data-impacting
  • No — this change is not data-impacting

Justification: Additive, opt-in telemetry plus a new optional config key ([otel].capture_content). No existing configuration, stored data, or API response is modified; export is inert unless [otel] is enabled.

Release notes

enhancement — Atryum can now emit its governance activity (tool calls and AI-judge decisions, including model, tokens, latency, and — optionally — prompt/response) as OpenTelemetry traces to any OTLP-compatible backend, configured per exporter in atryum.toml.

Checklist

  • What and why
  • Screenshots or videos (Frontend) — n/a, no UI change
  • How to test
  • What needs special review
  • Dependencies, breaking changes, and deployment notes
  • Labels applied
  • Data impact classified
  • PR linked to Shortcut
  • Unit tests added
  • Tested locally
  • Documentation updated (if required)
  • Environment variable additions/changes documented (if required)

juanmleng and others added 2 commits July 21, 2026 13:25
Atryum now exports its activity over OTLP so any OTEL-native backend
(Langfuse, Datadog, Arize, ...) can consume it with no vendor-specific
code. Each backend is just an endpoint plus headers in config.

Span tree per request:
  HTTP root (otelhttp) -> atryum.invocation -> atryum.ai_evaluation
                                            -> chat {model}

Both ingestion paths open an atryum.invocation span: Invoke (MCP proxy)
and Submit (the external harness-hook twin, tagged atryum.external).
Each records the server/source, tool, agent id, matched rule and
resulting disposition.

The judge span follows the OTEL GenAI conventions (gen_ai.system,
gen_ai.request.model, gen_ai.usage.*) so Langfuse renders it as a
generation, plus atryum.judge.verdict/.confidence. Capturing token
counts required widening the OpenAI and Anthropic response structs,
which previously read only the message text and discarded usage.

New [otel] config section takes a list of [[otel.exporters]]; multiple
entries fan the same spans out to several backends at once. Langfuse
authenticates via a public/secret key pair (the pair selects the
project), other backends via raw headers. deployment.environment reuses
atryum's existing instance identity - atryum_instance, else
public_base_url - so a deployment self-tags with the environment it
serves; Datadog reads it as the env tag.

Disabled by default: when [otel].enabled is false the tracer provider
is a no-op and the instrumentation costs nothing.

Span tests share a single recorder because the OTEL global only wires
its delegate on the first SetTracerProvider call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The VM (ValidMind-hosted) evaluator recorded only verdict and confidence on the
ai_evaluation span, so its judge calls appeared in OTEL backends with no model,
tokens, latency, or content — unlike the local evaluator.

Widen EvaluateResponse with model, usage, latency_ms, prompt and completion
(returned by the backend evaluate endpoint). On the VM path, synthesize a
"chat {model}" gen_ai generation span, backdated by the reported latency and
carrying the same gen_ai.* attributes the local path already emits, so both
evaluators render identically in any OTEL backend.

Attach the judge prompt and completion as gen_ai.prompt / gen_ai.completion on
both paths, gated by [otel].capture_content (default on) so deployments can keep
charter text and tool arguments out of external telemetry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jul 21, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@juanmleng juanmleng changed the title Emit Atryum activity as OpenTelemetry traces [SC-17274] Emit Atryum activity as OpenTelemetry traces Jul 21, 2026
@juanmleng juanmleng added the enhancement New feature or request label Jul 21, 2026
juanmleng and others added 3 commits July 22, 2026 14:56
Shorten the [otel] block, add the capture_content key with a note, and
frame Langfuse/Datadog/Arize as examples rather than built-in providers.
Replace the stale Langfuse host with a placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Install an otel.SetErrorHandler so lazy-exporter runtime failures
surface in Atryum's logs instead of the SDK's default stderr, and
correct a copy-paste span name in the content-capture test message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@juanmleng
juanmleng marked this pull request as ready for review July 22, 2026 13:46
@juanmleng
juanmleng requested review from hunner and nibalizer July 22, 2026 13:46
@juanmleng juanmleng self-assigned this Jul 22, 2026
trace.WithSpanKind(trace.SpanKindClient),
trace.WithTimestamp(start),
trace.WithAttributes(
attribute.String("gen_ai.system", "openai"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's possible we could use something not from open ai in the future. Maybe we need a lookup table based off of model name?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants