Skip to content

Commit 2373154

Browse files
committed
feat(otel[libtmux]) add trace context helpers
why: Link libtmux spans into vibe-tmux-python traces and propagate trace headers to tmux subprocesses. what: - Add optional OTEL helper module with span + header capture - Inject TRACEPARENT/TRACESTATE/BAGGAGE into tmux subprocess env - Add OTEL optional deps and mypy overrides for optional imports
1 parent b218951 commit 2373154

4 files changed

Lines changed: 505 additions & 6 deletions

File tree

pyproject.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ Documentation = "https://libtmux.git-pull.com"
4949
Repository = "https://github.com/tmux-python/libtmux"
5050
Changes = "https://github.com/tmux-python/libtmux/blob/master/CHANGES"
5151

52+
[project.optional-dependencies]
53+
otel = [
54+
"opentelemetry-api",
55+
"opentelemetry-sdk",
56+
"opentelemetry-exporter-otlp",
57+
]
58+
5259
[dependency-groups]
5360
dev = [
5461
# Docs
@@ -111,6 +118,11 @@ lint = [
111118
"ruff",
112119
"mypy",
113120
]
121+
otel = [
122+
"opentelemetry-api",
123+
"opentelemetry-sdk",
124+
"opentelemetry-exporter-otlp",
125+
]
114126

115127
[project.entry-points.pytest11]
116128
libtmux = "libtmux.pytest_plugin"
@@ -127,6 +139,13 @@ files = [
127139
"tests",
128140
]
129141

142+
[[tool.mypy.overrides]]
143+
module = [
144+
"opentelemetry",
145+
"opentelemetry.*",
146+
]
147+
ignore_missing_imports = true
148+
130149
[tool.coverage.run]
131150
branch = true
132151
parallel = true

src/libtmux/common.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import logging
11+
import os
1112
import re
1213
import shutil
1314
import subprocess
@@ -259,13 +260,30 @@ def __init__(self, *args: t.Any) -> None:
259260

260261
self.cmd = cmd
261262

263+
env = None
264+
try:
265+
from libtmux.otel import current_trace_headers
266+
except Exception: # pragma: no cover - optional dependency
267+
current_trace_headers = None # type: ignore[assignment]
268+
269+
if current_trace_headers is not None:
270+
headers = current_trace_headers()
271+
if headers and headers.traceparent:
272+
env = os.environ.copy()
273+
env["TRACEPARENT"] = headers.traceparent
274+
if headers.tracestate:
275+
env["TRACESTATE"] = headers.tracestate
276+
if headers.baggage:
277+
env["BAGGAGE"] = headers.baggage
278+
262279
try:
263280
self.process = subprocess.Popen(
264281
cmd,
265282
stdout=subprocess.PIPE,
266283
stderr=subprocess.PIPE,
267284
text=True,
268285
errors="backslashreplace",
286+
env=env,
269287
)
270288
stdout, stderr = self.process.communicate()
271289
returncode = self.process.returncode

src/libtmux/otel.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""OpenTelemetry helpers for libtmux.
2+
3+
This module is intentionally lightweight and optional. When OpenTelemetry
4+
dependencies are not installed, the APIs degrade to no-ops so libtmux can
5+
still be used without OTEL configured.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import contextlib
11+
import contextvars
12+
import logging
13+
import os
14+
import typing as t
15+
from dataclasses import dataclass
16+
17+
from .__about__ import __version__
18+
19+
logger = logging.getLogger(__name__)
20+
21+
propagate = None
22+
trace = None
23+
OTLPSpanExporter = None
24+
Resource = None
25+
TracerProvider = None
26+
BatchSpanProcessor = None
27+
28+
try: # pragma: no cover - optional dependency
29+
from opentelemetry import propagate as otel_propagate, trace as otel_trace
30+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
31+
OTLPSpanExporter as otel_otlp_exporter,
32+
)
33+
from opentelemetry.sdk.resources import (
34+
Resource as otel_resource,
35+
)
36+
from opentelemetry.sdk.trace import (
37+
TracerProvider as otel_tracer_provider,
38+
)
39+
from opentelemetry.sdk.trace.export import (
40+
BatchSpanProcessor as otel_batch_span_processor,
41+
)
42+
except Exception: # pragma: no cover - optional dependency
43+
propagate = None
44+
trace = None
45+
OTLPSpanExporter = None
46+
Resource = None
47+
TracerProvider = None
48+
BatchSpanProcessor = None
49+
else:
50+
propagate = otel_propagate
51+
trace = otel_trace
52+
OTLPSpanExporter = otel_otlp_exporter
53+
Resource = otel_resource
54+
TracerProvider = otel_tracer_provider
55+
BatchSpanProcessor = otel_batch_span_processor
56+
57+
58+
@dataclass(frozen=True)
59+
class TraceHeaders:
60+
"""Trace headers captured for tmux subprocess propagation."""
61+
62+
traceparent: str
63+
tracestate: str | None = None
64+
baggage: str | None = None
65+
66+
67+
_TRACE_HEADERS_STACK: contextvars.ContextVar[tuple[TraceHeaders, ...]] = (
68+
contextvars.ContextVar("libtmux_trace_headers", default=())
69+
)
70+
_OTEL_READY = False
71+
72+
73+
def _env_flag(name: str) -> bool | None:
74+
raw = os.environ.get(name)
75+
if raw is None:
76+
return None
77+
value = raw.strip().lower()
78+
if not value:
79+
return None
80+
if value in {"1", "true"}:
81+
return True
82+
if value in {"0", "false"}:
83+
return False
84+
return None
85+
86+
87+
def otel_enabled() -> bool:
88+
"""Return True when OTEL export is enabled by environment.
89+
90+
Examples
91+
--------
92+
>>> from libtmux.otel import otel_enabled
93+
>>> _ = otel_enabled()
94+
"""
95+
if _env_flag("VIBE_TMUX_OTEL") is not None:
96+
return _env_flag("VIBE_TMUX_OTEL") is True
97+
return bool(
98+
os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
99+
or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
100+
)
101+
102+
103+
def _ensure_provider() -> bool:
104+
global _OTEL_READY
105+
if _OTEL_READY:
106+
return True
107+
if (
108+
trace is None
109+
or propagate is None
110+
or Resource is None
111+
or TracerProvider is None
112+
or BatchSpanProcessor is None
113+
or OTLPSpanExporter is None
114+
):
115+
return False
116+
if not otel_enabled():
117+
return False
118+
119+
provider = trace.get_tracer_provider()
120+
if provider.__class__.__name__ != "ProxyTracerProvider":
121+
_OTEL_READY = True
122+
return True
123+
124+
try:
125+
resource = Resource.create(
126+
{
127+
"service.name": "libtmux",
128+
"service.version": __version__,
129+
}
130+
)
131+
tracer_provider = TracerProvider(resource=resource)
132+
exporter = OTLPSpanExporter()
133+
tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
134+
trace.set_tracer_provider(tracer_provider)
135+
except Exception: # pragma: no cover - optional dependency
136+
logger.debug("libtmux otel init failed", exc_info=True)
137+
return False
138+
else:
139+
_OTEL_READY = True
140+
return True
141+
142+
143+
def _inject_headers() -> TraceHeaders | None:
144+
if propagate is None:
145+
return None
146+
carrier: dict[str, str] = {}
147+
propagate.inject(carrier)
148+
traceparent = carrier.get("traceparent")
149+
if not traceparent:
150+
return None
151+
return TraceHeaders(
152+
traceparent=traceparent,
153+
tracestate=carrier.get("tracestate"),
154+
baggage=carrier.get("baggage"),
155+
)
156+
157+
158+
def current_trace_headers() -> TraceHeaders | None:
159+
"""Return the current trace headers, if any.
160+
161+
Examples
162+
--------
163+
>>> from libtmux.otel import current_trace_headers
164+
>>> _ = current_trace_headers()
165+
"""
166+
stack = _TRACE_HEADERS_STACK.get()
167+
if not stack:
168+
return None
169+
return stack[-1]
170+
171+
172+
def _push_headers(headers: TraceHeaders) -> contextvars.Token[tuple[TraceHeaders, ...]]:
173+
stack = list(_TRACE_HEADERS_STACK.get())
174+
stack.append(headers)
175+
return _TRACE_HEADERS_STACK.set(tuple(stack))
176+
177+
178+
@contextlib.contextmanager
179+
def start_span(name: str) -> t.Iterator[t.Any]:
180+
"""Start a span and set trace headers for subprocess propagation.
181+
182+
Examples
183+
--------
184+
>>> from libtmux.otel import start_span, current_trace_headers
185+
>>> with start_span("libtmux.test"):
186+
... _ = current_trace_headers()
187+
"""
188+
if trace is None or propagate is None:
189+
yield None
190+
return
191+
if not _ensure_provider():
192+
yield None
193+
return
194+
tracer = trace.get_tracer("libtmux")
195+
with tracer.start_as_current_span(name) as span:
196+
token = None
197+
headers = _inject_headers()
198+
if headers is not None:
199+
token = _push_headers(headers)
200+
try:
201+
yield span
202+
finally:
203+
if token is not None:
204+
_TRACE_HEADERS_STACK.reset(token)
205+
206+
207+
__all__ = [
208+
"TraceHeaders",
209+
"current_trace_headers",
210+
"start_span",
211+
]

0 commit comments

Comments
 (0)