coding-sh 0.1.3__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.
coding_sh/_trace.py ADDED
@@ -0,0 +1,641 @@
1
+ """Traces, spans, sessions and the AgentEvent layout.
2
+
3
+ Event layout matches what src/otel/map.ts produces for the same OTel spans, so an app traced
4
+ with this SDK and the same app traced with OTel gen_ai spans yield equal Session.stats:
5
+
6
+ trace (root) session.start {name, traceId, userId?, metadata?}
7
+ user.message {text} (trace input, when given)
8
+ ...children...
9
+ turn.end {ok, error?, durationMs, output?}
10
+ session.end {reason, error?} (only when the session is this trace)
11
+ model call llm.call {provider, requestModel, responseModel, ttftMs, stopReason, ok,
12
+ error?, toolCalls?} + usage, model, durationMs
13
+ assistant.message {text}
14
+ span("tool", n) tool.start {tool, toolUseId, input} . tool.end {tool, toolUseId, ok, durationMs, output}
15
+ span("step", n) span {name, ok, error?} (one event, durationMs on the event)
16
+
17
+ A model call or span made outside any trace gets an implicit root, exactly like a root OTel span.
18
+ Sessions: each top-level trace (or untraced call) is its own session, unless a session id is set
19
+ with trace(session_id=...) or with_session(...); then traces are turns of that conversation.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextvars
25
+ import functools
26
+ import inspect
27
+ import os
28
+ import threading
29
+ import time
30
+ import uuid
31
+ from typing import Any, Callable, Dict, Optional, TypeVar
32
+
33
+ from ._client import Config, Transport, dumps
34
+
35
+ F = TypeVar("F", bound=Callable[..., Any])
36
+
37
+ MAX_FIELD_CHARS = 64 * 1024
38
+
39
+ # ---------------------------------------------------------------------------- runtime
40
+
41
+ _lock = threading.Lock()
42
+ _transport: Optional[Transport] = None
43
+
44
+
45
+ def init(
46
+ url: Optional[str] = None,
47
+ key: Optional[str] = None,
48
+ service: Optional[str] = None,
49
+ capture: Optional[str] = None,
50
+ flush_interval_ms: Optional[float] = None,
51
+ max_batch: Optional[int] = None,
52
+ max_queue: Optional[int] = None,
53
+ disabled: Optional[bool] = None,
54
+ debug: Optional[bool] = None,
55
+ ) -> None:
56
+ """Configure the SDK. Optional: the first event configures it from env vars.
57
+
58
+ Env: CODING_SH_URL, CODING_SH_INGEST_KEY, CODING_SH_SERVICE (or OTEL_SERVICE_NAME),
59
+ CODING_SH_CAPTURE_MODE (full|metadata), CODING_SH_DISABLED, CODING_SH_DEBUG.
60
+ Without a URL the SDK does nothing.
61
+ """
62
+ global _transport
63
+ cfg = Config(url, key, service, capture, flush_interval_ms, max_batch, max_queue, disabled, debug)
64
+ with _lock:
65
+ old, _transport = _transport, Transport(cfg)
66
+ if old is not None:
67
+ try:
68
+ old.shutdown(1.0)
69
+ except Exception:
70
+ pass
71
+
72
+
73
+ def _get() -> Transport:
74
+ global _transport
75
+ t = _transport
76
+ if t is None:
77
+ with _lock:
78
+ if _transport is None:
79
+ _transport = Transport(Config())
80
+ t = _transport
81
+ return t
82
+
83
+
84
+ def enabled() -> bool:
85
+ try:
86
+ return _get().cfg.enabled
87
+ except Exception:
88
+ return False
89
+
90
+
91
+ def full_capture() -> bool:
92
+ return _get().cfg.full_capture
93
+
94
+
95
+ def flush(timeout: Optional[float] = None) -> bool:
96
+ """Send everything queued so far. Returns True when the queue drained in time."""
97
+ try:
98
+ return _get().flush(timeout)
99
+ except Exception:
100
+ return False
101
+
102
+
103
+ def shutdown(timeout: Optional[float] = 2.0) -> bool:
104
+ """Flush, then stop accepting events."""
105
+ try:
106
+ return _get().shutdown(timeout)
107
+ except Exception:
108
+ return False
109
+
110
+
111
+ def stats() -> Dict[str, int]:
112
+ """{queued, sent, dropped, failed} event counts for this process."""
113
+ try:
114
+ return _get().stats()
115
+ except Exception:
116
+ return {"queued": 0, "sent": 0, "dropped": 0, "failed": 0}
117
+
118
+
119
+ # ---------------------------------------------------------------------------- helpers
120
+
121
+
122
+ # One clock for every timestamp: perf_counter anchored to wall time. Mixing time.time() for
123
+ # starts with perf_counter() for durations lets a thread switch between the two reads order a
124
+ # parent's end before its children. The anchor follows wall-clock corrections over 1 s.
125
+ _anchor = (time.time(), time.perf_counter())
126
+ _anchor_checked = _anchor[1]
127
+
128
+
129
+ def now_ms() -> float:
130
+ global _anchor, _anchor_checked
131
+ p = time.perf_counter()
132
+ if p - _anchor_checked > 60.0:
133
+ _anchor_checked = p
134
+ wall = time.time()
135
+ if abs(_anchor[0] + (p - _anchor[1]) - wall) > 1.0:
136
+ _anchor = (wall, p)
137
+ return round((_anchor[0] + (p - _anchor[1])) * 1000.0, 3)
138
+
139
+
140
+ def _new_id() -> str:
141
+ return str(uuid.uuid4())
142
+
143
+
144
+ def _hex(nbytes: int) -> str:
145
+ return os.urandom(nbytes).hex()
146
+
147
+
148
+ def clip(text: Optional[str]) -> Optional[str]:
149
+ if text is None:
150
+ return None
151
+ if len(text) > MAX_FIELD_CHARS:
152
+ return text[:MAX_FIELD_CHARS] + "…[truncated]"
153
+ return text
154
+
155
+
156
+ def jsonable(value: Any) -> Any:
157
+ """A JSON-safe, size-bounded copy of an arbitrary input/output value."""
158
+ if value is None or isinstance(value, (bool, int, float)):
159
+ return value
160
+ if isinstance(value, str):
161
+ return clip(value)
162
+ try:
163
+ s = dumps(value)
164
+ except Exception:
165
+ return clip(repr(value))
166
+ if len(s) > MAX_FIELD_CHARS:
167
+ return clip(s)
168
+ try:
169
+ import json
170
+
171
+ return json.loads(s)
172
+ except Exception:
173
+ return clip(s)
174
+
175
+
176
+ def error_text(exc: BaseException) -> str:
177
+ msg = str(exc)
178
+ return clip(type(exc).__name__ + (": " + msg if msg else "")) or type(exc).__name__
179
+
180
+
181
+ def emit(event: Dict[str, Any]) -> None:
182
+ try:
183
+ _get().enqueue(event)
184
+ except Exception:
185
+ pass
186
+
187
+
188
+ # ---------------------------------------------------------------------------- context
189
+
190
+ _session_var: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar("coding_sh_session", default=None)
191
+ _node_var: "contextvars.ContextVar[Optional[Node]]" = contextvars.ContextVar("coding_sh_node", default=None)
192
+
193
+
194
+ def current_session_id() -> Optional[str]:
195
+ """The session the next event would go to, if one is set."""
196
+ n = _node_var.get()
197
+ if n is not None:
198
+ return n.root.session_id
199
+ return _session_var.get()
200
+
201
+
202
+ class Root:
203
+ """One trace: a session, or one turn of a conversation session."""
204
+
205
+ __slots__ = ("session_id", "trace_id", "conversation")
206
+
207
+ def __init__(self, session_id: Optional[str]) -> None:
208
+ self.trace_id = _hex(16)
209
+ self.conversation = session_id is not None
210
+ self.session_id = session_id if session_id is not None else _new_id()
211
+
212
+
213
+ class Node:
214
+ """A position in the trace tree that events attach to.
215
+
216
+ Like the TS SDK, every node has an anchor event (session.start for a root, tool.start / span /
217
+ llm.call for a child) whose id is fixed up front; a child's events point at their parent's
218
+ anchor through parentId.
219
+ """
220
+
221
+ __slots__ = ("root", "span_id", "parent_span_id", "anchor_id", "parent_anchor_id")
222
+
223
+ def __init__(self, root: Root, parent: Optional["Node"]) -> None:
224
+ self.root = root
225
+ self.span_id = _hex(8)
226
+ self.parent_span_id = parent.span_id if parent is not None else None
227
+ self.anchor_id = _new_id()
228
+ self.parent_anchor_id = parent.anchor_id if parent is not None else None
229
+
230
+ def event(
231
+ self, kind: str, ts: float, payload: Dict[str, Any], anchor: bool = False, child: bool = False, **extra: Any
232
+ ) -> Dict[str, Any]:
233
+ """anchor: this is the node's anchor event. child: link it to the parent's anchor."""
234
+ e: Dict[str, Any] = {
235
+ "id": self.anchor_id if anchor else _new_id(),
236
+ "v": 0,
237
+ "sessionId": self.root.session_id,
238
+ "kind": kind,
239
+ "ts": ts,
240
+ "source": "sdk",
241
+ "traceId": self.root.trace_id,
242
+ "spanId": self.span_id,
243
+ "payload": {k: v for k, v in payload.items() if v is not None},
244
+ }
245
+ if self.parent_span_id:
246
+ e["parentSpanId"] = self.parent_span_id
247
+ if child and self.parent_anchor_id:
248
+ e["parentId"] = self.parent_anchor_id
249
+ if self.root.conversation:
250
+ e["turnId"] = self.root.trace_id
251
+ for k, v in extra.items():
252
+ if v is not None:
253
+ e[k] = v
254
+ return e
255
+
256
+
257
+ class Scope:
258
+ """Base for anything that opens a node: traces, spans, implicit roots of model calls."""
259
+
260
+ def __init__(self) -> None:
261
+ self.node: Optional[Node] = None
262
+ self.is_root = False
263
+ self._token: Optional[contextvars.Token] = None
264
+ self._t0 = 0.0
265
+ self._p0 = 0.0
266
+ self._done = False
267
+ self.output: Any = None
268
+ self.input: Any = None
269
+
270
+ # Model calls do not bind the context: a returned stream outlives the call site.
271
+ bind_context = True
272
+
273
+ # subclasses
274
+ def _on_start(self) -> None: ...
275
+ def _on_end(self, error: Optional[BaseException], duration_ms: float, end_ts: float) -> None: ...
276
+
277
+ def start(self) -> "Scope":
278
+ self._t0 = now_ms()
279
+ self._p0 = time.perf_counter() # for ttft
280
+ try:
281
+ if enabled():
282
+ parent = _node_var.get()
283
+ if parent is None:
284
+ root = Root(_session_var.get())
285
+ self.node = Node(root, None)
286
+ self.is_root = True
287
+ else:
288
+ self.node = Node(parent.root, parent)
289
+ if self.bind_context:
290
+ self._token = _node_var.set(self.node)
291
+ self._root_start()
292
+ self._on_start()
293
+ except Exception:
294
+ self.node = None
295
+ return self
296
+
297
+ def end(self, error: Optional[BaseException] = None) -> None:
298
+ if self._done:
299
+ return
300
+ self._done = True
301
+ if self._token is not None:
302
+ try:
303
+ _node_var.reset(self._token)
304
+ except ValueError: # ended in a different context (generator, thread): leave it
305
+ pass
306
+ self._token = None
307
+ if self.node is None:
308
+ return
309
+ try:
310
+ end_ts = now_ms()
311
+ duration = round(max(0.0, end_ts - self._t0), 3)
312
+ self._on_end(error, duration, end_ts)
313
+ self._root_end(error, duration, end_ts)
314
+ except Exception:
315
+ pass
316
+
317
+ # The implicit-root wrapper every top-level scope gets.
318
+ def _root_name(self) -> str:
319
+ return "trace"
320
+
321
+ def _root_input(self) -> Optional[str]:
322
+ return None
323
+
324
+ def _root_meta(self) -> Dict[str, Any]:
325
+ return {}
326
+
327
+ def _root_start(self) -> None:
328
+ if not self.is_root or self.node is None:
329
+ return
330
+ n = self.node
331
+ payload: Dict[str, Any] = {"name": self._root_name(), "traceId": n.root.trace_id}
332
+ payload.update(self._root_meta())
333
+ emit(n.event("session.start", self._t0, payload, anchor=True))
334
+ text = self._root_input()
335
+ if text and full_capture():
336
+ emit(n.event("user.message", self._t0, {"text": clip(text)}))
337
+
338
+ def _root_output(self) -> Any:
339
+ return None
340
+
341
+ def _root_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
342
+ if not self.is_root or self.node is None:
343
+ return
344
+ n = self.node
345
+ p: Dict[str, Any] = {"ok": error is None, "error": error_text(error) if error else None, "durationMs": duration}
346
+ out = self._root_output()
347
+ if out is not None and full_capture():
348
+ p["output"] = out if isinstance(out, str) else jsonable(out)
349
+ if isinstance(p["output"], str):
350
+ p["output"] = clip(p["output"])
351
+ emit(n.event("turn.end", end_ts, p, durationMs=duration))
352
+ if not n.root.conversation:
353
+ emit(
354
+ n.event(
355
+ "session.end",
356
+ end_ts,
357
+ {"reason": "error" if error else "completed", "error": error_text(error) if error else None},
358
+ durationMs=duration,
359
+ )
360
+ )
361
+
362
+ # Used by the trace/span context managers.
363
+ def set_output(self, value: Any) -> None:
364
+ self.output = value
365
+
366
+ def set_input(self, value: Any) -> None:
367
+ self.input = value
368
+
369
+
370
+ # ---------------------------------------------------------------------------- trace / span
371
+
372
+
373
+ class _Trace(Scope):
374
+ def __init__(
375
+ self, name: str, session_id: Optional[str], user_id: Optional[str], metadata: Optional[Dict[str, Any]], input: Any
376
+ ) -> None:
377
+ super().__init__()
378
+ self.name = name
379
+ self.session_id_arg = session_id
380
+ self.user_id = user_id
381
+ self.metadata = metadata
382
+ self.input = input
383
+ self._sess_token: Optional[contextvars.Token] = None
384
+ self._input_sent = False
385
+
386
+ @property
387
+ def session_id(self) -> Optional[str]:
388
+ return self.node.root.session_id if self.node else self.session_id_arg
389
+
390
+ @property
391
+ def trace_id(self) -> Optional[str]:
392
+ return self.node.root.trace_id if self.node else None
393
+
394
+ def start(self) -> "Scope":
395
+ if self.session_id_arg is not None and _node_var.get() is None:
396
+ self._sess_token = _session_var.set(str(self.session_id_arg))
397
+ return super().start()
398
+
399
+ def end(self, error: Optional[BaseException] = None) -> None:
400
+ super().end(error)
401
+ if self._sess_token is not None:
402
+ try:
403
+ _session_var.reset(self._sess_token)
404
+ except ValueError:
405
+ pass
406
+ self._sess_token = None
407
+
408
+ def _root_name(self) -> str:
409
+ return self.name
410
+
411
+ def _root_meta(self) -> Dict[str, Any]:
412
+ m: Dict[str, Any] = {}
413
+ if self.user_id is not None:
414
+ m["userId"] = str(self.user_id)
415
+ if self.metadata:
416
+ m["metadata"] = jsonable(self.metadata)
417
+ return m
418
+
419
+ def _root_input(self) -> Optional[str]:
420
+ if self.input is None:
421
+ return None
422
+ self._input_sent = True
423
+ return self.input if isinstance(self.input, str) else dumps(jsonable(self.input))
424
+
425
+ def _root_output(self) -> Any:
426
+ return self.output
427
+
428
+ def _root_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
429
+ # Input given after start (set_input) still lands at the start of the turn.
430
+ if self.is_root and self.node is not None and not self._input_sent and self.input is not None and full_capture():
431
+ text = self.input if isinstance(self.input, str) else dumps(jsonable(self.input))
432
+ emit(self.node.event("user.message", self._t0, {"text": clip(text)}))
433
+ super()._root_end(error, duration, end_ts)
434
+
435
+ def _on_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
436
+ # A trace nested in another trace is a step of it.
437
+ if not self.is_root and self.node is not None:
438
+ _step_event(self.node, self.name, self._t0, duration, error, self.input, self.output, self.is_root)
439
+
440
+
441
+ def _step_event(
442
+ node: Node, name: str, ts: float, duration: float, error: Optional[BaseException], inp: Any, out: Any, is_root: bool = False
443
+ ) -> None:
444
+ attrs: Dict[str, Any] = {}
445
+ if full_capture():
446
+ if inp is not None:
447
+ attrs["input"] = jsonable(inp)
448
+ if out is not None:
449
+ attrs["output"] = jsonable(out)
450
+ p: Dict[str, Any] = {"name": name, "ok": error is None, "error": error_text(error) if error else None}
451
+ if attrs:
452
+ p["attributes"] = attrs
453
+ emit(node.event("span", ts, p, anchor=not is_root, child=True, durationMs=duration))
454
+
455
+
456
+ class _Span(Scope):
457
+ def __init__(self, kind: str, name: str, input: Any) -> None:
458
+ super().__init__()
459
+ self.kind = "tool" if kind == "tool" else "step"
460
+ self.name = name
461
+ self.input = input
462
+
463
+ def _root_name(self) -> str:
464
+ return self.name
465
+
466
+ def _on_start(self) -> None:
467
+ if self.kind == "tool" and self.node is not None:
468
+ p: Dict[str, Any] = {"tool": self.name, "toolUseId": self.node.span_id}
469
+ if self.input is not None and full_capture():
470
+ p["input"] = jsonable(self.input)
471
+ emit(self.node.event("tool.start", self._t0, p, anchor=not self.is_root, child=True))
472
+
473
+ def _on_end(self, error: Optional[BaseException], duration: float, end_ts: float) -> None:
474
+ if self.node is None:
475
+ return
476
+ if self.kind == "tool":
477
+ p: Dict[str, Any] = {
478
+ "tool": self.name,
479
+ "toolUseId": self.node.span_id,
480
+ "ok": error is None,
481
+ "durationMs": duration,
482
+ "error": error_text(error) if error else None,
483
+ }
484
+ if self.output is not None and full_capture():
485
+ p["output"] = jsonable(self.output)
486
+ emit(self.node.event("tool.end", end_ts, p, child=True, durationMs=duration))
487
+ else:
488
+ _step_event(self.node, self.name, self._t0, duration, error, self.input, self.output, self.is_root)
489
+
490
+
491
+ class _ScopeCM:
492
+ """Context manager (sync and async) and decorator around a Scope factory."""
493
+
494
+ def __init__(self, factory: Callable[[], Scope], capture_args: bool = False) -> None:
495
+ self._factory = factory
496
+ self._capture_args = capture_args
497
+ self._scopes: list = []
498
+
499
+ def __enter__(self) -> Scope:
500
+ s = self._factory().start()
501
+ self._scopes.append(s)
502
+ return s
503
+
504
+ def __exit__(self, exc_type: Any, exc: Optional[BaseException], tb: Any) -> bool:
505
+ if self._scopes:
506
+ self._scopes.pop().end(exc if isinstance(exc, Exception) else None)
507
+ return False
508
+
509
+ async def __aenter__(self) -> Scope:
510
+ return self.__enter__()
511
+
512
+ async def __aexit__(self, exc_type: Any, exc: Optional[BaseException], tb: Any) -> bool:
513
+ return self.__exit__(exc_type, exc, tb)
514
+
515
+ def __call__(self, fn: F) -> F:
516
+ factory = self._factory
517
+ capture_args = self._capture_args
518
+
519
+ def begin(args: Any, kwargs: Any) -> Scope:
520
+ s = factory()
521
+ if capture_args and s.input is None:
522
+ s.input = _bound_args(fn, args, kwargs)
523
+ return s.start()
524
+
525
+ if inspect.iscoroutinefunction(fn):
526
+
527
+ @functools.wraps(fn)
528
+ async def awrapper(*args: Any, **kwargs: Any) -> Any:
529
+ s = begin(args, kwargs)
530
+ try:
531
+ result = await fn(*args, **kwargs)
532
+ except Exception as e:
533
+ s.end(e)
534
+ raise
535
+ except BaseException:
536
+ s.end(None)
537
+ raise
538
+ if s.output is None:
539
+ s.output = result
540
+ s.end(None)
541
+ return result
542
+
543
+ return awrapper # type: ignore[return-value]
544
+
545
+ @functools.wraps(fn)
546
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
547
+ s = begin(args, kwargs)
548
+ try:
549
+ result = fn(*args, **kwargs)
550
+ except Exception as e:
551
+ s.end(e)
552
+ raise
553
+ except BaseException:
554
+ s.end(None)
555
+ raise
556
+ if s.output is None:
557
+ s.output = result
558
+ s.end(None)
559
+ return result
560
+
561
+ return wrapper # type: ignore[return-value]
562
+
563
+
564
+ def _bound_args(fn: Callable[..., Any], args: Any, kwargs: Any) -> Any:
565
+ try:
566
+ bound = inspect.signature(fn).bind_partial(*args, **kwargs)
567
+ out = {k: v for k, v in bound.arguments.items() if k not in ("self", "cls")}
568
+ return out or None
569
+ except Exception:
570
+ return None
571
+
572
+
573
+ def trace(
574
+ name: str = "trace",
575
+ session_id: Optional[str] = None,
576
+ user_id: Optional[str] = None,
577
+ metadata: Optional[Dict[str, Any]] = None,
578
+ input: Any = None,
579
+ ) -> _ScopeCM:
580
+ """A unit of work: `with coding_sh.trace("checkout", session_id=conv_id, input=prompt) as t:`.
581
+
582
+ Also a decorator (sync or async). The yielded object has set_input(), set_output(),
583
+ session_id and trace_id. A decorated function's return value becomes the output.
584
+ """
585
+ return _ScopeCM(lambda: _Trace(name, session_id, user_id, metadata, input))
586
+
587
+
588
+ def span(kind: str, name: str, input: Any = None) -> _ScopeCM:
589
+ """A tool call (`span("tool", "lookup_order")`) or a step (`span("step", "rerank")`).
590
+
591
+ Context manager or decorator; as a decorator, the call's arguments become the input and
592
+ its return value the output.
593
+ """
594
+ return _ScopeCM(lambda: _Span(kind, name, input), capture_args=True)
595
+
596
+
597
+ class _SessionCM:
598
+ def __init__(self, session_id: str) -> None:
599
+ self.session_id = str(session_id)
600
+ self._tokens: list = []
601
+
602
+ def __enter__(self) -> str:
603
+ self._tokens.append(_session_var.set(self.session_id))
604
+ return self.session_id
605
+
606
+ def __exit__(self, *exc: Any) -> bool:
607
+ if self._tokens:
608
+ try:
609
+ _session_var.reset(self._tokens.pop())
610
+ except ValueError:
611
+ pass
612
+ return False
613
+
614
+ async def __aenter__(self) -> str:
615
+ return self.__enter__()
616
+
617
+ async def __aexit__(self, *exc: Any) -> bool:
618
+ return self.__exit__(*exc)
619
+
620
+ def __call__(self, fn: F) -> F:
621
+ sid = self.session_id
622
+ if inspect.iscoroutinefunction(fn):
623
+
624
+ @functools.wraps(fn)
625
+ async def awrapper(*args: Any, **kwargs: Any) -> Any:
626
+ with _SessionCM(sid):
627
+ return await fn(*args, **kwargs)
628
+
629
+ return awrapper # type: ignore[return-value]
630
+
631
+ @functools.wraps(fn)
632
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
633
+ with _SessionCM(sid):
634
+ return fn(*args, **kwargs)
635
+
636
+ return wrapper # type: ignore[return-value]
637
+
638
+
639
+ def with_session(session_id: str) -> _SessionCM:
640
+ """Group traces and untraced calls into one conversation session (context manager or decorator)."""
641
+ return _SessionCM(session_id)