Skip to content

feat(otel/state-patch): route SPAN_QUEST_UPDATE; document port-dead siblings#49

Merged
slabgorb merged 1 commit intofeat/otel-dashboard-restorationfrom
feat/otel-state-patch-emission
Apr 25, 2026
Merged

feat(otel/state-patch): route SPAN_QUEST_UPDATE; document port-dead siblings#49
slabgorb merged 1 commit intofeat/otel-dashboard-restorationfrom
feat/otel-state-patch-emission

Conversation

@slabgorb
Copy link
Copy Markdown
Owner

Summary

Phase 2 emission rollout — narrow state-patch bundle to the only constant with a live Python production caller.

Stacked on #48 (feat/otel-dashboard-restorationdevelop). Will retarget to develop automatically once #48 merges.

What landed

  • quest_update_span() helper in sidequest/telemetry/spans.py (JSON-encodes the updates dict so OTEL's primitive-attribute restriction doesn't drop it).
  • SPAN_ROUTES[SPAN_QUEST_UPDATE]state_transition / component="quest_log" carrying the same payload shape the prior direct publish_event did — dashboard sees no shape change.
  • sidequest/server/narration_apply.py quest_log block now opens quest_update_span; the inline _watcher_publish call is deleted (spec §6.6 dedupe rule).
  • Translator routing test in tests/server/test_watcher_events.py.
  • Wiring integration test at tests/integration/test_state_patch_wiring.py — drives _apply_narration_result_to_snapshot end-to-end through a real WatcherSpanProcessor and asserts exactly one state_transition with component=quest_log reaches the hub. Uses a monkeypatched tracer() because OTEL forbids replacing an already-installed global provider mid-suite.

What stayed flat-only

SPAN_APPLY_WORLD_PATCH, SPAN_BUILD_PROTOCOL_DELTA, and SPAN_COMPUTE_DELTA remain in FLAT_ONLY_SPANS with port-artifact comments. Audit found no production caller for any of the three:

  • apply_world_patch lives in game/session.py but the Python port inlined those mutations directly in narration_apply.py.
  • build_protocol_delta doesn't exist as a function (only mentioned in a docstring).
  • compute_delta is exported from game/delta.py but called only from tests.

Wiring spans into dead functions would violate CLAUDE.md "Verify Wiring, Not Just Existence" — flagged for follow-up: either delete the constants or wire the helpers through the dispatch loop. Not in scope for this bundle.

Validation gates

  • tests/telemetry/test_routing_completeness.py — green (constant moved out of FLAT_ONLY_SPANS; new route registered).
  • tests/server/test_watcher_events.py — green.
  • tests/integration/test_state_patch_wiring.py — green.
  • Full tests/server/ tests/telemetry/ tests/integration/ suite — 500 passed, 2 skipped, no regressions.
  • ruff check on touched files — clean.

References

Test plan

  • routing-completeness lint passes
  • translator-routing test passes (faked span)
  • wiring integration test passes (real _apply_narration_result_to_snapshot call)
  • full server + telemetry + integration suites green
  • ruff check clean

🤖 Generated with Claude Code

…iblings

Phase 2 emission rollout — narrow state-patch bundle to the only constant
with a live Python production caller.

What landed
-----------
- helper: ``quest_update_span`` in ``sidequest/telemetry/spans.py``
  (JSON-encodes the updates dict so OTEL primitive-attr restriction
  doesn't drop it).
- route: ``SPAN_ROUTES[SPAN_QUEST_UPDATE]`` → ``state_transition`` /
  ``component="quest_log"`` with the same payload shape the prior
  direct ``publish_event`` carried — dashboard sees no change.
- migration: ``sidequest/server/narration_apply.py`` quest_log block
  now opens ``quest_update_span`` and the inline ``_watcher_publish``
  call is deleted (spec §6.6 dedupe rule).
- tests:
  * ``tests/server/test_watcher_events.py`` — translator routing row
    (fake span → typed event).
  * ``tests/integration/test_state_patch_wiring.py`` — drives
    ``_apply_narration_result_to_snapshot`` end-to-end through a real
    ``WatcherSpanProcessor`` and asserts exactly one ``state_transition``
    with ``component=quest_log`` reaches the hub. Uses a monkeypatched
    ``tracer()`` because OTEL forbids replacing an already-installed
    global provider mid-suite.

What stayed flat-only
---------------------
``SPAN_APPLY_WORLD_PATCH``, ``SPAN_BUILD_PROTOCOL_DELTA``, and
``SPAN_COMPUTE_DELTA`` remain in ``FLAT_ONLY_SPANS`` with port-artifact
comments. Audit found no production caller for any of the three:
``apply_world_patch`` lives in ``game/session.py`` but the Python port
inlined those mutations directly in ``narration_apply.py``;
``build_protocol_delta`` doesn't exist as a function (only mentioned in
a docstring); ``compute_delta`` is exported from ``game/delta.py`` but
called only from tests. Wiring spans into dead functions would violate
CLAUDE.md "Verify Wiring, Not Just Existence" — flagged for follow-up:
either delete the constants or wire the helpers through the dispatch
loop (not in scope for this bundle).

