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.
- nullrun/__init__.py +282 -0
- nullrun/__version__.py +4 -0
- nullrun/actions.py +455 -0
- nullrun/breaker/__init__.py +27 -0
- nullrun/breaker/circuit_breaker.py +402 -0
- nullrun/breaker/exceptions.py +319 -0
- nullrun/context.py +208 -0
- nullrun/decorators.py +649 -0
- nullrun/instrumentation/__init__.py +23 -0
- nullrun/instrumentation/_safe_patch.py +99 -0
- nullrun/instrumentation/auto.py +1095 -0
- nullrun/instrumentation/auto_requests.py +257 -0
- nullrun/instrumentation/autogen.py +163 -0
- nullrun/instrumentation/crewai.py +140 -0
- nullrun/instrumentation/langgraph.py +412 -0
- nullrun/instrumentation/llama_index.py +110 -0
- nullrun/observability.py +160 -0
- nullrun/py.typed +0 -0
- nullrun/runtime.py +1806 -0
- nullrun/toolbox/__init__.py +20 -0
- nullrun/toolbox/langgraph.py +94 -0
- nullrun/tracing.py +155 -0
- nullrun/transport.py +1509 -0
- nullrun/transport_websocket.py +627 -0
- nullrun-0.4.0.dist-info/METADATA +194 -0
- nullrun-0.4.0.dist-info/RECORD +28 -0
- nullrun-0.4.0.dist-info/WHEEL +4 -0
- nullrun-0.4.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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)
|