Skip to content

ST-10: per-attempt retry_attempt sibling-span emission across framework + direct-SDK wrappers - #23

Merged
manas-fortifyroot merged 4 commits into
fr-v0.52.6.xfrom
phase-st-10/framework-wrappers
May 20, 2026
Merged

ST-10: per-attempt retry_attempt sibling-span emission across framework + direct-SDK wrappers#23
manas-fortifyroot merged 4 commits into
fr-v0.52.6.xfrom
phase-st-10/framework-wrappers

Conversation

@manas-fortifyroot

@manas-fortifyroot manas-fortifyroot commented May 13, 2026

Copy link
Copy Markdown

Summary

Cross-package fork implementation of ST-10 retry-loop detection (per fr-backend/docs/development/RETRY_LOOP.md §4): each HTTP attempt in a customer's retry chain now emits a dedicated fortifyroot.<framework_or_provider>.retry_attempt sibling span under the existing parent span, so fr-backend's proc_retry_detector.go can group them by (parent_span_id, provider, model) and emit a RetryLoopEvent.

Branch: phase-st-10/framework-wrappersfr-v0.52.6.x.
Diff: 38 files, +8,512 / −107 (~5,500 LOC of new instrumentation + ~3,900 LOC of unit tests across 7 packages).

What landed by package

opentelemetry-instrumentation-fortifyroot — shared §4.7.1 framework-attempt registry

New retry_registry.py (+284 LOC) — module-level token-based registry shared across all framework retry emitters:

  • register_framework_attempt() → token called at attempt-start by framework wrappers (LiteLLM/LangChain/LlamaIndex).
  • unregister_framework_attempt(token) at attempt-end.
  • is_framework_owned(tid=None) → bool consulted by direct-SDK wrappers (OpenAI/Anthropic/Bedrock); if True, they SUPPRESS their own retry_attempt emission (§4.7 suppression discipline — prevents framework + direct double-emission).
  • Bounded-size + TTL eviction (_REGISTRY_MAX=4096, _REGISTRY_STALE_TTL_SEC=60) defends against framework crashes leaving tokens orphaned.
  • Reverse _TOKEN_TO_TID index so terminal callbacks running on different threads than the start callback still unregister against the originating TID (review-round-1 blocker fix).
  • Companion test file tests/test_retry_registry.py (+304 LOC) covering re-entrancy, stale-entry-with-no-subsequent-registration (load-bearing read-path eviction), cap-eviction, thread-ID reuse, and parent-end cleanup.

opentelemetry-instrumentation-litellm — framework retry emitter

  • New _FortifyRootRetryEmitter(_LiteLLMCustomLoggerBase) registered at instrumentor init (litellm.callbacks index 1, after the existing safety logger).
  • Hooks log_pre_api_call / async_log_pre_api_call / log_success_event / async_log_success_event / log_failure_event / async_log_failure_event — defensive both-sync-and-async to handle LiteLLM's dispatch quirks (review-round-2 Blocker 4).
  • _FortifyRootRetryEmitter inherits from litellm.integrations.custom_logger.CustomLogger (not duck-typed) — LiteLLM's dispatch loop gates every callback hook on isinstance(callback, CustomLogger), so a duck-typed class is silently skipped (verified end-to-end during review-batch-1 2026-05-10).
  • Per-call retry_attempt span name is fortifyroot.litellm.retry_attempt; litellm_call_id is the correlation key; bounded-size correlation map mirrors the registry's eviction policy.
  • _resolve_routed_provider() normalises LiteLLM's custom_llm_provider to RETRY_LOOP.md §4.2 canonical form (e.g. bedrock/bedrock_converse/amazon/aws"AWS"; vertex_ai/gemini"google"); special-case for bare claude-* model strings → "anthropic" (review-batch-1 Minor 4).
  • +543 LOC in package code; companion test file +565 LOC.

