myobs 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.
- myobs/__init__.py +37 -0
- myobs/adapters.py +135 -0
- myobs/client.py +407 -0
- myobs/context.py +25 -0
- myobs/ids.py +14 -0
- myobs/privacy.py +63 -0
- myobs/schema.py +49 -0
- myobs/transport.py +112 -0
- myobs/version.py +2 -0
- myobs-0.1.0.dist-info/METADATA +68 -0
- myobs-0.1.0.dist-info/RECORD +13 -0
- myobs-0.1.0.dist-info/WHEEL +5 -0
- myobs-0.1.0.dist-info/top_level.txt +1 -0
myobs/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
myobs — provider-agnostic AI observability SDK (explicit / function-based).
|
|
3
|
+
|
|
4
|
+
Quick start
|
|
5
|
+
-----------
|
|
6
|
+
import myobs
|
|
7
|
+
myobs.init(api_key="sk_obs_...", endpoint="http://localhost:8000")
|
|
8
|
+
|
|
9
|
+
resp = openai_client.chat.completions.create(...) # your call, your key
|
|
10
|
+
myobs.log_llm("openai", response=resp, latency_ms=812, user_id="usr_1")
|
|
11
|
+
|
|
12
|
+
The SDK never wraps your client and never sees your provider API key. It only
|
|
13
|
+
records what you explicitly hand it.
|
|
14
|
+
"""
|
|
15
|
+
from .client import (
|
|
16
|
+
init,
|
|
17
|
+
log_llm,
|
|
18
|
+
trace,
|
|
19
|
+
span,
|
|
20
|
+
observe,
|
|
21
|
+
flush,
|
|
22
|
+
shutdown,
|
|
23
|
+
)
|
|
24
|
+
from .schema import Usage
|
|
25
|
+
from .version import __version__
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"init",
|
|
29
|
+
"log_llm",
|
|
30
|
+
"trace",
|
|
31
|
+
"span",
|
|
32
|
+
"observe",
|
|
33
|
+
"flush",
|
|
34
|
+
"shutdown",
|
|
35
|
+
"Usage",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
myobs/adapters.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider adapters — the *only* place provider-specific knowledge lives.
|
|
3
|
+
|
|
4
|
+
An adapter is a pure function: provider response object -> (Usage, model).
|
|
5
|
+
It runs inside the customer process (they passed us the response object), so it
|
|
6
|
+
never needs network access or credentials. Anything without an adapter still
|
|
7
|
+
works: the caller passes `usage=` manually.
|
|
8
|
+
"""
|
|
9
|
+
from typing import Any, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .schema import Usage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get(obj: Any, *names, default=None):
|
|
15
|
+
"""Read a field whether obj is a dict or an attribute-style object."""
|
|
16
|
+
for name in names:
|
|
17
|
+
if obj is None:
|
|
18
|
+
return default
|
|
19
|
+
if isinstance(obj, dict):
|
|
20
|
+
if name in obj and obj[name] is not None:
|
|
21
|
+
return obj[name]
|
|
22
|
+
else:
|
|
23
|
+
val = getattr(obj, name, None)
|
|
24
|
+
if val is not None:
|
|
25
|
+
return val
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _int(v) -> int:
|
|
30
|
+
try:
|
|
31
|
+
return int(v)
|
|
32
|
+
except (TypeError, ValueError):
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Per-provider extractors ────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def extract_openai(resp: Any) -> Tuple[Usage, Optional[str]]:
|
|
39
|
+
usage = _get(resp, "usage")
|
|
40
|
+
model = _get(resp, "model")
|
|
41
|
+
u = Usage(
|
|
42
|
+
# Chat Completions API
|
|
43
|
+
input_tokens=_int(_get(usage, "prompt_tokens", "input_tokens")),
|
|
44
|
+
output_tokens=_int(_get(usage, "completion_tokens", "output_tokens")),
|
|
45
|
+
total_tokens=_int(_get(usage, "total_tokens")),
|
|
46
|
+
)
|
|
47
|
+
# cached prompt tokens (prompt_tokens_details.cached_tokens)
|
|
48
|
+
details = _get(usage, "prompt_tokens_details")
|
|
49
|
+
if details:
|
|
50
|
+
u.cache_read_tokens = _int(_get(details, "cached_tokens"))
|
|
51
|
+
return u.finalize(), model
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_anthropic(resp: Any) -> Tuple[Usage, Optional[str]]:
|
|
55
|
+
usage = _get(resp, "usage")
|
|
56
|
+
model = _get(resp, "model")
|
|
57
|
+
u = Usage(
|
|
58
|
+
input_tokens=_int(_get(usage, "input_tokens")),
|
|
59
|
+
output_tokens=_int(_get(usage, "output_tokens")),
|
|
60
|
+
cache_read_tokens=_int(_get(usage, "cache_read_input_tokens")),
|
|
61
|
+
cache_write_tokens=_int(_get(usage, "cache_creation_input_tokens")),
|
|
62
|
+
)
|
|
63
|
+
return u.finalize(), model
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract_gemini(resp: Any) -> Tuple[Usage, Optional[str]]:
|
|
67
|
+
meta = _get(resp, "usage_metadata", "usageMetadata")
|
|
68
|
+
model = _get(resp, "model", "model_version")
|
|
69
|
+
u = Usage(
|
|
70
|
+
input_tokens=_int(_get(meta, "prompt_token_count", "promptTokenCount")),
|
|
71
|
+
output_tokens=_int(_get(meta, "candidates_token_count", "candidatesTokenCount")),
|
|
72
|
+
total_tokens=_int(_get(meta, "total_token_count", "totalTokenCount")),
|
|
73
|
+
reasoning_tokens=_int(_get(meta, "thoughts_token_count", "thoughtsTokenCount")),
|
|
74
|
+
)
|
|
75
|
+
return u.finalize(), model
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def extract_bedrock(resp: Any) -> Tuple[Usage, Optional[str]]:
|
|
79
|
+
"""AWS Bedrock Converse API response (dict). For streaming invoke_agent,
|
|
80
|
+
mine the trace stream yourself and pass usage= manually."""
|
|
81
|
+
usage = _get(resp, "usage")
|
|
82
|
+
u = Usage(
|
|
83
|
+
input_tokens=_int(_get(usage, "inputTokens", "input_tokens")),
|
|
84
|
+
output_tokens=_int(_get(usage, "outputTokens", "output_tokens")),
|
|
85
|
+
total_tokens=_int(_get(usage, "totalTokens", "total_tokens")),
|
|
86
|
+
)
|
|
87
|
+
return u.finalize(), _get(resp, "modelId", "model")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_cohere(resp: Any) -> Tuple[Usage, Optional[str]]:
|
|
91
|
+
meta = _get(resp, "meta")
|
|
92
|
+
billed = _get(meta, "billed_units", "tokens") if meta else None
|
|
93
|
+
u = Usage(
|
|
94
|
+
input_tokens=_int(_get(billed, "input_tokens", "input")),
|
|
95
|
+
output_tokens=_int(_get(billed, "output_tokens", "output")),
|
|
96
|
+
)
|
|
97
|
+
return u.finalize(), _get(resp, "model")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def extract_generic(resp: Any) -> Tuple[Usage, Optional[str]]:
|
|
101
|
+
"""Best-effort: a plain dict/object already using normalized-ish names."""
|
|
102
|
+
usage = _get(resp, "usage") or resp
|
|
103
|
+
u = Usage(
|
|
104
|
+
input_tokens=_int(_get(usage, "input_tokens", "inputTokens", "prompt_tokens")),
|
|
105
|
+
output_tokens=_int(_get(usage, "output_tokens", "outputTokens", "completion_tokens")),
|
|
106
|
+
total_tokens=_int(_get(usage, "total_tokens", "totalTokens")),
|
|
107
|
+
)
|
|
108
|
+
return u.finalize(), _get(resp, "model")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
REGISTRY = {
|
|
112
|
+
"openai": extract_openai,
|
|
113
|
+
"azure_openai": extract_openai,
|
|
114
|
+
"azure-openai": extract_openai,
|
|
115
|
+
"anthropic": extract_anthropic,
|
|
116
|
+
"google": extract_gemini,
|
|
117
|
+
"gemini": extract_gemini,
|
|
118
|
+
"vertexai": extract_gemini,
|
|
119
|
+
"bedrock": extract_bedrock,
|
|
120
|
+
"aws": extract_bedrock,
|
|
121
|
+
"cohere": extract_cohere,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract(provider: Optional[str], response: Any) -> Tuple[Usage, Optional[str]]:
|
|
126
|
+
"""Resolve the adapter for `provider` and extract (Usage, model).
|
|
127
|
+
Unknown providers fall back to a best-effort generic extractor."""
|
|
128
|
+
if response is None:
|
|
129
|
+
return Usage(), None
|
|
130
|
+
fn = REGISTRY.get((provider or "").lower().strip(), extract_generic)
|
|
131
|
+
try:
|
|
132
|
+
return fn(response)
|
|
133
|
+
except Exception:
|
|
134
|
+
# Never let extraction break the caller — return empty usage.
|
|
135
|
+
return Usage(), None
|
myobs/client.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core client + the public capture primitives (log_llm / trace / span / observe).
|
|
3
|
+
|
|
4
|
+
This is the *explicit, function-based* integration (Design A): the customer
|
|
5
|
+
makes their own model calls and hands us the result. We never wrap their client
|
|
6
|
+
and never see their provider API key.
|
|
7
|
+
"""
|
|
8
|
+
import atexit
|
|
9
|
+
import functools
|
|
10
|
+
import inspect
|
|
11
|
+
import logging
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from . import adapters, context, ids
|
|
19
|
+
from .privacy import apply_privacy
|
|
20
|
+
from .schema import Usage
|
|
21
|
+
from .transport import Transport
|
|
22
|
+
from .version import SDK_NAME, __version__
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("myobs")
|
|
25
|
+
|
|
26
|
+
_client: Optional["Client"] = None
|
|
27
|
+
_warned = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now() -> datetime:
|
|
31
|
+
return datetime.now(timezone.utc)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _iso(dt: datetime) -> str:
|
|
35
|
+
return dt.isoformat()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Client:
|
|
39
|
+
def __init__(self, api_key=None, endpoint=None, environment="production",
|
|
40
|
+
release=None, capture_content=True, redact=None,
|
|
41
|
+
max_content_length=4000, sample_rate=1.0, flush_interval=2.0,
|
|
42
|
+
max_batch_size=100, timeout=5.0, debug=False, enabled=True):
|
|
43
|
+
self.environment = environment
|
|
44
|
+
self.release = release
|
|
45
|
+
self.capture_content = capture_content
|
|
46
|
+
self.redact = redact
|
|
47
|
+
self.max_content_length = max_content_length
|
|
48
|
+
self.sample_rate = sample_rate
|
|
49
|
+
self.debug = debug
|
|
50
|
+
self.enabled = enabled
|
|
51
|
+
self.transport = Transport(
|
|
52
|
+
endpoint=endpoint, api_key=api_key, max_batch_size=max_batch_size,
|
|
53
|
+
flush_interval=flush_interval, timeout=timeout, debug=debug,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def enqueue(self, span: dict) -> None:
|
|
57
|
+
if not self.enabled:
|
|
58
|
+
return
|
|
59
|
+
if self.sample_rate < 1.0 and random.random() > self.sample_rate:
|
|
60
|
+
return
|
|
61
|
+
self.transport.submit(span)
|
|
62
|
+
|
|
63
|
+
def flush(self, timeout: float = 5.0) -> None:
|
|
64
|
+
self.transport.flush(timeout)
|
|
65
|
+
|
|
66
|
+
def shutdown(self, timeout: float = 5.0) -> None:
|
|
67
|
+
self.transport.shutdown(timeout)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── module-level lifecycle ──────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def init(api_key: Optional[str] = None, endpoint: Optional[str] = None,
|
|
73
|
+
environment: str = "production", release: Optional[str] = None,
|
|
74
|
+
capture_content: bool = True, redact: Optional[Callable] = None,
|
|
75
|
+
max_content_length: int = 4000, sample_rate: float = 1.0,
|
|
76
|
+
flush_interval: float = 2.0, max_batch_size: int = 100,
|
|
77
|
+
timeout: float = 5.0, debug: bool = False) -> Client:
|
|
78
|
+
"""Initialize the SDK once at startup. Reads MYOBS_API_KEY / MYOBS_ENDPOINT
|
|
79
|
+
from the environment if not passed explicitly."""
|
|
80
|
+
import os
|
|
81
|
+
global _client
|
|
82
|
+
api_key = api_key or os.getenv("MYOBS_API_KEY")
|
|
83
|
+
endpoint = endpoint or os.getenv("MYOBS_ENDPOINT")
|
|
84
|
+
_client = Client(
|
|
85
|
+
api_key=api_key, endpoint=endpoint, environment=environment, release=release,
|
|
86
|
+
capture_content=capture_content, redact=redact,
|
|
87
|
+
max_content_length=max_content_length, sample_rate=sample_rate,
|
|
88
|
+
flush_interval=flush_interval, max_batch_size=max_batch_size,
|
|
89
|
+
timeout=timeout, debug=debug,
|
|
90
|
+
)
|
|
91
|
+
atexit.register(_client.shutdown)
|
|
92
|
+
if debug:
|
|
93
|
+
log.setLevel(logging.INFO)
|
|
94
|
+
if not log.handlers:
|
|
95
|
+
log.addHandler(logging.StreamHandler())
|
|
96
|
+
return _client
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_client() -> Optional[Client]:
|
|
100
|
+
global _warned
|
|
101
|
+
if _client is None and not _warned:
|
|
102
|
+
_warned = True
|
|
103
|
+
log.warning("myobs: init() was not called — capture is a no-op. Call myobs.init(...).")
|
|
104
|
+
return _client
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def flush(timeout: float = 5.0) -> None:
|
|
108
|
+
if _client:
|
|
109
|
+
_client.flush(timeout)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def shutdown(timeout: float = 5.0) -> None:
|
|
113
|
+
if _client:
|
|
114
|
+
_client.shutdown(timeout)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── span construction ───────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def _build_span(*, span_id, trace_id, parent_span_id, name, type, provider,
|
|
120
|
+
model, operation, input, output, model_params, usage: Usage,
|
|
121
|
+
cost, status, error, latency_ms, start_time, end_time,
|
|
122
|
+
user_id, session_id, tags, metadata) -> dict:
|
|
123
|
+
c = _client
|
|
124
|
+
attrs = context.get_attrs()
|
|
125
|
+
|
|
126
|
+
def pick(explicit, key, fallback=None):
|
|
127
|
+
if explicit is not None:
|
|
128
|
+
return explicit
|
|
129
|
+
if key in attrs and attrs[key] is not None:
|
|
130
|
+
return attrs[key]
|
|
131
|
+
return fallback
|
|
132
|
+
|
|
133
|
+
merged_tags = dict(attrs.get("tags") or {})
|
|
134
|
+
if tags:
|
|
135
|
+
merged_tags.update(tags)
|
|
136
|
+
merged_meta = dict(attrs.get("metadata") or {})
|
|
137
|
+
if metadata:
|
|
138
|
+
merged_meta.update(metadata)
|
|
139
|
+
|
|
140
|
+
capture = c.capture_content if c else True
|
|
141
|
+
redact = c.redact if c else None
|
|
142
|
+
max_len = c.max_content_length if c else 4000
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
"span_id": span_id,
|
|
146
|
+
"trace_id": trace_id,
|
|
147
|
+
"parent_span_id": parent_span_id,
|
|
148
|
+
"name": name,
|
|
149
|
+
"type": type,
|
|
150
|
+
"start_time": _iso(start_time),
|
|
151
|
+
"end_time": _iso(end_time),
|
|
152
|
+
"latency_ms": round(latency_ms, 2) if latency_ms is not None else None,
|
|
153
|
+
"provider": provider,
|
|
154
|
+
"model": model,
|
|
155
|
+
"operation": operation,
|
|
156
|
+
"input": apply_privacy(input, capture, redact, max_len),
|
|
157
|
+
"output": apply_privacy(output, capture, redact, max_len),
|
|
158
|
+
"model_params": model_params or None,
|
|
159
|
+
"usage": usage.to_dict(),
|
|
160
|
+
"cost": cost, # cost is computed server-side; left null here
|
|
161
|
+
"status": status,
|
|
162
|
+
"error": error,
|
|
163
|
+
"user_id": pick(user_id, "user_id"),
|
|
164
|
+
"session_id": pick(session_id, "session_id"),
|
|
165
|
+
"environment": pick(None, "environment", c.environment if c else None),
|
|
166
|
+
"release": pick(None, "release", c.release if c else None),
|
|
167
|
+
"tags": merged_tags or None,
|
|
168
|
+
"metadata": merged_meta or None,
|
|
169
|
+
"sdk_name": SDK_NAME,
|
|
170
|
+
"sdk_version": __version__,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _resolve_usage(provider, response, usage) -> tuple:
|
|
175
|
+
"""Combine adapter-extracted usage with any explicit usage/model override."""
|
|
176
|
+
u, model = adapters.extract(provider, response) if response is not None else (Usage(), None)
|
|
177
|
+
if usage:
|
|
178
|
+
if isinstance(usage, Usage):
|
|
179
|
+
u.merge(usage)
|
|
180
|
+
elif isinstance(usage, dict):
|
|
181
|
+
override = Usage(
|
|
182
|
+
input_tokens=int(usage.get("input_tokens", usage.get("inputTokens", 0)) or 0),
|
|
183
|
+
output_tokens=int(usage.get("output_tokens", usage.get("outputTokens", 0)) or 0),
|
|
184
|
+
total_tokens=int(usage.get("total_tokens", usage.get("totalTokens", 0)) or 0),
|
|
185
|
+
cache_read_tokens=int(usage.get("cache_read_tokens", 0) or 0),
|
|
186
|
+
cache_write_tokens=int(usage.get("cache_write_tokens", 0) or 0),
|
|
187
|
+
reasoning_tokens=int(usage.get("reasoning_tokens", 0) or 0),
|
|
188
|
+
)
|
|
189
|
+
u.merge(override)
|
|
190
|
+
return u.finalize(), model
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── public: one-shot log ─────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def log_llm(provider: Optional[str] = None, *, response: Any = None,
|
|
196
|
+
model: Optional[str] = None, usage=None, input: Any = None,
|
|
197
|
+
output: Any = None, latency_ms: Optional[float] = None,
|
|
198
|
+
name: Optional[str] = None, type: str = "llm", operation: str = "chat",
|
|
199
|
+
status: str = "ok", error: Any = None, user_id: Optional[str] = None,
|
|
200
|
+
session_id: Optional[str] = None, tags: Optional[dict] = None,
|
|
201
|
+
metadata: Optional[dict] = None, model_params: Optional[dict] = None) -> None:
|
|
202
|
+
"""Log a single completed LLM/agent call. Out-of-band: call it *after* your
|
|
203
|
+
own model call. Reads usage/model from `response` via the provider adapter,
|
|
204
|
+
or accepts them explicitly (works for any provider)."""
|
|
205
|
+
c = _get_client()
|
|
206
|
+
if c is None:
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
u, adapter_model = _resolve_usage(provider, response, usage)
|
|
210
|
+
model = model or adapter_model
|
|
211
|
+
# If the response carries content and the caller didn't pass output, keep None
|
|
212
|
+
end = _now()
|
|
213
|
+
lat = latency_ms
|
|
214
|
+
start = end
|
|
215
|
+
if lat is not None:
|
|
216
|
+
start = datetime.fromtimestamp(end.timestamp() - lat / 1000.0, tz=timezone.utc)
|
|
217
|
+
span = _build_span(
|
|
218
|
+
span_id=ids.span_id(),
|
|
219
|
+
trace_id=context.current_trace_id.get() or ids.trace_id(),
|
|
220
|
+
parent_span_id=context.current_span_id.get(),
|
|
221
|
+
name=name or f"{provider or 'llm'}.{operation}", type=type,
|
|
222
|
+
provider=provider, model=model, operation=operation,
|
|
223
|
+
input=input, output=output, model_params=model_params, usage=u,
|
|
224
|
+
cost=None, status=status, error=_as_error(error), latency_ms=lat,
|
|
225
|
+
start_time=start, end_time=end, user_id=user_id, session_id=session_id,
|
|
226
|
+
tags=tags, metadata=metadata,
|
|
227
|
+
)
|
|
228
|
+
c.enqueue(span)
|
|
229
|
+
except Exception as exc: # capture must never break the host app
|
|
230
|
+
if c.debug:
|
|
231
|
+
log.warning("myobs.log_llm failed: %s", exc)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _as_error(error) -> Optional[dict]:
|
|
235
|
+
if error is None:
|
|
236
|
+
return None
|
|
237
|
+
if isinstance(error, dict):
|
|
238
|
+
return error
|
|
239
|
+
if isinstance(error, BaseException):
|
|
240
|
+
return {"type": type(error).__name__, "message": str(error)}
|
|
241
|
+
return {"type": "Error", "message": str(error)}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ── public: trace context ────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
class TraceHandle:
|
|
247
|
+
def __init__(self, trace_id: str):
|
|
248
|
+
self.trace_id = trace_id
|
|
249
|
+
|
|
250
|
+
def update(self, **attrs) -> None:
|
|
251
|
+
cur = dict(context.current_attrs.get() or {})
|
|
252
|
+
cur.update({k: v for k, v in attrs.items() if v is not None})
|
|
253
|
+
context.current_attrs.set(cur)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@contextmanager
|
|
257
|
+
def trace(name: Optional[str] = None, *, user_id: Optional[str] = None,
|
|
258
|
+
session_id: Optional[str] = None, tags: Optional[dict] = None,
|
|
259
|
+
metadata: Optional[dict] = None, environment: Optional[str] = None,
|
|
260
|
+
release: Optional[str] = None):
|
|
261
|
+
"""Group everything logged inside this block under one trace_id, sharing
|
|
262
|
+
user/session/tags. Spans and log_llm calls inside attach automatically."""
|
|
263
|
+
tid = ids.trace_id()
|
|
264
|
+
attrs = dict(context.current_attrs.get() or {})
|
|
265
|
+
for k, v in dict(trace_name=name, user_id=user_id, session_id=session_id,
|
|
266
|
+
environment=environment, release=release).items():
|
|
267
|
+
if v is not None:
|
|
268
|
+
attrs[k] = v
|
|
269
|
+
if tags:
|
|
270
|
+
attrs["tags"] = {**(attrs.get("tags") or {}), **tags}
|
|
271
|
+
if metadata:
|
|
272
|
+
attrs["metadata"] = {**(attrs.get("metadata") or {}), **metadata}
|
|
273
|
+
|
|
274
|
+
t1 = context.current_trace_id.set(tid)
|
|
275
|
+
t2 = context.current_attrs.set(attrs)
|
|
276
|
+
try:
|
|
277
|
+
yield TraceHandle(tid)
|
|
278
|
+
finally:
|
|
279
|
+
context.current_trace_id.reset(t1)
|
|
280
|
+
context.current_attrs.reset(t2)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ── public: span context ─────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
class SpanHandle:
|
|
286
|
+
def __init__(self, name, type, operation):
|
|
287
|
+
self.name = name
|
|
288
|
+
self.type = type
|
|
289
|
+
self.operation = operation
|
|
290
|
+
self.provider = None
|
|
291
|
+
self.model = None
|
|
292
|
+
self.input = None
|
|
293
|
+
self.output = None
|
|
294
|
+
self.model_params = None
|
|
295
|
+
self.usage = Usage()
|
|
296
|
+
self.status = "ok"
|
|
297
|
+
self.error = None
|
|
298
|
+
self.tags = None
|
|
299
|
+
self.metadata = None
|
|
300
|
+
self.user_id = None
|
|
301
|
+
self.session_id = None
|
|
302
|
+
self._latency_override = None
|
|
303
|
+
|
|
304
|
+
def log(self, response: Any = None, *, provider: Optional[str] = None,
|
|
305
|
+
model: Optional[str] = None, usage=None, input: Any = None,
|
|
306
|
+
output: Any = None) -> "SpanHandle":
|
|
307
|
+
if provider:
|
|
308
|
+
self.provider = provider
|
|
309
|
+
u, adapter_model = _resolve_usage(provider or self.provider, response, usage)
|
|
310
|
+
self.usage.merge(u)
|
|
311
|
+
self.model = model or adapter_model or self.model
|
|
312
|
+
if input is not None:
|
|
313
|
+
self.input = input
|
|
314
|
+
if output is not None:
|
|
315
|
+
self.output = output
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
def set_input(self, v): self.input = v; return self
|
|
319
|
+
def set_output(self, v): self.output = v; return self
|
|
320
|
+
def set_model(self, v): self.model = v; return self
|
|
321
|
+
def set_provider(self, v): self.provider = v; return self
|
|
322
|
+
def set_params(self, v): self.model_params = v; return self
|
|
323
|
+
def set_tags(self, v): self.tags = v; return self
|
|
324
|
+
def set_usage(self, **kw):
|
|
325
|
+
self.usage.merge(Usage(**kw)); return self
|
|
326
|
+
def set_error(self, err):
|
|
327
|
+
self.status = "error"; self.error = _as_error(err); return self
|
|
328
|
+
def set_latency_ms(self, ms): self._latency_override = ms; return self
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@contextmanager
|
|
332
|
+
def span(name: str, *, type: str = "span", operation: str = "chat",
|
|
333
|
+
provider: Optional[str] = None, user_id: Optional[str] = None,
|
|
334
|
+
session_id: Optional[str] = None, tags: Optional[dict] = None):
|
|
335
|
+
"""Time and record a unit of work (an LLM call, tool call, retrieval, ...).
|
|
336
|
+
Auto-creates a trace if there isn't one, and nests under any parent span."""
|
|
337
|
+
c = _get_client()
|
|
338
|
+
handle = SpanHandle(name, type, operation)
|
|
339
|
+
handle.provider = provider
|
|
340
|
+
handle.user_id = user_id
|
|
341
|
+
handle.session_id = session_id
|
|
342
|
+
handle.tags = tags
|
|
343
|
+
|
|
344
|
+
# Capture the enclosing span/trace *before* we push our own scope.
|
|
345
|
+
parent_span = context.current_span_id.get()
|
|
346
|
+
tid = context.current_trace_id.get() or ids.trace_id()
|
|
347
|
+
sid = ids.span_id()
|
|
348
|
+
|
|
349
|
+
t1 = context.current_trace_id.set(tid)
|
|
350
|
+
s1 = context.current_span_id.set(sid)
|
|
351
|
+
|
|
352
|
+
start = _now()
|
|
353
|
+
perf = time.perf_counter()
|
|
354
|
+
try:
|
|
355
|
+
yield handle
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
handle.set_error(exc)
|
|
358
|
+
raise
|
|
359
|
+
finally:
|
|
360
|
+
end = _now()
|
|
361
|
+
lat = handle._latency_override
|
|
362
|
+
if lat is None:
|
|
363
|
+
lat = (time.perf_counter() - perf) * 1000.0
|
|
364
|
+
context.current_span_id.reset(s1)
|
|
365
|
+
context.current_trace_id.reset(t1)
|
|
366
|
+
if c is not None:
|
|
367
|
+
try:
|
|
368
|
+
c.enqueue(_build_span(
|
|
369
|
+
span_id=sid, trace_id=tid, parent_span_id=parent_span,
|
|
370
|
+
name=handle.name, type=handle.type, provider=handle.provider,
|
|
371
|
+
model=handle.model, operation=handle.operation, input=handle.input,
|
|
372
|
+
output=handle.output, model_params=handle.model_params,
|
|
373
|
+
usage=handle.usage.finalize(), cost=None, status=handle.status,
|
|
374
|
+
error=handle.error, latency_ms=lat, start_time=start, end_time=end,
|
|
375
|
+
user_id=handle.user_id, session_id=handle.session_id,
|
|
376
|
+
tags=handle.tags, metadata=handle.metadata,
|
|
377
|
+
))
|
|
378
|
+
except Exception as exc:
|
|
379
|
+
if c.debug:
|
|
380
|
+
log.warning("myobs.span failed: %s", exc)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ── public: decorator ────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
def observe(name: Optional[str] = None, *, type: str = "agent",
|
|
386
|
+
user_id: Optional[str] = None, session_id: Optional[str] = None,
|
|
387
|
+
tags: Optional[dict] = None):
|
|
388
|
+
"""Decorator that traces a whole function/agent run as a root span; any
|
|
389
|
+
log_llm / span calls inside attach to it automatically."""
|
|
390
|
+
def decorator(fn: Callable):
|
|
391
|
+
span_name = name or fn.__name__
|
|
392
|
+
|
|
393
|
+
if inspect.iscoroutinefunction(fn):
|
|
394
|
+
@functools.wraps(fn)
|
|
395
|
+
async def awrapper(*args, **kwargs):
|
|
396
|
+
with trace(name=span_name, user_id=user_id, session_id=session_id, tags=tags):
|
|
397
|
+
with span(span_name, type=type):
|
|
398
|
+
return await fn(*args, **kwargs)
|
|
399
|
+
return awrapper
|
|
400
|
+
|
|
401
|
+
@functools.wraps(fn)
|
|
402
|
+
def wrapper(*args, **kwargs):
|
|
403
|
+
with trace(name=span_name, user_id=user_id, session_id=session_id, tags=tags):
|
|
404
|
+
with span(span_name, type=type):
|
|
405
|
+
return fn(*args, **kwargs)
|
|
406
|
+
return wrapper
|
|
407
|
+
return decorator
|
myobs/context.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context propagation via contextvars.
|
|
3
|
+
|
|
4
|
+
Lets nested spans discover their enclosing trace and parent span without the
|
|
5
|
+
customer threading IDs through every function call. Because it's contextvars,
|
|
6
|
+
it is correct under threads and asyncio tasks.
|
|
7
|
+
"""
|
|
8
|
+
import contextvars
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
|
|
11
|
+
current_trace_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
12
|
+
"myobs_trace_id", default=None
|
|
13
|
+
)
|
|
14
|
+
current_span_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
15
|
+
"myobs_span_id", default=None
|
|
16
|
+
)
|
|
17
|
+
# Shared trace attributes (user_id, session_id, tags, metadata, environment,
|
|
18
|
+
# release, trace_name) inherited by spans created inside a trace().
|
|
19
|
+
current_attrs: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar(
|
|
20
|
+
"myobs_attrs", default={}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_attrs() -> Dict[str, Any]:
|
|
25
|
+
return dict(current_attrs.get() or {})
|
myobs/ids.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""ID generation. Runs inside the customer process, so uuid/time are fine."""
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def new_id(prefix: str) -> str:
|
|
6
|
+
return f"{prefix}_{uuid.uuid4().hex}"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def trace_id() -> str:
|
|
10
|
+
return new_id("t")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def span_id() -> str:
|
|
14
|
+
return new_id("sp")
|
myobs/privacy.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Privacy / serialization helpers.
|
|
3
|
+
|
|
4
|
+
All content leaves the customer process only after passing through here, so this
|
|
5
|
+
is where redaction, truncation, and JSON-safety are enforced.
|
|
6
|
+
"""
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
_MAX_ITEMS = 200
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_jsonable(value: Any, max_str: int = 4000, _depth: int = 0) -> Any:
|
|
13
|
+
"""Convert anything into a JSON-serializable structure, truncating long
|
|
14
|
+
strings and bounding depth/width so we never ship a huge or cyclic blob."""
|
|
15
|
+
if _depth > 6:
|
|
16
|
+
return "<max-depth>"
|
|
17
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
18
|
+
return value
|
|
19
|
+
if isinstance(value, str):
|
|
20
|
+
if max_str and len(value) > max_str:
|
|
21
|
+
return value[:max_str] + f"...<+{len(value) - max_str} chars>"
|
|
22
|
+
return value
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
out = {}
|
|
25
|
+
for i, (k, v) in enumerate(value.items()):
|
|
26
|
+
if i >= _MAX_ITEMS:
|
|
27
|
+
out["<truncated>"] = f"+{len(value) - _MAX_ITEMS} keys"
|
|
28
|
+
break
|
|
29
|
+
out[str(k)] = to_jsonable(v, max_str, _depth + 1)
|
|
30
|
+
return out
|
|
31
|
+
if isinstance(value, (list, tuple, set)):
|
|
32
|
+
seq = list(value)
|
|
33
|
+
out = [to_jsonable(v, max_str, _depth + 1) for v in seq[:_MAX_ITEMS]]
|
|
34
|
+
if len(seq) > _MAX_ITEMS:
|
|
35
|
+
out.append(f"<+{len(seq) - _MAX_ITEMS} items>")
|
|
36
|
+
return out
|
|
37
|
+
# Rich objects: pydantic models, SDK response objects, dataclasses, ...
|
|
38
|
+
for attr in ("model_dump", "dict", "to_dict"):
|
|
39
|
+
fn = getattr(value, attr, None)
|
|
40
|
+
if callable(fn):
|
|
41
|
+
try:
|
|
42
|
+
return to_jsonable(fn(), max_str, _depth + 1)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
if hasattr(value, "__dict__"):
|
|
46
|
+
try:
|
|
47
|
+
return to_jsonable(vars(value), max_str, _depth + 1)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return to_jsonable(str(value), max_str, _depth + 1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def apply_privacy(value: Any, capture_content: bool,
|
|
54
|
+
redact: Optional[Callable[[Any], Any]], max_str: int) -> Any:
|
|
55
|
+
"""Gate a payload (input/output) through capture + redaction + truncation."""
|
|
56
|
+
if not capture_content or value is None:
|
|
57
|
+
return None
|
|
58
|
+
if redact is not None:
|
|
59
|
+
try:
|
|
60
|
+
value = redact(value)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
return to_jsonable(value, max_str)
|
myobs/schema.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Normalized schema primitives shared by the whole SDK.
|
|
3
|
+
|
|
4
|
+
Everything the SDK captures reduces to a *span* (one unit of work) that groups
|
|
5
|
+
under a *trace* (one end-to-end operation). Token field names are normalized
|
|
6
|
+
here so every provider looks the same downstream.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
SPAN_TYPES = ("llm", "embedding", "tool", "retrieval", "agent", "span")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Usage:
|
|
15
|
+
input_tokens: int = 0
|
|
16
|
+
output_tokens: int = 0
|
|
17
|
+
total_tokens: int = 0
|
|
18
|
+
cache_read_tokens: int = 0
|
|
19
|
+
cache_write_tokens: int = 0
|
|
20
|
+
reasoning_tokens: int = 0
|
|
21
|
+
estimated: bool = False
|
|
22
|
+
|
|
23
|
+
def finalize(self) -> "Usage":
|
|
24
|
+
"""Fill total_tokens if the provider didn't give one."""
|
|
25
|
+
if not self.total_tokens:
|
|
26
|
+
self.total_tokens = (self.input_tokens or 0) + (self.output_tokens or 0)
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"input_tokens": int(self.input_tokens or 0),
|
|
32
|
+
"output_tokens": int(self.output_tokens or 0),
|
|
33
|
+
"total_tokens": int(self.total_tokens or 0),
|
|
34
|
+
"cache_read_tokens": int(self.cache_read_tokens or 0),
|
|
35
|
+
"cache_write_tokens": int(self.cache_write_tokens or 0),
|
|
36
|
+
"reasoning_tokens": int(self.reasoning_tokens or 0),
|
|
37
|
+
"estimated": bool(self.estimated),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def merge(self, other: "Usage") -> "Usage":
|
|
41
|
+
"""Overlay non-zero fields from `other` onto self."""
|
|
42
|
+
for f in ("input_tokens", "output_tokens", "total_tokens",
|
|
43
|
+
"cache_read_tokens", "cache_write_tokens", "reasoning_tokens"):
|
|
44
|
+
v = getattr(other, f)
|
|
45
|
+
if v:
|
|
46
|
+
setattr(self, f, v)
|
|
47
|
+
if other.estimated:
|
|
48
|
+
self.estimated = True
|
|
49
|
+
return self
|
myobs/transport.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async, batched, non-blocking transport.
|
|
3
|
+
|
|
4
|
+
Spans are handed to an in-process queue and flushed by a single background
|
|
5
|
+
thread in batches (by size or interval). The caller never blocks on network I/O
|
|
6
|
+
and an ingestion outage never raises into the host application.
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("myobs")
|
|
15
|
+
|
|
16
|
+
_STOP = object()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Flush:
|
|
20
|
+
__slots__ = ("event",)
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.event = threading.Event()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Transport:
|
|
27
|
+
def __init__(self, endpoint: Optional[str], api_key: Optional[str],
|
|
28
|
+
max_batch_size: int = 100, flush_interval: float = 2.0,
|
|
29
|
+
timeout: float = 5.0, debug: bool = False, max_queue: int = 10000):
|
|
30
|
+
self.endpoint = (endpoint.rstrip("/") + "/v1/events") if endpoint else None
|
|
31
|
+
self.api_key = api_key
|
|
32
|
+
self.max_batch_size = max_batch_size
|
|
33
|
+
self.flush_interval = flush_interval
|
|
34
|
+
self.timeout = timeout
|
|
35
|
+
self.debug = debug
|
|
36
|
+
self._q: "queue.Queue" = queue.Queue(maxsize=max_queue)
|
|
37
|
+
self._dropped = 0
|
|
38
|
+
self._session = None
|
|
39
|
+
self._thread = threading.Thread(target=self._run, name="myobs-transport", daemon=True)
|
|
40
|
+
self._thread.start()
|
|
41
|
+
|
|
42
|
+
# ── public ──────────────────────────────────────────────────────────
|
|
43
|
+
def submit(self, span: dict) -> None:
|
|
44
|
+
try:
|
|
45
|
+
self._q.put_nowait(span)
|
|
46
|
+
except queue.Full:
|
|
47
|
+
self._dropped += 1
|
|
48
|
+
if self.debug:
|
|
49
|
+
log.warning("myobs: queue full, dropped span (total dropped=%d)", self._dropped)
|
|
50
|
+
|
|
51
|
+
def flush(self, timeout: float = 5.0) -> None:
|
|
52
|
+
marker = _Flush()
|
|
53
|
+
try:
|
|
54
|
+
self._q.put_nowait(marker)
|
|
55
|
+
except queue.Full:
|
|
56
|
+
return
|
|
57
|
+
marker.event.wait(timeout)
|
|
58
|
+
|
|
59
|
+
def shutdown(self, timeout: float = 5.0) -> None:
|
|
60
|
+
self.flush(timeout)
|
|
61
|
+
try:
|
|
62
|
+
self._q.put_nowait(_STOP)
|
|
63
|
+
except queue.Full:
|
|
64
|
+
pass
|
|
65
|
+
self._thread.join(timeout)
|
|
66
|
+
|
|
67
|
+
# ── worker ──────────────────────────────────────────────────────────
|
|
68
|
+
def _run(self) -> None:
|
|
69
|
+
buffer: List[dict] = []
|
|
70
|
+
while True:
|
|
71
|
+
timeout = self.flush_interval if buffer else None
|
|
72
|
+
try:
|
|
73
|
+
item = self._q.get(timeout=timeout)
|
|
74
|
+
except queue.Empty:
|
|
75
|
+
self._send(buffer); buffer = []
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if item is _STOP:
|
|
79
|
+
self._send(buffer); buffer = []
|
|
80
|
+
return
|
|
81
|
+
if isinstance(item, _Flush):
|
|
82
|
+
self._send(buffer); buffer = []
|
|
83
|
+
item.event.set()
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
buffer.append(item)
|
|
87
|
+
if len(buffer) >= self.max_batch_size:
|
|
88
|
+
self._send(buffer); buffer = []
|
|
89
|
+
|
|
90
|
+
def _send(self, batch: List[dict]) -> None:
|
|
91
|
+
if not batch:
|
|
92
|
+
return
|
|
93
|
+
if not self.endpoint:
|
|
94
|
+
if self.debug:
|
|
95
|
+
for span in batch:
|
|
96
|
+
log.info("myobs[debug] span: %s", span)
|
|
97
|
+
return
|
|
98
|
+
try:
|
|
99
|
+
import requests # imported lazily so import myobs stays cheap
|
|
100
|
+
if self._session is None:
|
|
101
|
+
self._session = requests.Session()
|
|
102
|
+
headers = {"Content-Type": "application/json"}
|
|
103
|
+
if self.api_key:
|
|
104
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
105
|
+
resp = self._session.post(
|
|
106
|
+
self.endpoint, json={"events": batch}, headers=headers, timeout=self.timeout
|
|
107
|
+
)
|
|
108
|
+
if resp.status_code >= 300 and self.debug:
|
|
109
|
+
log.warning("myobs: ingest returned %s: %s", resp.status_code, resp.text[:300])
|
|
110
|
+
except Exception as exc: # network down, DNS, timeout — never propagate
|
|
111
|
+
if self.debug:
|
|
112
|
+
log.warning("myobs: failed to send %d span(s): %s", len(batch), exc)
|
myobs/version.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: myobs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Provider-agnostic AI observability SDK — explicit / function-based capture (Design A)
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.31
|
|
9
|
+
Provides-Extra: examples
|
|
10
|
+
Requires-Dist: openai>=1.0; extra == "examples"
|
|
11
|
+
|
|
12
|
+
# myobs — AI Observability SDK (function / out-of-band)
|
|
13
|
+
|
|
14
|
+
Provider-agnostic observability for LLM and agent apps. You make your own model
|
|
15
|
+
calls with your own client and API key; you hand the **result** to `myobs`.
|
|
16
|
+
The SDK is never in the request path and never touches your provider key.
|
|
17
|
+
|
|
18
|
+
## Install (local dev)
|
|
19
|
+
```bash
|
|
20
|
+
pip install -e .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Use
|
|
24
|
+
```python
|
|
25
|
+
import myobs, time
|
|
26
|
+
myobs.init(api_key="sk_obs_local_demo", endpoint="http://localhost:8000")
|
|
27
|
+
|
|
28
|
+
# (a) one-liner after any call
|
|
29
|
+
t0 = time.time()
|
|
30
|
+
resp = client.chat.completions.create(model="gpt-4o", messages=msgs)
|
|
31
|
+
myobs.log_llm("openai", response=resp, latency_ms=(time.time()-t0)*1000,
|
|
32
|
+
user_id="usr_1", session_id="s_9", tags={"feature": "chat"})
|
|
33
|
+
|
|
34
|
+
# (b) unknown provider — pass numbers yourself
|
|
35
|
+
myobs.log_llm("my-llm", model="llama-3-70b",
|
|
36
|
+
usage={"input_tokens": 900, "output_tokens": 300}, latency_ms=740)
|
|
37
|
+
|
|
38
|
+
# (c) multi-step agent — a trace with spans
|
|
39
|
+
with myobs.trace(name="support-agent", user_id="usr_1", session_id="s_9"):
|
|
40
|
+
with myobs.span("plan", type="llm") as s:
|
|
41
|
+
r = client.chat.completions.create(...); s.log(r, provider="openai")
|
|
42
|
+
with myobs.span("search_kb", type="tool") as s:
|
|
43
|
+
s.set_output(search(q))
|
|
44
|
+
|
|
45
|
+
# (d) decorator for a whole run
|
|
46
|
+
@myobs.observe(name="support-agent")
|
|
47
|
+
def run_agent(q, *, user_id, session_id): ...
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Guarantees
|
|
51
|
+
- **Non-blocking:** spans go to an in-process queue, flushed by a background
|
|
52
|
+
thread in batches. Your call returns immediately.
|
|
53
|
+
- **Never throws:** any SDK/network error is swallowed (enable `debug=True` to log).
|
|
54
|
+
- **Private:** `capture_content=False`, a `redact=fn` hook, `max_content_length`,
|
|
55
|
+
and `sample_rate` all apply before data leaves your process.
|
|
56
|
+
- Call `myobs.flush()` before a short-lived process exits (an `atexit` hook also
|
|
57
|
+
flushes automatically).
|
|
58
|
+
|
|
59
|
+
## Config (`myobs.init`)
|
|
60
|
+
| arg | default | meaning |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `api_key` | `MYOBS_API_KEY` env | observability service key (NOT your LLM key) |
|
|
63
|
+
| `endpoint` | `MYOBS_ENDPOINT` env | ingestion base URL |
|
|
64
|
+
| `environment` | `"production"` | tag every span |
|
|
65
|
+
| `capture_content` | `True` | store prompt/response text |
|
|
66
|
+
| `redact` | `None` | `fn(value) -> value` scrubber |
|
|
67
|
+
| `sample_rate` | `1.0` | fraction of spans to keep |
|
|
68
|
+
| `debug` | `False` | log drops/errors to stderr |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
myobs/__init__.py,sha256=MLM79IHjKdAqT0rApmZiYdehrUIAy9Grx3bGxcfEWzw,778
|
|
2
|
+
myobs/adapters.py,sha256=0HiBxXRCMatgDR-fr576dlrOMeCfWEQKC9mnVqM0vgc,5034
|
|
3
|
+
myobs/client.py,sha256=Sw5xSQ8vOaAjoRj92V84K6ChxAJKtnrIA8MmgDwW90w,16469
|
|
4
|
+
myobs/context.py,sha256=DR1cQDKWuecH_Yoc23WWrFm3NSJYcyoUoZ1a35b3RWk,880
|
|
5
|
+
myobs/ids.py,sha256=3sYKzdV8v3Jw2UgENmW5HDIQGcdYeBynRAQc19TMp_U,262
|
|
6
|
+
myobs/privacy.py,sha256=CnIsqsg5BwRJgWNmyGNO3Kp0i3u7JpINKWBI0BuU560,2324
|
|
7
|
+
myobs/schema.py,sha256=dogGqFkX1DD0kfmCav4BBJ1g6X8vO_3GL6XT5yDtnuI,1759
|
|
8
|
+
myobs/transport.py,sha256=YmOQHcNwaJD2xcWrWrADTWzPCitMVt_8VboNxDvuX-8,4146
|
|
9
|
+
myobs/version.py,sha256=1fmGyAvExsR_v92QDwCNsWqzjXMjs7DklKQmrsYkgCg,48
|
|
10
|
+
myobs-0.1.0.dist-info/METADATA,sha256=xicnw-S43GC3Z-zOliuXAoM5W-ti3M45WsgC2v_DYBM,2700
|
|
11
|
+
myobs-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
myobs-0.1.0.dist-info/top_level.txt,sha256=Uywnk7te6BMu-jWRMLT-8pO42AlWRTca9QJqy4AZG1k,6
|
|
13
|
+
myobs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
myobs
|