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.
- agent_observatory_cli-0.1.0.dist-info/METADATA +115 -0
- agent_observatory_cli-0.1.0.dist-info/RECORD +31 -0
- agent_observatory_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_observatory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_observatory_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_observatory_cli-0.1.0.dist-info/top_level.txt +1 -0
- observatory/__init__.py +5 -0
- observatory/__main__.py +105 -0
- observatory/activity.css +10 -0
- observatory/activity.js +116 -0
- observatory/auto.py +212 -0
- observatory/client.py +253 -0
- observatory/core.py +1654 -0
- observatory/distribution.py +67 -0
- observatory/findings.py +176 -0
- observatory/index.html +84 -0
- observatory/integrations/__init__.py +1 -0
- observatory/integrations/langchain.py +234 -0
- observatory/integrations/openai_agents.py +177 -0
- observatory/local_activity.py +207 -0
- observatory/local_agents.py +394 -0
- observatory/local_runtime.py +49 -0
- observatory/logs.py +109 -0
- observatory/metrics.py +281 -0
- observatory/otlp_proto.py +210 -0
- observatory/server.py +368 -0
- observatory/setup.css +40 -0
- observatory/setup.js +145 -0
- observatory/terminal.py +525 -0
- observatory/terminal_ui.py +1299 -0
- observatory/watch.py +62 -0
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
|