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.

@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: approve — Core semantics (retrieval CLIENT kind, data-source-id naming/attribute, opt-in remote-agent CLIENT) are correctly implemented and well tested; prior review rounds were addressed, and remaining items are minor consistency/observability nits plus an unresolved divergence from the ticket text.

General Comments

  • 🟡 minor (question): HYBIM-961's acceptance criteria still reads: 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} and only then to bare retrieval (span_converter.py:47-51, asserted by test_retriever_display_name_is_used_only_as_span_name_fallback).

I think the implemented behaviour is the right call — collapsing every un-tagged retriever to a single retrieval name would be a visible regression in the UI — and the PR body justifies it. But the ticket was last updated 2026-08-06 and still carries the contradicting text, so the acceptance criteria and the code disagree. Please get HYBIM-961 amended before merge so the next person reconciling the two doesn't "fix" the fallback away. Purely a docs/ticket action; no code change requested.

  • 🔵 nit (other): A few hunks are unrelated reformatting rather than part of this change: logger.py:368-372 (rewrapping the existing SplunkAOLoggerException raise) and three test bodies in tests/test_attribute_mapping.py:570-640 (collapsing ControlSpan(...) calls onto one line). They look like a formatter pass over untouched code. Harmless, but they make git blame on the control-span work point at this PR — worth splitting out if it's easy.

Follow-ups

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

  • src/splunk_ao/schema/logged.py:86-102: LoggedRetrieverSpan is the only Logged* model declaring model_config = ConfigDict(from_attributes=True); its siblings (LoggedAgentSpan, LoggedWorkflowSpan, LoggedLlmSpan, LoggedControlSpan) do not. That means passing a core AgentSpan/LlmSpan instance into LoggedTrace(spans=[...]) does not widen the way a core RetrieverSpan instance now does — an inconsistency in the union's accepted input shapes. Worth either extending from_attributes=True to the whole family (with a parity test asserting every union member widens from a core instance) or documenting that only the retriever path supports instance widening.
  • src/splunk_ao/decorator.py:323-327: explicit_span_params is assembled without reference to span_type, so mismatched combinations are accepted then silently discarded downstream: @log(span_type="llm", data_source_id="x") loses the ID during signature filtering in _complete_call (line 1128), and @log(span_kind=SpanKind.CLIENT) with no span_type never reaches the span_type == "agent" branch in _prepare_call. This matches how the other auto-mapped span params already behave, but a debug log when a hint is supplied for a span type that cannot consume it would save a confusing round of "why is my attribute missing".
  • src/splunk_ao/converter/attribute_mapping.py:30-65: test_every_alias_uses_an_explicit_destination_namespace checks namespacing but not coverage, so there is no test asserting which gen_ai.* keys build_span_attributes may emit without a splunk_ao.* mirror. Now that gen_ai.data_source.id is a deliberate standard-only attribute, an explicit allowlist test ("every emitted gen_ai.* key is either in the alias map or in this documented standard-only set") would make future additions a conscious decision rather than a silent omission, and would document the normalization layer's deprecation path.

Comment on lines 46 to 51
detail = getattr(span, field_name, None)
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 reads data_source_id with a bare getattr, but set_retriever_attributes reads the same field via _field() (attribute_mapping.py:426), which falls back to model_extra. For any span carrying data_source_id only as a model extra — or a duck-typed source object like the SimpleNamespace(**fields, model_extra={...}) pattern already exercised in test_control_mapping_accepts_schema_compatible_control_span — the attribute gen_ai.data_source.id is emitted while the span name silently falls back to retrieval {display_name}. Name and identity then disagree for the same span, which is exactly the coupling this PR is trying to establish.

Not reachable through add_retriever_span today (it always constructs a real LoggedRetrieverSpan), so minor. Using the same accessor in both places removes the divergence by construction. Note this also means the returned value is no longer guaranteed to be a normalized string, so keep the .strip()/str() handling on it.

Suggested change
detail = getattr(span, field_name, None)
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)
def _span_name(span: BaseStep, step_type: StepType) -> str:
prefix, field_name = _NAME_PARTS[step_type]
detail = getattr(span, field_name, None)
if step_type is StepType.retriever:
detail = _field(span, field_name)
if detail is None:
detail = getattr(span, "name", None)
return " ".join(part for part in (prefix, str(detail).strip() if detail is not None else "") if part)

🤖 Generated by the Astra agent

