Skip to content

feat(otel): add canonical attribute normalization#115

Open
pradystar wants to merge 1 commit into
feat/user-wired-otel-pathsfrom
feat/canonical-otel-attribute-normalization
Open

feat(otel): add canonical attribute normalization#115
pradystar wants to merge 1 commit into
feat/user-wired-otel-pathsfrom
feat/canonical-otel-attribute-normalization

Conversation

@pradystar

Copy link
Copy Markdown
Collaborator

Summary

Add a single canonical attribute mapping and normalization pipeline for telemetry exported through the Splunk Agent Observability SDK. SDK-native spans and spans from user-configured OpenTelemetry or OpenInference instrumentation now produce a consistent OTLP wire contract for both standalone and O11y deployments without mutating completed spans.

The exported attributes retain standard gen_ai.* fields, add the corresponding splunk_ao.* aliases, move input/output and other content fields to the Splunk AO namespace, and identify the source as splunk_ao.system=splunk_ao_python.

Why

The OTLP export foundation preserved upstream span attributes but did not yet apply the canonical Splunk AO attribute contract. Centralizing this behavior avoids divergent mappings across instrumentation types and provides one reusable builder for the upcoming handler-based JSON-to-OTLP conversion path.

For DTB, normalization runs on an immutable copy of each completed span after BatchSpanProcessor forms an export batch and immediately before OTLP serialization. This change does not add a second batching implementation.

What changed

  • Added a canonical builder for converting SDK span data into preliminary OpenTelemetry attributes across LLM, tool, retriever, workflow, agent, and trace spans.
  • Added an explicit gen_ai.* to splunk_ao.* mapping used consistently at the export boundary.
  • Preserved ordinary gen_ai.* attributes while duplicating their mapped values under splunk_ao.*.
  • Relocated input, output, system-instruction, tool-content, retrieval-document, and tool-definition fields to the splunk_ao.* namespace so content is not duplicated.
  • Added the fixed splunk_ao.system=splunk_ao_python source marker to exported spans.
  • Normalized session, provider, finish-reason, token, cost, and time-to-first-token attributes while preserving valid false and zero values.
  • Added an exporter wrapper that normalizes immutable span copies and combines attribute normalization with the existing routing-resource promotion before serialization.
  • Installed the wrapper exactly once through the shared standalone and O11y exporter factories, covering SDK-native and user-configured OpenTelemetry/OpenInference export.
  • Ensured SDK span attributes are finalized even when the instrumented operation raises an exception.
  • Aligned A2A attributes with the canonical contract without changing A2A transport or lifecycle behavior.
  • Added a private, undocumented development switch, SPLUNK_AO_DEV_ENABLE_ATTRIBUTE_NORMALIZATION=false, for comparing baseline gen_ai.* output with the normalized wire shape. Routing, authentication, batching, and export remain active when it is disabled.
  • Added unit coverage for canonical mapping, collision handling, structured content, immutability, exporter delegation, routing composition, SDK-native spans, user-configured OTel spans, and A2A attributes.

Testing

Root SDK suite: 1,965 passed, 10 skipped; bundled A2A suite: 68 passed; focused mapping/exporter/native suite: 95 passed; changed-source Ruff/format and mypy, A2A Ruff/format/mypy, and git diff --check passed.

Compatibility and scope

  • Standalone and O11y endpoint, authentication, routing-header, and batching behavior is unchanged.
  • Completed source spans are not mutated; normalization affects only the exported wire representation.
  • The attribute wire shape intentionally gains canonical Splunk AO aliases, and content attributes intentionally move to the splunk_ao.* namespace.
  • Handler-based integrations are not switched to OTLP in this change; their converter will consume the shared canonical builder in the subsequent egress work (HYBIM-890)
  • The development comparison switch is intentionally not part of the public SDK configuration or README.

@fercor-cisco fercor-cisco left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 This review was generated by the Astra agent (claude-opus-4-8). It may contain mistakes.

Verdict: request_changes — Solid, well-tested refactor, but the new finally-block in start_splunk_ao_span can mask user exceptions, and the ns precision round-trip loses data.

General Comments

  • 🟡 minor (question): A2A span attribute keys were renamed from a2a.task.id/a2a.context_id/a2a.rpc.method/a2a.task.state to the splunk_ao.a2a.* namespace, and gen_ai.system="a2a" was dropped entirely. The removed comment on these constants previously stated they "match API-side A2A extension expectations." Is the API/ingest side already updated to parse the splunk_ao.a2a.* keys (and to no longer rely on gen_ai.system)? If not, this is a wire-contract break that would silently drop A2A task/context correlation on the backend. Please confirm the coordinated rollout.

