Skip to content

Commit 0a8f604

Browse files
authored
feat(otel): Zero-code auto-instrumentation via opentelemetry-distro (#2349)
1 parent 3e5f40a commit 0a8f604

7 files changed

Lines changed: 282 additions & 190 deletions

File tree

CHANGELOG.md

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

3232
### Improvements
3333

34+
* The `shiny[otel]` optional dependency group now includes `opentelemetry-distro[otlp]`, so OpenTelemetry zero-code auto-instrumentation works out of the box: `opentelemetry-instrument shiny run app.py`. This is now the documented standard way to enable OpenTelemetry — the docs and the `examples/open-telemetry/` example no longer configure providers inside the app (in-code `set_tracer_provider()` setup is silently ignored when a provider is already installed, e.g. under `opentelemetry-instrument`). The OTLP exporters are also included, making it easy to switch between gRPC and HTTP export via standard `OTEL_*` environment variables. Note that `opentelemetry-distro` pins `opentelemetry-sdk` to a matching minor version, so if you combine `shiny[otel]` with other packages that pin the OpenTelemetry SDK (e.g. `logfire`), the resolver may need matching versions. (#2349)
35+
3436
* `@add_example()` (internal docs decorator) gained an `example_name=` parameter that looks up the example in the nearest `api-examples/` directory, and all `ex_dir=` call sites that pointed inside an `api-examples/` tree were migrated to it. `ex_dir=` remains only for examples outside the nearest `api-examples/` tree. This also fixed nine call sites that passed the example name positionally (where it was silently treated as `app_file=`), so their examples were missing from the generated docs. (#2328)
3537

3638
* Playwright's `OutputDataFrame.set_filter()` controller now supports multi-column filters (#2093)

examples/open-telemetry/README.md

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,28 @@ pip install -r requirements.txt
154154

155155
### Run the App
156156

157+
Run the app under OpenTelemetry zero-code auto-instrumentation
158+
(`opentelemetry-instrument` ships with `shiny[otel]`), printing spans and log
159+
events to the console:
160+
161+
```bash
162+
opentelemetry-instrument --traces_exporter console --logs_exporter console \
163+
--metrics_exporter none shiny run app.py
164+
```
165+
166+
To export to an observability backend instead, drop the exporter flags and use
167+
standard `OTEL_*` environment variables (OTLP is the default exporter):
168+
157169
```bash
158-
python app.py
170+
OTEL_SERVICE_NAME=my-shiny-app opentelemetry-instrument shiny run app.py
159171
```
160172

173+
Note: the app itself intentionally contains **no** OpenTelemetry setup code.
174+
Instrumentation is an operational concern applied at launch. Configuring providers
175+
inside the app (`trace.set_tracer_provider(...)`) is discouraged: providers can only
176+
be installed once per process, so in-code setup conflicts with — and is silently
177+
ignored under — external instrumentation.
178+
161179
The app will open in your browser. As you interact with the buttons:
162180

163181
- **"Compute (Normal Telemetry)"**: Creates Shiny spans for reactive execution
@@ -170,8 +188,8 @@ Watch the console output to see which spans are created.
170188
You can set the default collection level via environment variable:
171189

172190
```bash
173-
export SHINY_OTEL_COLLECT=session
174-
python app.py
191+
SHINY_OTEL_COLLECT=session opentelemetry-instrument --traces_exporter console \
192+
--logs_exporter console --metrics_exporter none shiny run app.py
175193
```
176194

177195
The `otel.suppress` decorator and context manager will override this default.
@@ -180,10 +198,15 @@ The `otel.suppress` decorator and context manager will override this default.
180198

181199
### Default Collection Level
182200

183-
The default collection level is set by:
201+
The default collection level is set by the `SHINY_OTEL_COLLECT` environment variable
202+
(defaults to `all`).
184203

185-
1. The `SHINY_OTEL_COLLECT` environment variable (defaults to `all`)
186-
2. Can be overridden programmatically with `otel.suppress` / `otel.suppress()`
204+
`otel.suppress` and `otel.collect` are *absolute* per-object overrides that take
205+
precedence over the global level: `suppress` forces telemetry off even when the
206+
global level is `all`, and `collect` forces it on even when the global level is
207+
lower — running this app with `SHINY_OTEL_COLLECT=session` still produces spans for
208+
the `otel.collect` card. Infrastructure spans (`session_start`, `session_end`,
209+
`reactive_update`) follow only the environment variable.
187210

188211
### Span Hierarchy
189212

examples/open-telemetry/app.py

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,43 @@
66
2. Automatic logging of reactive value updates
77
3. How `otel.suppress` suppresses both Shiny's internal spans and value logs
88
9-
Run with:
10-
SHINY_OTEL_COLLECT=all python app.py
9+
Run under OpenTelemetry zero-code auto-instrumentation (installed with
10+
`pip install "shiny[otel]"`), printing spans and log events to the console:
1111
12-
Or with shinylive:
13-
shinylive export . site
14-
python -m http.server --directory site 8008
12+
opentelemetry-instrument --traces_exporter console --logs_exporter console \\
13+
--metrics_exporter none shiny run app.py
14+
15+
To see how the global collect level interacts with `otel.suppress` / `otel.collect`,
16+
lower it with `SHINY_OTEL_COLLECT` (the app displays the active level):
17+
18+
SHINY_OTEL_COLLECT=session opentelemetry-instrument --traces_exporter console \\
19+
--logs_exporter console --metrics_exporter none shiny run app.py
20+
21+
At `session` level the "Normal" card stops producing spans, but the
22+
`otel.collect` card still does -- see the comment on `with otel.collect():`
23+
below for why.
1524
1625
Watch the console for spans and log events as you interact with the app.
1726
"""
1827

19-
# Set up OpenTelemetry logging to the console
20-
from opentelemetry import trace
21-
from opentelemetry._logs import set_logger_provider
22-
from opentelemetry.sdk._logs import LoggerProvider
23-
from opentelemetry.sdk._logs.export import (
24-
ConsoleLogRecordExporter,
25-
SimpleLogRecordProcessor,
26-
)
27-
from opentelemetry.sdk.trace import TracerProvider
28-
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
29-
3028
from shiny import App, otel, reactive, render, ui
3129

32-
# Set up debug OpenTelemetry tracing/logging to the console
33-
# NOT recommended for production use
34-
trace_provider = TracerProvider()
35-
trace_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
36-
trace.set_tracer_provider(trace_provider)
37-
log_provider = LoggerProvider()
38-
log_provider.add_log_record_processor(
39-
SimpleLogRecordProcessor(ConsoleLogRecordExporter())
40-
)
41-
set_logger_provider(log_provider)
42-
43-
# # Typical setup behavior:
44-
# import logfire
45-
# logfire.configure(
46-
# token="fake-token-for-demo",
47-
# service_name="shiny-open-telemetry-demo",
48-
# environment="development",
49-
# )
30+
# This app intentionally contains NO OpenTelemetry setup code. Instrumentation
31+
# is an operational concern: apply it at launch with `opentelemetry-instrument`
32+
# (see the module docstring above) and configure exporters via its CLI flags or
33+
# standard `OTEL_*` environment variables. Configuring providers inside the app
34+
# (`trace.set_tracer_provider(...)`) is discouraged -- providers can only be
35+
# installed once per process, so code-based setup conflicts with (and is ignored
36+
# under) external instrumentation.
5037

5138
app_ui = ui.page_fluid(
5239
ui.h2("OpenTelemetry: Collection Control & Value Logging"),
53-
ui.markdown("""
40+
ui.markdown(f"""
5441
This demo shows how `otel.suppress` controls **Shiny's internal spans and value logs**.
5542
43+
Global collect level: **`{otel.get_level().name.lower()}`**
44+
(set via `SHINY_OTEL_COLLECT`; defaults to `all`)
45+
5646
Watch the console to see:
5747
- **Spans**: Session lifecycle, reactive execution
5848
- **Logs**: Reactive value updates with source references
@@ -88,7 +78,8 @@
8878
ui.markdown("""
8979
**Telemetry:** ✅ Shiny spans + value logs
9080
Uses `@otel.collect` to re-enable Shiny telemetry inside
91-
a broad `otel.suppress()` block.
81+
a broad `otel.suppress()` block — even when the global
82+
`SHINY_OTEL_COLLECT` level is lower.
9283
"""),
9384
),
9485
),
@@ -147,8 +138,17 @@ def private_counter_display():
147138
counter_val = private_counter()
148139
return f"Slider: {slider_val}\nCounter: {counter_val}\n\n❌ No telemetry"
149140

141+
# Demonstrates re-enabling telemetry within a suppressed context.
142+
#
143+
# Note: `otel.collect` is an ABSOLUTE per-object setting, not a relative
144+
# one. Reactive objects created inside this block capture level ALL at
145+
# creation time, which takes precedence over the global level -- so
146+
# `output collect_counter_display` still produces spans even when
147+
# SHINY_OTEL_COLLECT is set lower (e.g. `session`). `otel.suppress` is
148+
# symmetric: it forces telemetry off even when the global level is
149+
# `all`. Use `collect` to keep a few critical outputs observable while
150+
# the rest of the app runs quiet.
150151
with otel.collect():
151-
# Demonstrates re-enabling telemetry within a suppressed context
152152
collect_counter = reactive.value(0)
153153

154154
@reactive.effect

pyproject.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,14 @@ dependencies = [
8080

8181
[project.optional-dependencies]
8282
theme = ["libsass>=0.23.0", "brand_yml>=0.2.0"]
83-
otel = ["opentelemetry-sdk>=1.24.0"]
83+
otel = [
84+
# 1.24.0+ required: same sanitized-stack-trace floor as opentelemetry-api above
85+
"opentelemetry-sdk>=1.24.0",
86+
# Enables zero-code auto-instrumentation via `opentelemetry-instrument shiny run
87+
# app.py`. The [otlp] extra provides the default `otlp` exporter so the bare
88+
# command works without extra flags (and allows switching between gRPC/HTTP).
89+
"opentelemetry-distro[otlp]>=0.45b0",
90+
]
8491
test = [
8592
"pytest>=6.2.4",
8693
"pytest-asyncio>=0.17.2",

0 commit comments

Comments
 (0)