agent-observatory-cli 0.1.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.
observatory/auto.py ADDED
@@ -0,0 +1,212 @@
1
+ """Launch a Python agent with model and framework instrumentation enabled."""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ import importlib.metadata
6
+ import importlib.util
7
+ import os
8
+ import platform
9
+ import queue
10
+ import runpy
11
+ import secrets
12
+ import socket
13
+ import sys
14
+ import threading
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+ from pathlib import Path
19
+ from urllib.parse import urlsplit
20
+
21
+ from .client import Client
22
+
23
+
24
+ class SetupError(Exception):
25
+ """A problem the user can fix before their agent starts."""
26
+
27
+
28
+ INTEGRATIONS = (
29
+ ("openai", "openai", "OpenAIInstrumentor"),
30
+ ("anthropic", "anthropic", "AnthropicInstrumentor"),
31
+ ("langchain_core", "langchain", "LangChainInstrumentor"),
32
+ )
33
+
34
+
35
+ def _receiver_endpoint(endpoint):
36
+ parsed = urlsplit(endpoint)
37
+ if (parsed.scheme not in {"http", "https"} or not parsed.hostname or
38
+ parsed.username or parsed.password or parsed.query or parsed.fragment):
39
+ raise SetupError("Use the dashboard's HTTP address for --endpoint, such as http://127.0.0.1:4318.")
40
+ return endpoint.rstrip("/")
41
+
42
+
43
+ def _check_receiver(endpoint, token):
44
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
45
+ try:
46
+ with urllib.request.urlopen(urllib.request.Request(endpoint + "/api/health", headers=headers),
47
+ timeout=3) as response:
48
+ if response.status != 200:
49
+ raise SetupError("The Observatory receiver is not ready.")
50
+ except urllib.error.HTTPError as exc:
51
+ if exc.code == 401:
52
+ raise SetupError("This receiver needs a token. Set OBSERVATORY_TOKEN in your agent's terminal.") from exc
53
+ raise SetupError(f"The receiver returned HTTP {exc.code}. Check --endpoint.") from exc
54
+ except (OSError, urllib.error.URLError) as exc:
55
+ raise SetupError(f"Cannot reach Observatory at {endpoint}. Start the dashboard first, then retry.") from exc
56
+
57
+
58
+ class _Connection:
59
+ def __init__(self, endpoint, service, integrations, capture_content, token, source_id, events):
60
+ self.client = Client(endpoint=endpoint, service=service, token=token, timeout=2)
61
+ self.payload = {"source_id": source_id, "service": service,
62
+ "runtime": f"Python {platform.python_version()}",
63
+ "integrations": integrations, "capture_content": capture_content,
64
+ "process_id": os.getpid(), "state": "running"}
65
+ self.stop = threading.Event()
66
+ self.events = events
67
+ self.thread = threading.Thread(target=self._heartbeat, name="observatory-connection", daemon=True)
68
+
69
+ def start(self):
70
+ if not self.client._post("/api/connections", self.payload):
71
+ raise SetupError("Could not register this agent with Observatory. Check the receiver address and token.")
72
+ self.thread.start()
73
+
74
+ def _heartbeat(self):
75
+ next_heartbeat = time.monotonic() + 10
76
+ while not self.stop.is_set() or not self.events.empty():
77
+ try:
78
+ event = self.events.get(timeout=0.5)
79
+ self.client._post("/api/progress", event)
80
+ except queue.Empty:
81
+ pass
82
+ if time.monotonic() >= next_heartbeat:
83
+ self.client._post("/api/connections", self.payload)
84
+ next_heartbeat = time.monotonic() + 10
85
+
86
+ def close(self):
87
+ self.stop.set()
88
+ if self.thread.is_alive():
89
+ self.thread.join(timeout=3)
90
+ self.client._post("/api/connections", {**self.payload, "state": "stopped"})
91
+
92
+
93
+ def run_agent(target, arguments=(), *, endpoint="http://127.0.0.1:4318", service="my-agent",
94
+ module=False, capture_content=False):
95
+ """Instrument installed SDKs, then run a script or module in this interpreter."""
96
+ endpoint = _receiver_endpoint(endpoint)
97
+ service = service.strip()
98
+ if not service or len(service) > 128:
99
+ raise SetupError("Choose a service name between 1 and 128 characters.")
100
+ path = Path(target).resolve() if not module else None
101
+ if path is not None and not path.is_file():
102
+ raise SetupError(f"Cannot find {target}. Run this command from your agent's folder or supply its full path.")
103
+ try:
104
+ from openinference.instrumentation import TraceConfig
105
+ from opentelemetry import trace
106
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
107
+ from opentelemetry.sdk.resources import Resource
108
+ from opentelemetry.sdk.trace import SpanLimits, TracerProvider
109
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
110
+ from opentelemetry.sdk.trace import SpanProcessor
111
+ from packaging.requirements import Requirement
112
+ except ImportError as exc:
113
+ raise SetupError('Automatic capture is not installed in this Python environment. Install this checkout with: python -m pip install ".[auto]"') from exc
114
+
115
+ token = os.environ.get("OBSERVATORY_TOKEN")
116
+ _check_receiver(endpoint, token)
117
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
118
+ source_id = secrets.token_hex(16)
119
+ events = queue.Queue(maxsize=256)
120
+ provider = TracerProvider(resource=Resource.create({"service.name": service,
121
+ "host.name": socket.gethostname(), "host.arch": platform.machine(),
122
+ "os.type": platform.system().lower(), "observatory.capture": "automatic",
123
+ "observatory.source_id": source_id}),
124
+ span_limits=SpanLimits(max_attribute_length=4000))
125
+ provider.add_span_processor(BatchSpanProcessor(
126
+ OTLPSpanExporter(endpoint=endpoint + "/v1/traces", headers=headers, timeout=3),
127
+ schedule_delay_millis=500, max_export_batch_size=64))
128
+
129
+ class FrameworkLinks(SpanProcessor):
130
+ def on_start(self, span, parent_context=None):
131
+ # LangChain callbacks intentionally do not attach their spans to the
132
+ # Python runtime context. Link SDK spans to that active chain instead.
133
+ if span.parent is not None or importlib.util.find_spec("langchain_core") is None:
134
+ return
135
+ try:
136
+ from openinference.instrumentation.langchain import get_current_span
137
+ parent = get_current_span()
138
+ if parent is not None and parent.get_span_context().is_valid:
139
+ span.add_link(parent.get_span_context(), {"observatory.link.reason": "langchain.runtime_context"})
140
+ except Exception:
141
+ pass
142
+
143
+ provider.add_span_processor(FrameworkLinks())
144
+
145
+ class LiveProgress(SpanProcessor):
146
+ def _send(self, span, stage):
147
+ scope = getattr(span, "instrumentation_scope", None)
148
+ if scope is None or scope.name not in {"openinference.instrumentation.openai", "openinference.instrumentation.anthropic"}:
149
+ return
150
+ event = {"trace_id": f"{span.context.trace_id:032x}", "stage": stage,
151
+ "message": f"{service} · {span.name}", "service": service}
152
+ try:
153
+ events.put_nowait(event)
154
+ except queue.Full:
155
+ pass
156
+
157
+ def on_start(self, span, parent_context=None):
158
+ self._send(span, "Model call started")
159
+
160
+ def on_end(self, span):
161
+ self._send(span, "Model call finished")
162
+
163
+ provider.add_span_processor(LiveProgress())
164
+ trace.set_tracer_provider(provider)
165
+ config = TraceConfig(hide_inputs=not capture_content, hide_outputs=not capture_content,
166
+ hide_input_images=True, hide_embeddings_vectors=True,
167
+ hide_embeddings_text=not capture_content,
168
+ hide_llm_invocation_parameters=not capture_content)
169
+ enabled, failures, instrumentors = [], [], []
170
+ connection = None
171
+ original_argv, original_path = sys.argv[:], sys.path[:]
172
+ try:
173
+ for sdk, name, class_name in INTEGRATIONS:
174
+ if importlib.util.find_spec(sdk) is None:
175
+ continue
176
+ try:
177
+ package = importlib.import_module(f"openinference.instrumentation.{name}")
178
+ instrumentor = getattr(package, class_name)()
179
+ for dependency in instrumentor.instrumentation_dependencies():
180
+ required = Requirement(dependency)
181
+ installed = importlib.metadata.version(required.name)
182
+ if installed not in required.specifier:
183
+ raise SetupError(f"requires {required}; found {installed}. Use a matching integration version or update that SDK.")
184
+ instrumentor.instrument(tracer_provider=provider, config=config)
185
+ if not instrumentor.is_instrumented_by_opentelemetry:
186
+ raise SetupError("SDK and instrumentor versions do not match")
187
+ instrumentors.append(instrumentor)
188
+ enabled.append(name)
189
+ except Exception as exc:
190
+ failures.append(f"{name}: {exc}")
191
+ if not enabled:
192
+ hint = " ".join(failures) if failures else "No supported SDK found."
193
+ raise SetupError("Automatic capture needs the OpenAI SDK, Anthropic SDK, or LangChain in this same Python environment. " + hint)
194
+ if failures:
195
+ print("Observatory: some integrations could not start: " + ", ".join(failures), file=sys.stderr)
196
+ connection = _Connection(endpoint, service, enabled, capture_content, token, source_id, events)
197
+ connection.start()
198
+ print(f"Observatory is listening to {service}: {', '.join(enabled)}.\nDashboard: {endpoint}", file=sys.stderr)
199
+ sys.argv = [target, *arguments]
200
+ if module:
201
+ runpy.run_module(target, run_name="__main__", alter_sys=True)
202
+ else:
203
+ sys.path.insert(0, str(path.parent))
204
+ runpy.run_path(str(path), run_name="__main__")
205
+ finally:
206
+ # Finished streaming calls are exported before the connection is marked stopped.
207
+ provider.shutdown()
208
+ if connection is not None:
209
+ connection.close()
210
+ for instrumentor in reversed(instrumentors):
211
+ instrumentor.uninstrument()
212
+ sys.argv, sys.path = original_argv, original_path
observatory/client.py ADDED
@@ -0,0 +1,253 @@
1
+ """Small, dependency-free client for instrumenting Python agent code."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import contextvars
6
+ import json
7
+ import secrets
8
+ import time
9
+ import urllib.request
10
+ from typing import Any
11
+
12
+ _ACTIVE_RUN: contextvars.ContextVar["Run | None"] = contextvars.ContextVar("observatory_run", default=None)
13
+ _ACTIVE_SPAN: contextvars.ContextVar["Span | None"] = contextvars.ContextVar("observatory_span", default=None)
14
+ _AUTO_PARENT = object()
15
+ MAX_REQUEST_BYTES = 8 * 1024 * 1024
16
+
17
+
18
+ def _random_hex(byte_count):
19
+ value = secrets.token_hex(byte_count)
20
+ while not any(char != "0" for char in value):
21
+ value = secrets.token_hex(byte_count)
22
+ return value
23
+
24
+
25
+ class Client:
26
+ """Emit framework-neutral spans and progress to an Observatory receiver."""
27
+
28
+ def __init__(self, endpoint="http://127.0.0.1:4318", service="python-agent",
29
+ token=None, timeout=3.0, strict=False, capture_exception_message=False,
30
+ resource_attributes=None):
31
+ self.endpoint = endpoint.rstrip("/")
32
+ self.service = str(service)
33
+ self.token = token
34
+ self.timeout = float(timeout)
35
+ self.strict = bool(strict)
36
+ self.capture_exception_message = bool(capture_exception_message)
37
+ self.resource_attributes = dict(resource_attributes or {})
38
+ self.last_error: Exception | None = None
39
+
40
+ def trace(self, name="agent.run", *, task=None, session_id=None, attributes=None):
41
+ return Run(self, name, task, session_id, attributes or {})
42
+
43
+ def _post_spans(self, spans):
44
+ base = {"service": self.service, "spans": []}
45
+ base_size = len(json.dumps(base, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
46
+ batch = []
47
+ batch_size = base_size
48
+ successful = True
49
+ last_error = None
50
+
51
+ def send():
52
+ nonlocal successful, last_error, batch, batch_size
53
+ if not batch:
54
+ return
55
+ if not self._post("/api/spans", {"service": self.service, "spans": batch}):
56
+ successful = False
57
+ last_error = self.last_error
58
+ batch = []
59
+ batch_size = base_size
60
+
61
+ for span in spans:
62
+ try:
63
+ item_size = len(json.dumps(span, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
64
+ except Exception as exc:
65
+ self.last_error = exc
66
+ if self.strict:
67
+ raise
68
+ successful = False
69
+ last_error = exc
70
+ continue
71
+ if base_size + item_size > MAX_REQUEST_BYTES:
72
+ exc = ValueError("a span exceeds the receiver's 8 MiB request limit")
73
+ self.last_error = exc
74
+ if self.strict:
75
+ raise exc
76
+ successful = False
77
+ last_error = exc
78
+ continue
79
+ separator = 1 if batch else 0
80
+ if batch and batch_size + separator + item_size > MAX_REQUEST_BYTES:
81
+ send()
82
+ separator = 0
83
+ batch.append(span)
84
+ batch_size += separator + item_size
85
+ send()
86
+ if not successful:
87
+ self.last_error = last_error
88
+ return successful
89
+
90
+ def _post(self, path, payload):
91
+ try:
92
+ data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
93
+ headers = {"Content-Type": "application/json"}
94
+ if self.token:
95
+ headers["Authorization"] = f"Bearer {self.token}"
96
+ request = urllib.request.Request(self.endpoint + path, data=data,
97
+ headers=headers, method="POST")
98
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
99
+ if not 200 <= response.status < 300:
100
+ raise OSError(f"Observatory returned HTTP {response.status}")
101
+ return True
102
+ except Exception as exc:
103
+ self.last_error = exc
104
+ if self.strict:
105
+ raise
106
+ return False
107
+
108
+
109
+ class Run:
110
+ def __init__(self, client, name, task, session_id, attributes):
111
+ self.client = client
112
+ self.trace_id = _random_hex(16)
113
+ self.session_id = str(session_id).strip() if session_id is not None else None
114
+ if self.session_id is not None and (not self.session_id or len(self.session_id) > 512):
115
+ raise ValueError("session_id must contain 1 to 512 characters")
116
+ self.spans: list[Span] = []
117
+ root_attributes = dict(attributes)
118
+ if task is not None:
119
+ root_attributes.setdefault("agent.task", str(task))
120
+ if self.session_id:
121
+ root_attributes.setdefault("session.id", self.session_id)
122
+ self.root = Span(self, name, root_attributes, parent_span_id=None)
123
+ self.spans.append(self.root)
124
+ self._run_token = None
125
+ self._closed = False
126
+
127
+ def __enter__(self):
128
+ if self._closed or self._run_token is not None:
129
+ raise RuntimeError("run cannot be entered more than once")
130
+ self._run_token = _ACTIVE_RUN.set(self)
131
+ self.root.__enter__()
132
+ return self
133
+
134
+ def __exit__(self, exc_type, exc, tb):
135
+ self._finish(exc_type, exc, tb)
136
+ self._flush()
137
+ return False
138
+
139
+ async def __aenter__(self):
140
+ return self.__enter__()
141
+
142
+ async def __aexit__(self, exc_type, exc, tb):
143
+ self._finish(exc_type, exc, tb)
144
+ await asyncio.to_thread(self._flush)
145
+ return False
146
+
147
+ def _finish(self, exc_type, exc, tb):
148
+ if self._closed:
149
+ return
150
+ self.root.__exit__(exc_type, exc, tb)
151
+ if self._run_token is not None:
152
+ _ACTIVE_RUN.reset(self._run_token)
153
+ self._run_token = None
154
+ self._closed = True
155
+
156
+ def span(self, name, *, attributes=None, parent_span_id=_AUTO_PARENT):
157
+ if self._closed:
158
+ raise RuntimeError("run is already closed")
159
+ if parent_span_id is _AUTO_PARENT:
160
+ active = _ACTIVE_SPAN.get()
161
+ parent_span_id = active.span_id if active is not None and active.run is self else self.root.span_id
162
+ span = Span(self, name, attributes or {}, parent_span_id=parent_span_id)
163
+ self.spans.append(span)
164
+ return span
165
+
166
+ def progress(self, stage, message="", percent=None):
167
+ if self._closed:
168
+ raise RuntimeError("run is already closed")
169
+ payload = {"trace_id": self.trace_id, "stage": str(stage), "message": str(message), "service": self.client.service}
170
+ if percent is not None:
171
+ payload["percent"] = percent
172
+ if self.session_id:
173
+ payload["session_id"] = self.session_id
174
+ return self.client._post("/api/progress", payload)
175
+
176
+ async def aprogress(self, stage, message="", percent=None):
177
+ return await asyncio.to_thread(self.progress, stage, message, percent)
178
+
179
+ def _flush(self):
180
+ payload = []
181
+ for span in self.spans:
182
+ payload.append({"trace_id": self.trace_id, "span_id": span.span_id,
183
+ "parent_span_id": span.parent_span_id, "name": span.name,
184
+ "start_unix_nano": span.start_ns, "end_unix_nano": span.end_ns,
185
+ "status": span.status, "attributes": span.attributes,
186
+ "events": span.events,
187
+ "resource_attributes": self.client.resource_attributes})
188
+ return self.client._post_spans(payload)
189
+
190
+
191
+ class Span:
192
+ def __init__(self, run, name, attributes, parent_span_id):
193
+ self.run = run
194
+ self.name = str(name)
195
+ self.span_id = _random_hex(8)
196
+ self.parent_span_id = parent_span_id
197
+ self.start_ns = time.time_ns()
198
+ self.end_ns = self.start_ns
199
+ self.status = "UNSET"
200
+ self.attributes: dict[str, Any] = dict(attributes)
201
+ self.events: list[dict[str, Any]] = []
202
+ self._context_token = None
203
+ self._finished = False
204
+
205
+ def __enter__(self):
206
+ if self._context_token is not None or self._finished:
207
+ raise RuntimeError("span cannot be entered more than once")
208
+ self._context_token = _ACTIVE_SPAN.set(self)
209
+ return self
210
+
211
+ def __exit__(self, exc_type, exc, tb):
212
+ self.finish(exc)
213
+ if self._context_token is not None:
214
+ _ACTIVE_SPAN.reset(self._context_token)
215
+ self._context_token = None
216
+ return False
217
+
218
+ def finish(self, error=None):
219
+ if self._finished:
220
+ return self
221
+ if error is not None:
222
+ self.status = "ERROR"
223
+ self.attributes.setdefault("exception.type", type(error).__name__)
224
+ exception = {"type": type(error).__name__}
225
+ if self.run.client.capture_exception_message:
226
+ message = str(error)
227
+ self.attributes.setdefault("exception.message", message)
228
+ exception["message"] = message
229
+ self.add_event("exception", exception)
230
+ self.end_ns = max(time.time_ns(), self.start_ns)
231
+ self._finished = True
232
+ return self
233
+
234
+ async def __aenter__(self):
235
+ return self.__enter__()
236
+
237
+ async def __aexit__(self, exc_type, exc, tb):
238
+ return self.__exit__(exc_type, exc, tb)
239
+
240
+ def set_attribute(self, key, value):
241
+ self.attributes[str(key)] = value
242
+ return self
243
+
244
+ def add_event(self, name, attributes=None):
245
+ self.events.append({"name": str(name), "time_unix_nano": time.time_ns(),
246
+ "attributes": dict(attributes or {})})
247
+ return self
248
+
249
+ def set_status(self, status):
250
+ if status not in {"UNSET", "OK", "ERROR"}:
251
+ raise ValueError("status must be UNSET, OK, or ERROR")
252
+ self.status = status
253
+ return self