Skip to content

feat(telemetry): emit adapter-function prepare/activate/deactivate spans from a tracing plugin - #1558

Draft
planetf1 wants to merge 5 commits into
generative-computing:mainfrom
planetf1:issue-1466
Draft

feat(telemetry): emit adapter-function prepare/activate/deactivate spans from a tracing plugin#1558
planetf1 wants to merge 5 commits into
generative-computing:mainfrom
planetf1:issue-1466

Conversation

@planetf1

@planetf1 planetf1 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Pull Request

Issue

Progresses #1466 (partial: lifecycle spans only — see "Where this fits" below for why this isn't Fixes).

Summary

Adapter functions — the LoRA/aLoRA models Mellea uses for RAG checks, safety checks, and core capability checks (answerability, requirement_check, etc.) — go through a five-phase lifecycle: prepare (download weights), activate, generate, parse, deactivate. None of it is observable today. If an adapter fails to download, activation hangs, or a deployment intermittently can't load a specific adapter, there is currently no trace showing where the time went or what failed — only a counter that says an invocation happened, with no way to see its shape.

This PR closes that gap for three of the five phases: prepare, activate, deactivate. It adds the missing ADAPTER_FUNCTION_*_START hooks (the family previously had only *_COMPLETE members, so no plugin had an event to open a span on — the structural reason #1454's earlier attempt had to open spans inline instead), fires them from the real call sites, and adds a tracing plugin that turns the hook pairs into a proper adapter_functionadapter_function.<phase> span tree. generate/parse are excluded on purpose — they need real generation running inside adapter_scope, which is #1465's job, in progress in parallel right now.

This landed in two passes, both in this PR: an initial implementation, then an independent 3-reviewer pass (run before any human looked at it) that found two real ordering/guarding bugs and four untested error paths. Both are described below, with the bugs fixed and verified against the pre-fix code, not just against the fixed code.

Where this fits — Epic #929, Phase 2

Per the epic's phase tracker, #1466 is one of six issues in the current parallel batch (#1142, #1516, #1465, #385, #1528, and this lifecycle-span slice of #1466), started 2026-08-18. Specifically:

What this PR deliberately does not include, so reviewers don't file findings against its absence:

  • adapter_function.generate / adapter_function.parse spans and their MELLEA_TRACES_CONTENT content-capture wiring — both need refactor(backends): route intrinsic generation through adapter_scope (Epic #929 Phase 2) #1465.
  • A span or hook for the release phase — WeightsBinding.release() runs outside any invocation, unlike the other four phases; see "Acceptance criteria" below for how the phase Literal still accounts for it.
  • Exemplar linkage between mellea.adapter_function.phase_duration and these spans — a known, documented gap; see "Two deliberate deviations" below.

What changed

Area Change
mellea/plugins/types.py, mellea/plugins/hooks/adapter_function.py New ADAPTER_FUNCTION_INVOCATION_START / ADAPTER_FUNCTION_PHASE_START hooks, paired with the existing *_COMPLETE hooks; new invocation_id correlation field on all four payloads; release added to the phase Literal
mellea/backends/adapters/adapter.py AdapterMixin.adapter_scope() fires the new start hooks around activate/deactivate, correlated by a per-scope invocation_id
mellea/backends/adapters/_core.py LocalFileBinding.prepare() fires its own invocation-start/complete pair around its phase-start/complete pair, since it runs outside adapter_scope — this also guarantees invocation-complete always fires (even if prepare() raises), which the phase-complete hook's success-only contract can't
mellea/telemetry/tracing.py New start_/finish_adapter_function_span and start_/finish_adapter_function_phase_span helpers, on the mellea.backend tracer
mellea/telemetry/tracing_plugins.py New AdapterFunctionTracingPlugin, turning the hooks above into the adapter_function span tree
docs/docs/observability/tracing.md New adapter_function span section: schema, tracer-scope rationale, both deviations below. Replaces docs/dev/adapter_observability.md, which #1483/#1548 already deleted and folded into published docs and code
test/telemetry/test_tracing_adapter_function.py (new), test/backends/test_adapters/*, test/telemetry/test_metrics_plugins.py Unit + integration coverage: hook firing, span nesting, registry drain on both success and failure, resolved-revision recording

Two deliberate deviations from the existing tracing conventions

  1. Explicit span parenting, not ambient-context attach. Every other span family in this codebase nests via ambient OTel context attach, which needs Python 3.12+ (_CONTEXT_ATTACH_SUPPORTED). That mechanism can't work here: ADAPTER_FUNCTION_*_START/_COMPLETE fire from synchronous code (adapter_scope, prepare()) via _run_async_in_thread, which runs each hook as an independent task seeded from a fresh contextvars snapshot of the calling thread — an attach inside one hook's task is invisible to the next hook's snapshot. adapter_function.<phase> instead parents explicitly via trace.set_span_in_context, looked up by invocation_id. This works identically on Python 3.11 and 3.12+, so no version gating applies to this family — verified unconditionally with InMemorySpanExporter.
  2. No exemplar linkage to mellea.adapter_function.phase_duration. Because no span in this family is ever ambiently attached, the pre-existing AdapterFunctionMetricsPlugin (a separate plugin subscribed to the same hooks) never has the right span current when it records — at best it samples an enclosing application span, never the adapter-function span the metric is about. This is a known, documented gap (in the plugin's docstring and in the tracing docs), not a silent one. Fixing it needs the metric recorded from inside the tracing plugin itself, a larger change left for a follow-up.

Independent review: two bugs found and fixed

Before this went to a human reviewer, three independent reviewer passes (escalated to the strongest available model, given the tracing/async domain) ran against the initial implementation. All three converged on the same two real bugs, each reproduced empirically:

  1. Hook-ordering inversion in prepare(). adapter_function_invocation_complete fired before adapter_function_phase_complete on the success path — the phase-complete call sat outside the with block, after the invocation-complete call in the finally inside it. This silently made the tracing plugin's defensive dangling-child-span cleanup (documented as the failure-only path) the only path that ever closed adapter_function.prepare's span, and inverted the order adapter_scope uses for the same pair. Fixed by firing phase-complete from an else: clause, before the finally.
  2. Unguarded payload construction in _fire_phase_start_hook. It built its hook payload outside its own try, so a non-str .revision on a duck-typed (non-LocalFileBinding) weights binding raised a pydantic.ValidationError that escaped adapter_scope entirely, aborting before activate() ever ran — exactly the failure the function's own docstring says it prevents. The sibling implementation in _core.py already guarded this correctly; adapter.py's now matches it. Fixed.

Both fixes are verified with a regression test each, and each test was confirmed to fail against the pre-fix code (reverted, observed the failure, restored) before being trusted — not just confirmed to pass on the fixed code. The same pass also flagged four adapter-function error paths with no test coverage at all (a failing prepare(), a failing invocation-start hook dispatch); all four are covered now. A handful of lower-severity cleanups from the same review — a dead attach_context parameter that could never be used correctly, a dict-iteration atomicity issue in the span-cleanup sweep, a stale docstring on adapter_scope still claiming "no start hook exists" (the exact gap this PR closes) — are folded in as well.

One finding was considered and deliberately deferred rather than actioned: renaming invocation_id to adapter_function_invocation_id to match the family-prefixed naming convention every sibling hook payload uses (tool_invocation_id, validation_id, etc.). It's a real, correctly-identified deviation, but nothing outside this PR constructs these payloads yet, and a broad rename late in this pass carried more regression risk than the naming inconsistency itself. Tracked here rather than silently dropped, for whoever picks it up.

Acceptance criteria

From #1466, honestly reflecting the lifecycle-only scope:

  • ADAPTER_FUNCTION_* has a start hook to pair with each completion hook
  • No telemetry.tracing import exists under mellea/backends/
  • Spans are emitted by a plugin; the tracer scope choice is stated with its rationale — in docs/docs/observability/tracing.md, not docs/dev/adapter_observability.md, which no longer exists (see "Where this fits")
  • adapter_function.prepare, .activate, .deactivate emitted as children of the adapter_function parent span — .parse needs refactor(backends): route intrinsic generation through adapter_scope (Epic #929 Phase 2) #1465, not in this PR
  • adapter_function.prepare records the resolved Hugging Face SHA as mellea.adapter_function.revision, not "main"
  • MELLEA_TRACES_CONTENT=1 produces content-capture events — not applicable yet: no phase in this PR's scope carries adapter input/output content; that's generate/parse, via refactor(backends): route intrinsic generation through adapter_scope (Epic #929 Phase 2) #1465
  • No span name or attribute introduced here departs from the adapter_function / mellea.adapter_function.* convention
  • release appears in the phase Literal, with a documented reason it has no firing site (WeightsBinding.release() runs outside any invocation)
  • Parent/child edges asserted with InMemorySpanExporter — unconditionally, not version-gated, since explicit parenting (deviation 1 above) doesn't depend on _CONTEXT_ATTACH_SUPPORTED
  • Exemplar linkage — documented as a structural gap instead (deviation 2 above), not covered by a test, since there's nothing for a test to observe
  • The in-flight span registry drains to zero after a lifecycle, on both success and failure
  • uv run pytest test/ -m "not qualitative" passes

Testing

uv run pytest test/ -m "not qualitative" — 3963 passed, 21 skipped (hardware-gated), 0 failures. ruff format --check / ruff check / mypy . clean. npx markdownlint-cli2 "docs/docs/observability/tracing.md" — 0 issues. Docstring quality gate (tooling/docs-autogen/audit_coverage.py --quality --fail-on-quality --threshold 100) — 100% coverage, 0 issues. git grep -n "telemetry.tracing" -- 'mellea/stdlib/*' 'mellea/backends/*' — exactly one hit, mellea/stdlib/session.py, the documented exception.

Assisted-by: Claude Code

Progresses generative-computing#1466. The ADAPTER_FUNCTION_INVOCATION_COMPLETE and
ADAPTER_FUNCTION_PHASE_COMPLETE hooks had no start-side sibling, so no
plugin could open a span for the adapter-function lifecycle -- this is
the structural root cause generative-computing#1454 worked around by opening spans inline
in mellea/backends/.

Adds ADAPTER_FUNCTION_INVOCATION_START and ADAPTER_FUNCTION_PHASE_START,
each carrying a new invocation_id correlation field (also added to the
existing COMPLETE payloads) so a tracing plugin can key spans safely
under concurrent invocations. Fires the new hooks from AdapterMixin.
adapter_scope() (activate/deactivate) and from LocalFileBinding.
prepare(), which now opens its own single-phase invocation since it
runs outside adapter_scope -- this also guarantees invocation-complete
always fires (even if prepare() raises), which the phase-complete hook's
success-only contract cannot, so a later span registry can drain to zero.

Reconciles the phase Literal: "release" now appears in it (per generative-computing#1466's
acceptance criteria) with a documented reason it has no firing site --
WeightsBinding.release() runs outside any invocation, unlike
prepare/activate/deactivate.

No spans yet -- that's the next commit, from a plugin in
mellea/telemetry/tracing_plugins.py per generative-computing#1464/generative-computing#1466.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Progresses generative-computing#1466. Adds AdapterFunctionTracingPlugin to
mellea/telemetry/tracing_plugins.py, which turns the ADAPTER_FUNCTION_*
hooks added in the previous commit into an adapter_function parent
span with one adapter_function.<phase> child per lifecycle phase
(prepare/activate/deactivate; generate/parse are blocked on generative-computing#1465).
On the mellea.backend tracer -- adapter/model lifecycle work is a
backend concern, not a user-facing operation.

The child spans parent explicitly via trace.set_span_in_context,
looked up by invocation_id, rather than via the ambient-attach
convention every other span pair in this codebase uses.
ADAPTER_FUNCTION_*_START/_COMPLETE fire from sync code (adapter_scope,
LocalFileBinding.prepare) via _run_async_in_thread, which runs each
hook as an independent task seeded from a fresh contextvars snapshot
of the calling thread -- an ambient-context attach inside one hook's
task is invisible to the next hook's snapshot, so ambient nesting
can't work here regardless of Python version. Explicit parenting
sidesteps that entirely and needs no _CONTEXT_ATTACH_SUPPORTED gating.

adapter_function_invocation_complete defensively closes any phase
child span still open (a phase that raised fires phase_start but never
its own success-only phase_complete), so the in-flight span registry
still drains to zero on a raised phase.

adapter_function.prepare records the resolved Hugging Face SHA as
mellea.adapter_function.revision, not "main" (moved from generative-computing#1141).
Content capture (MELLEA_TRACES_CONTENT) is not wired here: no phase in
this scope carries adapter input/output content -- that applies to
generate/parse, landing with generative-computing#1465.

Documents the span schema, the tracer choice and its rationale, and the
explicit-parenting decision in docs/docs/observability/tracing.md --
the current home for this content now that docs/dev/adapter_observability.md
(the location generative-computing#1466 named) has been deleted and folded into published
docs and code (see PR generative-computing#1483/generative-computing#1548).

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
…r linkage

Progresses generative-computing#1466. AdapterFunctionTracingPlugin and the pre-existing
AdapterFunctionMetricsPlugin are two separate plugins subscribed to
the same hooks, so exemplar linkage (SKILL.md §3) isn't structurally
guaranteed by "one plugin owns both". Checked and found genuinely
unreachable here regardless of firing order: no span in this family is
ever attached as ambient OTel context (a deliberate choice, since
ambient attach can't establish anything across separate
_run_async_in_thread-dispatched hook calls -- see the previous
commit), so there is nothing for the metrics plugin to sample as an
exemplar even if it ran while the span were still open. Documents this
as a known, explained gap rather than leaving it to be found later.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
@github-actions github-actions Bot added the enhancement New feature or request label Aug 18, 2026
…arding

Progresses generative-computing#1466. Fixes two real bugs found by independent review of the
previous two commits, both confirmed by reproducing them against the
pre-fix code and observing the failure:

- LocalFileBinding.prepare() fired adapter_function_invocation_complete
  before adapter_function_phase_complete on the success path (the phase
  hook fired outside the with-block, after the invocation hook's
  finally). This silently made finish_adapter_function_span's
  defensive dangling-child-span cleanup -- documented as the
  failure-only path -- the only path that ever closed
  adapter_function.prepare's span, and inverted the order
  AdapterMixin.adapter_scope uses for the same pair. Fixed by firing
  phase-complete from an else: clause, before the finally.

- adapter.py's _fire_phase_start_hook built its payload outside its own
  try, so a non-str .revision on a duck-typed (non-LocalFileBinding)
  WeightsBinding raised a pydantic ValidationError that escaped
  adapter_scope entirely, aborting before activate() ever ran --
  exactly the failure the function's docstring says it prevents.
  _core.py's sibling _fire_phase_start already guarded this correctly;
  adapter.py's now matches it.

Also fixes AdapterMixin.adapter_scope's docstring, which still claimed
the ADAPTER_FUNCTION_* family "currently has no start hook" -- the exact
gap the previous two commits closed -- and pointed at the deleted
docs/dev/adapter_observability.md.

Adds regression tests for both bugs, each verified against the pre-fix
code (temporarily reverted, confirmed failing, restored) per the
project's regression-guard verification standard, plus the two
error-path tests review flagged as untested: a failing prepare()
asserting both spans close ERROR and the registry drains, and a
failing adapter_function_invocation_start hook dispatch not blocking
activation (mirroring the existing invocation-complete coverage).

Also, cleanup from the same review pass:
- Drop the attach_context parameter from start_adapter_function_span/
  start_adapter_function_phase_span -- no caller ever passed it, and
  passing True would misbehave (mismatched attach/detach tasks), so it
  was configurability that could not be used correctly.
- Iterate list(_in_flight_spans) rather than the live dict in
  finish_adapter_function_span's dangling-child sweep, so a concurrent
  insert from another invocation's sync-dispatched hook can't raise
  "dictionary changed size during iteration".
- Record error.type on a dangling phase child span too, matching the
  parent invocation span's existing convention.
- Reword the "Nesting is unconditional"/exemplar-gap doc and docstring
  passages for precision (an enclosing application span can still be
  ambiently current; it's just not the adapter_function span the
  metric is about).
- Fix a stale test comment, a redundant re-import in a test, and a
  doc cross-reference to a note that had moved sections.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Per AGENTS.md section 13's own feedback-loop rule. Encountered while
fixing a bug found in review of generative-computing#1466: a code comment containing the
literal text "raise " false-triggered tooling/docs-autogen/audit_coverage.py's
"missing Raises section" check on a function with no actual raise
statement, since that check is a substring match over the whole
function source, not an AST check for real raise statements.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant