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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
@@ -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()