Skip to content

Fix/HYBIM-961 retrieval and agent span semantics - #210

Open
pradystar wants to merge 4 commits into
mainfrom
fix/HYBIM-961-operation-span-semantics
Open

Fix/HYBIM-961 retrieval and agent span semantics#210
pradystar wants to merge 4 commits into
mainfrom
fix/HYBIM-961-operation-span-semantics

Conversation

@pradystar

Copy link
Copy Markdown
Collaborator

Summary

Align path-1 retrieval and agent spans with OpenTelemetry GenAI operation semantics.

What changed

  • Export retrieval spans as SpanKind.CLIENT.
  • Name retrieval spans retrieval {data_source_id} when an explicit authoritative ID is provided; otherwise preserve the captured display name as retrieval {display_name}, falling back to retrieval when neither is available.
  • Emit explicit retrieval IDs as gen_ai.data_source.id.
  • Treat an empty data_source_id as absent without modifying valid non-empty IDs.
  • Allow remote-agent calls to opt into SpanKind.CLIENT; local agents remain INTERNAL.
  • Reject unsupported agent classifications by safely normalizing them to INTERNAL.
  • Keep runtime-only hints out of the deprecated proprietary ingestion payload.
  • Preserve source-owned names and kinds for native and user-wired OTel spans.

Testing

Full SDK suite: 2,148 passed, 4 skipped.

@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-5). It may contain mistakes.

Verdict: request_changes — Core mechanism is sound, but the new gen_ai.data_source.id attribute is missing from the splunk_ao alias map, whitespace-only IDs bypass the empty-ID normalizer, and the CHANGELOG contradicts the implemented naming fallback.

General Comments

  • 🟡 minor (design): The two new runtime-only hints are modeled inconsistently. span_kind lives on LoggedAgentSpan and is coerced by a before validator that silently discards anything that isn't literally SpanKind.CLIENT; data_source_id lives on LoggedRetrieverSpan and is coerced only for the exact empty string. Both are then read back out by the converter via getattr, so neither has a single place that owns "what does a valid value look like". Consider a small shared mixin (or at least a shared _OTEL_HINT_FIELD helper) that both classes use, so the exclude-from-payload behaviour and the normalization rules are declared once. This would also make the asymmetry (why does one normalize whitespace-ish input and the other not?) visible at the definition site instead of at two separate validators.
  • 🟡 minor (question): HYBIM-961 states the retrieval span "is named retrieval {gen_ai.data_source.id} when an authoritative data-source ID is supplied or simply retrieval otherwise". The implementation instead falls back to retrieval {display_name} when no ID is supplied. The PR description acknowledges and justifies this (preserving the captured display name avoids collapsing every un-tagged retriever to a single retrieval name, which would be a visible regression in the UI), and I think the implemented behaviour is the better one. But it is a deliberate divergence from the accepted ticket text — please confirm with the ticket owner and update HYBIM-961 so the story and the code agree, otherwise the next person reconciling the two will "fix" the fallback away.

Follow-ups

