Skip to content

feat(otel): Give each session's cycle its own reactive_update span - #2522

Draft
schloerke wants to merge 2 commits into
jat255/698-lock-flush-replacementfrom
schloerke/async-otel-session-span
Draft

schloerke wants to merge 2 commits into
jat255/698-lock-flush-replacementfrom
schloerke/async-otel-session-span

Conversation

@schloerke

Copy link
Copy Markdown
Collaborator

Stacked on #2520 (on #2517 → #2515 → #2508). Fixes #2508's TODO: reactive_update spans losing session.id.

This branch also contains a merge of the current #2517 into #2520's branch, which #2520 doesn't have yet. Until #2520 merges #2517, this PR's diff shows those changes too; the change itself is the last commit.

Why session.id was lost

On main, every flush ran inside a session's message loop, so reactive_update picked up the session that triggered it, even though the flush is global and runs every session's effects. With #2508, a requested flush runs in a fresh contextvars.Context() on purpose, so it carries no session at all. It can't carry the requesting one either: requests from several sessions are merged into one flush. And with concurrent effects, the flush span ended as soon as effects had started, before their async parts finished.

Change: follow Shiny for R

R ties the span to the session's cycle, not to the flush (R/otel-reactive-update.R; started in incrementBusyCount(), ended in decrementBusyCount()). #2508 gave py-shiny the same per-session busy count, so:

  • Start: AppSession._increment_busy_count() going 0 → 1 starts the session's reactive_update span, with session.id. Its parent is the current span, so the first cycle sits under session_start; later cycles each start a trace of their own.
  • End: _decrement_busy_count() going back to 0, or the session ending, ends it. It covers the session's async effects to the end.
  • Children: Effect_._run (and so every render output) runs under its session's span, so effect, output, and calc spans are its children.
  • The flush has no span of its own anymore. Effects without a session have no reactive_update parent.
  • New helpers: start_otel_span() / use_otel_span() in shiny/otel/_span_wrappers.py for a span that isn't one block of code.

Docs

  • shiny/otel/__init__.py collection-level docs.
  • The bundled skill's otel reference: the hierarchy diagram and level table.
  • A CHANGELOG entry under Breaking changes.

Verification

  • New tests/pytest/test_otel_reactive_update.py (9 tests):
    • effect and calc spans are children of the session's span, which has session.id and sits under session_start;
    • two sessions in one flush each get their own span;
    • the span lasts until an async effect finishes;
    • one span per cycle, with later ones as roots;
    • session-less effects are roots;
    • a session ending mid-cycle ends its span;
    • the collection levels session / reactive_update / all.
  • Tests for the old flush-level span are replaced by one asserting that a flush creates no reactive_update span.
  • Removing the effect's use of the span, or the end at idle, fails the new tests.
  • pytest tests/pytest: 1332 passed. Six parallel runs, at one worker per core, all passed. pyright, pyrefly, flake8, black, and the skill validator are clean.

schloerke added a commit that referenced this pull request Oct 2, 2026
@schloerke
schloerke added this pull request to stack #2516 October 2, 2026 20:17
@schloerke schloerke mentioned this pull request Oct 2, 2026
13 of 16 tasks
A reactive flush is global and can serve several sessions, so its
`reactive_update` span couldn't carry one `session.id`. Since flushes run in
a fresh context, it carried none, and with concurrent effects it ended as
soon as effects had started, before their async parts finished.

Follow Shiny for R instead: a session's `reactive_update` span starts when
its busy count goes 0 -> 1 and ends when it returns to 0 (or the session
ends). It carries the session's `session.id`, and the session's effects
(and so their calcs and outputs) run under it. The first cycle's span is a
child of `session_start`; later cycles start traces of their own. The flush
itself no longer creates a span, and effects without a session have no
`reactive_update` parent.

This branch has not been deployed

No deployments
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