myobs 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.
myobs-0.1.0/PKG-INFO 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 |
myobs-0.1.0/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # myobs — AI Observability SDK (function / out-of-band)
2
+
3
+ Provider-agnostic observability for LLM and agent apps. You make your own model
4
+ calls with your own client and API key; you hand the **result** to `myobs`.
5
+ The SDK is never in the request path and never touches your provider key.
6
+
7
+ ## Install (local dev)
8
+ ```bash
9
+ pip install -e .
10
+ ```
11
+
12
+ ## Use
13
+ ```python
14
+ import myobs, time
15
+ myobs.init(api_key="sk_obs_local_demo", endpoint="http://localhost:8000")
16
+
17
+ # (a) one-liner after any call
18
+ t0 = time.time()
19
+ resp = client.chat.completions.create(model="gpt-4o", messages=msgs)
20
+ myobs.log_llm("openai", response=resp, latency_ms=(time.time()-t0)*1000,
21
+ user_id="usr_1", session_id="s_9", tags={"feature": "chat"})
22
+
23
+ # (b) unknown provider — pass numbers yourself
24
+ myobs.log_llm("my-llm", model="llama-3-70b",
25
+ usage={"input_tokens": 900, "output_tokens": 300}, latency_ms=740)
26
+
27
+ # (c) multi-step agent — a trace with spans
28
+ with myobs.trace(name="support-agent", user_id="usr_1", session_id="s_9"):
29
+ with myobs.span("plan", type="llm") as s:
30
+ r = client.chat.completions.create(...); s.log(r, provider="openai")
31
+ with myobs.span("search_kb", type="tool") as s:
32
+ s.set_output(search(q))
33
+
34
+ # (d) decorator for a whole run
35
+ @myobs.observe(name="support-agent")
36
+ def run_agent(q, *, user_id, session_id): ...
37
+ ```
38
+
39
+ ## Guarantees
40
+ - **Non-blocking:** spans go to an in-process queue, flushed by a background
41
+ thread in batches. Your call returns immediately.
42
+ - **Never throws:** any SDK/network error is swallowed (enable `debug=True` to log).
43
+ - **Private:** `capture_content=False`, a `redact=fn` hook, `max_content_length`,
44
+ and `sample_rate` all apply before data leaves your process.
45
+ - Call `myobs.flush()` before a short-lived process exits (an `atexit` hook also
46
+ flushes automatically).
47
+
48
+ ## Config (`myobs.init`)
49
+ | arg | default | meaning |
50
+ |---|---|---|
51
+ | `api_key` | `MYOBS_API_KEY` env | observability service key (NOT your LLM key) |
52
+ | `endpoint` | `MYOBS_ENDPOINT` env | ingestion base URL |
53
+ | `environment` | `"production"` | tag every span |
54
+ | `capture_content` | `True` | store prompt/response text |
55
+ | `redact` | `None` | `fn(value) -> value` scrubber |
56
+ | `sample_rate` | `1.0` | fraction of spans to keep |
57
+ | `debug` | `False` | log drops/errors to stderr |
@@ -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
+ ]
@@ -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
@@ -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
@@ -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 {})
@@ -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")
@@ -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)
@@ -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
@@ -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)
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+ SDK_NAME = "myobs-python"
@@ -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,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ myobs/__init__.py
4
+ myobs/adapters.py
5
+ myobs/client.py
6
+ myobs/context.py
7
+ myobs/ids.py
8
+ myobs/privacy.py
9
+ myobs/schema.py
10
+ myobs/transport.py
11
+ myobs/version.py
12
+ myobs.egg-info/PKG-INFO
13
+ myobs.egg-info/SOURCES.txt
14
+ myobs.egg-info/dependency_links.txt
15
+ myobs.egg-info/requires.txt
16
+ myobs.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ requests>=2.31
2
+
3
+ [examples]
4
+ openai>=1.0
@@ -0,0 +1 @@
1
+ myobs
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "myobs"
3
+ version = "0.1.0"
4
+ description = "Provider-agnostic AI observability SDK — explicit / function-based capture (Design A)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "requests>=2.31",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ # These are only needed if you want the adapters to read provider response
14
+ # objects directly. The SDK works without them (pass usage manually).
15
+ examples = ["openai>=1.0"]
16
+
17
+ [build-system]
18
+ requires = ["setuptools>=68"]
19
+ build-backend = "setuptools.build_meta"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["."]
23
+ include = ["myobs*"]
myobs-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+