Suggested follow-up work that could be tracked as Jira tickets:

  • src/splunk_ao/schema/logged.py:86-97: LoggedRetrieverSpan inherits spans: List[Span] from core StepWithChildSpans, i.e. the core span union rather than the SDK-local LoggedSpan union that LoggedAgentSpan and LoggedWorkflowSpan both override. If a child were ever attached to a retriever span, validate_assignment would coerce a LoggedLlmSpan/LoggedAgentSpan child down to its core type, dropping widened multimodal content and the new span_kind hint. This is not reachable today (SplunkAOLogger never makes a retriever the current parent, so add_child_span_to_parent cannot target one) and it predates this PR — the old LoggedSpan union embedded plain RetrieverSpan with the same gap. Now that a dedicated SDK-local class exists, adding spans: list["LoggedSpan"] = Field(default_factory=list) would close it cheaply. Same applies to the still-core ToolSpan member of the LoggedSpan union.
  • src/splunk_ao/decorator.py:323-327: explicit_span_params is assembled without reference to span_type, so mismatched combinations are accepted and then silently discarded downstream: @log(span_type="llm", data_source_id="x") loses the ID during signature filtering in _complete_call, and @log(span_kind=SpanKind.CLIENT) (no span_type, i.e. the workflow path) never reaches the span_type == "agent" branch in _prepare_call. Neither is a bug introduced here — it matches how params and the other auto-mapped span params already behave — but a debug/warning log when a hint is supplied for a span type that cannot consume it would save users a confusing round of "why is my attribute missing".
  • tests/test_logger_otel_egress.py:148-150: test_agent_logger_allows_only_client_kind_override calls otlp_logger.conclude(output="answer") twice on consecutive lines. This is correct and necessary — the first closes the agent span, the second closes the trace envelope — but it reads exactly like a copy-paste duplication and the retriever test immediately above needs only one call. A one-line comment (# conclude the agent span, then the trace envelope) would stop a future reader from "cleaning up" the second call and silently changing what the test covers.

attrs["gen_ai.retrieval.documents"] = _content_value(span.output)
attrs["splunk_ao.retrieval.documents.count"] = len(span.output)
attrs["db.operation"] = "search"
_set_if_present(attrs, "gen_ai.data_source.id", _field(span, "data_source_id"))

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): gen_ai.data_source.id is emitted but never registered in SPLUNK_ALIAS_BY_GEN_AI, so normalize_attributes_for_export will not produce a splunk_ao.* mirror for it. Every other gen_ai.* attribute this function emits has an alias — including the two immediate neighbours here, gen_ai.retrieval.query.text and gen_ai.retrieval.top_k. The result is that with normalization enabled (SPLUNK_AO_DEV_ENABLE_ATTRIBUTE_NORMALIZATION), the new authoritative retrieval identity is the only retrieval field with no Splunk-namespaced counterpart, so any backend or dashboard consuming the splunk_ao.retrieval.* set silently sees no data source ID.

Impact is limited today because normalization is opt-in and off by default, which is why I'm rating this minor rather than major — but it will become a silent data gap the moment normalization is turned on. Add the alias next to the other retrieval entries. test_every_alias_uses_an_explicit_destination_namespace only checks namespacing, not coverage, so nothing currently catches this; consider a companion assertion that every gen_ai.* key produced by build_span_attributes appears in the alias map.

Suggested change
_set_if_present(attrs, "gen_ai.data_source.id", _field(span, "data_source_id"))
_set_if_present(attrs, "gen_ai.data_source.id", _field(span, "data_source_id"))
# and in SPLUNK_ALIAS_BY_GEN_AI, alongside the other retrieval entries:
# "gen_ai.data_source.id": "splunk_ao.data_source.id",

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

gen_ai.data_source.id must remain standard only, the normalization behavior will be removed fully in future

Comment on lines +47 to 51
if step_type is StepType.retriever:
if detail is not None:
return f"{prefix} {detail}"
detail = getattr(span, "name", None)
return " ".join(part for part in (prefix, str(detail).strip() if detail is not None else "") if part)

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 retriever branch returns f"{prefix} {detail}" without stripping, while the shared fallback path below it does str(detail).strip(). Combined with normalize_data_source_id, which only maps the exact empty string to None, a whitespace-only or padded ID leaks straight through:

  • data_source_id=" " → span name "retrieval " (trailing whitespace) and attribute gen_ai.data_source.id == " ".
  • data_source_id=" kb " → span name "retrieval kb ".

That is the exact class of malformed name test_missing_optional_name_parts_do_not_leave_whitespace exists to prevent — it just never exercises the new data_source_id path. Two options:

  1. Strip in the converter only (below). Cheap, but the attribute still carries the untrimmed value, so name and attribute disagree.
  2. Preferred: strip in LoggedRetrieverSpan.normalize_data_source_id and treat the result as absent when empty, so name and attribute stay consistent and the empty-vs-whitespace cases collapse into one rule:
@field_validator("data_source_id", mode="before")
@classmethod
def normalize_data_source_id(cls, value: object) -> object:
    if isinstance(value, str):
        stripped = value.strip()
        return stripped or None
    return value

Note option 2 changes the intent of test_retriever_name_and_attribute_use_explicit_data_source_id_byte_for_byte; that test's case ("knowledge base/v1") still passes since it has no leading/trailing whitespace, but please add a whitespace-only case either way.