Comment on lines +737 to +740
"retriever": [*common_params, "data_source_id"],
"tool": [*common_params, "tool_call_id"],
"workflow": common_params,
"agent": [*common_params, "agent_type"],
"agent": [*common_params, "agent_type", "span_kind"],

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): Adding data_source_id and span_kind to the auto-mapped lists means these are now populated from any decorated function parameter with a matching name (_prepare_input copies input_[param_name] into span_params at line 653-654), not just from the new decorator kwargs.

Two consequences worth weighing:

  1. data_source_id: a coincidentally-named parameter of the wrong type — e.g. @log(span_type="retriever") def search(q: str, data_source_id: UUID) — makes LoggedRetrieverSpan(...) raise ValidationError (the mode="before" validator passes non-str through untouched, and Pydantic won't coerce UUID/int to str). add_retriever_span is wrapped in @warn_catch_exception(exceptions=(Exception,)), so it returns None and the entire span is dropped with only a warning. Previously retriever auto-mapped only common_params, so a same-named argument was simply ignored.
  2. span_kind: a user parameter named span_kind holding anything other than the literal SpanKind.CLIENT enum member is silently coerced to INTERNAL (see the validator thread).

This mirrors how model/temperature/agent_type already behave, so it's consistent rather than novel — but those are descriptive metadata, whereas these two drive OTel span identity and kind. Either coerce defensively in the validator (str(value) for non-None non-str), or restrict these two hints to explicit_span_params only and drop them from _get_span_param_names, so a name collision can't degrade or destroy a span.

🤖 Generated by the Astra agent

Comment on lines +1775 to 1776
self.add_child_span_to_parent(span)
span._parent = parent

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.

🔵 nit (design): add_child_span_to_parent(span) runs before span._parent = parent, which is the inverse of both sibling helpers — _add_completed_leaf (line 650-651) and _attach_parentable_span (line 1874-1875) set _parent first. This preserves the old super().add_retriever_span(...) ordering so it isn't a behaviour change, but now that the construction is inlined here it's worth matching the local convention: any future logic in add_child_span_to_parent that reads span._parent would see it unset only on this one path.

Suggested change
self.add_child_span_to_parent(span)
span._parent = parent
span._parent = parent
self.add_child_span_to_parent(span)

🤖 Generated by the Astra agent

Comment on lines +130 to +132

@pytest.mark.parametrize(
("requested_kind", "expected_kind"), [(SpanKind.CLIENT, SpanKind.CLIENT), (SpanKind.SERVER, 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 (testing): This parametrization covers SpanKind.CLIENT and SpanKind.SERVER, i.e. only well-typed enum inputs. The path most likely to bite a user is the decorator auto-map one, where span_kind arrives as whatever the caller's function argument held — a string "CLIENT" or the int 2. test_agent_kind_allows_only_explicit_client_override in test_span_converter.py covers "CLIENT" at the model level, but nothing covers it end-to-end through @log(span_type="agent"), which is where the silent downgrade actually reaches users. Adding ("CLIENT", SpanKind.INTERNAL) and (2, SpanKind.INTERNAL) here would pin the intended contract at the egress boundary.

🤖 Generated by the Astra agent

@fercor-cisco

Copy link
Copy Markdown
Collaborator

Review verification summary

I verified all findings in the Astra review against the branch. All substantive findings hold up. Full suite: 2067 passed, 8 skipped; PR files clean under ruff check and ruff format.

Confirmed by reproduction:

  • Accessor divergence (span_converter.py:51) — a span carrying data_source_id only in model_extra emits gen_ai.data_source.id="vector-db-1" while the name falls back to retrieval my-retriever. Name and identity disagree. Unreachable via add_retriever_span, so minor.
  • Decorator auto-map collision (decorator.py:740) — worse than reported. A data_source_id: UUID/int function param drops the entire span with zero log records, not the warning the review assumed. The span_kind half also reproduces (string "CLIENT"INTERNAL). Only item I'd call merge-blocking.
  • from_attributes asymmetry — core RetrieverSpan widens; core AgentSpan raises ValidationError.
  • Ticket divergence — HYBIM-961 still reads "or simply retrieval otherwise" (updated 2026-08-06, In Review). Code is better; ticket needs amending.
  • _parent ordering (logger.py:1776) and test-coverage gap (test_logger_otel_egress.py:132) — both accurate.
  • Alias mapgen_ai.data_source.id absent, confirmed. Earlier round's whitespace-ID finding is now fixed.

One pushback: the "unrelated reformatting" nit misdiagnoses cause. Main is genuinely unformatted at those lines under line-length = 120, so any formatter run reintroduces them — splitting it out isn't available.

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