Comment thread src/splunk_ao/otel.py
Comment on lines 319 to +324
with tracer.start_as_current_span(galileo_span.name) as span:
yield span
# Set dataset attributes for ground truth/reference output support
_apply_dataset_attributes(
span, galileo_span.dataset_input, galileo_span.dataset_output, galileo_span.dataset_metadata
)
if isinstance(galileo_span, RetrieverSpan):
_set_retriever_span_attributes(span, galileo_span)
elif isinstance(galileo_span, ToolSpan):
_set_tool_span_attributes(span, galileo_span)
elif isinstance(galileo_span, WorkflowSpan):
_set_workflow_span_attributes(span, galileo_span)
try:
yield span
finally:
for key, value in build_span_attributes(galileo_span, _session_id_context.get(None)).items():
span.set_attribute(key, value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟠 major (bug): Moving attribute finalization into finally means it now also runs on the exception path (intended, per the PR description). But build_span_attributes can itself raise — TypeError for an unsupported span type (its own else branch), or an error while serializing a partially-populated span that failed mid-operation. When the body already raised, an exception here replaces the user's original exception; when the body succeeded, it turns instrumentation into a hard failure. Instrumentation must never throw out of the wrapped operation. Guard the finalization and log instead of propagating.

Suggested change
with tracer.start_as_current_span(galileo_span.name) as span:
yield span
# Set dataset attributes for ground truth/reference output support
_apply_dataset_attributes(
span, galileo_span.dataset_input, galileo_span.dataset_output, galileo_span.dataset_metadata
)
if isinstance(galileo_span, RetrieverSpan):
_set_retriever_span_attributes(span, galileo_span)
elif isinstance(galileo_span, ToolSpan):
_set_tool_span_attributes(span, galileo_span)
elif isinstance(galileo_span, WorkflowSpan):
_set_workflow_span_attributes(span, galileo_span)
try:
yield span
finally:
for key, value in build_span_attributes(galileo_span, _session_id_context.get(None)).items():
span.set_attribute(key, value)
with tracer.start_as_current_span(galileo_span.name) as span:
try:
yield span
finally:
try:
for key, value in build_span_attributes(galileo_span, _session_id_context.get(None)).items():
span.set_attribute(key, value)
except Exception:
logger.warning("Failed to finalize Splunk AO span attributes", exc_info=True)

🤖 Generated by the Astra agent

Comment on lines +182 to +184
if metrics.time_to_first_token_ns is not None:
attrs["gen_ai.response.time_to_first_chunk"] = metrics.time_to_first_token_ns / 1_000_000_000
attrs["splunk_ao.llm.time_to_first_token_ns"] = metrics.time_to_first_token_ns

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 minor (bug): The exact splunk_ao.llm.time_to_first_token_ns set here is later clobbered by normalize_attributes_for_export, which derives it from gen_ai.response.time_to_first_chunk via _alias_value (int(float(seconds) * 1e9)). That round-trip is lossy for values that aren't exact multiples: e.g. ns=123456789 → 0.123456789 s → int(...*1e9)=123456788, degrading the precise value that was already available. Consider not overwriting splunk_ao.llm.time_to_first_token_ns when it's already present in the source attrs (only derive it from the seconds field when the ns field is absent, i.e. the user-OTel path). The existing tests use round values (0.125, 0.25) and don't exercise this.

🤖 Generated by the Astra agent

Comment on lines +29 to +38
SPLUNK_ALIAS_BY_GEN_AI: Mapping[str, str] = {
"gen_ai.system": "splunk_ao.provider.name",
"gen_ai.operation.name": "splunk_ao.operation.name",
"gen_ai.conversation.id": "splunk_ao.session.id",
"gen_ai.workflow.name": "splunk_ao.workflow.name",
"gen_ai.agent.name": "splunk_ao.agent.name",
"gen_ai.agent.id": "splunk_ao.agent.id",
"gen_ai.agent.description": "splunk_ao.agent.description",
"gen_ai.agent.version": "splunk_ao.agent.version",
"gen_ai.provider.name": "splunk_ao.provider.name",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 minor (design): Both gen_ai.system and gen_ai.provider.name map to splunk_ao.provider.name. Which one wins when both are present depends solely on their insertion order in this dict literal (provider.name currently later, so it wins — as asserted by test_normalizer_gen_ai_wins_collisions). This is fragile: reordering the map silently flips the precedence. Consider making the precedence explicit (e.g. handle provider resolution in a dedicated step, or add a comment documenting the order dependency).

🤖 Generated by the Astra agent

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.

2 participants