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,502 @@
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
+ """GenAI Semantic Conventions validator and normalizer.
16
+
17
+ This module enforces OpenTelemetry GenAI semantic conventions and normalizes
18
+ framework-specific attributes to standard conventions.
19
+
20
+ Reference: https://opentelemetry.io/docs/specs/semconv/gen-ai/
21
+
22
+ Example:
23
+ >>> from agentreplay.genai_conventions import normalize_attributes, validate_genai_span
24
+ >>>
25
+ >>> # Normalize LangChain attributes to OTEL GenAI conventions
26
+ >>> langchain_attrs = {
27
+ ... "langchain.model": "gpt-4o",
28
+ ... "langchain.token_usage": 150
29
+ ... }
30
+ >>> normalized = normalize_attributes(langchain_attrs, framework="langchain")
31
+ >>> # Result: {"gen_ai.request.model": "gpt-4o", "gen_ai.usage.total_tokens": 150}
32
+ >>>
33
+ >>> # Validate span has required GenAI attributes
34
+ >>> warnings = validate_genai_span(normalized)
35
+ >>> for warning in warnings:
36
+ ... print(warning)
37
+ """
38
+
39
+ import logging
40
+ from typing import Dict, List, Optional, Any
41
+ from dataclasses import dataclass
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ @dataclass
47
+ class GenAIConventions:
48
+ """OpenTelemetry GenAI semantic conventions constants.
49
+
50
+ Reference: https://opentelemetry.io/docs/specs/semconv/gen-ai/
51
+ Updated for OTEL GenAI semantic conventions v1.36+
52
+ """
53
+
54
+ # =========================================================================
55
+ # PROVIDER IDENTIFICATION (REQUIRED)
56
+ # =========================================================================
57
+ SYSTEM = "gen_ai.system" # Legacy, use PROVIDER_NAME
58
+ PROVIDER_NAME = "gen_ai.provider.name" # "openai", "anthropic", "aws.bedrock", etc.
59
+ OPERATION_NAME = "gen_ai.operation.name" # "chat", "embeddings", "text_completion"
60
+
61
+ # Well-known provider names
62
+ PROVIDER_OPENAI = "openai"
63
+ PROVIDER_ANTHROPIC = "anthropic"
64
+ PROVIDER_AWS_BEDROCK = "aws.bedrock"
65
+ PROVIDER_AZURE_OPENAI = "azure.ai.openai"
66
+ PROVIDER_GCP_GEMINI = "gcp.gemini"
67
+ PROVIDER_GCP_VERTEX_AI = "gcp.vertex_ai"
68
+ PROVIDER_COHERE = "cohere"
69
+ PROVIDER_DEEPSEEK = "deepseek"
70
+ PROVIDER_GROQ = "groq"
71
+ PROVIDER_MISTRAL_AI = "mistral_ai"
72
+ PROVIDER_PERPLEXITY = "perplexity"
73
+ PROVIDER_X_AI = "x_ai"
74
+ PROVIDER_IBM_WATSONX = "ibm.watsonx.ai"
75
+
76
+ # =========================================================================
77
+ # MODEL INFORMATION (REQUIRED)
78
+ # =========================================================================
79
+ REQUEST_MODEL = "gen_ai.request.model"
80
+ RESPONSE_MODEL = "gen_ai.response.model"
81
+ RESPONSE_ID = "gen_ai.response.id"
82
+
83
+ # =========================================================================
84
+ # TOKEN USAGE (CRITICAL for cost calculation)
85
+ # =========================================================================
86
+ INPUT_TOKENS = "gen_ai.usage.input_tokens"
87
+ OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
88
+ TOTAL_TOKENS = "gen_ai.usage.total_tokens"
89
+ REASONING_TOKENS = "gen_ai.usage.reasoning_tokens" # OpenAI o1 models
90
+ CACHE_READ_TOKENS = "gen_ai.usage.cache_read_tokens" # Anthropic cache
91
+ CACHE_CREATION_TOKENS = "gen_ai.usage.cache_creation_tokens" # Anthropic cache
92
+
93
+ # =========================================================================
94
+ # FINISH REASONS
95
+ # =========================================================================
96
+ FINISH_REASONS = "gen_ai.response.finish_reasons"
97
+
98
+ # =========================================================================
99
+ # REQUEST PARAMETERS / HYPERPARAMETERS (RECOMMENDED)
100
+ # =========================================================================
101
+ TEMPERATURE = "gen_ai.request.temperature"
102
+ TOP_P = "gen_ai.request.top_p"
103
+ TOP_K = "gen_ai.request.top_k" # Anthropic/Google
104
+ MAX_TOKENS = "gen_ai.request.max_tokens"
105
+ FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
106
+ PRESENCE_PENALTY = "gen_ai.request.presence_penalty"
107
+ STOP_SEQUENCES = "gen_ai.request.stop_sequences"
108
+ SEED = "gen_ai.request.seed" # Reproducibility
109
+ CHOICE_COUNT = "gen_ai.request.choice.count" # n parameter
110
+
111
+ # =========================================================================
112
+ # SERVER INFORMATION (REQUIRED for distributed tracing)
113
+ # =========================================================================
114
+ SERVER_ADDRESS = "server.address"
115
+ SERVER_PORT = "server.port"
116
+
117
+ # =========================================================================
118
+ # ERROR TRACKING (REQUIRED when errors occur)
119
+ # =========================================================================
120
+ ERROR_TYPE = "error.type"
121
+
122
+ # =========================================================================
123
+ # AGENT ATTRIBUTES (for agentic systems)
124
+ # =========================================================================
125
+ AGENT_ID = "gen_ai.agent.id"
126
+ AGENT_NAME = "gen_ai.agent.name"
127
+ AGENT_DESCRIPTION = "gen_ai.agent.description"
128
+ CONVERSATION_ID = "gen_ai.conversation.id"
129
+
130
+ # =========================================================================
131
+ # TOOL CALL ATTRIBUTES
132
+ # =========================================================================
133
+ TOOL_NAME = "gen_ai.tool.name"
134
+ TOOL_TYPE = "gen_ai.tool.type" # "function", "extension", "datastore"
135
+ TOOL_DESCRIPTION = "gen_ai.tool.description"
136
+ TOOL_CALL_ID = "gen_ai.tool.call.id"
137
+ TOOL_CALL_ARGUMENTS = "gen_ai.tool.call.arguments"
138
+ TOOL_CALL_RESULT = "gen_ai.tool.call.result"
139
+ TOOL_DEFINITIONS = "gen_ai.tool.definitions" # Array of tool schemas
140
+
141
+ # =========================================================================
142
+ # CONTENT ATTRIBUTES
143
+ # =========================================================================
144
+ SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
145
+ INPUT_MESSAGES = "gen_ai.input.messages"
146
+ OUTPUT_MESSAGES = "gen_ai.output.messages"
147
+
148
+ # =========================================================================
149
+ # STRUCTURED PROMPTS/RESPONSES (indexed format)
150
+ # =========================================================================
151
+ PROMPT_PREFIX = "gen_ai.prompt"
152
+ COMPLETION_PREFIX = "gen_ai.completion"
153
+
154
+
155
+ # Framework-specific attribute mappings
156
+ FRAMEWORK_MAPPINGS = {
157
+ "langchain": {
158
+ "langchain.model": GenAIConventions.REQUEST_MODEL,
159
+ "langchain.model_name": GenAIConventions.REQUEST_MODEL,
160
+ "langchain.llm.model_name": GenAIConventions.REQUEST_MODEL,
161
+ "langchain.token_usage": GenAIConventions.TOTAL_TOKENS,
162
+ "langchain.tokens": GenAIConventions.TOTAL_TOKENS,
163
+ "langchain.prompt_tokens": GenAIConventions.INPUT_TOKENS,
164
+ "langchain.completion_tokens": GenAIConventions.OUTPUT_TOKENS,
165
+ "langchain.temperature": GenAIConventions.TEMPERATURE,
166
+ "langchain.max_tokens": GenAIConventions.MAX_TOKENS,
167
+ },
168
+ "llamaindex": {
169
+ "llama_index.model": GenAIConventions.REQUEST_MODEL,
170
+ "llama_index.model_name": GenAIConventions.REQUEST_MODEL,
171
+ "llama_index.token_count": GenAIConventions.TOTAL_TOKENS,
172
+ "llama_index.prompt_tokens": GenAIConventions.INPUT_TOKENS,
173
+ "llama_index.completion_tokens": GenAIConventions.OUTPUT_TOKENS,
174
+ "llama_index.temperature": GenAIConventions.TEMPERATURE,
175
+ },
176
+ "autogen": {
177
+ "autogen.model": GenAIConventions.REQUEST_MODEL,
178
+ "autogen.token_usage": GenAIConventions.TOTAL_TOKENS,
179
+ },
180
+ "crewai": {
181
+ "crewai.model": GenAIConventions.REQUEST_MODEL,
182
+ "crewai.llm_model": GenAIConventions.REQUEST_MODEL,
183
+ },
184
+ "openai": {
185
+ "openai.model": GenAIConventions.REQUEST_MODEL,
186
+ "openai.response.model": GenAIConventions.RESPONSE_MODEL,
187
+ "openai.response.id": GenAIConventions.RESPONSE_ID,
188
+ "openai.usage.prompt_tokens": GenAIConventions.INPUT_TOKENS,
189
+ "openai.usage.completion_tokens": GenAIConventions.OUTPUT_TOKENS,
190
+ "openai.usage.total_tokens": GenAIConventions.TOTAL_TOKENS,
191
+ "openai.usage.completion_tokens_details.reasoning_tokens": GenAIConventions.REASONING_TOKENS,
192
+ },
193
+ "anthropic": {
194
+ "anthropic.model": GenAIConventions.REQUEST_MODEL,
195
+ "anthropic.response.model": GenAIConventions.RESPONSE_MODEL,
196
+ "anthropic.response.id": GenAIConventions.RESPONSE_ID,
197
+ "anthropic.usage.input_tokens": GenAIConventions.INPUT_TOKENS,
198
+ "anthropic.usage.output_tokens": GenAIConventions.OUTPUT_TOKENS,
199
+ "anthropic.usage.cache_read_input_tokens": GenAIConventions.CACHE_READ_TOKENS,
200
+ },
201
+ }
202
+
203
+
204
+ def normalize_attributes(
205
+ attributes: Dict[str, Any],
206
+ framework: Optional[str] = None,
207
+ ) -> Dict[str, Any]:
208
+ """Normalize framework-specific attributes to GenAI conventions.
209
+
210
+ Takes attributes from various AI frameworks and maps them to standard
211
+ OpenTelemetry GenAI semantic conventions.
212
+
213
+ Args:
214
+ attributes: Original attributes dict
215
+ framework: Framework name (langchain, llamaindex, etc.)
216
+ If None, attempts auto-detection
217
+
218
+ Returns:
219
+ Normalized attributes dict with GenAI conventions
220
+
221
+ Example:
222
+ >>> attrs = {"langchain.model": "gpt-4o", "langchain.tokens": 150}
223
+ >>> normalized = normalize_attributes(attrs, framework="langchain")
224
+ >>> print(normalized["gen_ai.request.model"])
225
+ 'gpt-4o'
226
+ """
227
+ # Auto-detect framework if not specified
228
+ if framework is None:
229
+ framework = _detect_framework(attributes)
230
+
231
+ # Start with original attributes
232
+ normalized = dict(attributes)
233
+
234
+ # Apply framework-specific mappings
235
+ if framework and framework in FRAMEWORK_MAPPINGS:
236
+ mapping = FRAMEWORK_MAPPINGS[framework]
237
+
238
+ for old_key, new_key in mapping.items():
239
+ if old_key in attributes:
240
+ value = attributes[old_key]
241
+ normalized[new_key] = value
242
+ logger.debug(f"Mapped {old_key} -> {new_key}: {value}")
243
+
244
+ # Ensure system is set
245
+ if GenAIConventions.SYSTEM not in normalized:
246
+ # Try to infer from model name
247
+ if GenAIConventions.REQUEST_MODEL in normalized:
248
+ model = str(normalized[GenAIConventions.REQUEST_MODEL]).lower()
249
+ if "gpt" in model or "davinci" in model:
250
+ normalized[GenAIConventions.SYSTEM] = "openai"
251
+ elif "claude" in model:
252
+ normalized[GenAIConventions.SYSTEM] = "anthropic"
253
+ elif "gemini" in model or "palm" in model:
254
+ normalized[GenAIConventions.SYSTEM] = "google"
255
+ elif "llama" in model:
256
+ normalized[GenAIConventions.SYSTEM] = "meta"
257
+
258
+ # Calculate total_tokens if not present
259
+ if GenAIConventions.TOTAL_TOKENS not in normalized:
260
+ input_tokens = normalized.get(GenAIConventions.INPUT_TOKENS)
261
+ output_tokens = normalized.get(GenAIConventions.OUTPUT_TOKENS)
262
+
263
+ if input_tokens is not None and output_tokens is not None:
264
+ try:
265
+ total = int(input_tokens) + int(output_tokens)
266
+ normalized[GenAIConventions.TOTAL_TOKENS] = total
267
+ logger.debug(f"Calculated total_tokens: {total}")
268
+ except (ValueError, TypeError):
269
+ pass
270
+
271
+ # Ensure all numeric values are properly typed
272
+ _normalize_numeric_types(normalized)
273
+
274
+ return normalized
275
+
276
+
277
+ def validate_genai_span(attributes: Dict[str, Any]) -> List[str]:
278
+ """Validate that span has required GenAI attributes.
279
+
280
+ Checks for required fields according to OpenTelemetry GenAI semantic conventions
281
+ and returns a list of warnings for missing or invalid attributes.
282
+
283
+ Args:
284
+ attributes: Span attributes dict
285
+
286
+ Returns:
287
+ List of warning messages (empty if valid)
288
+
289
+ Example:
290
+ >>> attrs = {"gen_ai.system": "openai"}
291
+ >>> warnings = validate_genai_span(attrs)
292
+ >>> for warning in warnings:
293
+ ... print(f"WARNING: {warning}")
294
+ """
295
+ warnings = []
296
+
297
+ # Check required fields
298
+ if GenAIConventions.SYSTEM not in attributes:
299
+ warnings.append("Missing required field: gen_ai.system (e.g., 'openai', 'anthropic')")
300
+
301
+ if GenAIConventions.REQUEST_MODEL not in attributes:
302
+ warnings.append("Missing required field: gen_ai.request.model (e.g., 'gpt-4o')")
303
+
304
+ # Check token usage (required for cost calculation)
305
+ has_input = GenAIConventions.INPUT_TOKENS in attributes
306
+ has_output = GenAIConventions.OUTPUT_TOKENS in attributes
307
+ has_total = GenAIConventions.TOTAL_TOKENS in attributes
308
+
309
+ if not (has_input and has_output) and not has_total:
310
+ warnings.append(
311
+ "Missing token usage: should have gen_ai.usage.input_tokens and "
312
+ "gen_ai.usage.output_tokens (or gen_ai.usage.total_tokens)"
313
+ )
314
+
315
+ # Validate token counts are consistent
316
+ if has_input and has_output and has_total:
317
+ try:
318
+ input_val = int(attributes[GenAIConventions.INPUT_TOKENS])
319
+ output_val = int(attributes[GenAIConventions.OUTPUT_TOKENS])
320
+ total_val = int(attributes[GenAIConventions.TOTAL_TOKENS])
321
+
322
+ expected_total = input_val + output_val
323
+ if total_val != expected_total:
324
+ warnings.append(
325
+ f"Token count mismatch: total_tokens={total_val} but "
326
+ f"input_tokens + output_tokens = {expected_total}"
327
+ )
328
+ except (ValueError, TypeError):
329
+ warnings.append("Token counts must be numeric values")
330
+
331
+ # Validate model name format
332
+ if GenAIConventions.REQUEST_MODEL in attributes:
333
+ model = str(attributes[GenAIConventions.REQUEST_MODEL])
334
+ if not model or model.lower() == "unknown":
335
+ warnings.append("Model name should not be empty or 'unknown'")
336
+
337
+ # Check recommended fields
338
+ if GenAIConventions.OPERATION_NAME not in attributes:
339
+ warnings.append(
340
+ "Recommended field missing: gen_ai.operation.name "
341
+ "(e.g., 'chat', 'completion', 'embedding')"
342
+ )
343
+
344
+ return warnings
345
+
346
+
347
+ def get_missing_attributes(attributes: Dict[str, Any]) -> List[str]:
348
+ """Get list of recommended GenAI attributes that are missing.
349
+
350
+ Args:
351
+ attributes: Span attributes dict
352
+
353
+ Returns:
354
+ List of missing attribute names
355
+ """
356
+ recommended = [
357
+ GenAIConventions.SYSTEM,
358
+ GenAIConventions.REQUEST_MODEL,
359
+ GenAIConventions.OPERATION_NAME,
360
+ GenAIConventions.INPUT_TOKENS,
361
+ GenAIConventions.OUTPUT_TOKENS,
362
+ ]
363
+
364
+ return [attr for attr in recommended if attr not in attributes]
365
+
366
+
367
+ def _detect_framework(attributes: Dict[str, Any]) -> Optional[str]:
368
+ """Auto-detect framework from attribute keys.
369
+
370
+ Args:
371
+ attributes: Attributes dict
372
+
373
+ Returns:
374
+ Framework name or None
375
+ """
376
+ keys = set(attributes.keys())
377
+
378
+ # Check for framework-specific prefixes
379
+ if any(k.startswith("langchain.") for k in keys):
380
+ return "langchain"
381
+ elif any(k.startswith("llama_index.") for k in keys):
382
+ return "llamaindex"
383
+ elif any(k.startswith("autogen.") for k in keys):
384
+ return "autogen"
385
+ elif any(k.startswith("crewai.") for k in keys):
386
+ return "crewai"
387
+ elif any(k.startswith("openai.") for k in keys):
388
+ return "openai"
389
+ elif any(k.startswith("anthropic.") for k in keys):
390
+ return "anthropic"
391
+
392
+ return None
393
+
394
+
395
+ def _normalize_numeric_types(attributes: Dict[str, Any]) -> None:
396
+ """Ensure numeric attributes have correct types (modifies in-place).
397
+
398
+ Args:
399
+ attributes: Attributes dict to normalize
400
+ """
401
+ # Token counts should be integers
402
+ token_fields = [
403
+ GenAIConventions.INPUT_TOKENS,
404
+ GenAIConventions.OUTPUT_TOKENS,
405
+ GenAIConventions.TOTAL_TOKENS,
406
+ GenAIConventions.REASONING_TOKENS,
407
+ GenAIConventions.CACHE_READ_TOKENS,
408
+ GenAIConventions.MAX_TOKENS,
409
+ ]
410
+
411
+ for field in token_fields:
412
+ if field in attributes:
413
+ try:
414
+ attributes[field] = int(attributes[field])
415
+ except (ValueError, TypeError):
416
+ logger.warning(f"Could not convert {field} to int: {attributes[field]}")
417
+
418
+ # Hyperparameters should be floats
419
+ float_fields = [
420
+ GenAIConventions.TEMPERATURE,
421
+ GenAIConventions.TOP_P,
422
+ GenAIConventions.FREQUENCY_PENALTY,
423
+ GenAIConventions.PRESENCE_PENALTY,
424
+ ]
425
+
426
+ for field in float_fields:
427
+ if field in attributes:
428
+ try:
429
+ attributes[field] = float(attributes[field])
430
+ except (ValueError, TypeError):
431
+ logger.warning(f"Could not convert {field} to float: {attributes[field]}")
432
+
433
+
434
+ def create_genai_attributes_dict(
435
+ system: str,
436
+ model: str,
437
+ input_tokens: Optional[int] = None,
438
+ output_tokens: Optional[int] = None,
439
+ total_tokens: Optional[int] = None,
440
+ operation_name: Optional[str] = "chat",
441
+ **kwargs
442
+ ) -> Dict[str, Any]:
443
+ """Create a GenAI-compliant attributes dictionary.
444
+
445
+ Helper function to create attributes following semantic conventions.
446
+
447
+ Args:
448
+ system: Provider name (openai, anthropic, google, etc.)
449
+ model: Model name (gpt-4o, claude-3-5-sonnet, etc.)
450
+ input_tokens: Number of input tokens
451
+ output_tokens: Number of output tokens
452
+ total_tokens: Total tokens (calculated if not provided)
453
+ operation_name: Operation type (chat, completion, embedding)
454
+ **kwargs: Additional GenAI attributes
455
+
456
+ Returns:
457
+ Dict with GenAI semantic conventions
458
+
459
+ Example:
460
+ >>> attrs = create_genai_attributes_dict(
461
+ ... system="openai",
462
+ ... model="gpt-4o",
463
+ ... input_tokens=100,
464
+ ... output_tokens=50,
465
+ ... temperature=0.7
466
+ ... )
467
+ """
468
+ attributes = {
469
+ GenAIConventions.SYSTEM: system,
470
+ GenAIConventions.REQUEST_MODEL: model,
471
+ GenAIConventions.OPERATION_NAME: operation_name,
472
+ }
473
+
474
+ if input_tokens is not None:
475
+ attributes[GenAIConventions.INPUT_TOKENS] = input_tokens
476
+
477
+ if output_tokens is not None:
478
+ attributes[GenAIConventions.OUTPUT_TOKENS] = output_tokens
479
+
480
+ if total_tokens is not None:
481
+ attributes[GenAIConventions.TOTAL_TOKENS] = total_tokens
482
+ elif input_tokens is not None and output_tokens is not None:
483
+ attributes[GenAIConventions.TOTAL_TOKENS] = input_tokens + output_tokens
484
+
485
+ # Add any additional attributes
486
+ for key, value in kwargs.items():
487
+ if key.startswith("gen_ai."):
488
+ attributes[key] = value
489
+ else:
490
+ # Prefix with gen_ai. if not already prefixed
491
+ attributes[f"gen_ai.{key}"] = value
492
+
493
+ return attributes
494
+
495
+
496
+ __all__ = [
497
+ "GenAIConventions",
498
+ "normalize_attributes",
499
+ "validate_genai_span",
500
+ "get_missing_attributes",
501
+ "create_genai_attributes_dict",
502
+ ]
@@ -0,0 +1,159 @@
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
+ """Install the agentreplay-init.pth file to site-packages for auto-initialization.
16
+
17
+ This script copies the .pth file that enables zero-code auto-instrumentation.
18
+ Run after pip install: python -m agentreplay.install_pth
19
+
20
+ Or use the CLI command: agentreplay-install
21
+ """
22
+
23
+ import os
24
+ import site
25
+ import sys
26
+
27
+
28
+ PTH_CONTENT = """import agentreplay.bootstrap; agentreplay.bootstrap._auto_init()
29
+ """
30
+
31
+ PTH_FILENAME = "agentreplay-init.pth"
32
+
33
+
34
+ def get_site_packages():
35
+ """Get the user's site-packages directory."""
36
+ # Try user site first, then system site
37
+ paths = []
38
+
39
+ # User site-packages
40
+ user_site = site.getusersitepackages()
41
+ if user_site:
42
+ paths.append(user_site)
43
+
44
+ # System site-packages
45
+ for path in site.getsitepackages():
46
+ paths.append(path)
47
+
48
+ # Also check where agentreplay itself is installed
49
+ try:
50
+ import agentreplay
51
+ agentreplay_dir = os.path.dirname(agentreplay.__file__)
52
+ site_packages = os.path.dirname(agentreplay_dir)
53
+ if site_packages not in paths:
54
+ paths.insert(0, site_packages)
55
+ except ImportError:
56
+ pass
57
+
58
+ return paths
59
+
60
+
61
+ def install():
62
+ """Install the .pth file to site-packages."""
63
+ paths = get_site_packages()
64
+
65
+ installed = False
66
+ for site_packages in paths:
67
+ if not os.path.isdir(site_packages):
68
+ continue
69
+
70
+ pth_path = os.path.join(site_packages, PTH_FILENAME)
71
+
72
+ try:
73
+ with open(pth_path, 'w') as f:
74
+ f.write(PTH_CONTENT)
75
+ print(f"✅ Installed {pth_path}")
76
+ installed = True
77
+ break
78
+ except PermissionError:
79
+ print(f"⚠️ Permission denied: {pth_path}")
80
+ continue
81
+ except Exception as e:
82
+ print(f"⚠️ Failed to write {pth_path}: {e}")
83
+ continue
84
+
85
+ if not installed:
86
+ print("❌ Could not install .pth file to any site-packages directory.")
87
+ print(" Try running with sudo or in a virtual environment.")
88
+ return False
89
+
90
+ print("\n🎉 Agentreplay auto-instrumentation is now enabled!")
91
+ print(" Set AGENTREPLAY_ENABLED=true to activate tracing.")
92
+ print(" Set AGENTREPLAY_PROJECT_ID=<id> to specify your project.")
93
+ return True
94
+
95
+
96
+ def uninstall():
97
+ """Remove the .pth file from site-packages."""
98
+ paths = get_site_packages()
99
+
100
+ removed = False
101
+ for site_packages in paths:
102
+ pth_path = os.path.join(site_packages, PTH_FILENAME)
103
+ if os.path.exists(pth_path):
104
+ try:
105
+ os.remove(pth_path)
106
+ print(f"✅ Removed {pth_path}")
107
+ removed = True
108
+ except Exception as e:
109
+ print(f"⚠️ Failed to remove {pth_path}: {e}")
110
+
111
+ if not removed:
112
+ print("ℹ️ No .pth file found to remove.")
113
+
114
+ return removed
115
+
116
+
117
+ def main():
118
+ """CLI entry point."""
119
+ import argparse
120
+
121
+ parser = argparse.ArgumentParser(
122
+ description="Install Agentreplay auto-instrumentation .pth file"
123
+ )
124
+ parser.add_argument(
125
+ "--uninstall", "-u",
126
+ action="store_true",
127
+ help="Uninstall the .pth file"
128
+ )
129
+ parser.add_argument(
130
+ "--check", "-c",
131
+ action="store_true",
132
+ help="Check if .pth file is installed"
133
+ )
134
+
135
+ args = parser.parse_args()
136
+
137
+ if args.check:
138
+ paths = get_site_packages()
139
+ found = False
140
+ for site_packages in paths:
141
+ pth_path = os.path.join(site_packages, PTH_FILENAME)
142
+ if os.path.exists(pth_path):
143
+ print(f"✅ Found: {pth_path}")
144
+ found = True
145
+ if not found:
146
+ print("❌ .pth file not installed")
147
+ print(" Run: agentreplay-install")
148
+ return 0 if found else 1
149
+
150
+ if args.uninstall:
151
+ success = uninstall()
152
+ return 0 if success else 1
153
+
154
+ success = install()
155
+ return 0 if success else 1
156
+
157
+
158
+ if __name__ == "__main__":
159
+ sys.exit(main())