Validation gates
----------------
- ``tests/telemetry/test_routing_completeness.py`` — green (constant
  moved out of FLAT_ONLY_SPANS; new route registered).
- ``tests/server/test_watcher_events.py`` — green.
- ``tests/integration/test_state_patch_wiring.py`` — green.
- Full ``tests/server/ tests/telemetry/ tests/integration/`` suite —
  500 passed, 2 skipped (no regressions).
- ``ruff check`` on touched files — clean.

ADR-090 §"Phase 2 emission rollouts"; handoff
``docs/superpowers/plans/2026-04-25-otel-phase-2-emission-rollouts-HANDOFF.md``
bundle #1 (state-patch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@slabgorb slabgorb merged commit 9de3120 into feat/otel-dashboard-restoration Apr 25, 2026
@slabgorb slabgorb deleted the feat/otel-state-patch-emission branch April 25, 2026 14:55
slabgorb added a commit to slabgorb/sidequest that referenced this pull request Apr 25, 2026
* docs(spec): shared Room.snapshot for ADR-037 (Python port)

Design for the proper architectural fix to per-session GameSnapshot
divergence in multiplayer. Replaces the band-aid
_merge_peer_state_into_snapshot helper with a single canonical snapshot
held on SessionRoom and shared by every WS session bound to the slug.

Constraint that simplifies scope: no saved MP games exist on disk
(multiplayer has never worked end-to-end). No migration path needed.
Band-aid + its 5 merge tests are deleted in the same change.

Out of scope: ADR-028 LLM rewrites, per-recipient narration region
filtering, PlayerState overlay struct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(plan): shared Room.snapshot implementation plan (ADR-037 Python port)

Step-by-step plan implementing the spec at
docs/superpowers/specs/2026-04-25-shared-room-snapshot-design.md.
9 tasks, 8 commits, ends at 739 passed / 2 skipped on the server +
agents sweep. Sequential single-developer plan suitable for inline
execution as the Bicycle Repair Man dev agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(spec): OTEL dashboard restoration design (faithful ADR-031 port)

Forensic audit found four root causes of the OTEL dashboard regression
since the Rust→Python port: broken `just otel` recipe, ~80% dead SPAN_*
constants, missing Layer-3 narrative validator, and impoverished
translator. Design specifies a four-phase faithful port that restores
full parity with the Rust contract.

Approved interactively via /superpowers:brainstorming. Self-review pass
fixed an emit-double on json_extraction_result (translator owns; not
the validator) and reclassified SPAN_CONTENT_RESOLVE to FLAT_ONLY due
to volume.

Next: writing-plans for implementation plan.

* fix(justfile): point otel recipe at playtest_dashboard.py

- Update `just otel` to call `playtest_dashboard.py` via `uv run` instead of
  the deleted `playtest.py --dashboard-only`
- Add `__main__` entry point with argparse to `playtest_dashboard.py`
  (it was a library-only module with no CLI entry point)
- Add `websockets>=12.0` and `rich>=13.0` to orchestrator `pyproject.toml`
  (required by playtest_dashboard.py, previously missing from manifest)

* ci: smoke-test the just otel recipe to catch script renames

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(adr): ADR-090 OTEL Dashboard Restoration after Python Port

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(adr): amend ADR-031 with Python-port section, regen indexes

Add observability tag, replace stale Rust-only opening blockquote, and
append Python-port note pointing at the canonical telemetry implementation
files in sidequest-server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): OTEL dashboard restoration implementation plan

Pairs with the design spec at
docs/superpowers/specs/2026-04-25-otel-dashboard-restoration-design.md.
This is the 25-task plan (Phase 0–4) executed by the OTEL feat branches
across orchestrator, sidequest-server, and sidequest-ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(otel): Phase-2 port-audit follow-up after bundles #1 + #2

