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.

Activity

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

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