Skip to content

OTel: adopt gen_ai.tool.* semantic conventions on tool spans (+ fill GenAI attribute gaps) #421

Description

@initializ-mk

Summary

Forge emits a partial OTel GenAI attribute set today, and — most importantly — it publishes tool telemetry under proprietary forge.tool.* keys instead of the standardized gen_ai.tool.* semantic conventions. GenAI-native backends (Datadog LLM Observability, Honeycomb, Grafana/Tempo, Arize Phoenix, Langfuse) key their agent/tool dashboards off gen_ai.*, so Forge tool spans don't light up there even though the data is already on the span under a different name.

This issue tracks closing the gap — tool attributes first — and bumping the pinned semconv so the newer GenAI keys are available.

Source of truth for the target set: OTel semantic conventions, GenAI + MCP registries (see the attribute tables the reporter attached).

Current state (what Forge emits)

Emitted in forge-core/runtime/loop.go, keys centralized in forge-core/observability/attrs.go:

Span Attribute Notes
agent.execute, llm.completion gen_ai.system vendor; deprecated key — semconv renamed this to gen_ai.provider.name
agent.execute, llm.completion gen_ai.request.model
llm.completion gen_ai.usage.input_tokens / gen_ai.usage.output_tokens
llm.completion gen_ai.response.finish_reasons
llm.completion gen_ai.input.messages / gen_ai.output.messages opt-in (CaptureContent), emitted as JSON string
tool.<name> forge.tool.name / forge.tool.error / forge.tool.args / forge.tool.result ⚠️ proprietary keys, not gen_ai.tool.*
gen_ai.response.model declared in attrs.go:41 but never set (no emit site)

Pinned semconv: go.opentelemetry.io/otel/semconv/v1.26.0 + a comment "pinned to OTel semconv 1.26.0 GenAI" in attrs.go. Several target attributes below are from newer semconv and require a version bump.

Primary scope — tool-related gen_ai.tool.* (the emphasis)

Forge already has all the underlying data; this is mostly a re-key + a few additions on the tool.<name> span (loop.go:727). ToolCall carries ID, Type ("function"), Function.Name, Function.Arguments (forge-core/llm/types.go:26); ToolDefinition carries Type + Function schema (name, description, parameters).

semconv attribute type Forge status Data source
gen_ai.operation.name = execute_tool string missing constant — set on the tool span
gen_ai.tool.name string have as forge.tool.name tc.Function.Name
gen_ai.tool.call.id string missing tc.ID (already on the struct, threaded back to the LLM)
gen_ai.tool.call.arguments any (JSON) have as forge.tool.args (opt-in) tc.Function.Arguments
gen_ai.tool.call.result any (JSON) have as forge.tool.result (opt-in) tool result
gen_ai.tool.type string missing tc.Type ("function"); MCP tools → extension
gen_ai.tool.description string missing ToolDefinition.Function.Description
gen_ai.tool.definitions any (JSON) missing toolDefs at agent.execute / llm.completion (opt-in — can be large)

Also consider the semconv span-naming convention for tool execution: execute_tool <tool.name> (paired with gen_ai.operation.name=execute_tool). Forge currently names it tool.<name>. Renaming is a breaking change for existing dashboards — see "dual-emit" below.

MCP tool spans (mcp.* namespace)

Forge is an MCP client, and MCP tool calls surface as <server>__<tool>. MCP tool spans should additionally carry:

  • mcp.method.name = tools/call
  • mcp.session.id, mcp.protocol.version
  • and set gen_ai.tool.type = extension for MCP-backed tools

This dovetails with the MCP audit events Forge already emits (docs/mcp/audit-events.md) — same join key story as the existing gen_ai/audit correlation.

Also in scope — agent & conversation identity

These are cheap (all data already in hand at agent.execute :222) and high-value for grouping traces per agent/session, so they land with the tool work rather than in the deferred pile. Stamp on the agent.execute span:

semconv attribute type Forge status Data source
gen_ai.agent.id string missing (only forge.agent.id today) agent_id from forge.yaml
gen_ai.agent.name string missing agent name from forge.yaml
gen_ai.agent.version string missing agent version from forge.yaml (also service.version)
gen_ai.conversation.id string missing Forge session id = A2A task.ID (the session-store key that persists across turns — .forge/sessions/<task>.json)

