nullrun 0.4.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,20 @@
1
+ """
2
+ NullRun Toolbox.
3
+
4
+ A curated set of higher-level, ready-to-use integration helpers for
5
+ specific AI SDKs and frameworks. The `instrumentation/` package ships
6
+ the low-level patches (httpx, OpenAI v1+ attribute path, auto mode);
7
+ the `toolbox/` package ships opinionated wrappers that combine
8
+ instrumentation + cost enforcement + workflow scoping for the most
9
+ common agent runtimes (LangGraph, LlamaIndex, etc.).
10
+
11
+ The split keeps the curated public surface (`nullrun.init`,
12
+ `nullrun.protect`, `nullrun.track_*`) discoverable in `dir(nullrun)`
13
+ while the framework-specific glue lives one import away at
14
+ `nullrun.toolbox.<framework>`.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ __all__ = [
19
+ "langgraph",
20
+ ]
@@ -0,0 +1,94 @@
1
+ """
2
+ LangGraph toolbox helpers for NullRun.
3
+
4
+ This module is the user-facing entry point for LangGraph
5
+ integrations. It is a thin convenience layer that wires the
6
+ `NullRunCallback` from `nullrun.instrumentation.langgraph` onto a
7
+ LangGraph compiled app so that every `app.invoke(...)` and
8
+ `app.stream(...)` call fires the LangChain callback hooks. The
9
+ callback extracts `input_tokens` / `output_tokens` from the LLM
10
+ response and forwards them to the runtime's `track()` method —
11
+ cost is then recomputed by the backend from the org's pricing
12
+ policy.
13
+
14
+ Why this lives in `toolbox/`, not `instrumentation/`:
15
+ - `instrumentation/` ships the generic, low-level patches
16
+ (httpx, OpenAI v1+ attribute path, LangChain callback class).
17
+ These are reusable building blocks.
18
+ - `toolbox/langgraph.py` ships a ready-to-use `wrapper(app)`
19
+ that is a single function call for the most common
20
+ LangGraph case. It is the entry point the user is pointed
21
+ to from the LangGraph integration docs.
22
+
23
+ The previous location `nullrun.instrumentation.langgraph.instrument`
24
+ is removed as of Phase 1 Commit 6. Users who imported it should
25
+ switch to `nullrun.toolbox.langgraph.wrapper`.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from typing import Any
31
+
32
+ from nullrun.instrumentation.langgraph import NullRunCallback
33
+ from nullrun.runtime import NullRunRuntime, get_runtime
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ def wrapper(app: Any, runtime: Any | None = None) -> Any:
39
+ """
40
+ Wrap a compiled LangGraph app with NullRun tracking.
41
+
42
+ Every `app.invoke(...)` and `app.stream(...)` call gets a
43
+ `NullRunCallback` attached so the runtime sees the LLM
44
+ usage for cost accounting and policy enforcement.
45
+
46
+ Usage:
47
+ from nullrun import init
48
+ from nullrun.toolbox.langgraph import wrapper
49
+
50
+ runtime = init()
51
+ graph = build_my_graph()
52
+ graph = wrapper(graph, runtime=runtime)
53
+
54
+ result = graph.invoke({"messages": [("user", "hi")]})
55
+
56
+ Args:
57
+ app: A compiled LangGraph `StateGraph` (anything with
58
+ `.invoke` and `.stream`).
59
+ runtime: Optional `NullRunRuntime`. Defaults to the
60
+ module-level singleton from `get_runtime()`.
61
+
62
+ Returns:
63
+ The same `app` object, with `.invoke` and `.stream`
64
+ wrapped in place. The callback is added to LangChain's
65
+ `config["callbacks"]` list per call, so multiple
66
+ wrappers compose without colliding.
67
+ """
68
+ rt: NullRunRuntime = runtime or get_runtime()
69
+ callback = NullRunCallback(runtime=rt)
70
+ original_invoke = getattr(app, "invoke", None)
71
+ original_stream = getattr(app, "stream", None)
72
+
73
+ if original_invoke is not None:
74
+ def wrapped_invoke(input: Any, config: Any | None = None, **kwargs: Any) -> Any:
75
+ if config is None:
76
+ config = {}
77
+ if "callbacks" not in config:
78
+ config["callbacks"] = []
79
+ config["callbacks"].append(callback)
80
+ return original_invoke(input, config, **kwargs)
81
+ app.invoke = wrapped_invoke
82
+
83
+ if original_stream is not None:
84
+ def wrapped_stream(input: Any, config: Any | None = None, **kwargs: Any) -> Any:
85
+ if config is None:
86
+ config = {}
87
+ if "callbacks" not in config:
88
+ config["callbacks"] = []
89
+ config["callbacks"].append(callback)
90
+ return original_stream(input, config, **kwargs)
91
+ app.stream = wrapped_stream
92
+
93
+ logger.info("LangGraph app wrapped with NullRun tracking")
94
+ return app
nullrun/tracing.py ADDED
@@ -0,0 +1,155 @@
1
+ """
2
+ Trace/span context management via Python contextvars.
3
+
4
+ This module is the core of the new trace/span system (Phase 2 of
5
+ the SDK cleanup plan). The previous `nullrun.context` module
6
+ exposed loose `_trace_id` and `_span_id` contextvars — fine for
7
+ attaching IDs to events, but it didn't model the parent/child
8
+ hierarchy that a trace timeline needs.
9
+
10
+ `SpanContext` is a structured value: a single contextvar holds
11
+ the *current* span, and child spans are derived from it via
12
+ `create_child_span(parent)`. This is the same pattern OpenTelemetry
13
+ uses for its Python SDK (`opentelemetry.context.get_current`) and
14
+ gives `@protect` (Commit 4) and `track_*` (Commit 5) a uniform
15
+ way to attach `trace_id` / `span_id` / `parent_span_id` / `depth`
16
+ to every emitted event.
17
+
18
+ Thread/async safety: `ContextVar` is thread-local by default but
19
+ PEP 567 guarantees the right value is restored across `await`
20
+ boundaries in asyncio, so concurrent coroutines each see their
21
+ own current span.
22
+
23
+ What this module does NOT do:
24
+ - It does not emit events. `SpanContext` is a pure data
25
+ structure. The runtime's `track_event()` is what actually
26
+ posts `span_start` / `span_end` events to the backend. See
27
+ `_emit_span_start` / `_emit_span_end` in `nullrun.decorators`
28
+ for the wiring.
29
+ - It does not implement OTel-style attributes, status, or
30
+ exception recording. We keep the surface minimal — a span
31
+ is just an ID tuple, the dashboard reconstructs the rest
32
+ from the event stream.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import uuid
37
+ from contextvars import ContextVar
38
+ from dataclasses import dataclass
39
+
40
+
41
+ def _new_id() -> str:
42
+ """Generate a fresh span/trace id.
43
+
44
+ Returns a real UUID4 with dashes (e.g. ``95ca7c0b-...-2788803ef3b8``)
45
+ so the backend's `Uuid::parse_str` accepts it on the wire. Earlier
46
+ we shipped `uuid.uuid4().hex` (32 hex chars, no dashes) which the
47
+ backend silently dropped to NULL.
48
+ """
49
+ return str(uuid.uuid4())
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SpanContext:
54
+ """
55
+ One span in the call tree.
56
+
57
+ Attributes:
58
+ trace_id: Stable across the whole trace (root + all descendants).
59
+ span_id: Unique to this span. Children reference it as
60
+ `parent_span_id`.
61
+ parent_span_id: The parent's `span_id`, or None for the root span.
62
+ depth: 0 for the root, parent.depth + 1 for each child.
63
+ Useful for the waterfall UI's indentation.
64
+ """
65
+
66
+ trace_id: str
67
+ span_id: str
68
+ parent_span_id: str | None = None
69
+ depth: int = 0
70
+
71
+
72
+ # The currently-active span. `None` means "no trace in progress" — track_*
73
+ # will fall back to creating a synthetic root on each call so events are
74
+ # still attributed to *something*.
75
+ _current_span: ContextVar[SpanContext | None] = ContextVar(
76
+ "nullrun_span", default=None
77
+ )
78
+
79
+
80
+ def get_current_span() -> SpanContext | None:
81
+ """
82
+ Return the active span, or None if no `@protect` / manual `set_span`
83
+ has put us inside a trace.
84
+ """
85
+ return _current_span.get()
86
+
87
+
88
+ def create_child_span(parent: SpanContext) -> SpanContext:
89
+ """
90
+ Derive a new child span from `parent`.
91
+
92
+ The child inherits `parent.trace_id` and increments `parent.depth`.
93
+ `parent_span_id` is set to `parent.span_id` so the tree is fully
94
+ reconstructable from the event stream.
95
+
96
+ Raises:
97
+ ValueError: if `parent` is ``None``. The function does NOT
98
+ silently degrade to creating a root span — that would
99
+ hide bugs in the caller where a parent was expected.
100
+ Sprint 2.6 (B5): pre-fix this raised
101
+ ``TypeError: unsupported operand for None + 1`` on
102
+ ``parent.depth + 1`` which crashed the entire
103
+ ``@protect`` / track_* pipeline. Raise a clear
104
+ ``ValueError`` instead so the caller can fix the bug.
105
+ """
106
+ if parent is None:
107
+ raise ValueError(
108
+ "create_child_span requires a non-None parent SpanContext. "
109
+ "If you want a root span, use create_root_span() instead."
110
+ )
111
+ return SpanContext(
112
+ trace_id=parent.trace_id,
113
+ span_id=_new_id(),
114
+ parent_span_id=parent.span_id,
115
+ depth=parent.depth + 1,
116
+ )
117
+
118
+
119
+ def create_root_span() -> SpanContext:
120
+ """
121
+ Start a new trace. Returns a SpanContext with no parent and depth 0.
122
+ """
123
+ tid = _new_id()
124
+ return SpanContext(
125
+ trace_id=tid,
126
+ span_id=_new_id(),
127
+ parent_span_id=None,
128
+ depth=0,
129
+ )
130
+
131
+
132
+ def set_span(ctx: SpanContext):
133
+ """
134
+ Make `ctx` the current span. Returns a token that MUST be passed
135
+ back to `reset_span` in a `finally` block to restore the previous
136
+ context (which may itself be None).
137
+
138
+ Usage:
139
+ span = create_root_span()
140
+ token = set_span(span)
141
+ try:
142
+ ...
143
+ finally:
144
+ reset_span(token)
145
+ """
146
+ return _current_span.set(ctx)
147
+
148
+
149
+ def reset_span(token) -> None:
150
+ """
151
+ Restore the context that was active before the matching `set_span`.
152
+ Pair with `set_span` — never call reset_span with a token from a
153
+ different context.
154
+ """
155
+ _current_span.reset(token)