Skip to content

Commit 0d4f204

Browse files
committed
add unit test for parent/child relationship
1 parent 54d753b commit 0d4f204

File tree

2 files changed

+90
-11
lines changed

2 files changed

+90
-11
lines changed

util/opentelemetry-util-genai/src/opentelemetry/util/genai/generators.py

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,14 @@ def start(self, invocation: LLMInvocation):
230230
)
231231
if parent_state is not None:
232232
parent_state.children.append(invocation.run_id)
233+
span = self._start_span(
234+
name=f"{GenAI.GenAiOperationNameValues.CHAT.value} {invocation.request_model}",
235+
kind=SpanKind.CLIENT,
236+
parent_run_id=invocation.parent_run_id,
237+
)
238+
# Keep span active but do not end it here; it will be ended in finish()/error()
239+
with use_span(span, end_on_exit=False):
240+
self.spans[invocation.run_id] = _SpanState(span=span)
233241

234242
@contextmanager
235243
def _start_span_for_invocation(self, invocation: LLMInvocation):
@@ -255,18 +263,39 @@ def _finalize_invocation(self, invocation: LLMInvocation) -> None:
255263
self._end_span(invocation.run_id)
256264

257265
def finish(self, invocation: LLMInvocation):
258-
with self._start_span_for_invocation(invocation) as span:
259-
_apply_common_span_attributes(span, invocation)
260-
_maybe_set_span_messages(
261-
span, invocation.messages, invocation.chat_generations
262-
)
266+
state = self.spans.get(invocation.run_id)
267+
if state is None:
268+
with self._start_span_for_invocation(invocation) as span:
269+
_apply_common_span_attributes(span, invocation)
270+
_maybe_set_span_messages(
271+
span, invocation.messages, invocation.chat_generations
272+
)
273+
self._finalize_invocation(invocation)
274+
return
275+
276+
span = state.span
277+
_apply_common_span_attributes(span, invocation)
278+
_maybe_set_span_messages(
279+
span, invocation.messages, invocation.chat_generations
280+
)
263281
self._finalize_invocation(invocation)
264282

265283
def error(self, error: Error, invocation: LLMInvocation):
266-
with self._start_span_for_invocation(invocation) as span:
267-
span.set_status(Status(StatusCode.ERROR, error.message))
268-
if span.is_recording():
269-
span.set_attribute(
270-
ErrorAttributes.ERROR_TYPE, error.type.__qualname__
271-
)
284+
state = self.spans.get(invocation.run_id)
285+
if state is None:
286+
with self._start_span_for_invocation(invocation) as span:
287+
span.set_status(Status(StatusCode.ERROR, error.message))
288+
if span.is_recording():
289+
span.set_attribute(
290+
ErrorAttributes.ERROR_TYPE, error.type.__qualname__
291+
)
292+
self._finalize_invocation(invocation)
293+
return
294+
295+
span = state.span
296+
span.set_status(Status(StatusCode.ERROR, error.message))
297+
if span.is_recording():
298+
span.set_attribute(
299+
ErrorAttributes.ERROR_TYPE, error.type.__qualname__
300+
)
272301
self._finalize_invocation(invocation)

util/opentelemetry-util-genai/tests/test_utils.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,53 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use
169169

170170
assert isinstance(input_messages_json, str)
171171
assert isinstance(output_messages_json, str)
172+
173+
@patch_env_vars(
174+
stability_mode="gen_ai_latest_experimental",
175+
content_capturing="SPAN_ONLY",
176+
)
177+
def test_parent_child_span_relationship(self):
178+
parent_id = uuid4()
179+
child_id = uuid4()
180+
message = InputMessage(role="Human", parts=[Text(content="hi")])
181+
chat_generation = OutputMessage(
182+
role="AI", parts=[Text(content="ok")], finish_reason="stop"
183+
)
184+
185+
# Start parent and child (child references parent_run_id)
186+
self.telemetry_handler.start_llm(
187+
request_model="parent-model",
188+
prompts=[message],
189+
run_id=parent_id,
190+
provider="test-provider",
191+
)
192+
self.telemetry_handler.start_llm(
193+
request_model="child-model",
194+
prompts=[message],
195+
run_id=child_id,
196+
parent_run_id=parent_id,
197+
provider="test-provider",
198+
)
199+
200+
# Stop child first, then parent (order should not matter)
201+
self.telemetry_handler.stop_llm(
202+
child_id, chat_generations=[chat_generation]
203+
)
204+
self.telemetry_handler.stop_llm(
205+
parent_id, chat_generations=[chat_generation]
206+
)
207+
208+
spans = self.span_exporter.get_finished_spans()
209+
assert len(spans) == 2
210+
211+
# Identify spans irrespective of export order
212+
child_span = next(s for s in spans if s.name == "chat child-model")
213+
parent_span = next(s for s in spans if s.name == "chat parent-model")
214+
215+
# Same trace
216+
assert child_span.context.trace_id == parent_span.context.trace_id
217+
# Child has parent set to parent's span id
218+
assert child_span.parent is not None
219+
assert child_span.parent.span_id == parent_span.context.span_id
220+
# Parent should not have a parent (root)
221+
assert parent_span.parent is None

0 commit comments

Comments
 (0)