Note per semconv: gen_ai.conversation.id should only be set when a real conversation/thread identifier is available (which Forge's session id is) — never a synthesized UUID or trace id as a fallback.

Secondary scope — other GenAI gaps (lower priority)

Grouped; all have a ready data source in Forge:

  • Operation / provider identity
    • gen_ai.operation.name = chat on llm.completion (foundational; currently absent)
    • gen_ai.provider.name — the current key that supersedes the deprecated gen_ai.system (migrate; keep both for one release)
    • gen_ai.response.model — wire up the already-declared constant (attrs.go:41)
    • gen_ai.response.id — provider completion id (needs threading from provider responses)
  • Request params (available in ChatRequest: Temperature, MaxTokens, Stream)
    • gen_ai.request.max_tokens, gen_ai.request.temperature, gen_ai.request.stream
  • Usage detail (Forge ships prompt caching + supports reasoning models)
    • gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_write.input_tokens
    • gen_ai.usage.reasoning.output_tokens
    • (requires surfacing these from the provider usage blocks into llm.Usage)
  • Instructions
    • gen_ai.system_instructions — Forge has systemPrompt (today folded into input.messages); split it out per semconv
  • Memory / retrieval / embeddings (Forge has vector memory + search + embedders — a later, separate slice)
    • gen_ai.operation.name = embeddings / search_memory, gen_ai.embeddings.dimension.count, gen_ai.memory.*, gen_ai.retrieval.*

Design considerations

  • Semconv bump. provider.name, usage.cache_*, usage.reasoning.output_tokens, tool.definitions, tool.call.* come from semconv newer than the pinned 1.26.0. Bump forge-core/observability/otel.go (resource semconv/vX) and the attrs.go constants together — it's a single-file mechanical sweep by design (per the attrs.go header comment).
  • Replace forge.tool.* outright. These keys are not used in production yet, so drop them and emit gen_ai.tool.* as a straight replacement — no dual-emit window needed. (The gen_ai.systemgen_ai.provider.name rename is separate: gen_ai.system has shipped, so keep it as a deprecated alias for one release.)
  • Content-capture posture. tool.call.arguments, tool.call.result, tool.definitions, system_instructions, input/output.messages are all flagged sensitive by semconv. Keep them behind the existing CaptureContent + Redact + 4 KiB PrepareSpanContent pipeline. Consider honoring the standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var as an alias for CaptureContent.
  • Structured vs string. semconv prefers structured values for *.messages / tool.* and allows a JSON string on spans when structured isn't supported. Forge already emits JSON strings; acceptable, but note it for a future events-based emission.

Acceptance criteria

  • tool.<name> spans emit gen_ai.operation.name=execute_tool, gen_ai.tool.name, gen_ai.tool.call.id, gen_ai.tool.type, and (opt-in) gen_ai.tool.call.arguments / gen_ai.tool.call.result / gen_ai.tool.description.
  • MCP-backed tool spans additionally carry mcp.method.name=tools/call, mcp.session.id, mcp.protocol.version, and gen_ai.tool.type=extension.
  • gen_ai.tool.definitions emitted (opt-in) on the LLM/agent span.
  • agent.execute span emits gen_ai.agent.id, gen_ai.agent.name, gen_ai.agent.version (from forge.yaml), and gen_ai.conversation.id (Forge session/task.ID).
  • gen_ai.provider.name emitted; gen_ai.system retained one release as a deprecated alias.
  • gen_ai.response.model (existing constant) is actually set; gen_ai.operation.name=chat set on llm.completion.
  • Pinned semconv version bumped; attrs.go constants updated in one sweep.
  • forge.tool.* removed and replaced by gen_ai.tool.* (not yet used in production — no dual-emit needed). Update any attrs.go constants and tests accordingly.
  • Content attributes stay behind CaptureContent+Redact; no secrets by default.
  • docs/core-concepts/observability-tracing.md updated with the full attribute table; unit tests assert the new keys on tool/LLM spans.

References

Reviewed the current OTel wiring; no code changes in this issue — spec + backlog only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestepic:evalCross-repo observability + evaluation epic

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions