# Where must an instance of a safety intervention hook be bound in the OTel GenAI span lifecycle to remain trace-linkable **Date of Record:** 2026 November 13 **Scope:** This record applies to systems that emit OpenTelemetry GenAI semantic-convention spans (`gen_ai.*`) and attach safety-intervention hooks (content filters, tool-use gates, policy blocks) that must be correlated to the originating trace via span context. It does not apply to out-of-band moderation pipelines that operate on post-hoc logs, nor to hooks that are intentionally decoupled from request latency. ## Core Distinction The non-obvious boundary is between **hook binding at span creation** versus **hook binding at span end**. A safety intervention hook that raises, mutates, or short-circuits must be bound to the *active* span context at the moment the intervention decision is made — that is, while the `gen_ai.chat` (or `gen_ai.tool.call`) span is still the current span on the context stack. If the hook is invoked after `span.end()` has been called, or after the span has been detached from the OTel context (e.g., inside an `asyncio` task that did not inherit context via `contextvars` copy), the intervention cannot be recorded as an event on the originating span. It will either (a) silently attach to a new root span, (b) attach to the wrong parent, or (c) be dropped entirely. The trace then shows a clean completion with no safety event, which is the worst possible outcome: an intervention that *happened* but is *invisible* in the trace. ## What Was Observed In a production GenAI gateway, a content-safety hook was registered as a post-processing callback on the response object. The hook fired correctly and blocked the response — the user never saw the unsafe content. However, the OTel trace for those requests showed `gen_ai.chat` spans with `status=OK`, no `exception` event, and no `gen_ai.safety.intervention` attribute. The intervention was real at the application layer but absent at the observability layer. Root cause: the hook was invoked inside a `finally` block that ran after `with tracer.start_as_current_span(...)` had exited, so `trace.get_current_span()` returned an invalid (non-recording) span. The hook's `span.add_event("safety.intervention", ...)` call was a no-op. Separately, in a parallel code path using `asyncio.gather`, the hook ran in a task whose context was copied *before* the span was made current, so the intervention attached to the task's root context, producing orphan spans with no parent link. A second observed failure: hooks bound to the span *object* (captured by reference) rather than to the *context*. When the span was ended and a new span opened on the same context, the captured reference still pointed at the ended span, and late events were silently discarded by the SDK's `NonRecordingSpan` guard. ## Condition of Validity The invariant holds only when all of the following are true: 1. The hook executes **synchronously within the lexical scope** of the `start_as_current_span` context manager (or an equivalent `use_span` block), before `end()` is called. 2. For async code, the hook runs in a task that **inherits the context** established by `start_as_current_span` — i.e., the task is spawned *inside* the context block, not before it, and `contextvars` propagation is not disabled. 3. The hook records the intervention via `span.add_event(...)` or by setting attributes on the **currently active** span obtained through `trace.get_current_span()`, not a captured reference. 4. The span is not marked `NonRecordingSpan` (sampler dropped it, or it has already ended). If the span is non-recording, the hook must fall back to a structured log carrying the `trace_id`/`span_id` explicitly, or the intervention is unobservable. If any condition fails, the intervention is not trace-linkable and the trace is misleading. ## Decision Boundary The decision is **where to bind the hook**, and it is a binary choice with a hard edge: - **Bind at span creation (inside the context):** Use this when the hook must be trace-linkable and the intervention is part of the request lifecycle. The hook is a wrapper *around* the model call, invoked while the span is current. This is the default and correct choice for blocking filters, tool-use gates, and policy checks. - **Bind at span end (outside the context):** Use this only when the intervention is purely advisory, post-hoc, and explicitly *not* expected to appear on the span — in which case emit a separate linked span with an explicit `links=[Link(span_context)]` to preserve correlation. Never rely on the ended span's context. The boundary is not "before or after the model call" — it is "inside or outside the active context." A hook that runs after the model returns but *before* the context exits is still valid. A hook that runs after the context exits is invalid regardless of timing relative to the model. ## Operational Effect When the invariant is violated, the observable effect is **silent trace corruption**: safety events vanish from traces while the application behaves correctly. This defeats the primary purpose of embedding hooks in spans — auditability and post-incident reconstruction. Dashboards show 100% clean completions; incident response cannot find the blocked requests; SLOs for safety interventions read zero. The failure is invisible in application logs because the hook *did* fire. It only surfaces when someone cross-references a known-bad request against its trace and finds no event. Mitigation: assert span recording status inside the hook, and if the span is non-recording or ended, raise a hard error rather than silently no-op — forcing the binding bug to surface at development time instead of during an audit. ```python from opentelemetry import trace class SafetyHookBindingError(RuntimeError): """Raised when a safety intervention hook cannot bind to a live span.""" pass def record_safety_intervention(reason: str, policy_id: str) -> None: span = trace.get_current_span() ctx = span.get_span_context() # Condition of validity: span must be valid AND recording. if not ctx.is_valid or not span.is_recording(): raise SafetyHookBindingError( f"Safety hook fired outside active span context " f"(reason={reason!r}, policy={policy_id!r}, " f"valid={ctx.is_valid}, recording={span.is_recording()}). " f"Hook must be bound inside start_as_current_span before end()." ) span.add_event( "gen_ai.safety.intervention", attributes={ "safety.reason": reason, "safety.policy_id": policy_id, "safety.trace_id": format(ctx.trace_id, "032x"), "safety.span_id": format(ctx.span_id, "016x"), }, ) span.set_attribute("gen_ai.safety.intervened", True) ``` The `raise` is the point: a hook that cannot prove it is bound to a live span must fail loudly, because a silent no-op is indistinguishable from "no intervention occurred" in every downstream consumer of the trace.