netra-observe 0.1.0__tar.gz
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.
- netra_observe-0.1.0/.gitignore +5 -0
- netra_observe-0.1.0/PKG-INFO +87 -0
- netra_observe-0.1.0/README.md +71 -0
- netra_observe-0.1.0/examples/langchain_agent.py +35 -0
- netra_observe-0.1.0/pyproject.toml +23 -0
- netra_observe-0.1.0/src/netra_observe/__init__.py +99 -0
- netra_observe-0.1.0/src/netra_observe/_config.py +46 -0
- netra_observe-0.1.0/src/netra_observe/_inject.py +89 -0
- netra_observe-0.1.0/src/netra_observe/_provider.py +30 -0
- netra_observe-0.1.0/src/netra_observe/_runholder.py +81 -0
- netra_observe-0.1.0/tests/conftest.py +36 -0
- netra_observe-0.1.0/tests/test_config.py +36 -0
- netra_observe-0.1.0/tests/test_inject.py +133 -0
- netra_observe-0.1.0/tests/test_instrument.py +54 -0
- netra_observe-0.1.0/tests/test_provider.py +38 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netra-observe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One-line LangChain observability for Netra Runtime: traces into your Usage tab.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: httpx>=0.23
|
|
8
|
+
Requires-Dist: openinference-instrumentation-langchain>=0.1.67
|
|
9
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20
|
|
10
|
+
Requires-Dist: opentelemetry-sdk>=1.20
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: langchain-core>=0.2; extra == 'dev'
|
|
13
|
+
Requires-Dist: langchain-openai>=0.1; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# netra-observe
|
|
18
|
+
|
|
19
|
+
One-line LangChain observability for [Netra Runtime](https://netraruntime.com):
|
|
20
|
+
your agents, chains, tools, and LLM calls land as traces in the dashboard's
|
|
21
|
+
Usage tab, with cost and token numbers taken from the gateway's billing
|
|
22
|
+
ledger — never from client-side estimates.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install netra-observe
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from netra_observe import instrument
|
|
34
|
+
|
|
35
|
+
instrument(api_key="sk_live_...", project="support-agent")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Call it once at startup, **before** building your chains. Everything
|
|
39
|
+
LangChain runs after that — agents, chains, tools, retrievers, LLM calls —
|
|
40
|
+
is traced automatically (via OpenInference's LangChain instrumentation) and
|
|
41
|
+
exported to Netra over OTLP.
|
|
42
|
+
|
|
43
|
+
Point your LLM at the Netra gateway as usual:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from langchain_openai import ChatOpenAI
|
|
47
|
+
|
|
48
|
+
llm = ChatOpenAI(
|
|
49
|
+
model="qwen3.6-35b",
|
|
50
|
+
base_url="https://api.netraruntime.com/v1",
|
|
51
|
+
api_key="sk_live_...", # same Netra key
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
See `examples/langchain_agent.py` for a runnable tool-calling agent.
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
| `instrument()` argument | Env fallback | Default |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `api_key` | `NETRA_API_KEY` | — (required) |
|
|
62
|
+
| `project` | `NETRA_PROJECT` | `None` |
|
|
63
|
+
| `environment` | `NETRA_ENVIRONMENT` | `None` |
|
|
64
|
+
| `endpoint` | `NETRA_OTEL_ENDPOINT` | `https://api.netraruntime.com/v1/otel` |
|
|
65
|
+
| `tracer_provider` | — | netra-observe creates one |
|
|
66
|
+
|
|
67
|
+
`instrument()` returns a handle: `handle.flush()` forces an export,
|
|
68
|
+
`handle.shutdown()` (or using the handle as a context manager) flushes and
|
|
69
|
+
detaches everything. Pass your own `tracer_provider` to attach Netra's
|
|
70
|
+
exporter setup to an existing OpenTelemetry deployment instead of letting
|
|
71
|
+
the SDK own one.
|
|
72
|
+
|
|
73
|
+
## How cost attribution works
|
|
74
|
+
|
|
75
|
+
The SDK propagates W3C trace context (`traceparent`) on HTTP requests to
|
|
76
|
+
the Netra gateway — **propagation only, it never starts spans of its own**.
|
|
77
|
+
The gateway records the trace/span id on its billing ledger row, so the LLM
|
|
78
|
+
span in your trace is joined server-side to the exact request the gateway
|
|
79
|
+
metered. Tokens and cost shown in the Usage tab come from that ledger;
|
|
80
|
+
client-reported numbers are never billed.
|
|
81
|
+
|
|
82
|
+
## Failure isolation
|
|
83
|
+
|
|
84
|
+
netra-observe must never break your app: export failures are logged and
|
|
85
|
+
dropped (spans are batched and sent in the background), header injection
|
|
86
|
+
degrades to "no header" on any error, and `shutdown()` caps its final flush
|
|
87
|
+
at 5 seconds.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# netra-observe
|
|
2
|
+
|
|
3
|
+
One-line LangChain observability for [Netra Runtime](https://netraruntime.com):
|
|
4
|
+
your agents, chains, tools, and LLM calls land as traces in the dashboard's
|
|
5
|
+
Usage tab, with cost and token numbers taken from the gateway's billing
|
|
6
|
+
ledger — never from client-side estimates.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install netra-observe
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from netra_observe import instrument
|
|
18
|
+
|
|
19
|
+
instrument(api_key="sk_live_...", project="support-agent")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Call it once at startup, **before** building your chains. Everything
|
|
23
|
+
LangChain runs after that — agents, chains, tools, retrievers, LLM calls —
|
|
24
|
+
is traced automatically (via OpenInference's LangChain instrumentation) and
|
|
25
|
+
exported to Netra over OTLP.
|
|
26
|
+
|
|
27
|
+
Point your LLM at the Netra gateway as usual:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from langchain_openai import ChatOpenAI
|
|
31
|
+
|
|
32
|
+
llm = ChatOpenAI(
|
|
33
|
+
model="qwen3.6-35b",
|
|
34
|
+
base_url="https://api.netraruntime.com/v1",
|
|
35
|
+
api_key="sk_live_...", # same Netra key
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
See `examples/langchain_agent.py` for a runnable tool-calling agent.
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
| `instrument()` argument | Env fallback | Default |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `api_key` | `NETRA_API_KEY` | — (required) |
|
|
46
|
+
| `project` | `NETRA_PROJECT` | `None` |
|
|
47
|
+
| `environment` | `NETRA_ENVIRONMENT` | `None` |
|
|
48
|
+
| `endpoint` | `NETRA_OTEL_ENDPOINT` | `https://api.netraruntime.com/v1/otel` |
|
|
49
|
+
| `tracer_provider` | — | netra-observe creates one |
|
|
50
|
+
|
|
51
|
+
`instrument()` returns a handle: `handle.flush()` forces an export,
|
|
52
|
+
`handle.shutdown()` (or using the handle as a context manager) flushes and
|
|
53
|
+
detaches everything. Pass your own `tracer_provider` to attach Netra's
|
|
54
|
+
exporter setup to an existing OpenTelemetry deployment instead of letting
|
|
55
|
+
the SDK own one.
|
|
56
|
+
|
|
57
|
+
## How cost attribution works
|
|
58
|
+
|
|
59
|
+
The SDK propagates W3C trace context (`traceparent`) on HTTP requests to
|
|
60
|
+
the Netra gateway — **propagation only, it never starts spans of its own**.
|
|
61
|
+
The gateway records the trace/span id on its billing ledger row, so the LLM
|
|
62
|
+
span in your trace is joined server-side to the exact request the gateway
|
|
63
|
+
metered. Tokens and cost shown in the Usage tab come from that ledger;
|
|
64
|
+
client-reported numbers are never billed.
|
|
65
|
+
|
|
66
|
+
## Failure isolation
|
|
67
|
+
|
|
68
|
+
netra-observe must never break your app: export failures are logged and
|
|
69
|
+
dropped (spans are batched and sent in the background), header injection
|
|
70
|
+
degrades to "no header" on any error, and `shutdown()` caps its final flush
|
|
71
|
+
at 5 seconds.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Weather agent traced into Netra.
|
|
2
|
+
|
|
3
|
+
Run: NETRA_API_KEY=sk_live_... python examples/langchain_agent.py
|
|
4
|
+
The LLM calls go through Netra's gateway; the whole run lands as one
|
|
5
|
+
trace in the dashboard's Usage tab.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from langchain_core.tools import tool
|
|
12
|
+
from langchain_openai import ChatOpenAI
|
|
13
|
+
|
|
14
|
+
from netra_observe import instrument
|
|
15
|
+
|
|
16
|
+
handle = instrument(project="weather-agent-example", environment="dev")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@tool
|
|
20
|
+
def get_weather(city: str) -> str:
|
|
21
|
+
"""Get the current weather for a city."""
|
|
22
|
+
return f"22°C and sunny in {city}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
llm = ChatOpenAI(
|
|
26
|
+
model=os.environ.get("NETRA_MODEL", "qwen3.6-35b"),
|
|
27
|
+
base_url="https://api.netraruntime.com/v1",
|
|
28
|
+
api_key=os.environ["NETRA_API_KEY"],
|
|
29
|
+
).bind_tools([get_weather])
|
|
30
|
+
|
|
31
|
+
msg = llm.invoke("What's the weather in Jakarta?")
|
|
32
|
+
for call in msg.tool_calls:
|
|
33
|
+
print("tool call:", call["name"], call["args"])
|
|
34
|
+
|
|
35
|
+
handle.shutdown() # flush before exit
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "netra-observe"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "One-line LangChain observability for Netra Runtime: traces into your Usage tab."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"opentelemetry-sdk>=1.20",
|
|
14
|
+
"opentelemetry-exporter-otlp-proto-http>=1.20",
|
|
15
|
+
"openinference-instrumentation-langchain>=0.1.67",
|
|
16
|
+
"httpx>=0.23",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=7", "langchain-core>=0.2", "langchain-openai>=0.1"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from openinference.instrumentation.langchain import LangChainInstrumentor
|
|
8
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
9
|
+
|
|
10
|
+
from ._config import Config, NetraConfigError, resolve
|
|
11
|
+
from ._inject import install, uninstall
|
|
12
|
+
from ._provider import build_provider
|
|
13
|
+
from ._runholder import activate as _activate_runholder
|
|
14
|
+
from ._runholder import deactivate as _deactivate_runholder
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = ["instrument", "NetraInstrumentation", "NetraConfigError", "__version__"]
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("netra_observe")
|
|
20
|
+
|
|
21
|
+
_lock = threading.Lock()
|
|
22
|
+
_active: Optional["NetraInstrumentation"] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NetraInstrumentation:
|
|
26
|
+
"""Handle returned by instrument(). Context-manager friendly."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, provider: TracerProvider, owns_provider: bool) -> None:
|
|
29
|
+
self.provider = provider
|
|
30
|
+
self._owns_provider = owns_provider
|
|
31
|
+
|
|
32
|
+
def flush(self, timeout_ms: int = 5000) -> None:
|
|
33
|
+
try:
|
|
34
|
+
self.provider.force_flush(timeout_ms)
|
|
35
|
+
except Exception:
|
|
36
|
+
logger.debug("flush failed", exc_info=True)
|
|
37
|
+
|
|
38
|
+
def shutdown(self) -> None:
|
|
39
|
+
global _active
|
|
40
|
+
try:
|
|
41
|
+
LangChainInstrumentor().uninstrument()
|
|
42
|
+
except Exception:
|
|
43
|
+
logger.debug("uninstrument failed", exc_info=True)
|
|
44
|
+
_deactivate_runholder()
|
|
45
|
+
uninstall()
|
|
46
|
+
self.flush()
|
|
47
|
+
if self._owns_provider:
|
|
48
|
+
try:
|
|
49
|
+
self.provider.shutdown()
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.debug("provider shutdown failed", exc_info=True)
|
|
52
|
+
with _lock:
|
|
53
|
+
_active = None
|
|
54
|
+
|
|
55
|
+
def __enter__(self) -> "NetraInstrumentation":
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __exit__(self, *exc) -> None:
|
|
59
|
+
self.shutdown()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def instrument(
|
|
63
|
+
api_key: Optional[str] = None,
|
|
64
|
+
project: Optional[str] = None,
|
|
65
|
+
environment: Optional[str] = None,
|
|
66
|
+
endpoint: Optional[str] = None,
|
|
67
|
+
tracer_provider: Optional[TracerProvider] = None,
|
|
68
|
+
) -> NetraInstrumentation:
|
|
69
|
+
"""Wire LangChain tracing into Netra. Idempotent; never raises after
|
|
70
|
+
config validation succeeds."""
|
|
71
|
+
global _active
|
|
72
|
+
with _lock:
|
|
73
|
+
if _active is not None:
|
|
74
|
+
return _active
|
|
75
|
+
cfg = resolve(api_key, project, environment, endpoint)
|
|
76
|
+
if tracer_provider is not None:
|
|
77
|
+
provider, owns = tracer_provider, False
|
|
78
|
+
else:
|
|
79
|
+
provider, owns = build_provider(cfg), True
|
|
80
|
+
LangChainInstrumentor().instrument(tracer_provider=provider)
|
|
81
|
+
_activate_runholder()
|
|
82
|
+
install(cfg.gateway_host)
|
|
83
|
+
_active = NetraInstrumentation(provider, owns)
|
|
84
|
+
return _active
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reset_for_tests() -> None:
|
|
88
|
+
"""Test hook: tear down whatever instrument() set up."""
|
|
89
|
+
global _active
|
|
90
|
+
handle = _active
|
|
91
|
+
if handle is not None:
|
|
92
|
+
handle.shutdown()
|
|
93
|
+
else:
|
|
94
|
+
try:
|
|
95
|
+
LangChainInstrumentor().uninstrument()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
_deactivate_runholder()
|
|
99
|
+
uninstall()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
DEFAULT_ENDPOINT = "https://api.netraruntime.com/v1/otel"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NetraConfigError(ValueError):
|
|
12
|
+
"""Raised for unusable configuration (e.g. no API key anywhere)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Config:
|
|
17
|
+
api_key: str
|
|
18
|
+
endpoint: str
|
|
19
|
+
gateway_host: str
|
|
20
|
+
project: Optional[str]
|
|
21
|
+
environment: Optional[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve(
|
|
25
|
+
api_key: Optional[str] = None,
|
|
26
|
+
project: Optional[str] = None,
|
|
27
|
+
environment: Optional[str] = None,
|
|
28
|
+
endpoint: Optional[str] = None,
|
|
29
|
+
) -> Config:
|
|
30
|
+
key = api_key or os.environ.get("NETRA_API_KEY")
|
|
31
|
+
if not key:
|
|
32
|
+
raise NetraConfigError(
|
|
33
|
+
"netra-observe needs an API key: pass instrument(api_key=...) "
|
|
34
|
+
"or set NETRA_API_KEY"
|
|
35
|
+
)
|
|
36
|
+
ep = (endpoint or os.environ.get("NETRA_OTEL_ENDPOINT") or DEFAULT_ENDPOINT).rstrip("/")
|
|
37
|
+
host = urlparse(ep).netloc
|
|
38
|
+
if not host:
|
|
39
|
+
raise NetraConfigError(f"invalid OTLP endpoint: {ep!r}")
|
|
40
|
+
return Config(
|
|
41
|
+
api_key=key,
|
|
42
|
+
endpoint=ep,
|
|
43
|
+
gateway_host=host,
|
|
44
|
+
project=project or os.environ.get("NETRA_PROJECT"),
|
|
45
|
+
environment=environment or os.environ.get("NETRA_ENVIRONMENT"),
|
|
46
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from opentelemetry import propagate
|
|
8
|
+
from opentelemetry import trace as trace_api
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("netra_observe")
|
|
11
|
+
|
|
12
|
+
_original_send = None
|
|
13
|
+
_original_async_send = None
|
|
14
|
+
_gateway_host: Optional[str] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _current_context():
|
|
18
|
+
"""Injection context, best source first (never raises):
|
|
19
|
+
1. The OpenInference span for the LLM run recorded by our run-holder
|
|
20
|
+
callback — exact in both bare-invoke and in-chain shapes.
|
|
21
|
+
2. OpenInference's get_current_span() — inside a parent runnable this is
|
|
22
|
+
the surrounding run's span (right trace, coarser node).
|
|
23
|
+
3. None => propagate.inject falls back to ambient context.
|
|
24
|
+
OpenInference keeps its spans OUT of ambient context by design, which is
|
|
25
|
+
why 1 and 2 exist at all."""
|
|
26
|
+
try:
|
|
27
|
+
from openinference.instrumentation.langchain import (
|
|
28
|
+
LangChainInstrumentor,
|
|
29
|
+
get_current_span,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from ._runholder import current_llm_run_id
|
|
33
|
+
|
|
34
|
+
run_id = current_llm_run_id()
|
|
35
|
+
if run_id is not None:
|
|
36
|
+
span = LangChainInstrumentor().get_span(run_id)
|
|
37
|
+
if span is not None:
|
|
38
|
+
return trace_api.set_span_in_context(span)
|
|
39
|
+
span = get_current_span()
|
|
40
|
+
if span is not None:
|
|
41
|
+
return trace_api.set_span_in_context(span)
|
|
42
|
+
except Exception: # pragma: no cover - defensive
|
|
43
|
+
logger.debug("injection context resolution failed", exc_info=True)
|
|
44
|
+
return None # None => propagate.inject uses ambient context
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _inject(request: httpx.Request) -> None:
|
|
48
|
+
try:
|
|
49
|
+
if _gateway_host is None or request.url.netloc.decode() != _gateway_host:
|
|
50
|
+
return
|
|
51
|
+
ctx = _current_context()
|
|
52
|
+
if ctx is None and not trace_api.get_current_span().get_span_context().is_valid:
|
|
53
|
+
return # nothing to propagate
|
|
54
|
+
carrier: dict = {}
|
|
55
|
+
propagate.inject(carrier, context=ctx)
|
|
56
|
+
for k, v in carrier.items():
|
|
57
|
+
request.headers[k] = v
|
|
58
|
+
except Exception: # never break the host app
|
|
59
|
+
logger.debug("traceparent injection failed", exc_info=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def install(gateway_host: str) -> None:
|
|
63
|
+
global _original_send, _original_async_send, _gateway_host
|
|
64
|
+
_gateway_host = gateway_host
|
|
65
|
+
if _original_send is not None:
|
|
66
|
+
return # already installed; just retarget the host above
|
|
67
|
+
|
|
68
|
+
_original_send = httpx.Client.send
|
|
69
|
+
_original_async_send = httpx.AsyncClient.send
|
|
70
|
+
|
|
71
|
+
def send(self, request, **kwargs):
|
|
72
|
+
_inject(request)
|
|
73
|
+
return _original_send(self, request, **kwargs)
|
|
74
|
+
|
|
75
|
+
async def async_send(self, request, **kwargs):
|
|
76
|
+
_inject(request)
|
|
77
|
+
return await _original_async_send(self, request, **kwargs)
|
|
78
|
+
|
|
79
|
+
httpx.Client.send = send
|
|
80
|
+
httpx.AsyncClient.send = async_send
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def uninstall() -> None:
|
|
84
|
+
global _original_send, _original_async_send, _gateway_host
|
|
85
|
+
if _original_send is not None:
|
|
86
|
+
httpx.Client.send = _original_send
|
|
87
|
+
httpx.AsyncClient.send = _original_async_send
|
|
88
|
+
_original_send = _original_async_send = None
|
|
89
|
+
_gateway_host = None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
4
|
+
from opentelemetry.sdk.resources import Resource
|
|
5
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
6
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
7
|
+
|
|
8
|
+
from ._config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_resource(cfg: Config) -> Resource:
|
|
12
|
+
attrs = {"service.name": cfg.project or "netra-observe"}
|
|
13
|
+
if cfg.project:
|
|
14
|
+
attrs["netra.project"] = cfg.project
|
|
15
|
+
if cfg.environment:
|
|
16
|
+
attrs["deployment.environment"] = cfg.environment
|
|
17
|
+
return Resource.create(attrs)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_exporter(cfg: Config) -> OTLPSpanExporter:
|
|
21
|
+
return OTLPSpanExporter(
|
|
22
|
+
endpoint=cfg.endpoint + "/v1/traces",
|
|
23
|
+
headers={"Authorization": f"Bearer {cfg.api_key}"},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_provider(cfg: Config) -> TracerProvider:
|
|
28
|
+
provider = TracerProvider(resource=build_resource(cfg))
|
|
29
|
+
provider.add_span_processor(BatchSpanProcessor(build_exporter(cfg)))
|
|
30
|
+
return provider
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("netra_observe")
|
|
9
|
+
|
|
10
|
+
# The chat-model/LLM run currently in flight in this execution context.
|
|
11
|
+
# get_current_span() (OpenInference) only resolves under a PARENT runnable —
|
|
12
|
+
# a bare llm.invoke() leaves LangChain's config contextvar unset, and inside
|
|
13
|
+
# chains it resolves to the surrounding chain's run, not the LLM's. Recording
|
|
14
|
+
# the run id at on_(chat_model|llm)_start gives the exact LLM run in both
|
|
15
|
+
# shapes; _inject looks the span up in OpenInference's registry at HTTP time.
|
|
16
|
+
_current_llm_run: ContextVar[Optional[UUID]] = ContextVar(
|
|
17
|
+
"netra_current_llm_run", default=None
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# register_configure_hook injects the handler held by this var into EVERY
|
|
21
|
+
# callback configure() — including bare invokes with no callbacks passed.
|
|
22
|
+
_holder_var: ContextVar[Optional[Any]] = ContextVar(
|
|
23
|
+
"netra_run_holder", default=None
|
|
24
|
+
)
|
|
25
|
+
_hook_registered = False
|
|
26
|
+
_holder_cls: Optional[type] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def current_llm_run_id() -> Optional[UUID]:
|
|
30
|
+
return _current_llm_run.get()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_holder_cls() -> type:
|
|
34
|
+
"""Define the handler lazily: langchain-core is the USER's dependency —
|
|
35
|
+
importing netra_observe must never require (or crash without) it."""
|
|
36
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
37
|
+
|
|
38
|
+
class _RunHolder(BaseCallbackHandler):
|
|
39
|
+
"""Records the in-flight LLM run id. run_inline keeps async dispatch
|
|
40
|
+
in the caller's context so the ContextVar write is visible to the
|
|
41
|
+
HTTP call."""
|
|
42
|
+
|
|
43
|
+
run_inline = True
|
|
44
|
+
|
|
45
|
+
def on_chat_model_start(self, serialized: Any, messages: Any, *, run_id: UUID, **kw: Any) -> None:
|
|
46
|
+
_current_llm_run.set(run_id)
|
|
47
|
+
|
|
48
|
+
def on_llm_start(self, serialized: Any, prompts: Any, *, run_id: UUID, **kw: Any) -> None:
|
|
49
|
+
_current_llm_run.set(run_id)
|
|
50
|
+
|
|
51
|
+
def on_llm_end(self, response: Any, *, run_id: UUID, **kw: Any) -> None:
|
|
52
|
+
if _current_llm_run.get() == run_id:
|
|
53
|
+
_current_llm_run.set(None)
|
|
54
|
+
|
|
55
|
+
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kw: Any) -> None:
|
|
56
|
+
if _current_llm_run.get() == run_id:
|
|
57
|
+
_current_llm_run.set(None)
|
|
58
|
+
|
|
59
|
+
return _RunHolder
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def activate() -> None:
|
|
63
|
+
"""Register the run-holder into LangChain's global callback configuration.
|
|
64
|
+
No-op (with a debug log) when langchain-core is not installed."""
|
|
65
|
+
global _hook_registered, _holder_cls
|
|
66
|
+
try:
|
|
67
|
+
if _holder_cls is None:
|
|
68
|
+
_holder_cls = _build_holder_cls()
|
|
69
|
+
if not _hook_registered:
|
|
70
|
+
from langchain_core.tracers.context import register_configure_hook
|
|
71
|
+
|
|
72
|
+
register_configure_hook(_holder_var, inheritable=True)
|
|
73
|
+
_hook_registered = True
|
|
74
|
+
_holder_var.set(_holder_cls())
|
|
75
|
+
except Exception: # langchain-core absent or API drift — degrade quietly
|
|
76
|
+
logger.debug("run-holder activation failed", exc_info=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def deactivate() -> None:
|
|
80
|
+
_holder_var.set(None)
|
|
81
|
+
_current_llm_run.set(None)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _Capture(BaseHTTPRequestHandler):
|
|
10
|
+
captured: list[dict] = []
|
|
11
|
+
|
|
12
|
+
def do_GET(self): # noqa: N802
|
|
13
|
+
_Capture.captured.append(dict(self.headers))
|
|
14
|
+
self.send_response(200)
|
|
15
|
+
self.end_headers()
|
|
16
|
+
self.wfile.write(b"ok")
|
|
17
|
+
|
|
18
|
+
def do_POST(self): # noqa: N802 - ChatOpenAI POSTs /v1/chat/completions
|
|
19
|
+
self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
|
20
|
+
_Capture.captured.append(dict(self.headers))
|
|
21
|
+
self.send_response(500)
|
|
22
|
+
self.end_headers()
|
|
23
|
+
self.wfile.write(b"{}")
|
|
24
|
+
|
|
25
|
+
def log_message(self, *a): # silence
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture()
|
|
30
|
+
def capture_server():
|
|
31
|
+
_Capture.captured = []
|
|
32
|
+
srv = HTTPServer(("127.0.0.1", 0), _Capture)
|
|
33
|
+
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
|
34
|
+
t.start()
|
|
35
|
+
yield f"127.0.0.1:{srv.server_port}", _Capture.captured
|
|
36
|
+
srv.shutdown()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from netra_observe._config import Config, NetraConfigError, resolve
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_resolve_defaults(monkeypatch):
|
|
9
|
+
monkeypatch.delenv("NETRA_OTEL_ENDPOINT", raising=False)
|
|
10
|
+
cfg = resolve(api_key="sk_live_k")
|
|
11
|
+
assert cfg.endpoint == "https://api.netraruntime.com/v1/otel"
|
|
12
|
+
assert cfg.gateway_host == "api.netraruntime.com"
|
|
13
|
+
assert cfg.project is None and cfg.environment is None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_resolve_env_fallbacks(monkeypatch):
|
|
17
|
+
monkeypatch.setenv("NETRA_API_KEY", "sk_live_env")
|
|
18
|
+
monkeypatch.setenv("NETRA_OTEL_ENDPOINT", "http://localhost:8080/v1/otel/")
|
|
19
|
+
monkeypatch.setenv("NETRA_PROJECT", "p1")
|
|
20
|
+
monkeypatch.setenv("NETRA_ENVIRONMENT", "staging")
|
|
21
|
+
cfg = resolve()
|
|
22
|
+
assert cfg.api_key == "sk_live_env"
|
|
23
|
+
assert cfg.endpoint == "http://localhost:8080/v1/otel" # trailing slash stripped
|
|
24
|
+
assert cfg.gateway_host == "localhost:8080" # port preserved
|
|
25
|
+
assert cfg.project == "p1" and cfg.environment == "staging"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_args_beat_env(monkeypatch):
|
|
29
|
+
monkeypatch.setenv("NETRA_API_KEY", "sk_live_env")
|
|
30
|
+
assert resolve(api_key="sk_live_arg").api_key == "sk_live_arg"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_missing_key_raises(monkeypatch):
|
|
34
|
+
monkeypatch.delenv("NETRA_API_KEY", raising=False)
|
|
35
|
+
with pytest.raises(NetraConfigError):
|
|
36
|
+
resolve()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
6
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
7
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
8
|
+
|
|
9
|
+
from netra_observe._inject import install, uninstall
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(autouse=True)
|
|
13
|
+
def _clean_patch():
|
|
14
|
+
yield
|
|
15
|
+
uninstall()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tracer():
|
|
19
|
+
exporter = InMemorySpanExporter()
|
|
20
|
+
provider = TracerProvider()
|
|
21
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
22
|
+
return provider.get_tracer("test"), exporter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_injects_ambient_span_for_gateway_host(capture_server):
|
|
26
|
+
host, captured = capture_server
|
|
27
|
+
install(host)
|
|
28
|
+
tracer, exporter = _tracer()
|
|
29
|
+
with tracer.start_as_current_span("llm call"):
|
|
30
|
+
httpx.get(f"http://{host}/v1/chat/completions")
|
|
31
|
+
assert len(captured) == 1
|
|
32
|
+
tp = captured[0].get("traceparent")
|
|
33
|
+
assert tp is not None
|
|
34
|
+
span = exporter.get_finished_spans()[0]
|
|
35
|
+
assert format(span.context.trace_id, "032x") in tp
|
|
36
|
+
assert format(span.context.span_id, "016x") in tp
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_no_injection_for_other_hosts(capture_server):
|
|
40
|
+
host, captured = capture_server
|
|
41
|
+
install("api.netraruntime.com") # patch active, but scope differs
|
|
42
|
+
tracer, _ = _tracer()
|
|
43
|
+
with tracer.start_as_current_span("llm call"):
|
|
44
|
+
httpx.get(f"http://{host}/anything")
|
|
45
|
+
assert "traceparent" not in captured[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_no_span_no_header(capture_server):
|
|
49
|
+
host, captured = capture_server
|
|
50
|
+
install(host)
|
|
51
|
+
httpx.get(f"http://{host}/v1/chat/completions")
|
|
52
|
+
assert "traceparent" not in captured[0]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_install_idempotent_and_uninstall_restores(capture_server):
|
|
56
|
+
host, captured = capture_server
|
|
57
|
+
original = httpx.Client.send
|
|
58
|
+
install(host)
|
|
59
|
+
install(host) # second call must not double-wrap
|
|
60
|
+
patched = httpx.Client.send
|
|
61
|
+
uninstall()
|
|
62
|
+
assert httpx.Client.send is original
|
|
63
|
+
uninstall() # second uninstall is a no-op
|
|
64
|
+
assert httpx.Client.send is original
|
|
65
|
+
assert patched is not original
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_langchain_run_span_wins_over_ambient(capture_server):
|
|
69
|
+
"""Inside a LangChain run, the OpenInference run span (not ambient
|
|
70
|
+
context) must be the injected parent — that's the ledger-join contract."""
|
|
71
|
+
from langchain_core.runnables import RunnableLambda
|
|
72
|
+
from openinference.instrumentation.langchain import LangChainInstrumentor
|
|
73
|
+
|
|
74
|
+
host, captured = capture_server
|
|
75
|
+
exporter = InMemorySpanExporter()
|
|
76
|
+
provider = TracerProvider()
|
|
77
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
78
|
+
LangChainInstrumentor().instrument(tracer_provider=provider)
|
|
79
|
+
try:
|
|
80
|
+
install(host)
|
|
81
|
+
RunnableLambda(
|
|
82
|
+
lambda _: httpx.get(f"http://{host}/v1/chat/completions").status_code,
|
|
83
|
+
name="fake llm",
|
|
84
|
+
).invoke("hi")
|
|
85
|
+
finally:
|
|
86
|
+
LangChainInstrumentor().uninstrument()
|
|
87
|
+
tp = captured[0].get("traceparent")
|
|
88
|
+
assert tp is not None
|
|
89
|
+
run_span = exporter.get_finished_spans()[0]
|
|
90
|
+
assert format(run_span.context.trace_id, "032x") in tp
|
|
91
|
+
assert format(run_span.context.span_id, "016x") in tp
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_bare_chat_model_invoke_tags_the_llm_span(capture_server):
|
|
95
|
+
"""A BARE llm.invoke() (no surrounding chain — LangChain's config
|
|
96
|
+
contextvar is unset) must still inject a traceparent carrying the
|
|
97
|
+
OpenInference LLM span's ids. This is the exact prod-e2e failure mode:
|
|
98
|
+
get_current_span() only works under a parent runnable, so the run-holder
|
|
99
|
+
callback is the source of truth."""
|
|
100
|
+
from langchain_openai import ChatOpenAI
|
|
101
|
+
from openinference.instrumentation.langchain import LangChainInstrumentor
|
|
102
|
+
|
|
103
|
+
from netra_observe._runholder import activate, deactivate
|
|
104
|
+
|
|
105
|
+
host, captured = capture_server
|
|
106
|
+
exporter = InMemorySpanExporter()
|
|
107
|
+
provider = TracerProvider()
|
|
108
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
109
|
+
LangChainInstrumentor().instrument(tracer_provider=provider)
|
|
110
|
+
activate()
|
|
111
|
+
try:
|
|
112
|
+
install(host)
|
|
113
|
+
llm = ChatOpenAI(
|
|
114
|
+
model="m",
|
|
115
|
+
base_url=f"http://{host}/v1",
|
|
116
|
+
api_key="sk_live_x",
|
|
117
|
+
max_retries=0,
|
|
118
|
+
)
|
|
119
|
+
with pytest.raises(Exception):
|
|
120
|
+
llm.invoke("hi") # capture server answers GETs only → client errors
|
|
121
|
+
finally:
|
|
122
|
+
deactivate()
|
|
123
|
+
LangChainInstrumentor().uninstrument()
|
|
124
|
+
|
|
125
|
+
tp = next(
|
|
126
|
+
(h.get("traceparent") for h in captured if "traceparent" in h), None
|
|
127
|
+
)
|
|
128
|
+
assert tp is not None, "bare invoke sent no traceparent"
|
|
129
|
+
llm_spans = [s for s in exporter.get_finished_spans() if s.name == "ChatOpenAI"]
|
|
130
|
+
assert llm_spans, "OpenInference did not produce a ChatOpenAI span"
|
|
131
|
+
span = llm_spans[0]
|
|
132
|
+
assert format(span.context.trace_id, "032x") in tp
|
|
133
|
+
assert format(span.context.span_id, "016x") in tp
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
6
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
7
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
8
|
+
|
|
9
|
+
import netra_observe
|
|
10
|
+
from netra_observe import NetraInstrumentation, instrument
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _teardown():
|
|
15
|
+
yield
|
|
16
|
+
netra_observe._reset_for_tests()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_instrument_traces_a_langchain_run():
|
|
20
|
+
exporter = InMemorySpanExporter()
|
|
21
|
+
provider = TracerProvider()
|
|
22
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
23
|
+
handle = instrument(api_key="sk_live_k", tracer_provider=provider)
|
|
24
|
+
assert isinstance(handle, NetraInstrumentation)
|
|
25
|
+
|
|
26
|
+
from langchain_core.runnables import RunnableLambda
|
|
27
|
+
|
|
28
|
+
RunnableLambda(lambda x: x + 1, name="step").invoke(1)
|
|
29
|
+
handle.flush()
|
|
30
|
+
names = [s.name for s in exporter.get_finished_spans()]
|
|
31
|
+
assert "step" in names # OpenInference traced the run
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_instrument_is_idempotent():
|
|
35
|
+
provider = TracerProvider()
|
|
36
|
+
h1 = instrument(api_key="sk_live_k", tracer_provider=provider)
|
|
37
|
+
h2 = instrument(api_key="sk_live_k", tracer_provider=provider)
|
|
38
|
+
assert h1 is h2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_shutdown_restores_httpx():
|
|
42
|
+
original = httpx.Client.send
|
|
43
|
+
handle = instrument(api_key="sk_live_k", tracer_provider=TracerProvider())
|
|
44
|
+
assert httpx.Client.send is not original
|
|
45
|
+
handle.shutdown()
|
|
46
|
+
assert httpx.Client.send is original
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_attach_mode_does_not_own_provider():
|
|
50
|
+
provider = TracerProvider()
|
|
51
|
+
handle = instrument(api_key="sk_live_k", tracer_provider=provider)
|
|
52
|
+
handle.shutdown()
|
|
53
|
+
# user-owned provider must still be usable after our shutdown
|
|
54
|
+
provider.get_tracer("t").start_span("still alive").end()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from netra_observe._config import resolve
|
|
4
|
+
from netra_observe._provider import build_exporter, build_provider, build_resource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def cfg(**kw):
|
|
8
|
+
kw.setdefault("api_key", "sk_live_k")
|
|
9
|
+
return resolve(**kw)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_resource_attrs():
|
|
13
|
+
r = build_resource(cfg(project="support-agent", environment="prod"))
|
|
14
|
+
a = r.attributes
|
|
15
|
+
assert a["netra.project"] == "support-agent"
|
|
16
|
+
assert a["deployment.environment"] == "prod"
|
|
17
|
+
assert a["service.name"] == "support-agent"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_resource_defaults():
|
|
21
|
+
a = build_resource(cfg()).attributes
|
|
22
|
+
assert a["service.name"] == "netra-observe"
|
|
23
|
+
assert "netra.project" not in a and "deployment.environment" not in a
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_exporter_endpoint_and_auth():
|
|
27
|
+
e = build_exporter(cfg(endpoint="http://localhost:9999/v1/otel"))
|
|
28
|
+
assert e._endpoint == "http://localhost:9999/v1/otel/v1/traces"
|
|
29
|
+
# header key case varies across otlp-exporter versions — compare folded
|
|
30
|
+
headers = {k.lower(): v for k, v in dict(e._headers).items()}
|
|
31
|
+
assert headers.get("authorization") == "Bearer sk_live_k"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_provider_has_batch_processor():
|
|
35
|
+
p = build_provider(cfg())
|
|
36
|
+
# TracerProvider stores processors on a composite; presence is enough.
|
|
37
|
+
assert p._active_span_processor._span_processors # non-empty
|
|
38
|
+
p.shutdown()
|