paid-python 0.0.5a40__py3-none-any.whl → 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.
paid/logger.py ADDED
@@ -0,0 +1,21 @@
1
+ """
2
+ This file exports `logger` object for unified logging across the Paid SDK.
3
+ """
4
+
5
+ import logging
6
+ import os
7
+
8
+ import dotenv
9
+
10
+ # Configure logging
11
+ _ = dotenv.load_dotenv()
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Set default log level to ERROR, allow override via PAID_LOG_LEVEL environment variable
15
+ log_level_name = os.environ.get("PAID_LOG_LEVEL")
16
+ if log_level_name is not None:
17
+ log_level = getattr(logging, log_level_name.upper(), logging.ERROR)
18
+ else:
19
+ log_level = logging.ERROR
20
+
21
+ logger.setLevel(log_level)
paid/tracing/__init__.py CHANGED
@@ -1,18 +1,18 @@
1
1
  # Tracing module for OpenTelemetry integration
2
2
  from .autoinstrumentation import paid_autoinstrument
3
- from .tracing import (
4
- generate_and_set_tracing_token,
3
+ from .context_manager import paid_tracing
4
+ from .distributed_tracing import (
5
5
  generate_tracing_token,
6
- paid_tracing,
7
6
  set_tracing_token,
8
7
  unset_tracing_token,
9
8
  )
9
+ from .signal import signal
10
10
 
11
11
  __all__ = [
12
- "generate_and_set_tracing_token",
13
12
  "generate_tracing_token",
14
13
  "paid_autoinstrument",
15
14
  "paid_tracing",
16
15
  "set_tracing_token",
17
16
  "unset_tracing_token",
17
+ "signal",
18
18
  ]
@@ -8,7 +8,10 @@ sending traces to the Paid collector endpoint.
8
8
  from typing import List, Optional
9
9
 
10
10
  from . import tracing
11
- from .tracing import _initialize_tracing, logger
11
+ from .tracing import initialize_tracing_
12
+ from opentelemetry.trace import NoOpTracerProvider
13
+
14
+ from paid.logger import logger
12
15
 
13
16
  # Safe imports for instrumentation libraries
14
17
  try:
@@ -89,9 +92,9 @@ def paid_autoinstrument(libraries: Optional[List[str]] = None) -> None:
89
92
  >>> anthropic_client.messages.create(...)
90
93
  """
91
94
  # Initialize tracing if not already initialized
92
- if not tracing.paid_tracer_provider:
95
+ if isinstance(tracing.paid_tracer_provider, NoOpTracerProvider):
93
96
  logger.info("Tracing not initialized, initializing automatically")
94
- _initialize_tracing()
97
+ initialize_tracing_()
95
98
 
96
99
  # Default to all supported libraries if none specified
97
100
  if libraries is None:
@@ -0,0 +1,243 @@
1
+ import asyncio
2
+ import contextvars
3
+ import functools
4
+ from typing import Any, Callable, Dict, Optional, Tuple
5
+
6
+ from . import distributed_tracing, tracing
7
+ from .tracing import get_paid_tracer, get_token, initialize_tracing_, trace_async_, trace_sync_
8
+ from opentelemetry import trace
9
+ from opentelemetry.context import Context
10
+ from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, Status, StatusCode, TraceFlags
11
+
12
+ from paid.logger import logger
13
+
14
+
15
+ class paid_tracing:
16
+ """
17
+ Decorator and context manager for tracing with Paid.
18
+
19
+ This class can be used both as a decorator and as a context manager (with/async with),
20
+ providing flexible tracing capabilities for both functions and code blocks.
21
+
22
+ Parameters
23
+ ----------
24
+ external_customer_id : str
25
+ The external customer ID to associate with the trace.
26
+ external_agent_id : Optional[str], optional
27
+ The external agent ID to associate with the trace, by default None.
28
+ tracing_token : Optional[int], optional
29
+ Optional tracing token for distributed tracing, by default None.
30
+ store_prompt : bool, optional
31
+ Whether to store prompt contents in span attributes, by default False.
32
+ collector_endpoint: Optional[str], optional
33
+ OTEL collector HTTP endpoint, by default "https://collector.agentpaid.io:4318/v1/traces".
34
+ metadata : Optional[Dict[str, Any]], optional
35
+ Optional metadata to attach to the trace, by default None.
36
+
37
+ Examples
38
+ --------
39
+ As a decorator (sync):
40
+ >>> @paid_tracing(external_customer_id="customer123", external_agent_id="agent456")
41
+ ... def my_function(arg1, arg2):
42
+ ... return arg1 + arg2
43
+
44
+ As a decorator (async):
45
+ >>> @paid_tracing(external_customer_id="customer123")
46
+ ... async def my_async_function(arg1, arg2):
47
+ ... return arg1 + arg2
48
+
49
+ As a context manager (sync):
50
+ >>> with paid_tracing(external_customer_id="customer123", external_agent_id="agent456"):
51
+ ... result = expensive_computation()
52
+
53
+ As a context manager (async):
54
+ >>> async with paid_tracing(external_customer_id="customer123"):
55
+ ... result = await async_operation()
56
+
57
+ Notes
58
+ -----
59
+ If tracing is not already initialized, the decorator will automatically
60
+ initialize it using the PAID_API_KEY environment variable.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ external_customer_id: str,
66
+ *,
67
+ external_agent_id: Optional[str] = None,
68
+ tracing_token: Optional[int] = None,
69
+ store_prompt: bool = False,
70
+ collector_endpoint: Optional[str] = tracing.DEFAULT_COLLECTOR_ENDPOINT,
71
+ metadata: Optional[Dict[str, Any]] = None,
72
+ ):
73
+ self.external_customer_id = external_customer_id
74
+ self.external_agent_id = external_agent_id
75
+ self.tracing_token = tracing_token
76
+ self.store_prompt = store_prompt
77
+ self.collector_endpoint = collector_endpoint
78
+ self.metadata = metadata
79
+ self.span: Optional[Span] = None
80
+ self.span_ctx: Optional[Any] = None # Context manager for the span
81
+ self.reset_tokens: Optional[
82
+ Tuple[
83
+ contextvars.Token[Optional[str]], # external_customer_id
84
+ contextvars.Token[Optional[str]], # external_agent_id
85
+ contextvars.Token[Optional[bool]], # store_prompt
86
+ contextvars.Token[Optional[Dict[str, Any]]], # metadata
87
+ ]
88
+ ] = None
89
+
90
+ if not get_token():
91
+ initialize_tracing_(None, self.collector_endpoint)
92
+
93
+ def _setup_context(self) -> Optional[Context]:
94
+ """Set up context variables and return OTEL context if needed."""
95
+
96
+ # Set context variables
97
+ reset_customer_id_ctx_token = tracing.paid_external_customer_id_var.set(self.external_customer_id)
98
+ reset_agent_id_ctx_token = tracing.paid_external_agent_id_var.set(self.external_agent_id)
99
+ reset_store_prompt_ctx_token = tracing.paid_store_prompt_var.set(self.store_prompt)
100
+ reset_user_metadata_ctx_token = tracing.paid_user_metadata_var.set(self.metadata)
101
+
102
+ # Store reset tokens for cleanup
103
+ self.reset_tokens = (
104
+ reset_customer_id_ctx_token,
105
+ reset_agent_id_ctx_token,
106
+ reset_store_prompt_ctx_token,
107
+ reset_user_metadata_ctx_token,
108
+ )
109
+
110
+ # Handle distributed tracing token
111
+ override_trace_id = self.tracing_token
112
+ if not override_trace_id:
113
+ override_trace_id = tracing.paid_trace_id_var.get()
114
+
115
+ ctx: Optional[Context] = None
116
+ if override_trace_id is not None:
117
+ span_context = SpanContext(
118
+ trace_id=override_trace_id,
119
+ span_id=distributed_tracing.otel_id_generator.generate_span_id(),
120
+ is_remote=True,
121
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
122
+ )
123
+ ctx = trace.set_span_in_context(NonRecordingSpan(span_context))
124
+
125
+ return ctx
126
+
127
+ def _cleanup_context(self):
128
+ """Reset all context variables."""
129
+ if self.reset_tokens:
130
+ (
131
+ reset_customer_id_ctx_token,
132
+ reset_agent_id_ctx_token,
133
+ reset_store_prompt_ctx_token,
134
+ reset_user_metadata_ctx_token,
135
+ ) = self.reset_tokens
136
+ tracing.paid_external_customer_id_var.reset(reset_customer_id_ctx_token)
137
+ tracing.paid_external_agent_id_var.reset(reset_agent_id_ctx_token)
138
+ tracing.paid_store_prompt_var.reset(reset_store_prompt_ctx_token)
139
+ tracing.paid_user_metadata_var.reset(reset_user_metadata_ctx_token)
140
+ self.reset_tokens = None
141
+
142
+ # Context manager methods for sync
143
+ def __enter__(self):
144
+ return self._enter_ctx()
145
+
146
+ def __exit__(self, exc_type, exc_val, exc_tb):
147
+ return self._exit_ctx(exc_type, exc_val, exc_tb)
148
+
149
+ # Context manager methods for async
150
+ async def __aenter__(self):
151
+ return self._enter_ctx()
152
+
153
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
154
+ return self._exit_ctx(exc_type, exc_val, exc_tb)
155
+
156
+ def _enter_ctx(self):
157
+ ctx = self._setup_context()
158
+ tracer = get_paid_tracer()
159
+ logger.info(f"Creating span for external_customer_id: {self.external_customer_id}")
160
+ self.span_ctx = tracer.start_as_current_span("parent_span", context=ctx)
161
+ self.span = self.span_ctx.__enter__()
162
+ return self
163
+
164
+ def _exit_ctx(self, exc_type, exc_val, exc_tb):
165
+ """Exit synchronous context."""
166
+ try:
167
+ if self.span and self.span_ctx:
168
+ if exc_type is not None:
169
+ self.span.set_status(Status(StatusCode.ERROR, str(exc_val)))
170
+ else:
171
+ self.span.set_status(Status(StatusCode.OK))
172
+ logger.info("Context block executed successfully")
173
+
174
+ self.span_ctx.__exit__(exc_type, exc_val, exc_tb)
175
+ self.span_ctx = None
176
+ self.span = None
177
+
178
+ finally:
179
+ self._cleanup_context()
180
+
181
+ return False # Don't suppress exceptions
182
+
183
+ # Decorator functionality
184
+ def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
185
+ """Use as a decorator."""
186
+ if asyncio.iscoroutinefunction(func):
187
+
188
+ @functools.wraps(func)
189
+ async def async_wrapper(*args, **kwargs):
190
+ # Auto-initialize tracing if not done
191
+ if get_token() is None:
192
+ try:
193
+ initialize_tracing_(None, self.collector_endpoint)
194
+ except Exception as e:
195
+ logger.error(f"Failed to auto-initialize tracing: {e}")
196
+ # Fall back to executing function without tracing
197
+ return await func(*args, **kwargs)
198
+
199
+ try:
200
+ return await trace_async_(
201
+ external_customer_id=self.external_customer_id,
202
+ fn=func,
203
+ external_agent_id=self.external_agent_id,
204
+ tracing_token=self.tracing_token,
205
+ store_prompt=self.store_prompt,
206
+ metadata=self.metadata,
207
+ args=args,
208
+ kwargs=kwargs,
209
+ )
210
+ except Exception as e:
211
+ logger.error(f"Failed to trace async function {func.__name__}: {e}")
212
+ raise e
213
+
214
+ return async_wrapper
215
+ else:
216
+
217
+ @functools.wraps(func)
218
+ def sync_wrapper(*args, **kwargs):
219
+ # Auto-initialize tracing if not done
220
+ if get_token() is None:
221
+ try:
222
+ initialize_tracing_(None, self.collector_endpoint)
223
+ except Exception as e:
224
+ logger.error(f"Failed to auto-initialize tracing: {e}")
225
+ # Fall back to executing function without tracing
226
+ return func(*args, **kwargs)
227
+
228
+ try:
229
+ return trace_sync_(
230
+ external_customer_id=self.external_customer_id,
231
+ fn=func,
232
+ external_agent_id=self.external_agent_id,
233
+ tracing_token=self.tracing_token,
234
+ store_prompt=self.store_prompt,
235
+ metadata=self.metadata,
236
+ args=args,
237
+ kwargs=kwargs,
238
+ )
239
+ except Exception as e:
240
+ logger.error(f"Failed to trace sync function {func.__name__}: {e}")
241
+ raise e
242
+
243
+ return sync_wrapper
@@ -0,0 +1,113 @@
1
+ import warnings
2
+
3
+ from . import tracing
4
+ from opentelemetry.sdk.trace.id_generator import RandomIdGenerator
5
+
6
+ otel_id_generator = RandomIdGenerator()
7
+
8
+
9
+ def generate_tracing_token() -> int:
10
+ """
11
+ Generate a unique tracing token without setting it in the context.
12
+
13
+ Use this when you want to generate a trace ID to store or pass to another
14
+ process/service without immediately associating it with the current tracing context.
15
+ The token can later be used with set_tracing_token() to link traces across
16
+ different execution contexts.
17
+
18
+ Returns:
19
+ int: A unique OpenTelemetry trace ID.
20
+
21
+ Notes:
22
+ - This function only generates and returns the token; it does NOT set it in the context.
23
+ - Use this when you need to store the token separately before setting it.
24
+
25
+ Examples:
26
+ Generate token to store for later use:
27
+
28
+ from paid.tracing import generate_tracing_token, set_tracing_token
29
+
30
+ # Process 1: Generate and store
31
+ token = generate_tracing_token()
32
+ save_to_database("task_123", token)
33
+
34
+ # Process 2: Retrieve and use
35
+ token = load_from_database("task_123")
36
+ set_tracing_token(token)
37
+
38
+ @paid_tracing(external_customer_id="cust_123", external_agent_id="agent_456")
39
+ def process_task():
40
+ # This trace is now linked to the same token
41
+ pass
42
+
43
+ See Also:
44
+ set_tracing_token: Set a previously generated token.
45
+ """
46
+ return otel_id_generator.generate_trace_id()
47
+
48
+
49
+ def set_tracing_token(token: int):
50
+ """
51
+ Deprecated: Pass tracing_token directly to @paid_tracing() decorator instead.
52
+
53
+ This function is deprecated and will be removed in a future version.
54
+ Use the tracing_token parameter in @paid_tracing() to link traces across processes.
55
+
56
+ Instead of:
57
+ token = load_from_storage("workflow_123")
58
+ set_tracing_token(token)
59
+ @paid_tracing(external_customer_id="cust_123", external_agent_id="agent_456")
60
+ def process_workflow():
61
+ ...
62
+ unset_tracing_token()
63
+
64
+ Use:
65
+ token = load_from_storage("workflow_123")
66
+
67
+ @paid_tracing(
68
+ external_customer_id="cust_123",
69
+ external_agent_id="agent_456",
70
+ tracing_token=token
71
+ )
72
+ def process_workflow():
73
+ ...
74
+
75
+ Parameters:
76
+ token (int): A tracing token (for backward compatibility only).
77
+
78
+ Old behavior (for reference):
79
+ This function set a token in the context, so all subsequent @paid_tracing() calls
80
+ would use it automatically until unset_tracing_token() was called.
81
+ """
82
+ warnings.warn(
83
+ "set_tracing_token() is deprecated and will be removed in a future version. "
84
+ "Pass tracing_token directly to @paid_tracing(tracing_token=...) decorator instead.",
85
+ DeprecationWarning,
86
+ stacklevel=2,
87
+ )
88
+ _ = tracing.paid_trace_id_var.set(token)
89
+
90
+
91
+ def unset_tracing_token():
92
+ """
93
+ Deprecated: No longer needed. Use tracing_token parameter in @paid_tracing() instead.
94
+
95
+ This function is deprecated and will be removed in a future version.
96
+ Since tracing_token is now passed directly to @paid_tracing(), there's no need
97
+ to manually set/unset tokens in the context.
98
+
99
+ Old behavior (for reference):
100
+ This function unset a token previously set by set_tracing_token(), allowing subsequent @paid_tracing() calls
101
+ to have independent traces.
102
+
103
+ Migration:
104
+ If you were using set_tracing_token() + unset_tracing_token() pattern,
105
+ simply pass the token directly to @paid_tracing(tracing_token=...) instead.
106
+ """
107
+ warnings.warn(
108
+ "unset_tracing_token() is deprecated and will be removed in a future version. "
109
+ "Use tracing_token parameter in @paid_tracing(tracing_token=...) decorator instead.",
110
+ DeprecationWarning,
111
+ stacklevel=2,
112
+ )
113
+ _ = tracing.paid_trace_id_var.set(None)
paid/tracing/signal.py CHANGED
@@ -1,44 +1,69 @@
1
1
  import json