Discovered-state correction for the original Phase-2 emission-rollouts
handoff: the Python port (ADR-082) replaced most Rust info_span!/
start_as_current_span calls with logger.info(...) strings or direct
_watcher_publish calls. The SPAN_* constants in spans.py are mostly
byte-identical-to-Rust name-string prefixes, not actual span openings.

Records:
- Which families are genuinely live in Python (the ones that ship now).
- Which ~7 bundles in the original handoff are port-dead and require
  engine porting before routing.
- The two real Phase-2 workstreams: migrate live _watcher_publish sites
  (Workstream A — what bundles #49 and #50 did), vs. port the missing
  engines (Workstream B — out of scope for telemetry restoration).
- Suggested next bundle picks from Workstream A.
- Open question on whether to bridge port gaps via log-string parsing
  (recommended: no — Illusionism violation per ADR-031).

Closes the loop after PRs slabgorb/sidequest-server#49 and #50.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
slabgorb added a commit that referenced this pull request Apr 25, 2026
Phase 2 emission rollout — inventory bundle. Migrates the
narrator-extracted ``items_gained`` / ``items_lost`` block of
``server/narration_apply.py`` from a direct ``_watcher_publish``
call to a routed span helper. Stacked on #48
(``feat/otel-dashboard-restoration`` → ``develop``); will
auto-retarget once #48 lands.

What landed
-----------
- New ``SPAN_INVENTORY_NARRATOR_EXTRACTED`` constant + route. The
  prior ``SPAN_INVENTORY_EXTRACTION`` mirrors the unported Rust
  ``inventory_extractor`` agent and stays in ``FLAT_ONLY_SPANS`` —
  the integrated narrator pipeline replaces that standalone agent in
  the Python port, so its emissions need their own constant.
- ``inventory_narrator_extracted_span()`` helper. ``gained`` and
  ``lost`` are JSON-encoded into span attributes because OTEL silently
  drops list/dict attribute values; the route ``extract`` returns the
  JSON strings to the dashboard which already parses this shape.
- ``narration_apply.py`` opens the helper after computing the
  post-mutation ``added_names`` / ``removed_names`` (items_lost is
  case-insensitive and only records successful matches), so the route
  payload is byte-identical to what ``_watcher_publish`` sent.
  ``_watcher_publish`` call deleted (spec §6.6 dedupe).

Audit pivot
-----------
Original handoff recommended bundle #2 (encounter-lifecycle extras in
``apply_encounter_updates``). Audit found that function has zero
production callers — supplanted by the unified
``_apply_narration_result_to_snapshot`` (session_handler.py:2898). Per
project policy "do not wire spans into dead functions"
(CLAUDE.md "Verify Wiring, Not Just Existence" + the user's explicit
constraint), pivoted to bundle #3 (inventory) which has a live caller
and validator-correlation value.

