puvinoise-sdk 0.2.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.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: puvinoise-sdk
3
+ Version: 0.2.0
4
+ Summary: Independent OpenTelemetry SDK for AI agents (Anthropic, OpenAI, Ollama)
5
+ Author-email: Sarvesh Sahane <sarvesh.sahane@puvilabs.com>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://puvilabs.com
8
+ Keywords: puvinoise,opentelemetry,otel,tracing,llm,agents,observability
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Topic :: System :: Monitoring
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: opentelemetry-api>=1.20
20
+ Requires-Dist: opentelemetry-sdk>=1.20
21
+ Requires-Dist: opentelemetry-exporter-otlp>=1.20
22
+ Provides-Extra: anthropic
23
+ Requires-Dist: anthropic>=0.20; extra == "anthropic"
24
+ Provides-Extra: openai
25
+ Requires-Dist: openai>=1.0; extra == "openai"
26
+ Provides-Extra: all
27
+ Requires-Dist: anthropic>=0.20; extra == "all"
28
+ Requires-Dist: openai>=1.0; extra == "all"
@@ -0,0 +1,52 @@
1
+ """
2
+ Puvinoise - Independent OpenTelemetry SDK for AI agents
3
+ Supports Anthropic Claude, OpenAI, Ollama, and other LLM providers.
4
+ """
5
+
6
+ from puvinoise.bootstrap import bootstrap
7
+ from puvinoise.tracer import run_with_trace
8
+ from puvinoise.hooks import (
9
+ # Anthropic
10
+ instrument_anthropic_call,
11
+ instrument_anthropic_stream,
12
+ # OpenAI
13
+ instrument_openai_call,
14
+ instrument_openai_stream,
15
+ # Generic / Ollama
16
+ instrument_llm_call,
17
+ # Agent internals
18
+ instrument_memory_operation,
19
+ instrument_tool_call,
20
+ # Span utilities
21
+ add_span_attribute,
22
+ add_span_event,
23
+ )
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ # Bootstrap
29
+ "bootstrap",
30
+
31
+ # Core tracing
32
+ "run_with_trace",
33
+
34
+ # Anthropic hooks
35
+ "instrument_anthropic_call",
36
+ "instrument_anthropic_stream",
37
+
38
+ # OpenAI hooks
39
+ "instrument_openai_call",
40
+ "instrument_openai_stream",
41
+
42
+ # Generic / Ollama hooks
43
+ "instrument_llm_call",
44
+
45
+ # Agent internals
46
+ "instrument_memory_operation",
47
+ "instrument_tool_call",
48
+
49
+ # Span utilities
50
+ "add_span_attribute",
51
+ "add_span_event",
52
+ ]
@@ -0,0 +1,131 @@
1
+ from opentelemetry import trace
2
+ from opentelemetry.sdk.trace import TracerProvider
3
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
4
+ from opentelemetry.sdk.resources import Resource
5
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
6
+ import logging
7
+ import os
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _initialized = False
12
+
13
+
14
+ def bootstrap(
15
+ service_name: str,
16
+ otlp_endpoint: str = None,
17
+ console_export: bool = False,
18
+ batch_config: dict = None
19
+ ):
20
+ """
21
+ Initialize OpenTelemetry tracing for the agent.
22
+
23
+ Args:
24
+ service_name: Name of the service (e.g., "blog-agent")
25
+ otlp_endpoint: OTLP collector endpoint (defaults to env var or hardcoded)
26
+ console_export: If True, also export spans to console for debugging
27
+ batch_config: Custom batch processor configuration
28
+
29
+ Returns:
30
+ Tracer instance
31
+ """
32
+ global _initialized
33
+
34
+ # Prevent double initialization
35
+ if _initialized:
36
+ logger.info(f"Tracer already initialized for service: {service_name}")
37
+ return trace.get_tracer(service_name)
38
+
39
+ # Get endpoint from parameter, env var, or default
40
+ endpoint = (
41
+ otlp_endpoint
42
+ or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
43
+ # or "http://13.235.48.96:4318/v1/traces"
44
+ )
45
+ if not endpoint.endswith("/v1/traces"):
46
+ endpoint = endpoint.rstrip("/") + "/v1/traces"
47
+
48
+
49
+ tenant_id = os.getenv("TENANT_ID")
50
+
51
+ if not tenant_id:
52
+ raise ValueError("TENANT_ID environment variable must be set")
53
+
54
+ resource = Resource.create({
55
+ "service.name": service_name,
56
+ "service.version": os.getenv("SERVICE_VERSION", "0.1.0"),
57
+ "deployment.environment": os.getenv("ENVIRONMENT", "development"),
58
+ "tenant.id": tenant_id,
59
+ })
60
+
61
+
62
+
63
+ # Create tracer provider
64
+ provider = TracerProvider(resource=resource)
65
+ trace.set_tracer_provider(provider)
66
+
67
+ # Default batch configuration
68
+ default_batch_config = {
69
+ "schedule_delay_millis": 1000, # Export every 1 second
70
+ "max_export_batch_size": 64, # Max 64 spans per batch
71
+ "export_timeout_millis": 30000, # 30 second timeout
72
+ "max_queue_size": 2048, # Queue up to 2048 spans
73
+ }
74
+
75
+ # Merge with custom config if provided
76
+ if batch_config:
77
+ default_batch_config.update(batch_config)
78
+
79
+ # Configure OTLP exporter
80
+ try:
81
+ otlp_exporter = OTLPSpanExporter(endpoint=endpoint)
82
+ provider.add_span_processor(
83
+ BatchSpanProcessor(otlp_exporter, **default_batch_config)
84
+ )
85
+ logger.info(f"✅ OTLP exporter configured: {endpoint}")
86
+ except Exception as e:
87
+ logger.error(f"❌ Failed to configure OTLP exporter: {e}")
88
+ # Continue with console exporter as fallback
89
+ console_export = True
90
+
91
+ # Optional console exporter for debugging
92
+ if console_export:
93
+ console_exporter = ConsoleSpanExporter()
94
+ provider.add_span_processor(
95
+ BatchSpanProcessor(console_exporter, **default_batch_config)
96
+ )
97
+ logger.info("✅ Console exporter configured for debugging")
98
+
99
+ _initialized = True
100
+ logger.info(f"🚀 OpenTelemetry initialized for service: {service_name}")
101
+
102
+ return trace.get_tracer(service_name)
103
+
104
+
105
+ def shutdown():
106
+ """
107
+ Gracefully shutdown the tracer provider.
108
+ Call this when your application is terminating to ensure all spans are exported.
109
+ """
110
+ provider = trace.get_tracer_provider()
111
+ if isinstance(provider, TracerProvider):
112
+ provider.shutdown()
113
+ logger.info("OpenTelemetry tracer provider shut down successfully")
114
+
115
+
116
+ def force_flush(timeout_millis: int = 30000):
117
+ """
118
+ Force flush all pending spans.
119
+
120
+ Args:
121
+ timeout_millis: Maximum time to wait for flush in milliseconds
122
+ """
123
+ provider = trace.get_tracer_provider()
124
+ if isinstance(provider, TracerProvider):
125
+ success = provider.force_flush(timeout_millis)
126
+ if success:
127
+ logger.info("Successfully flushed all pending spans")
128
+ else:
129
+ logger.warning("Flush timed out or failed")
130
+ return success
131
+ return False
@@ -0,0 +1,76 @@
1
+ import uuid
2
+ import contextvars
3
+ from typing import Optional
4
+
5
+ # Context variables automatically propagate across async calls
6
+ _run_id = contextvars.ContextVar("run_id", default=None)
7
+ _agent_name = contextvars.ContextVar("agent_name", default=None)
8
+
9
+
10
+ class AgentContext:
11
+ """
12
+ Holds metadata for a single agent execution.
13
+ Uses contextvars for thread-safe and async-safe context propagation.
14
+ """
15
+
16
+ def __init__(self, agent_name: str, run_id: Optional[str] = None):
17
+ """
18
+ Initialize agent context.
19
+
20
+ Args:
21
+ agent_name: Name of the agent
22
+ run_id: Optional custom run ID (generates UUID if not provided)
23
+ """
24
+ self.run_id = run_id or str(uuid.uuid4())
25
+ self.agent_name = agent_name
26
+ self._tokens = []
27
+
28
+ def activate(self):
29
+ """Activate this context for the current execution."""
30
+ self._tokens = [
31
+ _run_id.set(self.run_id),
32
+ _agent_name.set(self.agent_name)
33
+ ]
34
+
35
+ def deactivate(self):
36
+ """Deactivate this context and restore previous values."""
37
+ for token in reversed(self._tokens):
38
+ try:
39
+ if token:
40
+ token.var.reset(token)
41
+ except Exception:
42
+ pass # Token may have already been reset
43
+ self._tokens.clear()
44
+
45
+ def __enter__(self):
46
+ """Context manager support."""
47
+ self.activate()
48
+ return self
49
+
50
+ def __exit__(self, exc_type, exc_val, exc_tb):
51
+ """Context manager cleanup."""
52
+ self.deactivate()
53
+ return False
54
+
55
+ @staticmethod
56
+ def get_run_id() -> Optional[str]:
57
+ """Get the current run ID from context."""
58
+ return _run_id.get()
59
+
60
+ @staticmethod
61
+ def get_agent_name() -> Optional[str]:
62
+ """Get the current agent name from context."""
63
+ return _agent_name.get()
64
+
65
+ @staticmethod
66
+ def get_context() -> dict:
67
+ """Get all current context values as a dictionary."""
68
+ return {
69
+ "run_id": _run_id.get(),
70
+ "agent_name": _agent_name.get()
71
+ }
72
+
73
+ @staticmethod
74
+ def is_active() -> bool:
75
+ """Check if there's an active agent context."""
76
+ return _run_id.get() is not None
@@ -0,0 +1,469 @@
1
+ """
2
+ Multi-provider instrumentation hooks for puvinoise SDK.
3
+ Supports Anthropic, OpenAI, Ollama, and generic LLM providers.
4
+ """
5
+
6
+ from opentelemetry import trace
7
+ from opentelemetry.trace import Status, StatusCode
8
+ from puvinoise.context import AgentContext
9
+ import functools
10
+
11
+ _tracer = trace.get_tracer("agent.hooks")
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Internal helpers
16
+ # ---------------------------------------------------------------------------
17
+
18
+ def _attach_agent_context(span):
19
+ """Attach run_id and agent_name from AgentContext to span (if active)."""
20
+ run_id = AgentContext.get_run_id()
21
+ agent_name = AgentContext.get_agent_name()
22
+ if run_id:
23
+ span.set_attribute("agent.run_id", run_id)
24
+ if agent_name:
25
+ span.set_attribute("agent.name", agent_name)
26
+
27
+
28
+ def _extract_openai_response(result, span):
29
+ """
30
+ Parse an OpenAI ChatCompletion response object and record span attributes.
31
+ Works for openai>=1.0 (the openai.types.chat.ChatCompletion object).
32
+ """
33
+ try:
34
+ # Model actually used (may differ from requested)
35
+ if hasattr(result, "model"):
36
+ span.set_attribute("llm.response_model", result.model)
37
+
38
+ choices = getattr(result, "choices", None)
39
+ if choices:
40
+ first = choices[0]
41
+ # finish_reason
42
+ finish_reason = getattr(first, "finish_reason", None)
43
+ if finish_reason:
44
+ span.set_attribute("llm.stop_reason", finish_reason)
45
+ # message content
46
+ message = getattr(first, "message", None)
47
+ if message:
48
+ content = getattr(message, "content", None) or ""
49
+ span.set_attribute("llm.response", content[:500])
50
+ span.set_attribute("llm.response_length", len(content))
51
+
52
+ # Token usage
53
+ usage = getattr(result, "usage", None)
54
+ if usage:
55
+ if hasattr(usage, "prompt_tokens"):
56
+ span.set_attribute("llm.input_tokens", usage.prompt_tokens)
57
+ if hasattr(usage, "completion_tokens"):
58
+ span.set_attribute("llm.output_tokens", usage.completion_tokens)
59
+ except Exception:
60
+ pass # Never let telemetry crash the caller
61
+
62
+
63
+ def _extract_anthropic_response(result, span):
64
+ """Parse an Anthropic MessageResponse and record span attributes."""
65
+ try:
66
+ if hasattr(result, "model"):
67
+ span.set_attribute("llm.response_model", result.model)
68
+
69
+ if hasattr(result, "content") and result.content:
70
+ if isinstance(result.content, list):
71
+ text_blocks = [b.text for b in result.content if hasattr(b, "text")]
72
+ response_text = " ".join(text_blocks)
73
+ else:
74
+ response_text = str(result.content)
75
+ span.set_attribute("llm.response", response_text[:500])
76
+ span.set_attribute("llm.response_length", len(response_text))
77
+
78
+ if hasattr(result, "stop_reason"):
79
+ span.set_attribute("llm.stop_reason", result.stop_reason)
80
+
81
+ usage = getattr(result, "usage", None)
82
+ if usage:
83
+ if hasattr(usage, "input_tokens"):
84
+ span.set_attribute("llm.input_tokens", usage.input_tokens)
85
+ if hasattr(usage, "output_tokens"):
86
+ span.set_attribute("llm.output_tokens", usage.output_tokens)
87
+ except Exception:
88
+ pass
89
+
90
+
91
+ def _extract_openai_messages(kwargs, span):
92
+ """Extract prompt info from OpenAI-style messages kwarg."""
93
+ messages = kwargs.get("messages")
94
+ if not messages:
95
+ return
96
+ span.set_attribute("llm.message_count", len(messages))
97
+ user_msgs = [m for m in messages if m.get("role") == "user"]
98
+ if user_msgs:
99
+ content = user_msgs[0].get("content", "")
100
+ if isinstance(content, str):
101
+ span.set_attribute("llm.prompt", content[:500])
102
+ elif isinstance(content, list):
103
+ texts = [b.get("text", "") for b in content if b.get("type") == "text"]
104
+ if texts:
105
+ span.set_attribute("llm.prompt", texts[0][:500])
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Anthropic hooks
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def instrument_anthropic_call(func):
113
+ """
114
+ Decorator to automatically trace synchronous Anthropic API calls.
115
+
116
+ Usage::
117
+
118
+ @instrument_anthropic_call
119
+ def call_claude(prompt, model="claude-sonnet-4-5-20250929"):
120
+ response = client.messages.create(...)
121
+ return response
122
+ """
123
+ @functools.wraps(func)
124
+ def wrapper(*args, **kwargs):
125
+ with _tracer.start_as_current_span("llm.anthropic") as span:
126
+ _attach_agent_context(span)
127
+ span.set_attribute("llm.provider", "anthropic")
128
+
129
+ model = kwargs.get("model")
130
+ if model:
131
+ span.set_attribute("llm.model", model)
132
+
133
+ _extract_openai_messages(kwargs, span) # same shape for Anthropic
134
+
135
+ if "temperature" in kwargs:
136
+ span.set_attribute("llm.temperature", float(kwargs["temperature"]))
137
+ if "max_tokens" in kwargs:
138
+ span.set_attribute("llm.max_tokens", int(kwargs["max_tokens"]))
139
+
140
+ try:
141
+ result = func(*args, **kwargs)
142
+ _extract_anthropic_response(result, span)
143
+ span.set_status(Status(StatusCode.OK))
144
+ return result
145
+ except Exception as e:
146
+ span.set_status(Status(StatusCode.ERROR, str(e)))
147
+ span.record_exception(e)
148
+ span.set_attribute("error.type", type(e).__name__)
149
+ raise
150
+
151
+ return wrapper
152
+
153
+
154
+ def instrument_anthropic_stream(func):
155
+ """
156
+ Decorator for *generator* streaming Anthropic API calls.
157
+
158
+ The span stays open for the entire stream duration (bug fix: previously
159
+ the span closed before iteration completed).
160
+
161
+ Usage::
162
+
163
+ @instrument_anthropic_stream
164
+ def stream_claude(prompt):
165
+ with client.messages.stream(...) as stream:
166
+ for text in stream.text_stream:
167
+ yield text
168
+ """
169
+ @functools.wraps(func)
170
+ def wrapper(*args, **kwargs):
171
+ span = _tracer.start_span("llm.anthropic.stream")
172
+ ctx = trace.use_span(span, end_on_exit=False)
173
+ ctx.__enter__()
174
+
175
+ _attach_agent_context(span)
176
+ span.set_attribute("llm.provider", "anthropic")
177
+ span.set_attribute("llm.streaming", True)
178
+
179
+ model = kwargs.get("model")
180
+ if model:
181
+ span.set_attribute("llm.model", model)
182
+
183
+ chunk_count = 0
184
+ total_length = 0
185
+
186
+ try:
187
+ for chunk in func(*args, **kwargs):
188
+ chunk_count += 1
189
+ if isinstance(chunk, str):
190
+ total_length += len(chunk)
191
+ yield chunk
192
+
193
+ span.set_attribute("llm.chunks_received", chunk_count)
194
+ span.set_attribute("llm.total_length", total_length)
195
+ span.set_status(Status(StatusCode.OK))
196
+ except Exception as e:
197
+ span.set_status(Status(StatusCode.ERROR, str(e)))
198
+ span.record_exception(e)
199
+ raise
200
+ finally:
201
+ span.end()
202
+
203
+ return wrapper
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # OpenAI hook
208
+ # ---------------------------------------------------------------------------
209
+
210
+ def instrument_openai_call(func):
211
+ """
212
+ Decorator to automatically trace OpenAI API calls (openai>=1.0).
213
+
214
+ API keys are read by the openai library itself (``OPENAI_API_KEY`` env var
215
+ or ``openai.api_key``). The SDK never touches credentials.
216
+
217
+ Usage::
218
+
219
+ @instrument_openai_call
220
+ def call_gpt(prompt, model="gpt-4o"):
221
+ response = client.chat.completions.create(
222
+ model=model,
223
+ messages=[{"role": "user", "content": prompt}],
224
+ )
225
+ return response
226
+ """
227
+ @functools.wraps(func)
228
+ def wrapper(*args, **kwargs):
229
+ with _tracer.start_as_current_span("llm.openai") as span:
230
+ _attach_agent_context(span)
231
+ span.set_attribute("llm.provider", "openai")
232
+
233
+ model = kwargs.get("model")
234
+ if model:
235
+ span.set_attribute("llm.model", model)
236
+
237
+ _extract_openai_messages(kwargs, span)
238
+
239
+ if "temperature" in kwargs:
240
+ span.set_attribute("llm.temperature", float(kwargs["temperature"]))
241
+ if "max_tokens" in kwargs:
242
+ span.set_attribute("llm.max_tokens", int(kwargs["max_tokens"]))
243
+
244
+ try:
245
+ result = func(*args, **kwargs)
246
+ _extract_openai_response(result, span)
247
+ span.set_status(Status(StatusCode.OK))
248
+ return result
249
+ except Exception as e:
250
+ span.set_status(Status(StatusCode.ERROR, str(e)))
251
+ span.record_exception(e)
252
+ span.set_attribute("error.type", type(e).__name__)
253
+ raise
254
+
255
+ return wrapper
256
+
257
+
258
+ def instrument_openai_stream(func):
259
+ """
260
+ Decorator for streaming OpenAI calls using ``stream=True``.
261
+
262
+ Usage::
263
+
264
+ @instrument_openai_stream
265
+ def stream_gpt(prompt, model="gpt-4o"):
266
+ for chunk in client.chat.completions.create(
267
+ model=model,
268
+ messages=[{"role": "user", "content": prompt}],
269
+ stream=True,
270
+ ):
271
+ delta = chunk.choices[0].delta.content or ""
272
+ yield delta
273
+ """
274
+ @functools.wraps(func)
275
+ def wrapper(*args, **kwargs):
276
+ span = _tracer.start_span("llm.openai.stream")
277
+ ctx = trace.use_span(span, end_on_exit=False)
278
+ ctx.__enter__()
279
+
280
+ _attach_agent_context(span)
281
+ span.set_attribute("llm.provider", "openai")
282
+ span.set_attribute("llm.streaming", True)
283
+
284
+ model = kwargs.get("model")
285
+ if model:
286
+ span.set_attribute("llm.model", model)
287
+
288
+ chunk_count = 0
289
+ total_length = 0
290
+
291
+ try:
292
+ for chunk in func(*args, **kwargs):
293
+ chunk_count += 1
294
+ if isinstance(chunk, str):
295
+ total_length += len(chunk)
296
+ yield chunk
297
+
298
+ span.set_attribute("llm.chunks_received", chunk_count)
299
+ span.set_attribute("llm.total_length", total_length)
300
+ span.set_status(Status(StatusCode.OK))
301
+ except Exception as e:
302
+ span.set_status(Status(StatusCode.ERROR, str(e)))
303
+ span.record_exception(e)
304
+ raise
305
+ finally:
306
+ span.end()
307
+
308
+ return wrapper
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Generic / Ollama hook
313
+ # ---------------------------------------------------------------------------
314
+
315
+ def instrument_llm_call(provider="unknown"):
316
+ """
317
+ Generic LLM call instrumentation (works with any provider: Ollama, Cohere, etc.).
318
+
319
+ Unlike the old version this is now a *parameterised* decorator so you can
320
+ tag the provider name cleanly::
321
+
322
+ @instrument_llm_call(provider="ollama")
323
+ def call_ollama(prompt, model="llama3"):
324
+ ...
325
+
326
+ For backward-compat, calling without parentheses still works (provider="unknown").
327
+ """
328
+ def decorator(func):
329
+ @functools.wraps(func)
330
+ def wrapper(*args, **kwargs):
331
+ with _tracer.start_as_current_span("llm.call") as span:
332
+ _attach_agent_context(span)
333
+ span.set_attribute("llm.provider", provider)
334
+
335
+ model = kwargs.get("model", "unknown")
336
+ span.set_attribute("llm.model", model)
337
+
338
+ # Accept a plain prompt string as first positional arg or kwarg
339
+ prompt = kwargs.get("prompt") or (
340
+ args[0] if args and isinstance(args[0], str) else None
341
+ )
342
+ if prompt:
343
+ span.set_attribute("llm.prompt", prompt[:500])
344
+
345
+ if "temperature" in kwargs:
346
+ span.set_attribute("llm.temperature", float(kwargs["temperature"]))
347
+
348
+ try:
349
+ result = func(*args, **kwargs)
350
+
351
+ if isinstance(result, str):
352
+ span.set_attribute("llm.response", result[:500])
353
+ span.set_attribute("llm.response_length", len(result))
354
+ elif isinstance(result, dict):
355
+ # Ollama /api/chat returns a dict
356
+ msg = (
357
+ result.get("message", {}).get("content")
358
+ or result.get("response", "")
359
+ )
360
+ if msg:
361
+ span.set_attribute("llm.response", msg[:500])
362
+ span.set_attribute("llm.response_length", len(msg))
363
+ # Ollama token counts
364
+ if "prompt_eval_count" in result:
365
+ span.set_attribute("llm.input_tokens", result["prompt_eval_count"])
366
+ if "eval_count" in result:
367
+ span.set_attribute("llm.output_tokens", result["eval_count"])
368
+
369
+ span.set_status(Status(StatusCode.OK))
370
+ return result
371
+ except Exception as e:
372
+ span.set_status(Status(StatusCode.ERROR, str(e)))
373
+ span.record_exception(e)
374
+ span.set_attribute("error.type", type(e).__name__)
375
+ raise
376
+
377
+ return wrapper
378
+
379
+ # Allow @instrument_llm_call (no parens) for backward compat
380
+ if callable(provider):
381
+ func, provider = provider, "unknown"
382
+ return decorator(func)
383
+
384
+ return decorator
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Memory & tool hooks (unchanged API, small robustness improvements)
389
+ # ---------------------------------------------------------------------------
390
+
391
+ def instrument_memory_operation(operation_type="query"):
392
+ """
393
+ Decorator to trace memory operations (retrieval, storage, delete, etc.).
394
+
395
+ Usage::
396
+
397
+ @instrument_memory_operation("retrieve")
398
+ def retrieve_memory(query):
399
+ ...
400
+ """
401
+ def decorator(func):
402
+ @functools.wraps(func)
403
+ def wrapper(*args, **kwargs):
404
+ with _tracer.start_as_current_span(f"memory.{operation_type}") as span:
405
+ _attach_agent_context(span)
406
+ span.set_attribute("memory.operation", operation_type)
407
+
408
+ try:
409
+ result = func(*args, **kwargs)
410
+ if isinstance(result, (list, tuple)):
411
+ span.set_attribute("memory.result_count", len(result))
412
+ span.set_status(Status(StatusCode.OK))
413
+ return result
414
+ except Exception as e:
415
+ span.set_status(Status(StatusCode.ERROR, str(e)))
416
+ span.record_exception(e)
417
+ raise
418
+
419
+ return wrapper
420
+ return decorator
421
+
422
+
423
+ def instrument_tool_call(tool_name=None):
424
+ """
425
+ Decorator to trace agent tool usage.
426
+
427
+ Usage::
428
+
429
+ @instrument_tool_call("web_search")
430
+ def search_web(query):
431
+ ...
432
+ """
433
+ def decorator(func):
434
+ @functools.wraps(func)
435
+ def wrapper(*args, **kwargs):
436
+ name = tool_name or func.__name__
437
+ with _tracer.start_as_current_span(f"tool.{name}") as span:
438
+ _attach_agent_context(span)
439
+ span.set_attribute("tool.name", name)
440
+
441
+ try:
442
+ result = func(*args, **kwargs)
443
+ span.set_status(Status(StatusCode.OK))
444
+ return result
445
+ except Exception as e:
446
+ span.set_status(Status(StatusCode.ERROR, str(e)))
447
+ span.record_exception(e)
448
+ raise
449
+
450
+ return wrapper
451
+ return decorator
452
+
453
+
454
+ # ---------------------------------------------------------------------------
455
+ # Span utilities
456
+ # ---------------------------------------------------------------------------
457
+
458
+ def add_span_attribute(key: str, value):
459
+ """Add a custom attribute to the current active span."""
460
+ span = trace.get_current_span()
461
+ if span and span.is_recording():
462
+ span.set_attribute(key, value)
463
+
464
+
465
+ def add_span_event(name: str, attributes: dict = None):
466
+ """Add an event to the current active span."""
467
+ span = trace.get_current_span()
468
+ if span and span.is_recording():
469
+ span.add_event(name, attributes or {})
@@ -0,0 +1,87 @@
1
+ from opentelemetry import trace
2
+ from opentelemetry.trace import Status, StatusCode
3
+ from puvinoise.context import AgentContext
4
+ import functools
5
+ import os
6
+
7
+ _tracer = trace.get_tracer("agent.runtime")
8
+
9
+
10
+ def run_with_trace(fn, agent_name="default-agent", *args, **kwargs):
11
+ """
12
+ Wraps a function execution with OpenTelemetry tracing and agent context.
13
+
14
+ Args:
15
+ fn: The function to execute
16
+ agent_name: Name of the agent for this execution
17
+ *args, **kwargs: Arguments to pass to the function
18
+
19
+ Returns:
20
+ The result of the function execution
21
+ """
22
+ ctx = AgentContext(agent_name)
23
+ ctx.activate()
24
+
25
+ with _tracer.start_as_current_span("agent.run") as span:
26
+ span.set_attribute("agent.run_id", ctx.run_id)
27
+ span.set_attribute("agent.name", ctx.agent_name)
28
+
29
+ tenant_id = os.getenv("TENANT_ID")
30
+ if tenant_id:
31
+ span.set_attribute("tenant.id", tenant_id)
32
+
33
+ try:
34
+ # Execute the function
35
+ result = fn(*args, **kwargs)
36
+
37
+ # Mark span as successful
38
+ span.set_status(Status(StatusCode.OK))
39
+
40
+ return result
41
+
42
+ except Exception as e:
43
+ # Record the error
44
+ span.set_status(Status(StatusCode.ERROR, str(e)))
45
+ span.record_exception(e)
46
+ raise
47
+
48
+ finally:
49
+ # Cleanup happens automatically when span exits
50
+ pass
51
+
52
+
53
+ def trace_function(operation_name=None):
54
+ """
55
+ Decorator to trace individual functions within an agent run.
56
+
57
+ Usage:
58
+ @trace_function("search_memory")
59
+ def search_memory(query):
60
+ ...
61
+ """
62
+ def decorator(fn):
63
+ @functools.wraps(fn)
64
+ def wrapper(*args, **kwargs):
65
+ span_name = operation_name or f"{fn.__module__}.{fn.__name__}"
66
+
67
+ with _tracer.start_as_current_span(span_name) as span:
68
+ # Add context from AgentContext if available
69
+ run_id = AgentContext.get_run_id()
70
+ agent_name = AgentContext.get_agent_name()
71
+
72
+ if run_id:
73
+ span.set_attribute("agent.run_id", run_id)
74
+ if agent_name:
75
+ span.set_attribute("agent.name", agent_name)
76
+
77
+ try:
78
+ result = fn(*args, **kwargs)
79
+ span.set_status(Status(StatusCode.OK))
80
+ return result
81
+ except Exception as e:
82
+ span.set_status(Status(StatusCode.ERROR, str(e)))
83
+ span.record_exception(e)
84
+ raise
85
+
86
+ return wrapper
87
+ return decorator
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: puvinoise-sdk
3
+ Version: 0.2.0
4
+ Summary: Independent OpenTelemetry SDK for AI agents (Anthropic, OpenAI, Ollama)
5
+ Author-email: Sarvesh Sahane <sarvesh.sahane@puvilabs.com>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://puvilabs.com
8
+ Keywords: puvinoise,opentelemetry,otel,tracing,llm,agents,observability
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Topic :: System :: Monitoring
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: opentelemetry-api>=1.20
20
+ Requires-Dist: opentelemetry-sdk>=1.20
21
+ Requires-Dist: opentelemetry-exporter-otlp>=1.20
22
+ Provides-Extra: anthropic
23
+ Requires-Dist: anthropic>=0.20; extra == "anthropic"
24
+ Provides-Extra: openai
25
+ Requires-Dist: openai>=1.0; extra == "openai"
26
+ Provides-Extra: all
27
+ Requires-Dist: anthropic>=0.20; extra == "all"
28
+ Requires-Dist: openai>=1.0; extra == "all"
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ puvinoise/__init__.py
3
+ puvinoise/bootstrap.py
4
+ puvinoise/context.py
5
+ puvinoise/hooks.py
6
+ puvinoise/tracer.py
7
+ puvinoise_sdk.egg-info/PKG-INFO
8
+ puvinoise_sdk.egg-info/SOURCES.txt
9
+ puvinoise_sdk.egg-info/dependency_links.txt
10
+ puvinoise_sdk.egg-info/requires.txt
11
+ puvinoise_sdk.egg-info/top_level.txt
@@ -0,0 +1,13 @@
1
+ opentelemetry-api>=1.20
2
+ opentelemetry-sdk>=1.20
3
+ opentelemetry-exporter-otlp>=1.20
4
+
5
+ [all]
6
+ anthropic>=0.20
7
+ openai>=1.0
8
+
9
+ [anthropic]
10
+ anthropic>=0.20
11
+
12
+ [openai]
13
+ openai>=1.0
@@ -0,0 +1 @@
1
+ puvinoise
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "puvinoise-sdk"
7
+ version = "0.2.0"
8
+ description = "Independent OpenTelemetry SDK for AI agents (Anthropic, OpenAI, Ollama)"
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.9"
11
+
12
+ authors = [
13
+ { name = "Sarvesh Sahane", email = "sarvesh.sahane@puvilabs.com" }
14
+ ]
15
+
16
+ license = { text = "Proprietary" }
17
+
18
+ keywords = ["puvinoise", "opentelemetry", "otel", "tracing", "llm", "agents", "observability"]
19
+
20
+ classifiers = [
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Operating System :: OS Independent",
26
+ "Intended Audience :: Developers",
27
+ "Topic :: Software Development :: Libraries",
28
+ "Topic :: System :: Monitoring",
29
+ ]
30
+
31
+ # Core deps — always required
32
+ dependencies = [
33
+ "opentelemetry-api>=1.20",
34
+ "opentelemetry-sdk>=1.20",
35
+ "opentelemetry-exporter-otlp>=1.20",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://puvilabs.com"
40
+
41
+ # Optional per-provider deps — install only what you need:
42
+ # pip install puvinoise[anthropic]
43
+ # pip install puvinoise[openai]
44
+ # pip install puvinoise[all]
45
+ [project.optional-dependencies]
46
+ anthropic = ["anthropic>=0.20"]
47
+ openai = ["openai>=1.0"]
48
+ all = ["anthropic>=0.20", "openai>=1.0"]
49
+
50
+ [tool.setuptools.packages.find]
51
+ where = ["."]
52
+ include = ["puvinoise*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+