Mimir.Event.OTel (Mimir v0.4.1)

Copy Markdown View Source

Canonical export-edge rendering for Mimir.Event — one mapper, one private renderer per domain, no per-consumer drift (spec §3.3: "gen_ai is a wire format at the export edge, not a domain model").

render/1 returns %{type: String.t(), attributes: %{optional(String.t()) => term()}}. type is the domain string ("llm", "agent", "workflow"); attributes is the OTel-attribute-shaped map for that event.

llm domain — byte-compatible with the retired Mimir.TurnEvents.GenAI

The llm mapper reproduces today's exported attribute names exactly for the three shapes the old Mimir.TurnEvents.GenAI helpers built: gen_ai.usage.input_tokens/gen_ai.usage.output_tokens, gen_ai.tool.name/gen_ai.tool.call.id (including the case where the tool id is nil — the call-id key is still present with a nil value, matching GenAI.tool_use/1's behavior verbatim), and the bare "milestone" reasoning marker (note: no gen_ai. prefix on milestone historically — preserved as documented, not "fixed"). Proof lives in test/support/fixtures/gen_ai_compat/*.json, captured from the live Mimir.TurnEvents.GenAI helpers before this module existed (see the fixture freeze commit and test/mimir/event/otel_test.exs, which assert render/1's output byte-equal against those frozen fixtures.

llm types with no dedicated historical builder (request_start, request_stop, turn_complete, exception) had no fixed attribute shape before this vocabulary existed — the old Mimir.Ingest module's private classify step forwarded whatever provider-shaped map arrived verbatim as the event's gen_ai payload. This mapper preserves that posture: it renders the event's raw map (string-keying any atom keys), so a collector that stashes provider-native gen_ai.* attributes in raw gets them exported unchanged.

agent domain — OTel GenAI agent conventions

gen_ai.operation.name is "invoke_agent" for every agent event — verified against the OpenTelemetry Semantic Conventions for Generative AI Agents (gen-ai-agent-spans, semconv-genai ~v1.40/1.41, still Development status as of 2026-07: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/). The registry defines two agent operation names, create_agent and invoke_agent; Mimir's session lifecycle (:session_open/ :session_reattach) is semantically an invocation of an existing or freshly-brokered agent in both cases, not the OTel create_agent sense (typically remote agent-service creation, e.g. an Assistants-API POST /assistants), so both map to invoke_agent.

session_id, when present, renders as gen_ai.conversation.id — the semconv-registered attribute for correlating a stream of events into one conversation/session (open-telemetry/semantic-conventions-genai#51).

:turn_start/:turn_end/:terminal/:error have no OTel operation name of their own (they're sub-moments within one invoke_agent), so the mapper adds a mimir.agent.event sub-type attribute carrying the Mimir type string, keeping that distinction legible at the export edge. :session_open/:session_reattach do not get this extra attribute — the operation name plus the correlation id already identify the invocation.

workflow domain — plain mimir.workflow.*, no GenAI pretense

Workflow spans do not participate in the GenAI vocabulary at all: render/1 never emits a gen_ai.* key for a :workflow event. Attributes are mimir.workflow.id, mimir.workflow.step_id (both omitted when nil, same idiom as Event.to_wire/1's put_present), and mimir.workflow.event (the Mimir type string, always present).

pathmimir.path, every domain

When event.path != [], render/1 adds a "mimir.path" attribute — the frames joined with / (e.g. "workflow:wf_123/workflow_step:step_5/agent:sess_9") — on top of whichever domain-specific attributes above. This is purely additive: an event with no path (the default, and every event recorded before this field existed) renders identically to before this field was added, which is what keeps the frozen gen_ai.* byte-compat goldens passing unchanged.

Summary

Functions

render(ev)

@spec render(Mimir.Event.t()) :: %{
  type: String.t(),
  attributes: %{optional(String.t()) => term()}
}