Suggested change
if step_type is StepType.retriever:
if detail is not None:
return f"{prefix} {detail}"
detail = getattr(span, "name", None)
return " ".join(part for part in (prefix, str(detail).strip() if detail is not None else "") if part)
if step_type is StepType.retriever:
if detail is not None:
return f"{prefix} {str(detail).strip()}"
detail = getattr(span, "name", None)

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed whitespace only IDs are now absent. Padded IDs are trimmed consistently before use in both the span name and attribute.

Comment on lines +79 to +83
@field_validator("span_kind", mode="before")
@classmethod
def normalize_span_kind(cls, value: object) -> SpanKind:
"""Allow only the explicit remote-agent client classification."""
return SpanKind.CLIENT if value is SpanKind.CLIENT else SpanKind.INTERNAL

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): The identity comparison (value is SpanKind.CLIENT) in a mode="before" validator rejects representations that Pydantic would normally accept for an Enum field, and does so with no diagnostic. SpanKind is a plain Enum with int values, so all of these silently become INTERNAL:

  • add_agent_span(span_kind=2)
  • @log(span_type="agent", span_kind="CLIENT") — plausible, since the decorator also auto-maps a span_kind function argument into span_params, where it arrives as whatever the caller passed
  • SpanKind.SERVER (genuinely unsupported, but the caller gets no signal that their request was dropped)

The first two are the concerning ones: the user asked for a remote-agent classification, got a local one, and has nothing in the logs to explain why. Compare by value so equivalent representations are honoured, and warn on genuinely unsupported kinds so the drop is observable:

The existing parametrized test asserts "CLIENT"INTERNAL, so it encodes the current behaviour; it would need updating alongside this. Please also confirm that silently normalizing (rather than raising) is the intended contract — the PR description says "safely normalizing", which suggests it is, but silence and normalization are separable choices.

Suggested change
@field_validator("span_kind", mode="before")
@classmethod
def normalize_span_kind(cls, value: object) -> SpanKind:
"""Allow only the explicit remote-agent client classification."""
return SpanKind.CLIENT if value is SpanKind.CLIENT else SpanKind.INTERNAL
@field_validator("span_kind", mode="before")
@classmethod
def normalize_span_kind(cls, value: object) -> SpanKind:
"""Allow only the explicit remote-agent client classification."""
try:
resolved = value if isinstance(value, SpanKind) else SpanKind[str(value)]
except (KeyError, ValueError):
resolved = None
if resolved is SpanKind.CLIENT:
return SpanKind.CLIENT
if value not in (None, SpanKind.INTERNAL):
_logger.warning("Unsupported agent span_kind %r; using SpanKind.INTERNAL.", value)
return SpanKind.INTERNAL

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

agent span kind can only be client or internal, only an actual SpanKind.CLIENT enables remote-agent classification

Comment thread src/splunk_ao/schema/logged.py Outdated
class LoggedRetrieverSpan(RetrieverSpan):
"""RetrieverSpan with SDK-local OTel data-source identity."""

model_config = ConfigDict(from_attributes=True, validate_assignment=True)

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 (question): Why does LoggedRetrieverSpan need model_config = ConfigDict(from_attributes=True, validate_assignment=True) when no other Logged* model declares one? validate_assignment=True is already inherited from BaseStep.model_config, so it is redundant. from_attributes=True is not exercised anywhere in this diff or in the surrounding code — nothing calls model_validate on a non-dict source for this type, and LoggedAgentSpan carries an analogous new field without it. If it is load-bearing for a path I've missed, please add a comment naming that path; otherwise drop the whole line so the model stays consistent with its siblings and doesn't quietly widen validation for arbitrary objects.

Suggested change
model_config = ConfigDict(from_attributes=True, validate_assignment=True)
data_source_id: str | None = Field(default=None, exclude=True)

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Partially agree - Removed redundant validate_assignment=True, retained required from_attributes=True, documented why it is necessary, and strengthened the compatibility assertion.

Comment thread CHANGELOG.md Outdated
Comment on lines +20 to +21
- Retriever spans exported over OTLP now use client operation semantics and
names derived only from an explicit data-source ID.

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 (documentation): "names derived only from an explicit data-source ID" contradicts the implementation. _span_name falls back to retrieval {name} when no data_source_id is present, and further to bare retrieval when neither is available — that fallback is deliberate, tested (test_retriever_display_name_is_used_only_as_span_name_fallback), and described correctly in the PR body. As written, the CHANGELOG tells users their existing un-tagged retriever spans will lose their display name in the OTLP name, which is the opposite of what happens.