What stayed flat-only
---------------------
``SPAN_INVENTORY_EXTRACTION`` remains in ``FLAT_ONLY_SPANS`` —
port-artifact for the unported Rust standalone extractor agent. Same
pattern as ``SPAN_APPLY_WORLD_PATCH`` siblings (#49) and
``SPAN_NPC_REGISTRATION`` siblings (#50). Comment added near the
constant.

Validation gates
----------------
- ``tests/telemetry/test_routing_completeness.py`` — green
  (constant added to ``SPAN_ROUTES``; not duplicated in
  ``FLAT_ONLY_SPANS``).
- ``tests/server/test_watcher_events.py`` — green (new
  translator-routing test for the inventory route, asserts
  JSON-encoded gained/lost survive the OTEL primitive-types
  restriction).
- ``tests/integration/test_inventory_wiring.py`` — green
  (3 tests: items_gained, items_lost case-insensitive matching, no
  double-emission).
- Full ``tests/server/ tests/telemetry/ tests/integration/`` suite —
  508 passed, 2 skipped (no regressions; +4 vs prior bundle).
- ``ruff check`` on touched files — clean.

References
----------
- ADR-090 §"Phase 2 emission rollouts"
- Audit: ``docs/superpowers/plans/2026-04-25-otel-phase-2-port-audit-followup.md``
  (Workstream A, recommended pick #3)
- Builds on #49 (state-patch) and #50 (NPC)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
slabgorb added a commit that referenced this pull request Apr 25, 2026
…validator pipeline (#48)

* feat(telemetry): gate ConsoleSpanExporter behind SIDEQUEST_OTEL_CONSOLE

Normal server runs no longer spam stdout with span dumps. Console export
is only enabled when SIDEQUEST_OTEL_CONSOLE=1 is set explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(telemetry): add SpanRoute, SPAN_ROUTES, FLAT_ONLY_SPANS scaffolding

* feat(telemetry): seed SPAN_ROUTES and FLAT_ONLY_SPANS for live spans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(watcher): translator emits typed events via SPAN_ROUTES on close

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(telemetry): lint every SPAN_* constant has a routing decision

Add tests/telemetry/test_routing_completeness.py — static lint that
fails if any SPAN_* constant in spans.py is missing both a SPAN_ROUTES
entry and membership in FLAT_ONLY_SPANS.

Bulk-add 72 currently-dead (Rust-port) span constants to FLAT_ONLY_SPANS
as a Phase 2 deferred baseline. Each Phase 2 family rollout will move
entries out of this set and into SPAN_ROUTES with proper extract lambdas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(telemetry): add turn_span() root context manager

* feat(server): open turn_span at dispatch entry to anchor traces

Wraps _execute_narration_turn body in turn_span() so every span
emitted during a dispatch turn (orchestrator, local_dm, encounter, etc.)
fires as a child of the root 'turn' span. Without this, traces are
orphaned and the OTEL Timing tab cannot group by turn.

Adds tests/server/test_turn_span_wiring.py with two tests:
- test_dispatch_opens_turn_span: asserts a 'turn' span is produced and
  no other span is a true orphan (parent=None without a turn root)
- test_turn_span_carries_required_attributes: asserts turn_id, player_id,
  and agent_name are set on the span per ADR-031 §Layer 2 contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(telemetry): TurnRecord and PatchSummary dataclasses

* feat(telemetry): Validator skeleton with bounded queue and lifecycle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(validator): entity_check warns on narration of unknown entities

* feat(validator): inventory_check warns on narration/patch mismatches

* feat(validator): patch_legality_check flags HP overflow and dead-actor patches

Adds patch_legality_check to validator.py (hp > hp_max for characters/NPCs,
dead NPC referenced as actor in combat patches) and registers it in Validator.__init__.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(validator): trope_alignment_check warns on beats fired without keywords

* feat(validator): subsystem exercise summary + coverage_gap from sliding window

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(validator): emit turn_complete from each TurnRecord

Validator._validate now publishes turn_complete (with turn_id,
player_id, agent_name, and all key counters) before iterating checks,
per spec §6.7.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(validator): periodic heartbeat surfaces queue depth and check timing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(validator): crash-containment and backpressure coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(server): wire Validator lifecycle into FastAPI startup/shutdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(server): assemble TurnRecord at dispatch end and submit to validator

Wires the turn hot path to the Layer-3 narrative validator (Task 21).
At the end of _execute_narration_turn, a TurnRecord is assembled from
the NarrationTurnResult and submitted to self._validator. The validator
reference is injected via WebSocketSessionHandler.__init__(validator=...)
and passed from app.state.validator at connection time. Protected by
try/except so validator failures never crash the dispatch path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(server): consolidate turn_complete emission in validator (ADR-089 §6.7)

Removes the dispatch-side turn_complete watcher emit in
_execute_narration_turn now that the TurnRecord is forwarded to the
validator (Task 21). Per ADR-089 §6.7 the validator owns the
turn_complete event. The validator's turn_complete payload is
enriched to be the union of fields previously emitted from both
sites — turn_id/turn_number, player_id, player_input, agent_name,
timing & metering, patches (full + legacy short-form),
beats_fired, delta_empty — so existing dashboard consumers keep
working. Severity flips to "warning" when is_degraded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(otel/state-patch): route SPAN_QUEST_UPDATE; document port-dead siblings (#49)

Phase 2 emission rollout — narrow state-patch bundle to the only constant
with a live Python production caller.

What landed
-----------
- helper: ``quest_update_span`` in ``sidequest/telemetry/spans.py``
  (JSON-encodes the updates dict so OTEL primitive-attr restriction
  doesn't drop it).
- route: ``SPAN_ROUTES[SPAN_QUEST_UPDATE]`` → ``state_transition`` /
  ``component="quest_log"`` with the same payload shape the prior
  direct ``publish_event`` carried — dashboard sees no change.
- migration: ``sidequest/server/narration_apply.py`` quest_log block
  now opens ``quest_update_span`` and the inline ``_watcher_publish``
  call is deleted (spec §6.6 dedupe rule).
- tests:
  * ``tests/server/test_watcher_events.py`` — translator routing row
    (fake span → typed event).
  * ``tests/integration/test_state_patch_wiring.py`` — drives
    ``_apply_narration_result_to_snapshot`` end-to-end through a real
    ``WatcherSpanProcessor`` and asserts exactly one ``state_transition``
    with ``component=quest_log`` reaches the hub. Uses a monkeypatched
    ``tracer()`` because OTEL forbids replacing an already-installed
    global provider mid-suite.

What stayed flat-only
---------------------
``SPAN_APPLY_WORLD_PATCH``, ``SPAN_BUILD_PROTOCOL_DELTA``, and
``SPAN_COMPUTE_DELTA`` remain in ``FLAT_ONLY_SPANS`` with port-artifact
comments. Audit found no production caller for any of the three:
``apply_world_patch`` lives in ``game/session.py`` but the Python port
inlined those mutations directly in ``narration_apply.py``;
``build_protocol_delta`` doesn't exist as a function (only mentioned in
a docstring); ``compute_delta`` is exported from ``game/delta.py`` but
called only from tests. Wiring spans into dead functions would violate
CLAUDE.md "Verify Wiring, Not Just Existence" — flagged for follow-up:
either delete the constants or wire the helpers through the dispatch
loop (not in scope for this bundle).

Validation gates
----------------
- ``tests/telemetry/test_routing_completeness.py`` — green (constant
  moved out of FLAT_ONLY_SPANS; new route registered).
- ``tests/server/test_watcher_events.py`` — green.
- ``tests/integration/test_state_patch_wiring.py`` — green.
- Full ``tests/server/ tests/telemetry/ tests/integration/`` suite —
  500 passed, 2 skipped (no regressions).
- ``ruff check`` on touched files — clean.

ADR-090 §"Phase 2 emission rollouts"; handoff
``docs/superpowers/plans/2026-04-25-otel-phase-2-emission-rollouts-HANDOFF.md``
bundle #1 (state-patch).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(otel/npc): route SPAN_NPC_AUTO_REGISTERED + SPAN_NPC_REINVENTED (#50)

Phase 2 emission rollout — NPC bundle. Both auto_registered and
reinvented identity-drift paths now emit through SPAN_ROUTES instead
of direct ``publish_event`` calls.

What landed
-----------
- helpers: ``npc_auto_registered_span``, ``npc_reinvented_span`` in
  ``sidequest/telemetry/spans.py``. Use ``npc_name`` (not ``name``)
  for the entity attribute to avoid colliding with the OTEL span
  ``name`` reserved attribute.
- routes: ``SPAN_NPC_AUTO_REGISTERED`` and ``SPAN_NPC_REINVENTED`` →
  ``state_transition`` / ``component=npc_registry`` carrying
  ``op=auto_registered`` and ``op=reinvented`` respectively. Same
  payload shape the dashboard already consumes.
- watcher: ``WatcherSpanProcessor`` gains a span-attribute escape
  hatch for severity. When the helper sets ``severity=warning`` as
  an attribute the typed event surfaces ``severity=warning`` instead
  of the OTEL-status-derived default. Required because OTEL Status
  only carries OK/ERROR — there's no native warning level.
- migration: ``narration_apply.py`` opens ``npc_auto_registered_span``
  and ``session_helpers._detect_npc_identity_drift`` opens
  ``npc_reinvented_span``. Both inline ``_watcher_publish`` calls are
  deleted (spec §6.6 dedupe). Now-unused ``publish_event`` import
  removed from ``session_helpers.py``.
- tests:
  * Translator routing rows for both routes in
    ``tests/server/test_watcher_events.py``, including a dedicated
    test that ``severity=warning`` propagates from span attribute to
    typed event.
  * ``test_state_transition_fires_on_npc_auto_register`` updated to
    install the local TracerProvider+WatcherSpanProcessor, so it
    still exercises the production code path after migration.
  * New wiring integration test ``tests/integration/test_npc_wiring.py``
    drives both ``_apply_narration_result_to_snapshot`` (auto) and
    ``_detect_npc_identity_drift`` (reinvented) end-to-end through a
    real ``WatcherSpanProcessor`` and asserts exactly one
    ``state_transition`` per path with the right component/op/severity.

What stayed flat-only
---------------------
``SPAN_NPC_REGISTRATION`` and ``SPAN_NPC_MERGE_PATCH`` remain in
``FLAT_ONLY_SPANS``. Audit found no production caller for either —
the Python port did not port the Rust ``register_npc`` /
``merge_npc_patch`` helpers; ``narration_apply.py`` mutates
``npc_registry`` directly. Same port-artifact pattern documented for
state-patch in the prior PR. Flagged for follow-up.

Validation gates
----------------
- ``tests/telemetry/test_routing_completeness.py`` — green.
- ``tests/server/test_watcher_events.py`` — green (4 NPC-related
  tests including warning-severity propagation).
- ``tests/integration/test_npc_wiring.py`` — green (auto + reinvented).
- ``tests/server/test_npc_identity_drift.py`` — green (no regression).
- Full ``tests/server/ tests/telemetry/ tests/integration/`` suite —
  504 passed, 2 skipped (no regressions; +4 vs prior bundle).
- ``ruff check`` on touched files — clean.

ADR-090 §"Phase 2 emission rollouts"; handoff bundle #2 (NPC).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(otel/inventory): route SPAN_INVENTORY_NARRATOR_EXTRACTED (#52)

Phase 2 emission rollout — inventory bundle. Migrates the
narrator-extracted ``items_gained`` / ``items_lost`` block of
``server/narration_apply.py`` from a direct ``_watcher_publish``
call to a routed span helper. Stacked on #48
(``feat/otel-dashboard-restoration`` → ``develop``); will
auto-retarget once #48 lands.

What landed
-----------
- New ``SPAN_INVENTORY_NARRATOR_EXTRACTED`` constant + route. The
  prior ``SPAN_INVENTORY_EXTRACTION`` mirrors the unported Rust
  ``inventory_extractor`` agent and stays in ``FLAT_ONLY_SPANS`` —
  the integrated narrator pipeline replaces that standalone agent in
  the Python port, so its emissions need their own constant.
- ``inventory_narrator_extracted_span()`` helper. ``gained`` and
  ``lost`` are JSON-encoded into span attributes because OTEL silently
  drops list/dict attribute values; the route ``extract`` returns the
  JSON strings to the dashboard which already parses this shape.
- ``narration_apply.py`` opens the helper after computing the
  post-mutation ``added_names`` / ``removed_names`` (items_lost is
  case-insensitive and only records successful matches), so the route
  payload is byte-identical to what ``_watcher_publish`` sent.
  ``_watcher_publish`` call deleted (spec §6.6 dedupe).

Audit pivot
-----------
Original handoff recommended bundle #2 (encounter-lifecycle extras in
``apply_encounter_updates``). Audit found that function has zero
production callers — supplanted by the unified
``_apply_narration_result_to_snapshot`` (session_handler.py:2898). Per
project policy "do not wire spans into dead functions"
(CLAUDE.md "Verify Wiring, Not Just Existence" + the user's explicit
constraint), pivoted to bundle #3 (inventory) which has a live caller
and validator-correlation value.

What stayed flat-only
---------------------
``SPAN_INVENTORY_EXTRACTION`` remains in ``FLAT_ONLY_SPANS`` —
port-artifact for the unported Rust standalone extractor agent. Same
pattern as ``SPAN_APPLY_WORLD_PATCH`` siblings (#49) and
``SPAN_NPC_REGISTRATION`` siblings (#50). Comment added near the
constant.

Validation gates
----------------
- ``tests/telemetry/test_routing_completeness.py`` — green
  (constant added to ``SPAN_ROUTES``; not duplicated in
  ``FLAT_ONLY_SPANS``).
- ``tests/server/test_watcher_events.py`` — green (new
  translator-routing test for the inventory route, asserts
  JSON-encoded gained/lost survive the OTEL primitive-types
  restriction).
- ``tests/integration/test_inventory_wiring.py`` — green
  (3 tests: items_gained, items_lost case-insensitive matching, no
  double-emission).
- Full ``tests/server/ tests/telemetry/ tests/integration/`` suite —
  508 passed, 2 skipped (no regressions; +4 vs prior bundle).
- ``ruff check`` on touched files — clean.

References
----------
- ADR-090 §"Phase 2 emission rollouts"
- Audit: ``docs/superpowers/plans/2026-04-25-otel-phase-2-port-audit-followup.md``
  (Workstream A, recommended pick #3)
- Builds on #49 (state-patch) and #50 (NPC)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
slabgorb added a commit that referenced this pull request Apr 25, 2026
Phase 2 emission rollout — audio bundle (Workstream A pick #1 from the
port-audit follow-up). Migrates all 5 audio ``_watcher_publish`` sites
in ``server/session_handler.py`` to routed span helpers. High-volume
during play; lights up the GM panel's ``audio`` Subsystems-tab
component immediately. Branches off ``develop`` directly (the prior
restoration branch landed in #48).

What landed
-----------
- 4 new constants + routes:
  * ``SPAN_AUDIO_BACKEND_ENABLED`` — backend init success (genre,
    mood_count, sfx_count).
  * ``SPAN_AUDIO_BACKEND_DISABLED`` — backend init bail
    (``pack_dir_missing`` / ``empty_config``).
  * ``SPAN_AUDIO_SKIPPED`` — per-turn skip (``no_audio_config`` /
    ``no_narration`` / ``empty_cues`` / ``error``). The original
    ``_audio_skip`` helper accepted a flexible ``extra`` dict; the
    span attribute is JSON-encoded (``extra_json``) because OTEL
    silently drops dict attribute values, and the route extract
    returns the JSON string for dashboard parity.
  * ``SPAN_AUDIO_DISPATCHED`` — per-turn cue dispatch (turn_number,
    mood, music_track, sfx_count).
- 4 helpers: ``audio_backend_enabled_span``,
  ``audio_backend_disabled_span``, ``audio_skipped_span``,
  ``audio_dispatched_span``. ``mood`` / ``music_track`` from
  ``AudioCuePayload`` can be ``None``; the dispatched helper coerces
  to ``""`` so OTEL doesn't drop the attribute.
- ``session_handler.py`` migrations: ``_build_audio_backend`` opens
  enabled/disabled spans (3 sites collapsed); ``_audio_skip`` and
  ``_audio_dispatched`` open their respective spans. All 5
  ``_watcher_publish`` calls deleted (spec §6.6 dedupe).

What stayed flat-only
---------------------
``SPAN_MUSIC_EVALUATE`` / ``SPAN_MUSIC_CLASSIFY_MOOD`` remain in
``FLAT_ONLY_SPANS`` — they mirror the unported Rust ``music_director``
agent (the integrated cue pipeline replaces it in the port). Comment
added near the constant. Same port-artifact pattern as
``SPAN_INVENTORY_EXTRACTION`` (#52), ``SPAN_NPC_REGISTRATION`` (#50),
and ``SPAN_APPLY_WORLD_PATCH`` siblings (#49).

Tests
-----
- 4 translator-routing tests in ``tests/server/test_watcher_events.py``
  (one per audio route) using a shared fake-span factory + capturing
  hub harness.
- 5 wiring integration tests in ``tests/integration/test_audio_wiring.py``
  driving ``WebSocketSessionHandler._audio_skip`` and
  ``_audio_dispatched`` end-to-end through a real ``WatcherSpanProcessor``.
  The handler methods don't read ``self``; called as unbound methods
  with a ``MagicMock`` to side-step ``__init__``.

Validation gates
----------------
- ``tests/telemetry/test_routing_completeness.py`` — green (4 new
  constants present in ``SPAN_ROUTES``; not duplicated in
  ``FLAT_ONLY_SPANS``).
- ``tests/server/test_watcher_events.py`` — green (4 new audio routes).
- ``tests/integration/test_audio_wiring.py`` — green (5 tests).
- Full ``tests/server/ tests/telemetry/ tests/integration/`` suite —
  524 passed, 2 skipped (no regressions; +9 vs prior bundle).
- ``ruff check`` — clean on all touched files for the diff range. Two
  pre-existing SIM102/SIM105 errors at session_handler.py lines 594
  (committed 2026-04-23) and 1031 (committed 2026-04-20) are unrelated
  to this bundle and predate it; flagged as out of scope for follow-up.

References
----------
- ADR-090 §"Phase 2 emission rollouts"
- Audit: ``docs/superpowers/plans/2026-04-25-otel-phase-2-port-audit-followup.md``
  (Workstream A, recommended pick #1)
- Builds on #48 (restoration), #49 (state-patch), #50 (NPC), #52 (inventory)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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