|
| 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). |
0 commit comments