Skip to content

Commit 0ef502b

Browse files
authored
feat: Add bundled otel Agent Skill (#2356)
1 parent 0a8f604 commit 0ef502b

4 files changed

Lines changed: 186 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727

2828
* Added a `bookmarking` Agent Skill (under `shiny/.agents/skills/`) that teaches coding agents to save and restore app state with `bookmark_store=`, the `session.bookmark` lifecycle hooks, custom bookmark values, and server-side bookmark storage. (#2345)
2929

30+
* Added an `otel` Agent Skill (under `shiny/.agents/skills/`) that teaches coding agents to observe Shiny apps with OpenTelemetry: zero-code auto-instrumentation via `opentelemetry-instrument shiny run`, collection levels with `SHINY_OTEL_COLLECT`, per-object control with `otel.suppress`/`otel.collect`, and exporting to OTLP backends. (#2356)
31+
3032
* Added `offcanvas()` for creating sliding Bootstrap Offcanvas panels that appear from a viewport edge. Panels can be triggered by a UI element, revealed programmatically with `show_offcanvas()`, or controlled by id with `hide_offcanvas()` and `toggle_offcanvas()`. (#2279)
3133

3234
### Improvements

shiny/.agents/skills/otel/SKILL.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
---
2+
name: otel
3+
description: Use when adding observability to a Shiny for Python (py-shiny) app with OpenTelemetry - tracing reactive execution, profiling slow outputs or update cycles, monitoring sessions in production, exporting spans to a backend (Jaeger, Logfire, Honeycomb, Datadog, OTLP), suppressing telemetry for sensitive code, or when tempted to call trace.set_tracer_provider() inside app code to set up instrumentation.
4+
---
5+
6+
# OpenTelemetry for Shiny for Python apps
7+
8+
## Overview
9+
10+
Shiny has built-in OpenTelemetry instrumentation: it emits spans for session
11+
lifecycle, reactive update cycles, and individual calc/effect/output executions.
12+
Enable it with **zero-code auto-instrumentation** — launch the app under
13+
`opentelemetry-instrument`. Do NOT configure providers inside app code
14+
(`trace.set_tracer_provider(...)`): providers install once per process, so
15+
in-code setup is silently ignored under external instrumentation.
16+
17+
## Setup and run
18+
19+
```bash
20+
uv pip install "shiny[otel]" # includes opentelemetry-distro[otlp]
21+
```
22+
23+
Print spans to the console while developing:
24+
25+
```bash
26+
opentelemetry-instrument --traces_exporter console --logs_exporter console \
27+
--metrics_exporter none shiny run app.py
28+
```
29+
30+
Export to a backend (OTLP to `http://localhost:4317` is the default; any
31+
OTLP-compatible backend works — Jaeger, Logfire, Honeycomb, Datadog, ...):
32+
33+
```bash
34+
OTEL_SERVICE_NAME=my-shiny-app opentelemetry-instrument shiny run app.py
35+
```
36+
37+
All standard `OTEL_*` environment variables apply (`OTEL_EXPORTER_OTLP_ENDPOINT`,
38+
`OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_EXPORTER_OTLP_PROTOCOL`, sampling via
39+
`OTEL_TRACES_SAMPLER`). `--reload` is compatible. The app needs no OTel code at
40+
all. Shiny resolves the tracer provider lazily at span-creation time, so the
41+
wrapper's provider is picked up automatically.
42+
43+
At the `all` level the span hierarchy looks like:
44+
45+
```text
46+
session_start
47+
└─ reactive_update
48+
├─ reactive.calc filtered_data
49+
└─ output result
50+
```
51+
52+
## Collection levels: `SHINY_OTEL_COLLECT`
53+
54+
Controls how much Shiny telemetry is emitted (default `all`):
55+
56+
| Level | Emits | Use |
57+
|---|---|---|
58+
| `none` | nothing from Shiny | disable Shiny spans, keep your own |
59+
| `session` | session lifecycle only | low-overhead production |
60+
| `reactive_update` | + one span per flush cycle | balanced production |
61+
| `reactivity` | + per calc/effect/output spans, value-update logs | development, debugging |
62+
| `all` | everything (currently = `reactivity`) | maximum detail |
63+
64+
```bash
65+
SHINY_OTEL_COLLECT=session opentelemetry-instrument shiny run app.py
66+
```
67+
68+
Read the current level in code with `shiny.otel.get_level()`.
69+
70+
## Per-object control: `otel.suppress` / `otel.collect`
71+
72+
Disable Shiny telemetry for sensitive reactives (secrets, PII), or force it on
73+
when the global level is low. Both work as decorators and context managers:
74+
75+
```python
76+
from shiny import otel, reactive, render
77+
78+
@render.text
79+
@otel.suppress # must be BELOW @render/@reactive (closer to the function)
80+
def result_private():
81+
return authenticate(input.username(), input.password())
82+
83+
with otel.suppress():
84+
@reactive.calc # everything created in this block is suppressed
85+
def sensitive_calc():
86+
return load_secrets()
87+
88+
with otel.collect():
89+
@reactive.calc # re-enabled despite the outer suppress
90+
def public_calc():
91+
return load_public_data()
92+
```
93+
94+
Key semantics:
95+
96+
- The setting is captured when the reactive object is **created**, not when it
97+
runs. `with otel.suppress():` inside a reactive function body does nothing to
98+
that reactive's spans.
99+
- `suppress`/`collect` are absolute per-object overrides: they beat
100+
`SHINY_OTEL_COLLECT` in both directions. Infrastructure spans
101+
(`session_start`, `session_end`, `reactive_update`) follow only the env var.
102+
- Only Shiny's internal telemetry is affected — spans you create manually are
103+
always recorded.
104+
105+
## Custom spans for business logic
106+
107+
Use the standard OpenTelemetry API; no Shiny-specific setup needed:
108+
109+
```python
110+
from opentelemetry import trace
111+
112+
tracer = trace.get_tracer(__name__)
113+
114+
@reactive.calc
115+
def expensive_computation():
116+
with tracer.start_as_current_span("database_query") as span:
117+
result = run_query()
118+
span.set_attribute("query.rows", len(result))
119+
return result
120+
```
121+
122+
## Common mistakes
123+
124+
- Calling `trace.set_tracer_provider()` in app code under
125+
`opentelemetry-instrument` → logs `Overriding of current TracerProvider is
126+
not allowed` and is ignored. Configure via the wrapper + env vars instead.
127+
(Exception: SDKs that manage OTel themselves, e.g. `logfire.configure()`
128+
then run `shiny run app.py` directly, without the wrapper.)
129+
- `@otel.suppress` placed **above** `@reactive.calc`/`@render.*``TypeError`.
130+
It must wrap the plain function, not the reactive object.
131+
- Suppressing at runtime with `with otel.suppress():` inside a reactive body →
132+
no effect on that reactive's Shiny spans; the level was captured at creation.
133+
- Traces show `service.name: unknown_service` → set `OTEL_SERVICE_NAME`.
134+
- No Shiny spans at all → check the app was launched under
135+
`opentelemetry-instrument`, and that `SHINY_OTEL_COLLECT` is not `none`.
136+
- Too much overhead in production → lower `SHINY_OTEL_COLLECT` to `session` or
137+
`reactive_update`, and/or sample with
138+
`OTEL_TRACES_SAMPLER=parentbased_traceidratio` + `OTEL_TRACES_SAMPLER_ARG=0.1`.
139+
140+
See `shiny/otel/__init__.py`'s module docs and `examples/open-telemetry/` in
141+
the shiny repo for backend-specific configuration (Jaeger, Logfire, Honeycomb,
142+
Datadog, New Relic).

shiny/.agents/skills/testing/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ auto-retry.
1717
## Setup
1818

1919
```bash
20-
pip install pytest playwright pytest-playwright
20+
uv pip install pytest playwright pytest-playwright
2121
playwright install chromium
2222
```
2323

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Guard that bundled Agent Skills stay in sync with the public APIs they document.
2+
3+
Bundled skills (``shiny/.agents/skills/``) are user-facing documentation, but
4+
nothing at runtime fails if the code they describe is renamed or removed -- the
5+
skill just silently goes stale. These tests assert that the API surface a skill
6+
documents is still mentioned in its ``SKILL.md``, so renames and removals fail
7+
loudly here. They check presence only, not prose accuracy.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
14+
import shiny.otel
15+
from shiny.otel._collect import OtelCollectLevel
16+
17+
REPO_ROOT = Path(__file__).parents[2]
18+
SKILLS_DIR = REPO_ROOT / "shiny" / ".agents" / "skills"
19+
20+
21+
def skill_md_text(name: str) -> str:
22+
return (SKILLS_DIR / name / "SKILL.md").read_text()
23+
24+
25+
def test_otel_skill_mentions_public_exports() -> None:
26+
text = skill_md_text("otel")
27+
for export in shiny.otel.__all__:
28+
assert f"otel.{export}" in text, (
29+
f"shiny.otel.__all__ export `{export}` is not mentioned in the otel "
30+
"SKILL.md -- update the skill to match the public API."
31+
)
32+
33+
34+
def test_otel_skill_mentions_collect_levels() -> None:
35+
text = skill_md_text("otel")
36+
assert "SHINY_OTEL_COLLECT" in text
37+
for level in OtelCollectLevel:
38+
assert f"`{level.name.lower()}`" in text, (
39+
f"OtelCollectLevel.{level.name} is not documented in the otel "
40+
"SKILL.md's collection-levels table."
41+
)

0 commit comments

Comments
 (0)