agentreplay 0.1.2__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.
@@ -0,0 +1,298 @@
1
+ # Copyright 2025 Sushanth (https://github.com/sushanthpy)
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Sampling strategies for trace collection.
16
+
17
+ This module provides OTEL-compatible sampling strategies to control
18
+ trace data volume while maintaining statistical validity.
19
+
20
+ Supported samplers:
21
+ - AlwaysOnSampler: Sample every trace (100%)
22
+ - AlwaysOffSampler: Sample no traces (0%)
23
+ - TraceIdRatioBasedSampler: Sample based on trace ID hash
24
+ - ParentBasedSampler: Respect parent span's sampling decision
25
+
26
+ Example:
27
+ >>> from agentreplay.sampling import TraceIdRatioBasedSampler
28
+ >>>
29
+ >>> # Sample 10% of traces
30
+ >>> sampler = TraceIdRatioBasedSampler(0.1)
31
+ >>>
32
+ >>> # Check if trace should be sampled
33
+ >>> if sampler.should_sample(trace_id=0x123abc):
34
+ ... # Record trace
35
+ ... pass
36
+ """
37
+
38
+ import logging
39
+ from typing import Optional
40
+ from abc import ABC, abstractmethod
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class Sampler(ABC):
46
+ """Abstract base class for sampling strategies."""
47
+
48
+ @abstractmethod
49
+ def should_sample(
50
+ self,
51
+ trace_id: int,
52
+ parent_sampled: Optional[bool] = None,
53
+ ) -> bool:
54
+ """Determine if a trace should be sampled.
55
+
56
+ Args:
57
+ trace_id: 128-bit trace identifier
58
+ parent_sampled: Whether parent span was sampled (if known)
59
+
60
+ Returns:
61
+ True if trace should be sampled
62
+ """
63
+ pass
64
+
65
+ @abstractmethod
66
+ def get_description(self) -> str:
67
+ """Get human-readable description of sampler.
68
+
69
+ Returns:
70
+ Description string
71
+ """
72
+ pass
73
+
74
+
75
+ class AlwaysOnSampler(Sampler):
76
+ """Sample all traces (100%).
77
+
78
+ Use this for development or low-volume production workloads.
79
+ """
80
+
81
+ def should_sample(
82
+ self,
83
+ trace_id: int,
84
+ parent_sampled: Optional[bool] = None,
85
+ ) -> bool:
86
+ """Always returns True."""
87
+ return True
88
+
89
+ def get_description(self) -> str:
90
+ """Get description."""
91
+ return "AlwaysOnSampler"
92
+
93
+
94
+ class AlwaysOffSampler(Sampler):
95
+ """Sample no traces (0%).
96
+
97
+ Use this to completely disable tracing.
98
+ """
99
+
100
+ def should_sample(
101
+ self,
102
+ trace_id: int,
103
+ parent_sampled: Optional[bool] = None,
104
+ ) -> bool:
105
+ """Always returns False."""
106
+ return False
107
+
108
+ def get_description(self) -> str:
109
+ """Get description."""
110
+ return "AlwaysOffSampler"
111
+
112
+
113
+ class TraceIdRatioBasedSampler(Sampler):
114
+ """Sample traces based on trace ID hash.
115
+
116
+ Uses deterministic sampling: traces with the same ID always get
117
+ the same sampling decision. This ensures consistent sampling across
118
+ distributed services.
119
+
120
+ Args:
121
+ rate: Sampling rate between 0.0 and 1.0
122
+
123
+ Example:
124
+ >>> # Sample 10% of traces
125
+ >>> sampler = TraceIdRatioBasedSampler(0.1)
126
+ >>>
127
+ >>> # Same trace ID always gets same decision
128
+ >>> assert sampler.should_sample(0x123) == sampler.should_sample(0x123)
129
+ """
130
+
131
+ def __init__(self, rate: float):
132
+ """Initialize ratio-based sampler.
133
+
134
+ Args:
135
+ rate: Sampling rate (0.0 = none, 1.0 = all)
136
+
137
+ Raises:
138
+ ValueError: If rate is not in [0.0, 1.0]
139
+ """
140
+ if not 0.0 <= rate <= 1.0:
141
+ raise ValueError(f"Sampling rate must be in [0.0, 1.0], got {rate}")
142
+
143
+ self.rate = rate
144
+ # Calculate threshold for comparison
145
+ # Use upper 64 bits of trace_id for sampling decision
146
+ self.threshold = int(rate * (2**64 - 1))
147
+
148
+ logger.info(f"TraceIdRatioBasedSampler initialized: rate={rate:.2%}")
149
+
150
+ def should_sample(
151
+ self,
152
+ trace_id: int,
153
+ parent_sampled: Optional[bool] = None,
154
+ ) -> bool:
155
+ """Determine if trace should be sampled based on trace ID.
156
+
157
+ Uses the upper 64 bits of the 128-bit trace ID for sampling decision.
158
+ This ensures uniform distribution and deterministic decisions.
159
+
160
+ Args:
161
+ trace_id: 128-bit trace identifier
162
+ parent_sampled: Ignored (not used for ratio-based sampling)
163
+
164
+ Returns:
165
+ True if trace should be sampled
166
+ """
167
+ if self.rate == 1.0:
168
+ return True
169
+ if self.rate == 0.0:
170
+ return False
171
+
172
+ # Extract upper 64 bits of trace_id
173
+ upper_64 = (trace_id >> 64) & ((1 << 64) - 1)
174
+
175
+ # Compare with threshold
176
+ return upper_64 < self.threshold
177
+
178
+ def get_description(self) -> str:
179
+ """Get description."""
180
+ return f"TraceIdRatioBasedSampler(rate={self.rate:.2%})"
181
+
182
+
183
+ class ParentBasedSampler(Sampler):
184
+ """Sample based on parent span's sampling decision.
185
+
186
+ If parent span was sampled, sample this span too. This ensures
187
+ complete traces are captured (no partial traces with missing spans).
188
+
189
+ Falls back to root_sampler for root spans (no parent).
190
+
191
+ Args:
192
+ root_sampler: Sampler to use for root spans (no parent)
193
+
194
+ Example:
195
+ >>> # Use 10% sampling for root spans, but always sample if parent was sampled
196
+ >>> root_sampler = TraceIdRatioBasedSampler(0.1)
197
+ >>> sampler = ParentBasedSampler(root_sampler)
198
+ """
199
+
200
+ def __init__(self, root_sampler: Sampler):
201
+ """Initialize parent-based sampler.
202
+
203
+ Args:
204
+ root_sampler: Sampler for root spans
205
+ """
206
+ self.root_sampler = root_sampler
207
+ logger.info(f"ParentBasedSampler initialized with root: {root_sampler.get_description()}")
208
+
209
+ def should_sample(
210
+ self,
211
+ trace_id: int,
212
+ parent_sampled: Optional[bool] = None,
213
+ ) -> bool:
214
+ """Determine if trace should be sampled.
215
+
216
+ Args:
217
+ trace_id: 128-bit trace identifier
218
+ parent_sampled: Whether parent span was sampled
219
+
220
+ Returns:
221
+ True if trace should be sampled
222
+ """
223
+ # If parent sampling decision is known, use it
224
+ if parent_sampled is not None:
225
+ return parent_sampled
226
+
227
+ # No parent (root span), use root sampler
228
+ return self.root_sampler.should_sample(trace_id, parent_sampled=None)
229
+
230
+ def get_description(self) -> str:
231
+ """Get description."""
232
+ return f"ParentBasedSampler(root={self.root_sampler.get_description()})"
233
+
234
+
235
+ def create_sampler_from_config(sampler_name: str, sampler_arg: str = "1.0") -> Sampler:
236
+ """Create sampler from OTEL environment variable values.
237
+
238
+ Args:
239
+ sampler_name: OTEL_TRACES_SAMPLER value
240
+ sampler_arg: OTEL_TRACES_SAMPLER_ARG value
241
+
242
+ Returns:
243
+ Configured Sampler instance
244
+
245
+ Example:
246
+ >>> import os
247
+ >>> sampler = create_sampler_from_config(
248
+ ... os.getenv("OTEL_TRACES_SAMPLER", "always_on"),
249
+ ... os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0")
250
+ ... )
251
+ """
252
+ sampler_name = sampler_name.lower().strip()
253
+
254
+ if sampler_name == "always_on":
255
+ return AlwaysOnSampler()
256
+
257
+ elif sampler_name == "always_off":
258
+ return AlwaysOffSampler()
259
+
260
+ elif sampler_name == "traceidratio":
261
+ try:
262
+ rate = float(sampler_arg)
263
+ rate = max(0.0, min(1.0, rate)) # Clamp to [0, 1]
264
+ return TraceIdRatioBasedSampler(rate)
265
+ except ValueError:
266
+ logger.warning(f"Invalid sampler_arg: {sampler_arg}, using 1.0")
267
+ return TraceIdRatioBasedSampler(1.0)
268
+
269
+ elif sampler_name == "parentbased_always_on":
270
+ return ParentBasedSampler(AlwaysOnSampler())
271
+
272
+ elif sampler_name == "parentbased_always_off":
273
+ return ParentBasedSampler(AlwaysOffSampler())
274
+
275
+ elif sampler_name == "parentbased_traceidratio":
276
+ try:
277
+ rate = float(sampler_arg)
278
+ rate = max(0.0, min(1.0, rate))
279
+ root_sampler = TraceIdRatioBasedSampler(rate)
280
+ return ParentBasedSampler(root_sampler)
281
+ except ValueError:
282
+ logger.warning(f"Invalid sampler_arg: {sampler_arg}, using 1.0")
283
+ root_sampler = TraceIdRatioBasedSampler(1.0)
284
+ return ParentBasedSampler(root_sampler)
285
+
286
+ else:
287
+ logger.warning(f"Unknown sampler: {sampler_name}, using AlwaysOnSampler")
288
+ return AlwaysOnSampler()
289
+
290
+
291
+ __all__ = [
292
+ "Sampler",
293
+ "AlwaysOnSampler",
294
+ "AlwaysOffSampler",
295
+ "TraceIdRatioBasedSampler",
296
+ "ParentBasedSampler",
297
+ "create_sampler_from_config",
298
+ ]
agentreplay/session.py ADDED
@@ -0,0 +1,164 @@
1
+ # Copyright 2025 Sushanth (https://github.com/sushanthpy)
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Session management for conversational applications."""
16
+
17
+ import time
18
+ from typing import Optional
19
+ from agentreplay.client import AgentreplayClient
20
+ from agentreplay.models import SpanType
21
+ from agentreplay.span import Span
22
+
23
+
24
+ class Session:
25
+ """Session manager for chatbot and conversational applications.
26
+
27
+ Automatically tracks session_id and message counts, simplifying
28
+ session lifecycle management for multi-turn conversations.
29
+
30
+ Args:
31
+ client: Agentreplay client instance
32
+ session_id: Optional session identifier (auto-generated if not provided)
33
+ agent_id: Optional agent identifier (uses client default if not provided)
34
+
35
+ Attributes:
36
+ session_id: Session identifier
37
+ message_count: Number of messages/traces in this session
38
+ start_time: Session start timestamp (microseconds)
39
+
40
+ Example:
41
+ >>> client = AgentreplayClient(url="http://localhost:8080", tenant_id=1)
42
+ >>> session = Session(client)
43
+ >>>
44
+ >>> # Track conversation turns
45
+ >>> with session.trace(SpanType.LLM) as turn1:
46
+ ... turn1.set_attribute("prompt", "Hello")
47
+ ... turn1.set_token_count(50)
48
+ ...
49
+ >>> with session.trace(SpanType.LLM) as turn2:
50
+ ... turn2.set_attribute("prompt", "How are you?")
51
+ ... turn2.set_token_count(60)
52
+ ...
53
+ >>> print(f"Session {session.session_id} had {session.message_count} turns")
54
+ >>> session.end()
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ client: AgentreplayClient,
60
+ session_id: Optional[int] = None,
61
+ agent_id: Optional[int] = None,
62
+ ):
63
+ """Initialize session manager.
64
+
65
+ Args:
66
+ client: Agentreplay client
67
+ session_id: Session identifier (auto-generated if None)
68
+ agent_id: Agent identifier (uses client default if None)
69
+ """
70
+ self.client = client
71
+ self.session_id = session_id if session_id is not None else int(time.time() * 1000)
72
+ self.agent_id = agent_id if agent_id is not None else client.agent_id
73
+ self.message_count = 0
74
+ self.start_time = int(time.time() * 1_000_000)
75
+ self._end_time: Optional[int] = None # Track when session ended
76
+ self._ended = False
77
+
78
+ def __enter__(self) -> "Session":
79
+ """Context manager entry."""
80
+ return self
81
+
82
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
83
+ """Context manager exit - end session."""
84
+ self.end()
85
+
86
+ def trace(
87
+ self,
88
+ span_type: SpanType = SpanType.ROOT,
89
+ **metadata,
90
+ ) -> Span:
91
+ """Create a trace within this session.
92
+
93
+ Automatically sets session_id and tracks message count.
94
+
95
+ Args:
96
+ span_type: Type of span (default: ROOT)
97
+ **metadata: Additional metadata to attach to span
98
+
99
+ Returns:
100
+ Span context manager
101
+
102
+ Example:
103
+ >>> session = Session(client)
104
+ >>> with session.trace(SpanType.LLM, model="gpt-4") as span:
105
+ ... span.set_token_count(100)
106
+ """
107
+ if self._ended:
108
+ raise RuntimeError("Cannot create trace in ended session")
109
+
110
+ self.message_count += 1
111
+
112
+ # Add session metadata
113
+ full_metadata = {
114
+ "message_num": self.message_count,
115
+ "session_duration_us": int(time.time() * 1_000_000) - self.start_time,
116
+ **metadata,
117
+ }
118
+
119
+ return self.client.trace(
120
+ span_type=span_type,
121
+ agent_id=self.agent_id,
122
+ session_id=self.session_id,
123
+ )
124
+
125
+ def end(self) -> None:
126
+ """Mark session as ended.
127
+
128
+ Optionally send session summary metrics to backend.
129
+ """
130
+ if self._ended:
131
+ return
132
+
133
+ self._end_time = int(time.time() * 1_000_000)
134
+ self._ended = True
135
+
136
+ # Compute session statistics
137
+ duration_us = self._end_time - self.start_time
138
+
139
+ # Could send session summary span
140
+ # with self.client.trace(
141
+ # span_type=SpanType.ROOT,
142
+ # agent_id=self.agent_id,
143
+ # session_id=self.session_id
144
+ # ) as summary:
145
+ # summary.set_attribute("session_ended", True)
146
+ # summary.set_attribute("total_messages", self.message_count)
147
+ # summary.set_attribute("duration_us", duration_us)
148
+
149
+ @property
150
+ def is_active(self) -> bool:
151
+ """Check if session is still active."""
152
+ return not self._ended
153
+
154
+ @property
155
+ def duration_seconds(self) -> float:
156
+ """Get session duration in seconds.
157
+
158
+ Returns accurate duration even after session has ended.
159
+ """
160
+ if self._ended and self._end_time is not None:
161
+ # Return actual session duration
162
+ return (self._end_time - self.start_time) / 1_000_000
163
+ # Session still active, return current duration
164
+ return (int(time.time() * 1_000_000) - self.start_time) / 1_000_000
@@ -0,0 +1,73 @@
1
+ # Copyright 2025 Sushanth (https://github.com/sushanthpy)
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Agentreplay automatic instrumentation via sitecustomize.
17
+
18
+ This file is automatically loaded by Python if it's in the site-packages directory.
19
+ It enables zero-code instrumentation - just set env vars and run!
20
+
21
+ Usage:
22
+ export AGENTREPLAY_ENABLED=true
23
+ export AGENTREPLAY_URL=http://localhost:47100
24
+ python my_app.py # Automatically instrumented!
25
+
26
+ Environment Variables:
27
+ AGENTREPLAY_ENABLED: Set to 'true' to enable auto-instrumentation
28
+ AGENTREPLAY_URL: Agentreplay server URL (default: http://localhost:47100)
29
+ AGENTREPLAY_TENANT_ID: Tenant ID (default: 1)
30
+ AGENTREPLAY_PROJECT_ID: Project ID (default: 0)
31
+ AGENTREPLAY_DEBUG: Set to 'true' for verbose logging
32
+ OTEL_SERVICE_NAME: Service name for traces
33
+ OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: Capture message content
34
+
35
+ Note:
36
+ This module is loaded very early in the Python startup process.
37
+ It must handle all errors gracefully to avoid breaking user applications.
38
+ """
39
+
40
+ import os
41
+ import sys
42
+
43
+ # Only auto-instrument if explicitly enabled
44
+ if os.getenv('AGENTREPLAY_ENABLED', '').lower() == 'true':
45
+ try:
46
+ # Import and initialize BEFORE any user code runs
47
+ from agentreplay.bootstrap import init_otel_instrumentation
48
+
49
+ init_otel_instrumentation(
50
+ service_name=os.getenv('OTEL_SERVICE_NAME', os.path.basename(sys.argv[0])),
51
+ agentreplay_url=os.getenv('AGENTREPLAY_URL', 'http://localhost:47100'),
52
+ tenant_id=int(os.getenv('AGENTREPLAY_TENANT_ID', '1')),
53
+ project_id=int(os.getenv('AGENTREPLAY_PROJECT_ID', '0')),
54
+ capture_content=os.getenv('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT', 'false').lower() == 'true'
55
+ )
56
+
57
+ # Silent by default, verbose if DEBUG enabled
58
+ if os.getenv('AGENTREPLAY_DEBUG', '').lower() == 'true':
59
+ print("[Agentreplay] ✓ Auto-instrumentation enabled", file=sys.stderr)
60
+ print(f"[Agentreplay] Service: {os.getenv('OTEL_SERVICE_NAME', os.path.basename(sys.argv[0]))}", file=sys.stderr)
61
+ print(f"[Agentreplay] URL: {os.getenv('AGENTREPLAY_URL', 'http://localhost:47100')}", file=sys.stderr)
62
+ print(f"[Agentreplay] Project: {os.getenv('AGENTREPLAY_PROJECT_ID', '0')}", file=sys.stderr)
63
+
64
+ except ImportError as e:
65
+ if os.getenv('AGENTREPLAY_DEBUG', '').lower() == 'true':
66
+ print(f"[Agentreplay] ✗ Failed to auto-instrument: {e}", file=sys.stderr)
67
+ print("[Agentreplay] Install: pip install opentelemetry-api opentelemetry-sdk", file=sys.stderr)
68
+
69
+ except Exception as e:
70
+ if os.getenv('AGENTREPLAY_DEBUG', '').lower() == 'true':
71
+ print(f"[Agentreplay] ✗ Auto-instrumentation error: {e}", file=sys.stderr)
72
+ import traceback
73
+ traceback.print_exc(file=sys.stderr)