2
2
  import typing
3
3
 
4
- from .tracing import get_paid_tracer, logger, paid_external_agent_id_var, paid_external_customer_id_var, paid_token_var
5
- from opentelemetry import trace
4
+ from .tracing import get_paid_tracer
6
5
  from opentelemetry.trace import Status, StatusCode
7
6
 
7
+ from paid.logger import logger
8
8
 
9
- def _signal(event_name: str, enable_cost_tracing: bool, data: typing.Optional[typing.Dict] = None):
10
- if not event_name:
11
- logger.error("Event name is required for signal.")
12
- return
13
-
14
- # Check if there's an active span (from capture())
15
- current_span = trace.get_current_span()
16
- if current_span == trace.INVALID_SPAN:
17
- logger.error("Cannot send signal: you should call signal() within tracing context")
18
- return
19
-
20
- external_customer_id = paid_external_customer_id_var.get()
21
- external_agent_id = paid_external_agent_id_var.get()
22
- token = paid_token_var.get()
23
- if not (external_customer_id and external_agent_id and token):
24
- logger.error(
25
- f"Missing some of: external_customer_id: {external_customer_id}, "
26
- f"external_agent_id: {external_agent_id}, or token. "
27
- f"You should call signal() within a tracing context"
28
- )
29
- return
9
+
10
+ def signal(event_name: str, enable_cost_tracing: bool = False, data: typing.Optional[dict[str, typing.Any]] = None):
11
+ """
12
+ Emit a signal within a tracing context.
13
+
14
+ This function must be called within an active @paid_tracing() context (decorator or context manager).
15
+
16
+ Parameters
17
+ ----------
18
+ event_name : str
19
+ The name of the signal (e.g., "user_signup", "payment_processed", "task_completed").
20
+ enable_cost_tracing : bool, optional
21
+ If True, associates this signal with cost/usage traces from the same tracing context.
22
+ Should only be called once per tracing context to avoid multiple signals referring to the same costs.
23
+ Default is False.
24
+ data : dict[str, Any], optional
25
+ Additional context data to attach to the signal. Will be JSON-serialized and stored
26
+ as a span attribute. Example: {"user_id": "123", "amount": 99.99}.
27
+
28
+ Notes
29
+ -----
30
+ - Signal must be called within a @paid_tracing() context; calling outside will log an error and return.
31
+ - Use enable_cost_tracing=True when you want to mark the point where costs were incurred
32
+ and link that signal to cost/usage data from the same trace.
33
+
34
+ Examples
35
+ --------
36
+ Basic signal within a tracing context:
37
+
38
+ from paid.tracing import paid_tracing, signal
39
+
40
+ @paid_tracing(external_customer_id="cust_123", external_agent_id="agent_456")
41
+ def process_order(order_id):
42
+ # ... do work ...
43
+ signal("order_processed", data={"order_id": order_id})
44
+
45
+ Signal with cost tracking:
46
+
47
+ @paid_tracing(external_customer_id="cust_123", external_agent_id="agent_456")
48
+ def call_ai_api():
49
+ # ... call AI provider ...
50
+ signal("ai_api_call_complete", enable_cost_tracing=True)
51
+
52
+ Using context manager:
53
+
54
+ with paid_tracing(external_customer_id="cust_123", external_agent_id="agent_456"):
55
+ # ... do work ...
56
+ signal("milestone_reached", data={"step": "validation_complete"})
57
+ """
30
58
 
