mcp-telemetry 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ """mcp-telemetry — zero-config observability for MCP servers.
2
+
3
+ Core is stdlib-only. Optional exporters:
4
+ - OTLP/HTTP (needs httpx) -> pip install mcp-telemetry[otlp]
5
+ - built-in JSONL + live TUI terminal panel (no deps)
6
+
7
+ Extended surface — propagation, sampling, metrics, OTel provider, replay:
8
+ from mcp_telemetry.propagator import generate_traceparent, parse_traceparent
9
+ from mcp_telemetry.sampler import parent_based, ratio, rate_limited
10
+ from mcp_telemetry.metrics import Registry, metrics_from_store
11
+ from mcp_telemetry.otel_provider import OtelProvider
12
+ from mcp_telemetry.fastmcp import patch_fastmcp, make_server
13
+ mcp-trace --replay store.jsonl --console
14
+ """
15
+ from mcp_telemetry.api import (
16
+ auto,
17
+ instrument,
18
+ session,
19
+ span,
20
+ trace_store,
21
+ )
22
+ from mcp_telemetry import monkey
23
+ from mcp_telemetry.monkey import wrap_tool_call, patch_mcp_sdk
24
+ from mcp_telemetry.store import TraceStore, default_store, set_default_store
25
+
26
+ __all__ = [
27
+ "auto",
28
+ "instrument",
29
+ "session",
30
+ "span",
31
+ "trace_store",
32
+ "monkey",
33
+ "wrap_tool_call",
34
+ "patch_mcp_sdk",
35
+ "TraceStore",
36
+ "default_store",
37
+ "set_default_store",
38
+ "__version__",
39
+ ]
40
+
41
+ __version__ = "0.2.0"
mcp_telemetry/api.py ADDED
@@ -0,0 +1,98 @@
1
+ """Public API for mcp-telemetry.
2
+
3
+ import mcp_telemetry as mt
4
+ mt.auto() # patch official mcp SDK + fanout to JSONL
5
+ mt.instrument(exporter=..., scrubbed=True)
6
+
7
+ with mt.span("chat"): # manual span around an LLM call
8
+ ...
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from contextlib import contextmanager
15
+ from typing import Iterator, Optional
16
+
17
+ from mcp_telemetry import context as _ctx
18
+ from mcp_telemetry import exporters
19
+ from mcp_telemetry import model
20
+ from mcp_telemetry import redact
21
+ from mcp_telemetry.monkey import is_patched, patch_mcp_sdk, wrap_tool_call
22
+ from mcp_telemetry.store import TraceStore, default_store, set_default_store
23
+
24
+ _ENABLED = False
25
+
26
+
27
+ def instrument(
28
+ store: Optional[TraceStore] = None,
29
+ trace_exporter: Optional[exporters.Exporter] = None,
30
+ enabled: bool = True,
31
+ ) -> None:
32
+ """Configure the active store + exporter fanout."""
33
+ global _ENABLED
34
+ eff = store or default_store()
35
+ if store is not None:
36
+ set_default_store(store)
37
+ _ENABLED = enabled
38
+ eff.on_record = exporters.DEFAULT.export if enabled else None
39
+ if trace_exporter is not None:
40
+ exporters.DEFAULT._targets = [trace_exporter]
41
+
42
+
43
+ def auto(store: Optional[TraceStore] = None, enabled: bool = True) -> bool:
44
+ """All-in-one: enforce defaults + patch the official mcp SDK.
45
+
46
+ If the mcp SDK is missing, patch is a no-op and auto() still returns True
47
+ so callers can instrument() without knowing the runtime shape.
48
+ """
49
+ instrument(store=store, enabled=enabled)
50
+ if enabled and "MCP_TELEMETRY_DISABLE" not in os.environ:
51
+ patch_mcp_sdk(store)
52
+ if enabled and sys.stderr.isatty():
53
+ exporters.DEFAULT.add(exporters.TextExporter())
54
+ return patch_mcp_sdk(store) or True
55
+
56
+
57
+ def session(trace_id: Optional[str] = None):
58
+ """Context manager: start a trace, attach all nested spans to it."""
59
+ store = default_store()
60
+ trace = store.start(trace_id)
61
+ prev = _ctx.get_current()
62
+ _ctx.set_current(trace)
63
+
64
+ @contextmanager
65
+ def _scope() -> Iterator["TraceStore"]:
66
+ try:
67
+ yield store
68
+ finally:
69
+ store.record(trace)
70
+ _ctx.set_current(prev)
71
+
72
+ return _scope()
73
+
74
+
75
+ @contextmanager
76
+ def span(name: str, kind: str = model.SPAN_AGENT) -> Iterator[model.Span]:
77
+ """Manual span. Attaches to the ambient session trace if one is open,
78
+ otherwise starts a standalone trace."""
79
+ store = default_store()
80
+ current = _ctx.get_current()
81
+ if current is not None:
82
+ trace = current
83
+ else:
84
+ trace = store.start()
85
+ sp = trace._make_span(name, None, kind=kind)
86
+ try:
87
+ yield sp
88
+ except Exception as exc:
89
+ sp.finish("ERROR", f"{type(exc).__name__}: {exc}")
90
+ store.record(trace, finished=current is None)
91
+ raise
92
+ else:
93
+ sp.finish("OK")
94
+ store.record(trace, finished=current is None)
95
+
96
+
97
+ def trace_store() -> TraceStore:
98
+ return default_store()
mcp_telemetry/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """CLI: mcp-trace — render, tail, and clear the local telemetry feed.
2
+
3
+ mcp-trace # last 25 spans in ANSI table
4
+ mcp-trace --tail # follow the JSONL feed
5
+ mcp-trace --json # dump raw JSONL to stdout (pipe friendly)
6
+ mcp-trace --clear # truncate the local JSONL log
7
+ mcp-trace --replay <file> [--console] [--max N] # replay recorded traces through the exporter stack
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import time
14
+
15
+
16
+ def _read(path: str) -> list[dict]:
17
+ rows: list[dict] = []
18
+ try:
19
+ with open(path, encoding="utf-8") as fh:
20
+ for line in fh:
21
+ line = line.strip()
22
+ if line:
23
+ rows.append(json.loads(line))
24
+ except FileNotFoundError:
25
+ pass
26
+ return rows
27
+
28
+
29
+ def _render(rows: list[dict]) -> None:
30
+ for tr in rows[-25:]:
31
+ for s in tr["spans"]:
32
+ hit = "OK" if s["status"] == "OK" else "ERR"
33
+ color = 32 if hit == "OK" else 31
34
+ print(
35
+ f"\x1b[36m{tr['id']}\x1b[0m "
36
+ f"\x1b[1m{s['tool']}\x1b[0m "
37
+ f"\x1b[{color}m{hit}\x1b[0m "
38
+ f"\x1b[33m{s.get('latency_ms') or 0:.1f}ms\x1b[0m "
39
+ f"in#{s.get('input_hash') or '-'} "
40
+ + (f"\x1b[31m{s['error']}\x1b[0m" if s.get("error") else "")
41
+ )
42
+
43
+
44
+ def main() -> int:
45
+ parser = argparse.ArgumentParser(prog="mcp-trace", description="Render the mcp-telemetry local feed")
46
+ parser.add_argument("--tail", action="store_true", help="follow the JSONL log")
47
+ parser.add_argument("--json", action="store_true", help="dump raw records as NDJSON")
48
+ parser.add_argument("--clear", action="store_true", help="clear the local log")
49
+ parser.add_argument("--replay", metavar="FILE", help="replay a recorded JSONL store through the exporter stack")
50
+ parser.add_argument("--console", action="store_true", help="with --replay, print OTLP-shaped traces")
51
+ parser.add_argument("--max", type=int, metavar="N", help="with --replay, cap the number of traces")
52
+ parser.add_argument("--path", default="mcp-telemetry.jsonl", help="log file path")
53
+ args = parser.parse_args()
54
+
55
+ if args.replay is not None:
56
+ from mcp_telemetry.replay import main as replay_main
57
+
58
+ sub = [args.replay]
59
+ if args.console:
60
+ sub.append("--console")
61
+ if args.max:
62
+ sub += ["--max", str(args.max)]
63
+ return replay_main(sub)
64
+
65
+ if args.clear:
66
+ open(args.path, "w", encoding="utf-8").close() # noqa: SIM115
67
+ return 0
68
+
69
+ if args.tail:
70
+ seen = 0
71
+ while True:
72
+ rows = _read(args.path)
73
+ for tr in rows[seen:]:
74
+ for s in tr["spans"]:
75
+ print(json.dumps({"trace": tr["id"], **s}))
76
+ seen += 1
77
+ time.sleep(0.5)
78
+ return 0
79
+
80
+ rows = _read(args.path)
81
+ if args.json:
82
+ for tr in rows:
83
+ print(json.dumps(tr))
84
+ return 0
85
+ _render(rows)
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ raise SystemExit(main())
@@ -0,0 +1,17 @@
1
+ """Ambient trace cursor shared across api.py and monkey.py without cycles."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from mcp_telemetry.model import Trace
7
+
8
+ _current: Optional[Trace] = None
9
+
10
+
11
+ def set_current(trace: Optional[Trace]) -> None:
12
+ global _current
13
+ _current = trace
14
+
15
+
16
+ def get_current() -> Optional[Trace]:
17
+ return _current
@@ -0,0 +1,117 @@
1
+ """Span payload builders + exporters.
2
+
3
+ Exporters implement `dump(trace) -> str` so they compose cleanly:
4
+ - JsonlExporter -> ndjson for pipes / local debug
5
+ - OtlpExporter -> OTLP/HTTP JSON payload (httpx optional extra)
6
+ - TextExporter -> ANSI table for the live TUI panel (stdlib only)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ import threading
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Protocol
16
+
17
+ from mcp_telemetry.model import Trace
18
+
19
+
20
+ class Exporter(Protocol):
21
+ def export(self, trace: Trace) -> None: ...
22
+
23
+
24
+ @dataclass
25
+ class JsonlExporter:
26
+ path: str = "mcp-telemetry.jsonl"
27
+ _lock: threading.Lock = field(default_factory=threading.Lock)
28
+
29
+ def export(self, trace: Trace) -> None:
30
+ with self._lock:
31
+ with open(self.path, "a", encoding="utf-8") as fh:
32
+ fh.write(json.dumps(trace.to_dict()) + "\n")
33
+
34
+
35
+ @dataclass
36
+ class TextExporter:
37
+ """Minimal ANSI 256-color renderer. No external deps."""
38
+
39
+ _lock: threading.Lock = field(default_factory=threading.Lock)
40
+
41
+ def export(self, trace: Trace) -> None:
42
+ rows = []
43
+ for s in trace.spans:
44
+ status = "\x1b[32mOK\x1b[0m" if s.status == "OK" else f"\x1b[31m{s.status}\x1b[0m"
45
+ lines = [
46
+ f" \x1b[36m{trace.id}\x1b[0m {s.tool or s.name} {status} "
47
+ f"\x1b[33m{s.latency_ms or 0:.1f}ms\x1b[0m"
48
+ ]
49
+ if s.input_hash:
50
+ lines.append(f" input#{s.input_hash} {s.server or ''}")
51
+ if s.error:
52
+ lines.append(f" \x1b[31m{s.error}\x1b[0m")
53
+ rows.extend(lines)
54
+ payload = "\n".join(rows)
55
+ with self._lock:
56
+ print(payload, file=sys.stderr)
57
+
58
+
59
+ @dataclass
60
+ class OtlpExporter:
61
+ """OTLP/HTTP JSON (v1 logs/bridge). Requires httpx (extra: [otlp]).
62
+
63
+ Sends one span record per tool call with gen_ai keys in attributes.
64
+ """
65
+
66
+ endpoint: str = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/logs")
67
+ service_name: str = os.environ.get("OTEL_SERVICE_NAME", "mcp-server")
68
+ headers: dict[str, str] = field(default_factory=lambda: {"Content-Type": "application/json"})
69
+
70
+ def export(self, trace: Trace) -> None:
71
+ try:
72
+ import httpx # type: ignore
73
+ except ImportError as exc: # pragma: no cover
74
+ raise RuntimeError("OTLP export requires httpx: pip install 'mcp-telemetry[otlp]'") from exc
75
+
76
+ records = []
77
+ for s in trace.spans:
78
+ attrs = {
79
+ "gen_ai.tool.name": s.tool or s.name,
80
+ "gen_ai.server.name": s.server or "",
81
+ "gen_ai.input.bytes": s.input_hash or "",
82
+ "gen_ai.usage.input_tokens": s.tokens_in or 0,
83
+ "gen_ai.usage.output_tokens": s.tokens_out or 0,
84
+ "gen_ai.operation.span.id": trace.id,
85
+ "gen_ai.operation.name": s.name,
86
+ }
87
+ if s.error:
88
+ attrs["gen_ai.error.type"] = s.error
89
+ records.append(
90
+ {
91
+ "scope": {"name": "mcp-telemetry", "version": "0.1.0"},
92
+ "resource": {"service.name": self.service_name},
93
+ "severity_text": "ERROR" if s.error else "INFO",
94
+ "body": {"string_value": f"{s.tool} {s.status}"},
95
+ "attributes": attrs,
96
+ "timeUnixNano": str(int((s.end or s.start) * 1_000_000_000)),
97
+ }
98
+ )
99
+ httpx.post(self.endpoint, json={"resourceLogs": [{"scopeLogs": [{"logRecords": records}]}]}, headers=self.headers, timeout=5.0)
100
+
101
+
102
+ class Fanout:
103
+ def __init__(self) -> None:
104
+ self._targets: list[Exporter] = [JsonlExporter()]
105
+
106
+ def add(self, exporter: Exporter) -> None:
107
+ self._targets.append(exporter)
108
+
109
+ def export(self, trace: Trace) -> None:
110
+ for t in self._targets:
111
+ try:
112
+ t.export(trace)
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ DEFAULT = Fanout()
@@ -0,0 +1,54 @@
1
+ """FastMCP integration — one-call instrumentation.
2
+
3
+ `patch_fastmcp(server)` wraps every tool handler the FastMCP server registers
4
+ so each invocation emits a GenAI-style trace span, deduped per tool, feeding
5
+ the active TraceStore/provider. No SDK import required to USE this module; the
6
+ SDK is fetched lazily so `pip install mcp-telemetry` alone is harmless.
7
+
8
+ If you do not run FastMCP, call `patch_mcp_sdk()` from the core to auto-wrap
9
+ the stdlib/sdk tool handlers instead — the same spans drop out underneath.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Callable, Optional
14
+
15
+ from mcp_telemetry.api import span
16
+ from mcp_telemetry.monkey import wrap_tool_call
17
+
18
+
19
+ def patch_fastmcp(server: Any, *, store: Any = None) -> int:
20
+ """Wrap all handlers currently registered on a FastMCP server object. The
21
+ instance may be post-`@server.tool(...)` decorated — we walk `server._tool_manager`."""
22
+ try:
23
+ import fastmcp # noqa: F401
24
+ except ImportError:
25
+ raise RuntimeError("fastmcp not installed — import it or pip install fastmcp")
26
+
27
+ tool_manager = getattr(server, "_tool_manager", None)
28
+ if tool_manager is None:
29
+ return 0
30
+ tools = getattr(tool_manager, "_tools", None) or getattr(tool_manager, "tools", {})
31
+ wrapped = 0
32
+ for tool in tools.values() if isinstance(tools, dict) else tools:
33
+ fn = getattr(tool, "fn", None) or getattr(tool, "func", None)
34
+ if fn is None:
35
+ continue
36
+ name = getattr(tool, "name", None) or getattr(fn, "__name__", "tool")
37
+ tool.fn = wrap_tool_call(fn, store=store, tool_name=name) # type: ignore[attr-defined]
38
+ wrapped += 1
39
+ return wrapped
40
+
41
+
42
+ def make_server(name: str, store: Any) -> Any:
43
+ """Slim FastMCP-compatible shim for a server name: emits a trace span per
44
+ tool invocation and records it. Use when you want instrumentation without
45
+ the SDK present — identical span shape, so replays report the same way."""
46
+ # class bodies do not close over enclosing function locals — build via type()
47
+ def tool(fn: Callable[..., Any], *, tool_name: Optional[str] = None) -> Callable[..., Any]:
48
+ return wrap_tool_call(fn, name=tool_name or getattr(fn, "__name__", "tool"), store=store)
49
+
50
+ shim = type("FastMcpShim", (), {"name": name, "tool": staticmethod(tool)})()
51
+ return shim
52
+
53
+
54
+ __all__ = ["patch_fastmcp", "make_server"]
@@ -0,0 +1,157 @@
1
+ """In-process metrics registry — counters + latency histograms, zero-dep.
2
+
3
+ Aggregates tool-call counts and latency distributions in memory and exposes:
4
+ - snapshot() plain dict for JSON / admin dashboards
5
+ - openmetrics() Prometheus/OpenTelemetry text exposition
6
+ - metrics_from_store() auto-wire a registry to a TraceStore via on_record
7
+ Render negative-space: no units, no timestamps — just numbers, honestly kept.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ from typing import Any, Optional
13
+
14
+ _BUCKETS_MS = (1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000)
15
+
16
+ # fine-grain buckets for UI-place histograms; coarser for exporter cost
17
+ _BUCKETS_COARSE = (10, 50, 100, 500, 1000, 5000)
18
+
19
+
20
+ class Counter:
21
+ def __init__(self, name: str, labels: Optional[dict[str, str]] = None) -> None:
22
+ self.name = name
23
+ self.labels = labels or {}
24
+ self.value = 0
25
+
26
+ def inc(self, amount: int = 1) -> None:
27
+ self.value += amount
28
+
29
+
30
+ class Histogram:
31
+ def __init__(self, name: str, labels: Optional[dict[str, str]] = None, buckets: tuple[float, ...] = _BUCKETS_MS) -> None:
32
+ self.name = name
33
+ self.labels = labels or {}
34
+ self.buckets = buckets
35
+ self.counts = [0] * (len(buckets) + 1)
36
+ self.sum = 0.0
37
+ self.count = 0
38
+ self.min = float("inf")
39
+ self.max = 0.0
40
+
41
+ def observe(self, value_ms: float) -> None:
42
+ self.sum += value_ms
43
+ self.count += 1
44
+ if value_ms < self.min:
45
+ self.min = value_ms
46
+ if value_ms > self.max:
47
+ self.max = value_ms
48
+ for i, b in enumerate(self.buckets):
49
+ if value_ms <= b:
50
+ self.counts[i] += 1
51
+ break
52
+ else:
53
+ self.counts[-1] += 1
54
+
55
+ def snapshot(self) -> dict[str, Any]:
56
+ cumulative = 0
57
+ cdf: dict[str, int] = {}
58
+ for i, b in enumerate(self.buckets):
59
+ cumulative += self.counts[i]
60
+ cdf[f"le_{b:g}"] = cumulative
61
+ cdf["le_+Inf"] = self.count
62
+ p50 = self.quantile(0.5)
63
+ p95 = self.quantile(0.95)
64
+ p99 = self.quantile(0.99)
65
+ return {
66
+ "count": self.count,
67
+ "sum_ms": round(self.sum, 3),
68
+ "min_ms": 0.0 if self.min == float("inf") else round(self.min, 3),
69
+ "p50_ms": round(p50, 3),
70
+ "p95_ms": round(p95, 3),
71
+ "p99_ms": round(p99, 3),
72
+ "max_ms": round(self.max, 3),
73
+ "cdf": cdf,
74
+ }
75
+
76
+ def quantile(self, q: float) -> float:
77
+ if self.count == 0:
78
+ return 0.0
79
+ target = self.count * q
80
+ acc = 0
81
+ bounds = (0.0,) + self.buckets + (float("inf"),)
82
+ for i, hi in enumerate(bounds):
83
+ acc += self.counts[i]
84
+ if acc >= target:
85
+ lo = bounds[i]
86
+ if hi == float("inf"):
87
+ return lo
88
+ return lo + (hi - lo) * 0.5
89
+ return 0.0
90
+
91
+
92
+ class Registry:
93
+ """Thread-safe metrics registry."""
94
+
95
+ def __init__(self) -> None:
96
+ self._lock = threading.Lock()
97
+ self._counters: list[Counter] = []
98
+ self._histograms: list[Histogram] = []
99
+
100
+ def counter(self, name: str, labels: Optional[dict[str, str]] = None) -> Counter:
101
+ with self._lock:
102
+ c = Counter(name, labels)
103
+ self._counters.append(c)
104
+ return c
105
+
106
+ def histogram(self, name: str, labels: Optional[dict[str, str]] = None) -> Histogram:
107
+ with self._lock:
108
+ h = Histogram(name, labels)
109
+ self._histograms.append(h)
110
+ return h
111
+
112
+ def snapshot(self) -> dict[str, Any]:
113
+ with self._lock:
114
+ counters = [{"name": c.name, "labels": c.labels, "value": c.value} for c in self._counters]
115
+ histos = [{"name": h.name, "labels": h.labels, **h.snapshot()} for h in self._histograms]
116
+ return {"counters": counters, "histograms": histos}
117
+
118
+ def openmetrics(self) -> str:
119
+ """Prometheus text exposition (0.0.1)."""
120
+ lines = ["# TYPE mcp_metric counter", "# TYPE mcp_latency histogram"]
121
+ snap = self.snapshot()
122
+ for c in snap["counters"]:
123
+ labels = ",".join(f'{k}="{v}"' for k, v in c["labels"].items())
124
+ suffix = f"{{{labels}}}" if labels else ""
125
+ lines.append(f'mcp_metric{suffix} {c["value"]}')
126
+ for h in snap["histograms"]:
127
+ base = ",".join(f'{k}="{v}"' for k, v in h["labels"].items())
128
+ for le, val in h["cdf"].items():
129
+ lbl = f"{base},le=\"{le.removeprefix('le_')}\"" if base else f"le=\"{le.removeprefix('le_')}\""
130
+ lines.append(f'mcp_latency_bucket{{{lbl}}} {val}')
131
+ suffix = f"{{{base}}}" if base else ""
132
+ lines.append(f'mcp_latency_sum{suffix} {h["sum_ms"]}')
133
+ lines.append(f'mcp_latency_count{suffix} {h["count"]}')
134
+ return "\n".join(lines) + "\n"
135
+
136
+
137
+ def metrics_from_store(store: Any) -> Registry:
138
+ """Wire a registry to a TraceStore's on_record hook: one counter per tool
139
+ status, one latency histogram per tool."""
140
+ reg = Registry()
141
+ histos: dict[str, Histogram] = {}
142
+ counters: dict[str, Counter] = {}
143
+
144
+ def on_record(trace: Any) -> None:
145
+ for span in trace.spans:
146
+ if not span.tool:
147
+ continue
148
+ key = span.tool
149
+ if key not in histos:
150
+ histos[key] = reg.histogram("mcp_tool_latency", {"tool": key})
151
+ counters[key] = reg.counter("mcp_calls", {"tool": key, "status": "OK"})
152
+ histos[key].observe(float(span.latency_ms or 0))
153
+ counters[key].inc()
154
+ counters[key].labels["status"] = span.status
155
+
156
+ store.on_record = on_record # type: ignore[attr-defined]
157
+ return reg
mcp_telemetry/model.py ADDED
@@ -0,0 +1,74 @@
1
+ """Trace/span model aligned with OpenTelemetry GenAI conventions.
2
+
3
+ Span kinds we emit, matching the OTel GenAI semantic conventions:
4
+ gen_ai.client.tool_call -> tool invocation span
5
+ gen_ai.client.op -> chat / invoke_agent spans
6
+
7
+ Every span carries: name in walrus form, stable kinds for the tool layer,
8
+ and an input *fingerprint* (hash) instead of raw payloads by default.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import dataclass, field, asdict
14
+ from typing import Any, Optional
15
+
16
+ SPAN_TOOL = "gen_ai.client.tool_call"
17
+ SPAN_CHAT = "gen_ai.client.op"
18
+ SPAN_AGENT = "gen_ai.agent.invoke"
19
+
20
+ t0 = time.time()
21
+
22
+
23
+ def _wall() -> float:
24
+ return time.time() - t0
25
+
26
+
27
+ @dataclass
28
+ class Span:
29
+ name: str
30
+ kind: str = SPAN_TOOL
31
+ tool: Optional[str] = None
32
+ server: Optional[str] = None
33
+ start: float = field(default_factory=_wall)
34
+ end: Optional[float] = None
35
+ status: str = "OK"
36
+ error: Optional[str] = None
37
+ input_hash: Optional[str] = None
38
+ latency_ms: Optional[float] = None
39
+ tokens_in: Optional[int] = None
40
+ tokens_out: Optional[int] = None
41
+ meta: dict[str, Any] = field(default_factory=dict)
42
+ parent_span_id: Optional[str] = None
43
+
44
+ def finish(self, status: str = "OK", error: Optional[str] = None) -> "Span":
45
+ self.end = _wall()
46
+ self.latency_ms = round((self.end - self.start) * 1000, 3)
47
+ self.status = status
48
+ self.error = error
49
+ return self
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ return asdict(self)
53
+
54
+
55
+ @dataclass
56
+ class Trace:
57
+ id: str
58
+ started: float = field(default_factory=_wall)
59
+ spans: list[Span] = field(default_factory=list)
60
+
61
+ def record(self, span: Span) -> None:
62
+ self.spans.append(span)
63
+
64
+ def _make_span(self, tool: str, server: Optional[str] = None, kind: str = SPAN_TOOL) -> Span:
65
+ span = Span(name=f"{tool} {kind}", tool=tool, server=server, kind=kind)
66
+ self.record(span)
67
+ return span
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {"id": self.id, "started": round(self.started, 3), "spans": [s.to_dict() for s in self.spans]}
71
+
72
+
73
+ def new_trace(trace_id: Optional[str] = None) -> Trace:
74
+ return Trace(id=trace_id or f"{int(time.time_ns())}")