Skip to content

Commit 93411f9

Browse files
committed
refactor(genai): share provider response ID resolution
1 parent d83982c commit 93411f9

6 files changed

Lines changed: 300 additions & 122 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
InvokeAgentInvocation,
3636
ReactStepInvocation,
3737
)
38+
from opentelemetry.util.genai.response_id import resolve_response_id
3839
from opentelemetry.util.genai.types import (
3940
FunctionToolDefinition,
4041
GenericToolDefinition,
@@ -69,38 +70,6 @@ def obj_get(value: Any, field: str, default: Any = None) -> Any:
6970
return getattr(value, field, default)
7071

7172

72-
def response_identifier(
73-
value: Any,
74-
*,
75-
fields: tuple[str, ...] = (
76-
"id",
77-
"request_id",
78-
"response_id",
79-
"_request_id",
80-
),
81-
) -> str | None:
82-
"""Return a normalized provider/framework response identifier.
83-
84-
OpenAI-compatible providers expose ``id`` while native DashScope-style
85-
responses commonly call the same correlation value ``request_id``. Keep
86-
the extraction intentionally narrow so unrelated object identifiers are
87-
never promoted to ``gen_ai.response.id``.
88-
"""
89-
90-
if isinstance(value, str):
91-
normalized_value = value.strip()
92-
return normalized_value or None
93-
94-
for field in fields:
95-
candidate = obj_get(value, field)
96-
if not isinstance(candidate, (str, int)):
97-
continue
98-
normalized = str(candidate).strip()
99-
if normalized:
100-
return normalized
101-
return None
102-
103-
10473
def _normalize_platform(value: Any) -> str:
10574
platform = getattr(value, "value", value)
10675
return str(platform or "").strip().lower()
@@ -715,17 +684,10 @@ def update_llm_invocation_from_response(
715684
else:
716685
invocation.response_model_name = invocation.request_model
717686

718-
# Prefer the ID captured from the provider SDK stream. Hermes currently
719-
# synthesizes ``stream-*`` IDs while aggregating OpenAI-compatible chunks,
720-
# so the final framework response is only a fallback. Native-style
721-
# ``request_id`` fields are checked before the framework ``id`` for the
722-
# same reason.
723-
response_id = response_identifier(
687+
response_id = resolve_response_id(
724688
provider_response_id,
725-
fields=("id",),
726-
) or response_identifier(
727689
response,
728-
fields=("request_id", "id", "response_id", "_request_id"),
690+
framework_fields=("request_id", "id", "response_id"),
729691
)
730692
if response_id:
731693
invocation.response_id = response_id

instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/wrappers.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from opentelemetry import trace as trace_api
2828
from opentelemetry.util.genai.extended_handler import ExtendedTelemetryHandler
29+
from opentelemetry.util.genai.response_id import extract_response_id
2930
from opentelemetry.util.genai.types import Error
3031

3132
from .helpers import (
@@ -39,7 +40,6 @@
3940
push_state,
4041
reset_state,
4142
resolve_entry_platform,
42-
response_identifier,
4343
start_step,
4444
state,
4545
step_finish_reason,
@@ -64,7 +64,8 @@
6464
)
6565
_PROVIDER_CREATE_WRAPPED = "_otel_hermes_response_id_capture_wrapped"
6666
_PROVIDER_ATTEMPT = threading.local()
67-
_MISSING_PROVIDER_ATTEMPT = object()
67+
_PROVIDER_CAPTURE = threading.local()
68+
_MISSING_THREAD_LOCAL = object()
6869

6970

7071
class _ProviderResponseAttempt:
@@ -80,7 +81,10 @@ def response_id(self) -> str | None:
8081
return self._response_id
8182

8283
def record(self, value: Any) -> None:
83-
response_id = response_identifier(value)
84+
response_id = extract_response_id(
85+
value,
86+
fields=("request_id", "id", "response_id"),
87+
)
8488
if response_id is None:
8589
return
8690
with self._lock:
@@ -99,6 +103,10 @@ def adopt_current_attempt(self) -> None:
99103
attempt = getattr(_PROVIDER_ATTEMPT, "value", None)
100104
if attempt is None:
101105
return
106+
_PROVIDER_CAPTURE.value = self
107+
self.adopt(attempt)
108+
109+
def adopt(self, attempt: _ProviderResponseAttempt) -> None:
102110
with self._lock:
103111
self._attempt = attempt
104112

@@ -143,16 +151,21 @@ def __getattr__(self, name: str) -> Any:
143151

144152

145153
def _wrap_provider_create(resource: Any) -> None:
146-
if resource is None or getattr(resource, _PROVIDER_CREATE_WRAPPED, False):
147-
return
148-
create = getattr(resource, "create", None)
149-
if not callable(create):
154+
create = (
155+
getattr(resource, "create", None) if resource is not None else None
156+
)
157+
if not callable(create) or getattr(
158+
create, _PROVIDER_CREATE_WRAPPED, False
159+
):
150160
return
151161

152162
@functools.wraps(create)
153163
def _capturing_create(*args, **kwargs):
154164
attempt = _ProviderResponseAttempt()
155165
_PROVIDER_ATTEMPT.value = attempt
166+
capture = getattr(_PROVIDER_CAPTURE, "value", None)
167+
if capture is not None:
168+
capture.adopt(attempt)
156169
response = create(*args, **kwargs)
157170
attempt.record(response)
158171

@@ -164,9 +177,9 @@ def _capturing_create(*args, **kwargs):
164177
return _ProviderStreamProxy(response, attempt)
165178
return response
166179

180+
setattr(_capturing_create, _PROVIDER_CREATE_WRAPPED, True)
167181
try:
168182
setattr(resource, "create", _capturing_create)
169-
setattr(resource, _PROVIDER_CREATE_WRAPPED, True)
170183
except (AttributeError, TypeError):
171184
# A provider resource may use slots/read-only descriptors. Telemetry
172185
# must never make the model call fail; the framework response remains
@@ -179,13 +192,9 @@ class ProviderClientWrapper:
179192

180193
def __call__(self, wrapped, instance, args, kwargs):
181194
client = wrapped(*args, **kwargs)
182-
resources = (
183-
getattr(getattr(client, "chat", None), "completions", None),
184-
getattr(client, "responses", None),
185-
getattr(client, "completions", None),
195+
_wrap_provider_create(
196+
getattr(getattr(client, "chat", None), "completions", None)
186197
)
187-
for resource in resources:
188-
_wrap_provider_create(resource)
189198
return client
190199

191200

@@ -443,9 +452,15 @@ def __call__(self, wrapped, instance, args, kwargs):
443452
previous_provider_attempt = getattr(
444453
_PROVIDER_ATTEMPT,
445454
"value",
446-
_MISSING_PROVIDER_ATTEMPT,
455+
_MISSING_THREAD_LOCAL,
456+
)
457+
previous_provider_capture = getattr(
458+
_PROVIDER_CAPTURE,
459+
"value",
460+
_MISSING_THREAD_LOCAL,
447461
)
448462
_PROVIDER_ATTEMPT.value = None
463+
_PROVIDER_CAPTURE.value = provider_response_capture
449464

450465
try:
451466
if self._streaming:
@@ -521,11 +536,16 @@ def _wrapped_first_delta():
521536
finish_step(instance, "error", exc=exc)
522537
raise
523538
finally:
524-
if previous_provider_attempt is _MISSING_PROVIDER_ATTEMPT:
539+
if previous_provider_attempt is _MISSING_THREAD_LOCAL:
525540
with suppress(AttributeError):
526541
del _PROVIDER_ATTEMPT.value
527542
else:
528543
_PROVIDER_ATTEMPT.value = previous_provider_attempt
544+
if previous_provider_capture is _MISSING_THREAD_LOCAL:
545+
with suppress(AttributeError):
546+
del _PROVIDER_CAPTURE.value
547+
else:
548+
_PROVIDER_CAPTURE.value = previous_provider_capture
529549
current_state["active_llm_depth"] = max(
530550
0, current_state["active_llm_depth"] - 1
531551
)

instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/tests/test_telemetry_spec.py

Lines changed: 87 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -523,60 +523,20 @@ def test_streaming_request_id_from_usage_trailer_is_preserved(
523523
assert llm_span.attributes["gen_ai.response.id"] == "dashscope-request-456"
524524

525525

526-
def test_streaming_retry_uses_successful_provider_attempt_id(
527-
instrumentation_module,
528-
tracer_provider,
529-
meter_provider,
530-
span_exporter,
531-
):
532-
runtime = _runtime(instrumentation_module, tracer_provider, meter_provider)
533-
agent = _FakeAgent(session_id="session-provider-retry")
534-
wrappers_module = importlib.import_module(
535-
"opentelemetry.instrumentation.hermes_agent.wrappers"
536-
)
537-
provider_client_wrapper = wrappers_module.ProviderClientWrapper()
538-
client = _provider_client(
539-
[
540-
iter([_stream_chunk("chatcmpl-failed-attempt")]),
541-
iter([_stream_chunk("chatcmpl-successful-attempt")]),
542-
]
543-
)
544-
545-
def streaming_call(_api_kwargs, *, on_first_delta):
546-
request_client = provider_client_wrapper(
547-
lambda: client,
548-
agent,
549-
(),
550-
{},
551-
)
552-
for _chunk in request_client.chat.completions.create(stream=True):
553-
on_first_delta()
554-
for _chunk in request_client.chat.completions.create(stream=True):
555-
on_first_delta()
556-
return _response(
557-
content="恢复成功",
558-
response_id="stream-retry-framework-id",
559-
)
560-
561-
runtime.streaming_llm_wrapper(
562-
streaming_call,
563-
agent,
564-
({"model": agent.model, "messages": []},),
565-
{},
566-
)
567-
568-
llm_span = _spans_by_kind(span_exporter, "LLM")[0]
569-
assert (
570-
llm_span.attributes["gen_ai.response.id"]
571-
== "chatcmpl-successful-attempt"
572-
)
573-
574-
575-
def test_streaming_retry_does_not_reuse_failed_provider_attempt_id(
526+
@pytest.mark.parametrize(
527+
("retry_response_id", "expected_response_id"),
528+
[
529+
("chatcmpl-successful-attempt", "chatcmpl-successful-attempt"),
530+
(None, "stream-retry-framework-fallback"),
531+
],
532+
)
533+
def test_streaming_retry_uses_only_final_provider_attempt(
576534
instrumentation_module,
577535
tracer_provider,
578536
meter_provider,
579537
span_exporter,
538+
retry_response_id,
539+
expected_response_id,
580540
):
581541
runtime = _runtime(instrumentation_module, tracer_provider, meter_provider)
582542
agent = _FakeAgent(session_id="session-provider-retry-fallback")
@@ -587,21 +547,39 @@ def test_streaming_retry_does_not_reuse_failed_provider_attempt_id(
587547
client = _provider_client(
588548
[
589549
iter([_stream_chunk("chatcmpl-failed-attempt")]),
590-
iter([_stream_chunk()]),
550+
iter([_stream_chunk(retry_response_id)]),
591551
]
592552
)
593553

594554
def streaming_call(_api_kwargs, *, on_first_delta):
595-
request_client = provider_client_wrapper(
596-
lambda: client,
597-
agent,
598-
(),
599-
{},
600-
)
601-
for _chunk in request_client.chat.completions.create(stream=True):
602-
on_first_delta()
603-
for _chunk in request_client.chat.completions.create(stream=True):
604-
on_first_delta()
555+
errors = []
556+
557+
def provider_worker():
558+
try:
559+
request_client = provider_client_wrapper(
560+
lambda: client,
561+
agent,
562+
(),
563+
{},
564+
)
565+
for _chunk in request_client.chat.completions.create(
566+
stream=True
567+
):
568+
# Hermes invokes this callback once for the whole logical
569+
# call, even when it starts another provider attempt.
570+
on_first_delta()
571+
for _chunk in request_client.chat.completions.create(
572+
stream=True
573+
):
574+
pass
575+
except BaseException as exc: # pragma: no cover - defensive
576+
errors.append(exc)
577+
578+
worker = threading.Thread(target=provider_worker)
579+
worker.start()
580+
worker.join(timeout=5)
581+
assert not worker.is_alive()
582+
assert errors == []
605583
return _response(
606584
content="恢复成功",
607585
response_id="stream-retry-framework-fallback",
@@ -615,10 +593,7 @@ def streaming_call(_api_kwargs, *, on_first_delta):
615593
)
616594

617595
llm_span = _spans_by_kind(span_exporter, "LLM")[0]
618-
assert (
619-
llm_span.attributes["gen_ai.response.id"]
620-
== "stream-retry-framework-fallback"
621-
)
596+
assert llm_span.attributes["gen_ai.response.id"] == expected_response_id
622597

623598

624599
def test_streaming_without_provider_id_falls_back_to_hermes_response_id(
@@ -692,6 +667,53 @@ def streaming_call(_api_kwargs, *, on_first_delta):
692667
)
693668

694669

670+
def test_provider_error_is_reported_without_masking_original_exception(
671+
instrumentation_module,
672+
tracer_provider,
673+
meter_provider,
674+
span_exporter,
675+
):
676+
runtime = _runtime(instrumentation_module, tracer_provider, meter_provider)
677+
agent = _FakeAgent(session_id="session-provider-error")
678+
wrappers_module = importlib.import_module(
679+
"opentelemetry.instrumentation.hermes_agent.wrappers"
680+
)
681+
provider_client_wrapper = wrappers_module.ProviderClientWrapper()
682+
683+
class ProviderError(RuntimeError):
684+
pass
685+
686+
class FailingResource:
687+
@staticmethod
688+
def create(**_kwargs):
689+
raise ProviderError("provider unavailable")
690+
691+
client = SimpleNamespace(
692+
chat=SimpleNamespace(completions=FailingResource())
693+
)
694+
695+
def streaming_call(_api_kwargs, *, on_first_delta):
696+
del on_first_delta
697+
request_client = provider_client_wrapper(
698+
lambda: client,
699+
agent,
700+
(),
701+
{},
702+
)
703+
return request_client.chat.completions.create(stream=True)
704+
705+
with pytest.raises(ProviderError, match="provider unavailable"):
706+
runtime.streaming_llm_wrapper(
707+
streaming_call,
708+
agent,
709+
({"model": agent.model, "messages": []},),
710+
{},
711+
)
712+
713+
llm_span = _spans_by_kind(span_exporter, "LLM")[0]
714+
assert llm_span.status.status_code == StatusCode.ERROR
715+
716+
695717
def test_provider_attempt_from_previous_call_does_not_leak(
696718
instrumentation_module,
697719
tracer_provider,

0 commit comments

Comments
 (0)