feat(telemetry): emit adapter-function prepare/activate/deactivate spans from a tracing plugin - #1558
Draft
planetf1 wants to merge 5 commits into
Draft
feat(telemetry): emit adapter-function prepare/activate/deactivate spans from a tracing plugin#1558planetf1 wants to merge 5 commits into
planetf1 wants to merge 5 commits into
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 missingADAPTER_FUNCTION_*_STARThooks (the family previously had only*_COMPLETEmembers, 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 properadapter_function→adapter_function.<phase>span tree.generate/parseare excluded on purpose — they need real generation running insideadapter_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:generate/parsespans only — that issue wires real generation throughAdapterMixin.adapter_scope, which these two spans need. The lifecycle spans in this PR (prepare/activate/deactivate) don't depend on it and can land independently.generate/parsefrom a follow-up to this PR.What this PR deliberately does not include, so reviewers don't file findings against its absence:
adapter_function.generate/adapter_function.parsespans and theirMELLEA_TRACES_CONTENTcontent-capture wiring — both need refactor(backends): route intrinsic generation through adapter_scope (Epic #929 Phase 2) #1465.releasephase —WeightsBinding.release()runs outside any invocation, unlike the other four phases; see "Acceptance criteria" below for how the phaseLiteralstill accounts for it.mellea.adapter_function.phase_durationand these spans — a known, documented gap; see "Two deliberate deviations" below.What changed
mellea/plugins/types.py,mellea/plugins/hooks/adapter_function.pyADAPTER_FUNCTION_INVOCATION_START/ADAPTER_FUNCTION_PHASE_STARThooks, paired with the existing*_COMPLETEhooks; newinvocation_idcorrelation field on all four payloads;releaseadded to thephaseLiteralmellea/backends/adapters/adapter.pyAdapterMixin.adapter_scope()fires the new start hooks around activate/deactivate, correlated by a per-scopeinvocation_idmellea/backends/adapters/_core.pyLocalFileBinding.prepare()fires its own invocation-start/complete pair around its phase-start/complete pair, since it runs outsideadapter_scope— this also guarantees invocation-complete always fires (even ifprepare()raises), which the phase-complete hook's success-only contract can'tmellea/telemetry/tracing.pystart_/finish_adapter_function_spanandstart_/finish_adapter_function_phase_spanhelpers, on themellea.backendtracermellea/telemetry/tracing_plugins.pyAdapterFunctionTracingPlugin, turning the hooks above into theadapter_functionspan treedocs/docs/observability/tracing.mdadapter_functionspan section: schema, tracer-scope rationale, both deviations below. Replacesdocs/dev/adapter_observability.md, which #1483/#1548 already deleted and folded into published docs and codetest/telemetry/test_tracing_adapter_function.py(new),test/backends/test_adapters/*,test/telemetry/test_metrics_plugins.pyTwo deliberate deviations from the existing tracing conventions
_CONTEXT_ATTACH_SUPPORTED). That mechanism can't work here:ADAPTER_FUNCTION_*_START/_COMPLETEfire from synchronous code (adapter_scope,prepare()) via_run_async_in_thread, which runs each hook as an independent task seeded from a freshcontextvarssnapshot 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 viatrace.set_span_in_context, looked up byinvocation_id. This works identically on Python 3.11 and 3.12+, so no version gating applies to this family — verified unconditionally withInMemorySpanExporter.mellea.adapter_function.phase_duration. Because no span in this family is ever ambiently attached, the pre-existingAdapterFunctionMetricsPlugin(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:
prepare().adapter_function_invocation_completefired beforeadapter_function_phase_completeon the success path — the phase-complete call sat outside thewithblock, after the invocation-complete call in thefinallyinside it. This silently made the tracing plugin's defensive dangling-child-span cleanup (documented as the failure-only path) the only path that ever closedadapter_function.prepare's span, and inverted the orderadapter_scopeuses for the same pair. Fixed by firing phase-complete from anelse:clause, before thefinally._fire_phase_start_hook. It built its hook payload outside its owntry, so a non-str.revisionon a duck-typed (non-LocalFileBinding) weights binding raised apydantic.ValidationErrorthat escapedadapter_scopeentirely, aborting beforeactivate()ever ran — exactly the failure the function's own docstring says it prevents. The sibling implementation in_core.pyalready 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 deadattach_contextparameter that could never be used correctly, a dict-iteration atomicity issue in the span-cleanup sweep, a stale docstring onadapter_scopestill 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_idtoadapter_function_invocation_idto 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 hooktelemetry.tracingimport exists undermellea/backends/docs/docs/observability/tracing.md, notdocs/dev/adapter_observability.md, which no longer exists (see "Where this fits")adapter_function.prepare,.activate,.deactivateemitted as children of theadapter_functionparent span —.parseneeds refactor(backends): route intrinsic generation through adapter_scope (Epic #929 Phase 2) #1465, not in this PRadapter_function.preparerecords the resolved Hugging Face SHA asmellea.adapter_function.revision, not"main"MELLEA_TRACES_CONTENT=1produces content-capture events — not applicable yet: no phase in this PR's scope carries adapter input/output content; that'sgenerate/parse, via refactor(backends): route intrinsic generation through adapter_scope (Epic #929 Phase 2) #1465adapter_function/mellea.adapter_function.*conventionreleaseappears in thephaseLiteral, with a documented reason it has no firing site (WeightsBinding.release()runs outside any invocation)InMemorySpanExporter— unconditionally, not version-gated, since explicit parenting (deviation 1 above) doesn't depend on_CONTEXT_ATTACH_SUPPORTEDuv run pytest test/ -m "not qualitative"passesTesting
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