Suggested change
- Retriever spans exported over OTLP now use client operation semantics and
names derived only from an explicit data-source ID.
- Retriever spans exported over OTLP now use client operation semantics and are
named `retrieval {data_source_id}` when an explicit data-source ID is supplied,
falling back to the captured display name and then to `retrieval`.

🤖 Generated by the Astra agent

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated

@pradystar

Copy link
Copy Markdown
Collaborator Author

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

Verdict: request_changes — Core mechanism is sound, but the new gen_ai.data_source.id attribute is missing from the splunk_ao alias map, whitespace-only IDs bypass the empty-ID normalizer, and the CHANGELOG contradicts the implemented naming fallback.

General Comments

  • 🟡 minor (design): The two new runtime-only hints are modeled inconsistently. span_kind lives on LoggedAgentSpan and is coerced by a before validator that silently discards anything that isn't literally SpanKind.CLIENT; data_source_id lives on LoggedRetrieverSpan and is coerced only for the exact empty string. Both are then read back out by the converter via getattr, so neither has a single place that owns "what does a valid value look like". Consider a small shared mixin (or at least a shared _OTEL_HINT_FIELD helper) that both classes use, so the exclude-from-payload behaviour and the normalization rules are declared once. This would also make the asymmetry (why does one normalize whitespace-ish input and the other not?) visible at the definition site instead of at two separate validators.
  • 🟡 minor (question): HYBIM-961 states the retrieval span "is named retrieval {gen_ai.data_source.id} when an authoritative data-source ID is supplied or simply retrieval otherwise". The implementation instead falls back to retrieval {display_name} when no ID is supplied. The PR description acknowledges and justifies this (preserving the captured display name avoids collapsing every un-tagged retriever to a single retrieval name, which would be a visible regression in the UI), and I think the implemented behaviour is the better one. But it is a deliberate divergence from the accepted ticket text — please confirm with the ticket owner and update HYBIM-961 so the story and the code agree, otherwise the next person reconciling the two will "fix" the fallback away.

Follow-ups

Suggested follow-up work that could be tracked as Jira tickets:

  • src/splunk_ao/schema/logged.py:86-97: LoggedRetrieverSpan inherits spans: List[Span] from core StepWithChildSpans, i.e. the core span union rather than the SDK-local LoggedSpan union that LoggedAgentSpan and LoggedWorkflowSpan both override. If a child were ever attached to a retriever span, validate_assignment would coerce a LoggedLlmSpan/LoggedAgentSpan child down to its core type, dropping widened multimodal content and the new span_kind hint. This is not reachable today (SplunkAOLogger never makes a retriever the current parent, so add_child_span_to_parent cannot target one) and it predates this PR — the old LoggedSpan union embedded plain RetrieverSpan with the same gap. Now that a dedicated SDK-local class exists, adding spans: list["LoggedSpan"] = Field(default_factory=list) would close it cheaply. Same applies to the still-core ToolSpan member of the LoggedSpan union.
  • src/splunk_ao/decorator.py:323-327: explicit_span_params is assembled without reference to span_type, so mismatched combinations are accepted and then silently discarded downstream: @log(span_type="llm", data_source_id="x") loses the ID during signature filtering in _complete_call, and @log(span_kind=SpanKind.CLIENT) (no span_type, i.e. the workflow path) never reaches the span_type == "agent" branch in _prepare_call. Neither is a bug introduced here — it matches how params and the other auto-mapped span params already behave — but a debug/warning log when a hint is supplied for a span type that cannot consume it would save users a confusing round of "why is my attribute missing".
  • tests/test_logger_otel_egress.py:148-150: test_agent_logger_allows_only_client_kind_override calls otlp_logger.conclude(output="answer") twice on consecutive lines. This is correct and necessary — the first closes the agent span, the second closes the trace envelope — but it reads exactly like a copy-paste duplication and the retriever test immediately above needs only one call. A one-line comment (# conclude the agent span, then the trace envelope) would stop a future reader from "cleaning up" the second call and silently changing what the test covers.

The shared mixin and child-union/decorator follow-ups are unnecessary for this PR. I added the useful comment explaining the intentional double conclude() call.

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