opentelemetry-instrumentation-langchain — framework retry emitter

  • New _FortifyRootRetryHandler(BaseCallbackHandler) — hooks BOTH on_chat_model_start (F1 finding from ST-10.0 C2 POC: chat models fire ONLY this hook, NOT on_llm_start) AND on_llm_start (legacy completion LLMs).
  • _resolve_parent_span() uses a two-strategy approach to find the workflow parent across multi-attempt retries:
    • Strategy A (Traceloop spans-dict lookup): Traceloop handler is passed as a DIRECT reference at _FortifyRootRetryHandler construction time (NOT via mutable shared back-reference — review-round-2 Major-5 fix); parent_run_id keys into traceloop_handler.spans to recover the workflow SpanHolder.
    • Strategy B (ambient fallback): when parent_run_id is None (legitimate root) OR no Traceloop handler is wired (standalone/test).
    • No-emission policy (review-round-2 Blocker 2): if parent_run_id is set + Traceloop handler is provided + lookup fails, refuse to fall back to ambient (which would be Traceloop's per-LLM span and would break sibling-grouping). Return None; emission skipped.
  • callback_handler.py: SpanHolder.tokens list + LIFO detach in _end_span fixes the OTel-context-token leak that was bleeding ambient context across pytest tests (review-round-2 Blocker 1).
  • +95 LOC to __init__.py for instrumentor wiring; +65 / -11 LOC to callback_handler.py; new retry_handler.py +653 LOC; companion test file +638 LOC.

opentelemetry-instrumentation-llamaindex — framework retry emitter

  • New _FortifyRootRetryHandler(BaseSpanHandler) registered on LlamaIndex's dispatcher BEFORE OpenLLMetrySpanHandler so the retry handler sees the user's enclosing workflow span (not the per-call Traceloop span) for sibling-grouping.
  • Hooks new_span / prepare_to_exit_span / prepare_to_drop_span.
  • F4 finding from ST-10.0 C3 POC: LlamaIndex fires dispatcher spans on BOTH the public chat() AND inner _chat() methods, which would emit 2× retry_attempts per HTTP attempt without de-dup. Filtered via _OUTER_LLM_METHODS whitelist (chat/achat/stream_chat/astream_chat/complete/acomplete/stream_complete/astream_complete/predict/apredict/structured_predict/etc.) — inner methods with leading _ are rejected.
  • dispatcher_wrapper.py +15 LOC for instrumentor wiring; new retry_handler.py +488 LOC; companion test file +487 LOC.

opentelemetry-instrumentation-openai — direct-SDK retry emitter

  • New retry_handler.py (+691 LOC) wrapping openai._base_client.SyncHttpxClientWrapper.send + AsyncHttpxClientWrapper.send. Fires per HTTP attempt inside the SDK's internal retry loop (no global httpx monkey-patch).
  • Endpoint allow-list (/chat/completions, /completions, /embeddings, /responses, /messages) so non-LLM SDK traffic (e.g. /v1/models) doesn't emit retry_attempt.
  • §4.7 suppression check via BOTH SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY (contextvar) AND is_framework_owned() (TID registry); if EITHER signals owned-by-framework, emit is skipped.
  • OPENAI_DIRECT_RETRY_PARENT_ACTIVE_KEY override key lets OpenAI's own chat_wrapper keep emitting retry_attempt under its own logical span (it sets SUPPRESS_KEY=True to prevent OTHER instrumentors from double-counting, but is itself the openai instrumentor and wants retry_attempt to emit).
  • Private-symbol guard with try/except so a version-skew in _base_client doesn't crash instrumentation init — it just logs and skips retry-attempt emission for that variant.
  • Streaming skip (stream=True in send() kwargs): retry_attempt emission disabled because the wrapper can't consume the SSE body for usage extraction without breaking the SDK; deferred under ST-10.4-FOLLOWUP-streaming-usage.
  • chat_wrappers.py +26 / -10 LOC to set the override key + instrument_retry_emitter / uninstrument_retry_emitter lifecycle in v1/__init__.py +16 LOC.
  • Companion test file +936 LOC covering 4-failure-shape verification (429→200, timeout→200, 5xx→200, all-fail) per RETRY_LOOP.md §4.4.1 + framework-owned suppression + private-symbol guard + idempotent install/uninstall.
  • Suppression-detach fix to _OpenAITracingWrapper.__call__ finally-block (review-round-2 Blocker 3) ensures the suppression token is released even on exception.

opentelemetry-instrumentation-anthropic — direct-SDK retry emitter

  • Mirror of OpenAI: wraps anthropic._base_client.SyncHttpxClientWrapper.send + AsyncHttpxClientWrapper.send.
  • gen_ai.system="Anthropic" (title case — proc_retry_detector.go normalises to lowercase).
  • _wrap / _awrap uses with trace.use_span(span, end_on_exit=False) — important not to undo when consolidating with OpenAI shared code.
  • +558 LOC new retry_handler.py; +651 LOC companion tests.

opentelemetry-instrumentation-bedrock — direct-SDK retry emitter

  • Different transport (botocore), different hook strategy: registers before-send.bedrock-runtime.* (attempt-start) + response-received.bedrock-runtime.* (attempt-end) on the boto3 client.
  • 2026-05-18 partial fix: _response_received_hook now accepts BOTH old http_response=/parsed= and new response_dict=/parsed_response= param names (botocore 1.42.x renamed). Necessary but not sufficient — the correlation-mechanism rework is tracked separately as ST-10.4-FOLLOWUP-bedrock-context-correlation (current botocore doesn't pass context= kwarg to before-send and request.context is None at fire time, so the existing request.context[_CTX_SPAN_KEY] correlation never creates a retry_attempt span end-to-end). The unit tests in test_retry_attempt_emission.py still pass because they invoke hooks directly with manually-prepared request.context = {}.
  • +542 LOC new retry_handler.py; +569 LOC companion tests.

Cross-cutting

  • traceloop-sdk/tests/test_*.py (5 files): filter fortifyroot.* retry-attempt spans from upstream/non-FR exact-span assertions (role-based filter, not name-prefix filter — robust against future fortifyroot-prefixed spans being added).
  • openai/tests/traces/test_*.py (4 files): same upstream-test hardening.

Review history captured in the diff

Five rounds of review fed back into this PR:

  • Round 1 (CustomLogger inheritance + reverse TID index): LiteLLM emitter MUST inherit from CustomLogger; _TOKEN_TO_TID reverse index needed so cross-thread terminal callbacks unregister against the originating TID.
  • Round 2 blockers (1-4): SpanHolder.tokens LIFO detach; LangChain no-emission policy when parent_run_id set + Traceloop lookup fails; _OpenAITracingWrapper.__call__ finally-block detach; async_log_pre_api_call defensive hook.
  • Round 2 majors (5-6): Traceloop handler reference passed at construction time (not mutable shared back-ref); LiteLLM provider normalisation for AWS/google variants.
  • Round 3-4 (registry shape + read-path eviction): Refcount-via-token map (not bare set[int]); in-band stale-eviction on the is_framework_owned() read path so orphans are bounded by TTL even with no subsequent registration on that TID.
  • Round 5-6 (cap-eviction + rate-limited warnings + Fallback B proof): explicit registry-size assertion shape; rate-limited eviction warnings; fallback_b_child_emission_proof evidence requirement in the hook-table.

Plus a 2026-05-18 botocore 1.42.x compat fix (Bedrock response-received param-name reconciliation) that landed late.

Test plan

  • nx run-many -t test across all 7 modified packages — passes locally.
  • Per-package targeted runs:
    • nx run opentelemetry-instrumentation-fortifyroot:test — covers registry re-entrancy / stale-eviction / cap-eviction / thread-ID reuse / parent-end cleanup.
    • nx run opentelemetry-instrumentation-{litellm,langchain,llamaindex,openai,anthropic,bedrock}:test — covers per-package retry_attempt emission against the 4 failure shapes (429/timeout/5xx/all-fail).
  • nx run opentelemetry-instrumentation-langchain:test::test_no_leaked_ambient_context_after_simulated_workflow — end-to-end regression guard for the SpanHolder.tokens LIFO detach fix (review-round-2 Blocker 1).
  • End-to-end validation lives in fr-system-tests (companion PR fix: Disable image uploader for non-tl endpoints #19): direct-SDK matrix (ST-10.6) PASSES on dev-api fixture mode 6 passed + 1 xfail Bedrock; framework matrix (ST-10.7) is scaffolded but skipped pending deferred follow-ups.

LiteLLM / LangChain / LlamaIndex wrappers emit one
fortifyroot.<framework>.retry_attempt sibling span per HTTP attempt
under the safety_wrapper / Traceloop parent. Shared §4.7.1
token-based framework-attempt registry under
opentelemetry-instrumentation-fortifyroot. Bounded TTL + cap
eviction defends against framework crashes leaving attempts open.

LiteLLM emitter inherits CustomLogger (dispatch isinstance gate).
LangChain handler hooks both on_chat_model_start (F1) and
on_llm_start; receives Traceloop handler at construction time so
multi-attempt retries under shared parent_run_id become OTel
siblings under one parent (review-round-2 Major 5 / Blocker 2).
SpanHolder.tokens list with LIFO detach in _end_span fixes the
context-token leak that bled OTel ambient across pytest tests
(review-round-2 Blocker 1).
_OpenAITracingWrapper.__call__ now detaches its suppression token
in finally (review-round-2 Blocker 3).
LiteLLM emitter adds async_log_pre_api_call defensive hook
(review-round-2 Blocker 4).
LlamaIndex SpanHandler registered BEFORE OpenLLMetrySpanHandler;
F4 outer-method dedup filter prevents double-emission across
outer chat() and inner _chat().

Per-wrapper unit tests + end-to-end regression guard
(test_no_leaked_ambient_context_after_simulated_workflow) included.
- Stabilize LangChain retry handler ordering and context cleanup
- Filter FR implementation spans from legacy LangChain upstream assertions
- Add retry-attempt prompt attribution coverage for LangChain, LiteLLM, and LlamaIndex
- Fix LiteLLM retry emitter dispatch by ensuring CustomLogger compatibility
- Infer bare Claude model IDs as Anthropic in LiteLLM retry-attempt routing
- Update retry-attempt tests for token usage, prompt attrs, and instrumentation symmetry
@manas-fortifyroot manas-fortifyroot changed the title ST-10.1/.2/.3: per-attempt retry_attempt emission in framework wrappers ST-10: per-attempt retry_attempt emission in framework wrappers May 16, 2026
… Anthropic / Bedrock)

Add direct-SDK retry interception per RETRY_LOOP.md §4. Each direct SDK has its
own retry mechanism (max_retries=N, internal httpx retries, botocore Retryer);
this commit hooks them so every HTTP attempt fires the per-call instrumentor
and emits a fortifyroot.<provider>.retry_attempt sibling span the backend's
§4.5 dedup promotes to the canonical LLMUsageEvent.

New files (one per provider package):
- opentelemetry-instrumentation-openai/.../retry_handler.py
- opentelemetry-instrumentation-anthropic/.../retry_handler.py
- opentelemetry-instrumentation-bedrock/.../retry_handler.py

Per-SDK hook surface (pinned in ST-10.0 hook-table addendum):
- OpenAI + Anthropic: wrap _base_client.SyncHttpxClientWrapper.send (private
  API; import-time wrappability check + WARNING log + skip on missing or
  incompatible symbol; never crashes normal instrumentation).
- Bedrock: register public botocore event hooks before-send.bedrock-runtime.*
  + response-received.bedrock-runtime.* (NOT after-call.* — absent in
  botocore 1.35.x).

All emitters check SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY and the §4.7.1
framework-attempt registry before emitting; an endpoint allow-list scopes
emission to OpenAI {chat/completions, completions, embeddings, responses,
messages}, Anthropic {messages, complete}, and Bedrock implicit.

OpenAI/Anthropic _finalize_success reads the non-streaming response body via
response.json() (httpx caches via _content; SDK re-read works) and copies id,
model, and usage.{prompt,completion,input,output}_tokens to the retry_attempt
span — required because the backend makes a qualifying retry_attempt the
canonical LLMUsageEvent (even single-attempt) and reads tokens from that
span. Streaming retry_attempt is skipped (detected via kwargs["stream"]); see
ST-10.4-FOLLOWUP-streaming-usage in SYSTEM_TESTS_PLAN.md for the deferred
SSE-chunk follow-up.

Anthropic-specific: _wrap / _awrap now wrap the underlying call in
`with trace.use_span(span, end_on_exit=False):` so retry_handler can find the
parent span via trace.get_current_span() — previously the default no-op
tracer was returned and no_parent_guard silently skipped emission, which is
why anthropic looked "green" while openai's retry_attempt did fire (parity
bug, not a real anthropic-side feature). retry_handler also uses
gen_ai.system="Anthropic" (title case) to match __init__.py's casing for
backend canonicalization.

OpenAI-specific: new OPENAI_DIRECT_RETRY_PARENT_ACTIVE_KEY context key
distinguishes chat_wrappers' own internal suppression (allow retry_attempt)
from external user/framework suppression (skip emission). Anthropic + Bedrock
don't have this collision — they only check the suppression key at entry and
never set it during the wrapped call.

Bedrock-specific: streaming _instrumented_model_invoke_with_response_stream
and _instrumented_converse_stream wrap the boto3 call in
`with trace.use_span(span, end_on_exit=False):` so the streaming span is
ambient for the duration of botocore event-hook firing. Event-hook level
skip for InvokeModelWithResponseStream / ConverseStream (response stream
isn't a finalized body at the callback site).

Test coverage:
- 80 new retry_attempt unit tests (37 openai + 27 anthropic + 16 bedrock):
  tracer_provider plumbing, override-key bypass, external-suppression-still-
  suppresses, streaming-skip behaviour, two-concurrent-async-sends regression
  guard for the §4.7.1 self-registration C1 fix, endpoint allow-list,
  no_parent_guard, importable-symbol check.
- Per-package _NoFortifyRootSpanExporter in conftest filters spans with
  fortifyroot.span.role=retry_attempt from upstream exact-span-list
  assertions (role-based not name-based, so legitimate fortifyroot.*.safety
  / .llm_wrapper / .has_native_otel_child spans remain visible).
- 18 OpenAI context-propagation tests updated to spy
  _base_client.SyncHttpxClientWrapper.send after ST-10.4 wraps that surface;
  test_responses_trace_context_propagation_unit now uses
  provider.get_tracer(...) directly instead of mutating the global.
- 5 traceloop-sdk test files (test_association_properties, test_privacy_no_
  prompts, test_prompt_management, test_sdk_initialization, test_workflows)
  inline the same role-based retry_attempt filter so the new fortifyroot.*
  spans don't trip upstream span-count / span-order assertions.
- instrument_retry_emitter(tracer_provider=...) accepts and stashes the
  provider in all three packages so retry_attempt spans land on the same
  exporter as the parent openai / anthropic / bedrock span — previously
  they fell through to the global no-op when consumers passed an explicit
  provider.

Validation:
- ./scripts/run-tests.sh --all → 31 executed packages, 1412/1412 passed,
  0 failed.
- Companion fortifyroot-sdk-py poetry run pytest → 458 passed, 0 failed
  (vendored locally per AGENT.md §17; SDK PR ships in ST-10.5).
- Companion fr-system-tests against local docker fr-backend carrying the
  ST-10.0b §4.5 trace-summary parity fix → 10/10 representative scenarios
  PASS (openai sync/streaming/async ×2, anthropic sync/streaming, bedrock
  sync, litellm sync, llamaindex streaming, safety_openai streaming smoke).
…ived param names

The ``_response_received_hook`` in the Bedrock retry_handler was written
against an older botocore event signature (``http_response=`` +
``parsed=``). Current botocore (verified on 1.42.75) emits the
``response-received.bedrock-runtime.*`` event with ``response_dict=`` +
``parsed_response=`` kwargs instead — discovered during ST-10.6
implementation against dev-api.fortifyroot.com via the fr-system-tests
test_retry_loop.py probe.

Symptom under newer botocore: ``_response_received_hook`` received
``http_response=None`` and ``parsed=None`` because the matching kwargs
went into ``**_kwargs`` instead. ``_finalize_success`` then saw
``status_code=None`` → no OTel status set on the retry_attempt span →
backend's IsError returned False for every attempt → RetryDetector
skipped the sibling group on its ``failedCount == 0`` check → no
RetryLoopEvent for any Bedrock retry chain.

Fix:
  - Add ``response_dict`` and ``parsed_response`` kwargs to the hook
    signature so they bind by name instead of falling into
    ``**_kwargs``.
  - Reconcile to the existing ``http_response`` / ``parsed`` body via
    a new ``_ResponseDictAdapter`` class that exposes ``.status_code``
    and ``.headers`` over a botocore-shaped ``{'status_code': int,
    'headers': dict}`` dict. ``_finalize_success`` continues to work
    unchanged because it reads from those two attributes.
  - The existing ``http_response`` / ``parsed`` path is preserved
    intact — the fork-side unit tests in
    ``tests/test_retry_attempt_emission.py`` that directly invoke the
    hook with the old param names still pass.

This is PART 1 of the Bedrock retry-loop fix. PART 2 — re-designing the
``before-send`` ↔ ``response-received`` correlation mechanism so it
works under current botocore where ``before-send`` doesn't receive a
``context`` kwarg and ``request.context`` is ``None`` at fire time — is
tracked separately as ``ST-10.4-FOLLOWUP-bedrock-context-correlation``
in fr-system-tests/SYSTEM_TESTS_PLAN.md. Until PART 2 lands,
fr-system-tests/tests/sdk_telemetry/test_retry_loop.py::
test_bedrock_botocore_retries_429_then_200 remains
``@pytest.mark.xfail(strict=False)``.

Validation:
  - ``./scripts/run-tests.sh --all``: 1412 passed, 0 failed, 32 skipped
    (skips are pre-existing platform issues unrelated to ST-10).
  - opentelemetry-instrumentation-bedrock: 116/116 passed (no
    regression in the direct-invoke unit-test path).

Files:
  packages/opentelemetry-instrumentation-bedrock/opentelemetry/
    instrumentation/bedrock/retry_handler.py
@manas-fortifyroot
manas-fortifyroot marked this pull request as ready for review May 20, 2026 05:49
@manas-fortifyroot manas-fortifyroot changed the title ST-10: per-attempt retry_attempt emission in framework wrappers ST-10: per-attempt retry_attempt sibling-span emission across framework + direct-SDK wrappers May 20, 2026
@manas-fortifyroot
manas-fortifyroot merged commit bd73906 into fr-v0.52.6.x May 20, 2026
9 checks passed
@manas-fortifyroot
manas-fortifyroot deleted the phase-st-10/framework-wrappers branch May 20, 2026 06:05
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.

1 participant