31
59
  tracer = get_paid_tracer()
32
60
  with tracer.start_as_current_span("signal") as span:
33
- attributes: typing.Dict[str, typing.Union[str, bool, int, float]] = {
34
- "external_customer_id": external_customer_id,
35
- "external_agent_id": external_agent_id,
61
+ attributes: dict[str, typing.Union[str, bool, int, float]] = {
36
62
  "event_name": event_name,
37
63
  }
38
64
 
39
65
  if enable_cost_tracing:
40
66
  # let the app know to associate this signal with cost traces
41
- attributes["enable_cost_tracing"] = True
42
67
  if data is None:
43
68
  data = {"paid": {"enable_cost_tracing": True}}
44
69
  else:
@@ -46,7 +71,12 @@ def _signal(event_name: str, enable_cost_tracing: bool, data: typing.Optional[ty
46
71
 
47
72
  # optional data (ex. manual cost tracking)
48
73
  if data:
49
- attributes["data"] = json.dumps(data)
74
+ try:
75
+ attributes["data"] = json.dumps(data)
76
+ except (TypeError, ValueError) as e:
77
+ logger.error(f"Failed to serialize data into JSON for signal [{event_name}]: {e}")
78
+ if enable_cost_tracing:
79
+ attributes["data"] = json.dumps({"paid": {"enable_cost_tracing": True}})
50
80
 
51
81
  span.set_attributes(attributes)
52
82
  # Mark span as successful