observo-ai 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: observo-ai
3
+ Version: 0.1.2
4
+ Summary: Python SDK for the Observo AI Observability & Evaluation Platform
5
+ Author-email: Observo <sdk@observo.dev>
6
+ License: MIT
7
+ Project-URL: Homepage, https://observo.dev
8
+ Project-URL: Documentation, https://docs.observo.dev
9
+ Project-URL: Repository, https://github.com/yourusername/observo-platform
10
+ Project-URL: Bug Tracker, https://github.com/yourusername/observo-platform/issues
11
+ Keywords: llm,observability,ai,monitoring,evaluation,tracing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: httpx>=0.24.0
26
+ Requires-Dist: typing-extensions>=4.0.0; python_version < "3.11"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
30
+ Requires-Dist: pytest-mock>=3.12.0; extra == "dev"
31
+ Requires-Dist: respx>=0.20.0; extra == "dev"
32
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
33
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
@@ -0,0 +1,239 @@
1
+ """
2
+ Observo SDK — Public API
3
+
4
+ Install: pip install observo-ai
5
+ Docs: https://docs.observo.dev
6
+
7
+ Usage:
8
+ import observo
9
+
10
+ # 1. Initialize once (e.g. in app startup)
11
+ observo.init(api_key="obs_live_...", environment="production")
12
+
13
+ # 2a. Decorator — automatic tracing (zero code change)
14
+ @observo.trace
15
+ def ask_llm(prompt: str) -> str:
16
+ return my_llm.generate(prompt)
17
+
18
+ # 2b. Decorator with metadata
19
+ @observo.trace(model="gpt-4o", provider="openai")
20
+ async def ask_gpt(prompt: str) -> str:
21
+ return await openai_client.chat(prompt)
22
+
23
+ # 2c. Context manager — manual control
24
+ with observo.span("rag_pipeline") as span:
25
+ context = retrieve_docs(question)
26
+ answer = llm.generate(question)
27
+ span.set_input(question)
28
+ span.set_output(answer)
29
+ span.set_ground_truth("Expected answer") # optional
30
+ span.set_metadata(model="gemini-1.5-flash")
31
+
32
+ # 2d. Manual logging
33
+ observo.log(
34
+ input="What is Python?",
35
+ output="Python is a programming language.",
36
+ model="gpt-4o",
37
+ provider="openai",
38
+ latency_ms=320,
39
+ )
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import atexit
45
+ import warnings
46
+ from typing import Any, Optional
47
+
48
+ from . import _state
49
+ from .client import ObservoClient
50
+ from .context import ObservoSpan
51
+ from .decorators import trace
52
+ from .types import ObservoConfig, TraceData
53
+
54
+ __version__ = "0.1.0"
55
+ __all__ = ["init", "trace", "span", "log", "flush", "shutdown", "__version__"]
56
+
57
+
58
+ def init(
59
+ api_key: Optional[str] = None,
60
+ *,
61
+ api_url: Optional[str] = None,
62
+ environment: Optional[str] = None,
63
+ flush_interval_sec: float = 1.0,
64
+ max_buffer_size: int = 1000,
65
+ max_batch_size: int = 100,
66
+ timeout_sec: float = 5.0,
67
+ max_retries: int = 3,
68
+ debug: bool = False,
69
+ auto_patch: bool = True,
70
+ ) -> None:
71
+ """
72
+ Initialize the Observo SDK.
73
+ Call this once at application startup before any @trace decorators fire.
74
+
75
+ Args:
76
+ api_key: Your Observo API key (starts with obs_live_...)
77
+ api_url: Observo API base URL (default: localhost for dev)
78
+ environment: Deployment environment tag (production/staging/development)
79
+ flush_interval_sec: How often to batch-send traces (default: 1s)
80
+ max_buffer_size: Max traces to hold in memory if API is down (default: 1000)
81
+ max_batch_size: Max traces per HTTP request (default: 100)
82
+ timeout_sec: HTTP request timeout (default: 5s)
83
+ max_retries: Retry attempts on failure (default: 3)
84
+ debug: Log SDK internals to stdout (default: False)
85
+ auto_patch: Enable auto-instrumentation (default: True)
86
+
87
+ Example:
88
+ observo.init(
89
+ api_key="obs_live_ab12_xxxxxxxxxxxxx",
90
+ api_url="https://observo-demo.onrender.com",
91
+ environment="production",
92
+ auto_patch=True, # New: Zero-config auto instrumentation
93
+ )
94
+ """
95
+ import os
96
+
97
+ # 1. Resolve configuration from args or environment
98
+ resolved_api_key = api_key or os.environ.get("OBSERVO_API_KEY")
99
+ env_url = os.environ.get("OBSERVO_API_URL", "http://localhost:8080")
100
+ resolved_api_url = api_url or env_url
101
+
102
+ # Strip trailing /v1 if present to prevent double-prefixing in client.py
103
+ resolved_api_url = resolved_api_url.rstrip("/")
104
+ if resolved_api_url.endswith("/v1"):
105
+ resolved_api_url = resolved_api_url[:-3]
106
+
107
+ resolved_env = environment or os.environ.get("OBSERVO_ENV", "production")
108
+
109
+ if not resolved_api_key:
110
+ raise ValueError("observo.init() requires an api_key. Provide it as an argument or via OBSERVO_API_KEY environment variable.")
111
+
112
+ if not resolved_api_key.startswith("obs_"):
113
+ warnings.warn(
114
+ "API key does not start with 'obs_' — double-check your key.",
115
+ stacklevel=2,
116
+ )
117
+
118
+ config = ObservoConfig(
119
+ api_key=resolved_api_key,
120
+ api_url=resolved_api_url.rstrip("/"),
121
+ environment=resolved_env,
122
+ flush_interval_sec=flush_interval_sec,
123
+ max_buffer_size=max_buffer_size,
124
+ max_batch_size=max_batch_size,
125
+ timeout_sec=timeout_sec,
126
+ max_retries=max_retries,
127
+ debug=debug,
128
+ )
129
+
130
+ # Shutdown existing client if re-initializing
131
+ if _state.client is not None:
132
+ _state.client.shutdown()
133
+
134
+ _state.client = ObservoClient(config)
135
+
136
+ # Register automatic shutdown on process exit
137
+ atexit.register(shutdown)
138
+
139
+ # ── Zero-Config Auto-Instrumentation ──
140
+ if auto_patch:
141
+ try:
142
+ from . import auto
143
+ auto.install()
144
+ except Exception as e:
145
+ if debug:
146
+ print(f"āš ļø Observo: Auto-instrumentation failed: {e}")
147
+
148
+
149
+ def span(name: str = "llm_call") -> ObservoSpan:
150
+ """
151
+ Create a trace span for manual instrumentation.
152
+
153
+ Usage (sync):
154
+ with observo.span("rag_pipeline") as span:
155
+ answer = llm.generate(question)
156
+ span.set_input(question)
157
+ span.set_output(answer)
158
+
159
+ Usage (async):
160
+ async with observo.span("async_pipeline") as span:
161
+ answer = await llm.generate(question)
162
+ span.set_input(question).set_output(answer)
163
+ """
164
+ return ObservoSpan(name)
165
+
166
+
167
+ def log(
168
+ input: str,
169
+ output: str,
170
+ *,
171
+ model: Optional[str] = None,
172
+ provider: Optional[str] = None,
173
+ environment: Optional[str] = None,
174
+ latency_ms: Optional[int] = None,
175
+ input_tokens: Optional[int] = None,
176
+ output_tokens: Optional[int] = None,
177
+ ground_truth: Optional[str] = None,
178
+ **metadata: Any,
179
+ ) -> None:
180
+ """
181
+ Manually log a single LLM trace.
182
+ Use when you want full control without decorators or context managers.
183
+
184
+ Example:
185
+ observo.log(
186
+ input="Translate to French: Hello",
187
+ output="Bonjour",
188
+ model="gpt-4o",
189
+ provider="openai",
190
+ latency_ms=280,
191
+ ground_truth="Bonjour",
192
+ )
193
+ """
194
+ if _state.client is None:
195
+ warnings.warn(
196
+ "observo.log() called before observo.init() — trace dropped.",
197
+ stacklevel=2,
198
+ )
199
+ return
200
+
201
+ try:
202
+ trace_data = TraceData(
203
+ input=input,
204
+ output=output,
205
+ model=model,
206
+ provider=provider,
207
+ environment=environment or _state.client._config.environment,
208
+ latency_ms=latency_ms,
209
+ input_tokens=input_tokens,
210
+ output_tokens=output_tokens,
211
+ ground_truth=ground_truth,
212
+ metadata=dict(metadata),
213
+ )
214
+ _state.client.enqueue(trace_data)
215
+ except Exception:
216
+ pass # Never crash the caller
217
+
218
+
219
+ def flush() -> None:
220
+ """
221
+ Force an immediate flush of all buffered traces.
222
+ Useful in tests or before shutting down your application.
223
+
224
+ Example:
225
+ observo.flush() # ensure all traces are sent before exit
226
+ """
227
+ if _state.client:
228
+ _state.client.flush()
229
+
230
+
231
+ def shutdown() -> None:
232
+ """
233
+ Gracefully shut down the SDK.
234
+ Flushes remaining traces and stops the background thread.
235
+ Called automatically on process exit via atexit.
236
+ """
237
+ if _state.client:
238
+ _state.client.shutdown()
239
+ _state.client = None
@@ -0,0 +1,57 @@
1
+ """
2
+ Observo SDK — In-Memory Trace Buffer
3
+ Thread-safe queue that holds traces when the API is temporarily unreachable.
4
+
5
+ NFR: Durability — traces are never lost due to temporary API outages.
6
+ The buffer holds up to max_buffer_size traces in memory.
7
+ When the API recovers, the background flusher drains the buffer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from collections import deque
14
+ from typing import List
15
+
16
+ from .types import TraceData
17
+
18
+
19
+ class TraceBuffer:
20
+ """
21
+ Thread-safe FIFO buffer for traces.
22
+
23
+ - Acts as a safety net when the Observo API is temporarily unreachable
24
+ - maxlen ensures memory usage is bounded (oldest traces dropped if full)
25
+ - Lock ensures thread safety across the decorator thread + flush thread
26
+ """
27
+
28
+ def __init__(self, max_size: int = 1000) -> None:
29
+ self._buffer: deque[TraceData] = deque(maxlen=max_size)
30
+ self._lock = threading.Lock()
31
+
32
+ def add(self, trace: TraceData) -> None:
33
+ """Add a trace to the buffer. Thread-safe."""
34
+ with self._lock:
35
+ self._buffer.append(trace)
36
+
37
+ def drain(self, batch_size: int = 100) -> List[TraceData]:
38
+ """
39
+ Remove and return up to batch_size traces from the front.
40
+ Thread-safe. Returns empty list if buffer is empty.
41
+ """
42
+ with self._lock:
43
+ batch: List[TraceData] = []
44
+ for _ in range(min(batch_size, len(self._buffer))):
45
+ batch.append(self._buffer.popleft())
46
+ return batch
47
+
48
+ def size(self) -> int:
49
+ """Current number of traces in the buffer."""
50
+ return len(self._buffer)
51
+
52
+ def is_empty(self) -> bool:
53
+ return len(self._buffer) == 0
54
+
55
+ def clear(self) -> None:
56
+ with self._lock:
57
+ self._buffer.clear()
@@ -0,0 +1,19 @@
1
+ """
2
+ Observo SDK — Global State
3
+ Holds the singleton client instance created by observo.init().
4
+ Kept in a separate module to avoid circular imports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from .client import ObservoClient
12
+
13
+ from contextvars import ContextVar
14
+
15
+ # The singleton client — None until observo.init() is called
16
+ client: "ObservoClient | None" = None
17
+
18
+ # Context variable to prevent double-logging during auto-instrumentation
19
+ is_tracing: ContextVar[bool] = ContextVar("is_tracing", default=False)
@@ -0,0 +1,119 @@
1
+ import sys
2
+ import os
3
+ import logging
4
+ import functools
5
+ import importlib
6
+ from typing import Any, Callable, Optional
7
+
8
+ # Set up a silent logger for auto-instrumentation
9
+ logger = logging.getLogger("observo.auto")
10
+
11
+ # Guard flag to prevent duplicate install() runs
12
+ _installed = False
13
+ _message_printed = False
14
+
15
+ def _get_dashboard_url():
16
+ """Returns the local or production dashboard URL."""
17
+ base = os.environ.get("OBSERVO_DASHBOARD_URL", "http://localhost:3000")
18
+ project_id = os.environ.get("OBSERVO_PROJECT_ID", "default")
19
+ return f"{base}/traces"
20
+
21
+ def _print_wow_message():
22
+ """Prints a single, clean startup banner to the developer's console."""
23
+ global _message_printed
24
+ if _message_printed:
25
+ return
26
+ _message_printed = True
27
+
28
+ url = _get_dashboard_url()
29
+ PURPLE = "\033[95m"
30
+ CYAN = "\033[96m"
31
+ BOLD = "\033[1m"
32
+ DIM = "\033[2m"
33
+ END = "\033[0m"
34
+
35
+ print(f"\n{PURPLE}{BOLD}šŸš€ Observo:{END} {CYAN}Auto-tracing active.{END}")
36
+ print(f" {DIM}Dashboard:{END} {BOLD}{url}{END}\n")
37
+
38
+ def _wrap_llm_method(original_method: Callable, provider: str) -> Callable:
39
+ """
40
+ Wraps an LLM call method (like create) to automatically trace it.
41
+ """
42
+ from .decorators import trace
43
+
44
+ # Pre-configure the trace with provider info
45
+ return trace(provider=provider)(original_method)
46
+
47
+ def patch_openai():
48
+ """Detects and patches OpenAI client methods."""
49
+ try:
50
+ import openai
51
+ # Modern OpenAI SDK (v1+)
52
+ try:
53
+ from openai.resources.chat.completions import Completions, AsyncCompletions
54
+
55
+ # Patch Sync
56
+ if not hasattr(Completions.create, "__observo_patched__"):
57
+ original = Completions.create
58
+ Completions.create = _wrap_llm_method(original, "OpenAI")
59
+ Completions.create.__observo_patched__ = True
60
+
61
+ # Patch Async
62
+ if not hasattr(AsyncCompletions.create, "__observo_patched__"):
63
+ original = AsyncCompletions.create
64
+ AsyncCompletions.create = _wrap_llm_method(original, "OpenAI")
65
+ AsyncCompletions.create.__observo_patched__ = True
66
+
67
+ except (ImportError, AttributeError):
68
+ pass # Likely an older version of OpenAI or different structure
69
+
70
+ except ImportError:
71
+ pass # OpenAI not used
72
+
73
+ def patch_groq():
74
+ """Detects and patches Groq client methods."""
75
+ try:
76
+ import groq
77
+ from groq.resources.chat.completions import Completions, AsyncCompletions
78
+
79
+ if not hasattr(Completions.create, "__observo_patched__"):
80
+ original = Completions.create
81
+ Completions.create = _wrap_llm_method(original, "Groq")
82
+ Completions.create.__observo_patched__ = True
83
+
84
+ if not hasattr(AsyncCompletions.create, "__observo_patched__"):
85
+ original = AsyncCompletions.create
86
+ AsyncCompletions.create = _wrap_llm_method(original, "Groq")
87
+ AsyncCompletions.create.__observo_patched__ = True
88
+
89
+ except ImportError:
90
+ pass # Groq not used
91
+
92
+ def install():
93
+ """Entry point to start auto-instrumentation. Fail-safe. Only runs once."""
94
+ global _installed
95
+ if _installed:
96
+ return
97
+ _installed = True
98
+
99
+ try:
100
+ # 1. Initialize the Client (Background Sender) if not already done
101
+ from . import _state, init
102
+ if _state.client is None:
103
+ init(debug=os.environ.get("OBSERVO_DEBUG") == "true", auto_patch=False)
104
+
105
+ # 2. Apply patches
106
+ patched = []
107
+ patch_openai()
108
+ patch_groq()
109
+
110
+ # 3. Print the startup banner (once)
111
+ if os.environ.get("OBSERVO_SILENT") != "true":
112
+ _print_wow_message()
113
+
114
+ except Exception as e:
115
+ # Fail silently to ensure the user's app never crashes
116
+ logger.warning(f"Observo: Auto-instrumentation failed (silent): {e}")
117
+
118
+ # If imported via 'import observo.auto', it runs immediately
119
+ install()
@@ -0,0 +1,61 @@
1
+ import sys
2
+ import os
3
+ import runpy
4
+ import argparse
5
+
6
+ def main():
7
+ parser = argparse.ArgumentParser(
8
+ prog="observo-run",
9
+ description="Observo Zero-Touch Instrumentation: Run your AI app with automatic tracing."
10
+ )
11
+ parser.add_argument("script", help="The python script to run (e.g., main.py)")
12
+ parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments for your script")
13
+
14
+ if len(sys.argv) < 2:
15
+ parser.print_help()
16
+ sys.exit(1)
17
+
18
+ args = parser.parse_args()
19
+
20
+ # 1. Activate Auto-Instrumentation
21
+ try:
22
+ try:
23
+ import dotenv
24
+ dotenv.load_dotenv()
25
+ except ImportError:
26
+ pass # python-dotenv not installed, proceed without it
27
+
28
+ import observo.auto
29
+ except Exception as e:
30
+ print(f"āš ļø [Observo] Auto-instrumentation failed to start: {e}")
31
+
32
+ # 2. Prepare the environment for the user's script
33
+ script_path = args.script
34
+
35
+ # Handle redundant 'python' argument if user still types it
36
+ if (script_path == "python" or script_path == "python3") and args.args:
37
+ script_path = args.args[0]
38
+ args.args = args.args[1:]
39
+
40
+ if not os.path.exists(script_path):
41
+ print(f"āŒ [Observo] Script not found: {script_path}")
42
+ sys.exit(1)
43
+
44
+ # Update sys.argv so the user's script thinks it was run normally
45
+ sys.argv = [script_path] + args.args
46
+
47
+ # 3. RUN the user's script
48
+ print(f"✨ [Observo] Running {script_path}...")
49
+ print("─" * 40)
50
+
51
+ try:
52
+ # run_path executes the script as if it were the main module
53
+ runpy.run_path(script_path, run_name="__main__")
54
+ except SystemExit as e:
55
+ sys.exit(e.code)
56
+ except Exception as e:
57
+ print(f"\nāŒ [Observo] App crashed during execution:")
58
+ raise e
59
+
60
+ if __name__ == "__main__":
61
+ main()