aiqa-client 0.1.2__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiqa-client
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: OpenTelemetry-based Python client for tracing functions and sending traces to the AIQA server
5
5
  Author-email: AIQA <info@aiqa.dev>
6
6
  License: MIT
@@ -72,15 +72,7 @@ export AIQA_API_KEY="your-api-key"
72
72
  ### Basic Usage
73
73
 
74
74
  ```python
75
- from dotenv import load_dotenv
76
- from aiqa import get_aiqa_client, WithTracing
77
-
78
- # Load environment variables from .env file (if using one)
79
- load_dotenv()
80
-
81
- # Initialize client (must be called before using WithTracing)
82
- # This loads environment variables and initializes the tracing system
83
- get_aiqa_client()
75
+ from aiqa import WithTracing
84
76
 
85
77
  @WithTracing
86
78
  def my_function(x, y):
@@ -116,12 +108,12 @@ def my_function(data):
116
108
  Spans are automatically flushed every 5 seconds. To flush immediately:
117
109
 
118
110
  ```python
119
- from aiqa import flush_tracing
111
+ from aiqa import flush_spans
120
112
  import asyncio
121
113
 
122
114
  async def main():
123
115
  # Your code here
124
- await flush_tracing()
116
+ await flush_spans()
125
117
 
126
118
  asyncio.run(main())
127
119
  ```
@@ -152,87 +144,6 @@ def my_function():
152
144
  # ... rest of function
153
145
  ```
154
146
 
155
- ### Grouping Traces by Conversation
156
-
157
- To group multiple traces together that are part of the same conversation or session:
158
-
159
- ```python
160
- from aiqa import WithTracing, set_conversation_id
161
-
162
- @WithTracing
163
- def handle_user_request(user_id: str, session_id: str):
164
- # Set conversation ID to group all traces for this user session
165
- set_conversation_id(f"user_{user_id}_session_{session_id}")
166
- # All spans created in this function and its children will have this gen_ai.conversation.id
167
- # ... rest of function
168
- ```
169
-
170
- The `gen_ai.conversation.id` attribute allows you to filter and group traces in the AIQA server by conversation, making it easier to analyze multi-step interactions or user sessions. See the [OpenTelemetry GenAI Events specification](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-events/) for more details.
171
-
172
- ### Trace ID Propagation Across Services/Agents
173
-
174
- To link traces across different services or agents, you can extract and propagate trace IDs:
175
-
176
- #### Getting Current Trace ID
177
-
178
- ```python
179
- from aiqa import get_trace_id, get_span_id
180
-
181
- # Get the current trace ID and span ID
182
- trace_id = get_trace_id() # Returns hex string (32 chars) or None
183
- span_id = get_span_id() # Returns hex string (16 chars) or None
184
-
185
- # Pass these to another service (e.g., in HTTP headers, message queue, etc.)
186
- ```
187
-
188
- #### Continuing a Trace in Another Service
189
-
190
- ```python
191
- from aiqa import create_span_from_trace_id
192
-
193
- # Continue a trace from another service/agent
194
- # trace_id and parent_span_id come from the other service
195
- with create_span_from_trace_id(
196
- trace_id="abc123...",
197
- parent_span_id="def456...",
198
- span_name="service_b_operation"
199
- ):
200
- # Your code here - this span will be linked to the original trace
201
- pass
202
- ```
203
-
204
- #### Using OpenTelemetry Context Propagation (Recommended)
205
-
206
- For HTTP requests, use the built-in context propagation:
207
-
208
- ```python
209
- from aiqa import inject_trace_context, extract_trace_context
210
- import requests
211
- from opentelemetry.trace import use_span
212
-
213
- # In the sending service:
214
- headers = {}
215
- inject_trace_context(headers) # Adds trace context to headers
216
- response = requests.get("http://other-service/api", headers=headers)
217
-
218
- # In the receiving service:
219
- # Extract context from incoming request headers
220
- ctx = extract_trace_context(request.headers)
221
-
222
- # Use the context to create a span
223
- from opentelemetry.trace import use_span
224
- with use_span(ctx):
225
- # Your code here
226
- pass
227
-
228
- # Or create a span with the context
229
- from opentelemetry import trace
230
- tracer = trace.get_tracer("aiqa-tracer")
231
- with tracer.start_as_current_span("operation", context=ctx):
232
- # Your code here
233
- pass
234
- ```
235
-
236
147
  ## Features
237
148
 
238
149
  - Automatic tracing of function calls (sync and async)
@@ -240,7 +151,6 @@ with tracer.start_as_current_span("operation", context=ctx):
240
151
  - Automatic error tracking and exception recording
241
152
  - Thread-safe span buffering and auto-flushing
242
153
  - OpenTelemetry context propagation for nested spans
243
- - Trace ID propagation utilities for distributed tracing
244
154
 
245
155
  ## Example
246
156
 
@@ -0,0 +1,120 @@
1
+ # A Python client for the AIQA server
2
+
3
+ OpenTelemetry-based client for tracing Python functions and sending traces to the AIQA server.
4
+
5
+ ## Installation
6
+
7
+ ### From PyPI (recommended)
8
+
9
+ ```bash
10
+ pip install aiqa-client
11
+ ```
12
+
13
+ ### From source
14
+
15
+ ```bash
16
+ python -m venv .venv
17
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
18
+ pip install -r requirements.txt
19
+ pip install -e .
20
+ ```
21
+
22
+ See [TESTING.md](TESTING.md) for detailed testing instructions.
23
+
24
+ ## Setup
25
+
26
+ Set the following environment variables:
27
+
28
+ ```bash
29
+ export AIQA_SERVER_URL="http://localhost:3000"
30
+ export AIQA_API_KEY="your-api-key"
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Basic Usage
36
+
37
+ ```python
38
+ from aiqa import WithTracing
39
+
40
+ @WithTracing
41
+ def my_function(x, y):
42
+ return x + y
43
+
44
+ @WithTracing
45
+ async def my_async_function(x, y):
46
+ await asyncio.sleep(0.1)
47
+ return x * y
48
+ ```
49
+
50
+ ### Custom Span Name
51
+
52
+ ```python
53
+ @WithTracing(name="custom_span_name")
54
+ def my_function():
55
+ pass
56
+ ```
57
+
58
+ ### Input/Output Filtering
59
+
60
+ ```python
61
+ @WithTracing(
62
+ filter_input=lambda x: {"filtered": str(x)},
63
+ filter_output=lambda x: {"result": x}
64
+ )
65
+ def my_function(data):
66
+ return {"processed": data}
67
+ ```
68
+
69
+ ### Flushing Spans
70
+
71
+ Spans are automatically flushed every 5 seconds. To flush immediately:
72
+
73
+ ```python
74
+ from aiqa import flush_spans
75
+ import asyncio
76
+
77
+ async def main():
78
+ # Your code here
79
+ await flush_spans()
80
+
81
+ asyncio.run(main())
82
+ ```
83
+
84
+ ### Shutting Down
85
+
86
+ To ensure all spans are sent before process exit:
87
+
88
+ ```python
89
+ from aiqa import shutdown_tracing
90
+ import asyncio
91
+
92
+ async def main():
93
+ # Your code here
94
+ await shutdown_tracing()
95
+
96
+ asyncio.run(main())
97
+ ```
98
+
99
+ ### Setting Span Attributes and Names
100
+
101
+ ```python
102
+ from aiqa import set_span_attribute, set_span_name
103
+
104
+ def my_function():
105
+ set_span_attribute("custom.attribute", "value")
106
+ set_span_name("custom_span_name")
107
+ # ... rest of function
108
+ ```
109
+
110
+ ## Features
111
+
112
+ - Automatic tracing of function calls (sync and async)
113
+ - Records function inputs and outputs as span attributes
114
+ - Automatic error tracking and exception recording
115
+ - Thread-safe span buffering and auto-flushing
116
+ - OpenTelemetry context propagation for nested spans
117
+
118
+ ## Example
119
+
120
+ See `example.py` for a complete working example.
@@ -0,0 +1,29 @@
1
+ """
2
+ Python client for AIQA server - OpenTelemetry tracing decorators.
3
+ """
4
+
5
+ from .tracing import (
6
+ WithTracing,
7
+ flush_spans,
8
+ shutdown_tracing,
9
+ set_span_attribute,
10
+ set_span_name,
11
+ get_active_span,
12
+ provider,
13
+ exporter,
14
+ )
15
+
16
+ __version__ = "0.1.4"
17
+
18
+ __all__ = [
19
+ "WithTracing",
20
+ "flush_spans",
21
+ "shutdown_tracing",
22
+ "set_span_attribute",
23
+ "set_span_name",
24
+ "get_active_span",
25
+ "provider",
26
+ "exporter",
27
+ "__version__",
28
+ ]
29
+
@@ -8,12 +8,11 @@ import json
8
8
  import logging
9
9
  import threading
10
10
  import time
11
- import io
12
11
  from typing import List, Dict, Any, Optional
13
12
  from opentelemetry.sdk.trace import ReadableSpan
14
13
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
15
14
 
16
- logger = logging.getLogger("AIQA")
15
+ logger = logging.getLogger(__name__)
17
16
 
18
17
 
19
18
  class AIQASpanExporter(SpanExporter):
@@ -40,7 +39,6 @@ class AIQASpanExporter(SpanExporter):
40
39
  self._api_key = api_key
41
40
  self.flush_interval_ms = flush_interval_seconds * 1000
42
41
  self.buffer: List[Dict[str, Any]] = []
43
- self.buffer_span_keys: set = set() # Track (traceId, spanId) tuples to prevent duplicates (Python 3.8 compatible)
44
42
  self.buffer_lock = threading.Lock()
45
43
  self.flush_lock = threading.Lock()
46
44
  self.shutdown_requested = False
@@ -63,39 +61,21 @@ class AIQASpanExporter(SpanExporter):
63
61
  def export(self, spans: List[ReadableSpan]) -> SpanExportResult:
64
62
  """
65
63
  Export spans to the AIQA server. Adds spans to buffer for async flushing.
66
- Deduplicates spans based on (traceId, spanId) to prevent repeated exports.
67
64
  """
68
65
  if not spans:
69
66
  logger.debug("export() called with empty spans list")
70
67
  return SpanExportResult.SUCCESS
71
- logger.debug(f"AIQA export() called with {len(spans)} spans")
72
- # Serialize and add to buffer, deduplicating by (traceId, spanId)
68
+
69
+ # Serialize and add to buffer
73
70
  with self.buffer_lock:
74
- serialized_spans = []
75
- duplicates_count = 0
76
- for span in spans:
77
- serialized = self._serialize_span(span)
78
- span_key = (serialized["traceId"], serialized["spanId"])
79
- if span_key not in self.buffer_span_keys:
80
- serialized_spans.append(serialized)
81
- self.buffer_span_keys.add(span_key)
82
- else:
83
- duplicates_count += 1
84
- logger.debug(f"export() skipping duplicate span: traceId={serialized['traceId']}, spanId={serialized['spanId']}")
85
-
71
+ serialized_spans = [self._serialize_span(span) for span in spans]
86
72
  self.buffer.extend(serialized_spans)
87
73
  buffer_size = len(self.buffer)
88
74
 
89
- if duplicates_count > 0:
90
- logger.debug(
91
- f"export() added {len(serialized_spans)} span(s) to buffer, skipped {duplicates_count} duplicate(s). "
92
- f"Total buffered: {buffer_size}"
93
- )
94
- else:
95
- logger.debug(
96
- f"export() added {len(spans)} span(s) to buffer. "
97
- f"Total buffered: {buffer_size}"
98
- )
75
+ logger.debug(
76
+ f"export() added {len(spans)} span(s) to buffer. "
77
+ f"Total buffered: {buffer_size}"
78
+ )
99
79
 
100
80
  return SpanExportResult.SUCCESS
101
81
 
@@ -158,8 +138,8 @@ class AIQASpanExporter(SpanExporter):
158
138
  "duration": self._time_to_tuple(span.end_time - span.start_time) if span.end_time else None,
159
139
  "ended": span.end_time is not None,
160
140
  "instrumentationLibrary": {
161
- "name": self._get_instrumentation_name(),
162
- "version": self._get_instrumentation_version(),
141
+ "name": span.instrumentation_info.name if hasattr(span, "instrumentation_info") else "",
142
+ "version": span.instrumentation_info.version if hasattr(span, "instrumentation_info") else None,
163
143
  },
164
144
  }
165
145
 
@@ -168,19 +148,6 @@ class AIQASpanExporter(SpanExporter):
168
148
  seconds = int(nanoseconds // 1_000_000_000)
169
149
  nanos = int(nanoseconds % 1_000_000_000)
170
150
  return (seconds, nanos)
171
-
172
- def _get_instrumentation_name(self) -> str:
173
- """Get instrumentation library name - always 'aiqa-tracer'."""
174
- from .client import AIQA_TRACER_NAME
175
- return AIQA_TRACER_NAME
176
-
177
- def _get_instrumentation_version(self) -> Optional[str]:
178
- """Get instrumentation library version from __version__."""
179
- try:
180
- from . import __version__
181
- return __version__
182
- except (ImportError, AttributeError):
183
- return None
184
151
 
185
152
  def _build_request_headers(self) -> Dict[str, str]:
186
153
  """Build HTTP headers for span requests."""
@@ -210,38 +177,24 @@ class AIQASpanExporter(SpanExporter):
210
177
  Atomically extract and remove all spans from buffer (thread-safe).
211
178
  Returns the extracted spans. This prevents race conditions where spans
212
179
  are added between extraction and clearing.
213
- Note: Does NOT clear buffer_span_keys - that should be done after successful send
214
- to avoid unnecessary clearing/rebuilding on failures.
215
180
  """
216
181
  with self.buffer_lock:
217
182
  spans = self.buffer[:]
218
183
  self.buffer.clear()
219
184
  return spans
220
-
221
- def _remove_span_keys_from_tracking(self, spans: List[Dict[str, Any]]) -> None:
222
- """
223
- Remove span keys from tracking set (thread-safe). Called after successful send.
224
- """
225
- with self.buffer_lock:
226
- for span in spans:
227
- span_key = (span["traceId"], span["spanId"])
228
- self.buffer_span_keys.discard(span_key)
229
185
 
230
186
  def _prepend_spans_to_buffer(self, spans: List[Dict[str, Any]]) -> None:
231
187
  """
232
188
  Prepend spans back to buffer (thread-safe). Used to restore spans
233
- if sending fails. Rebuilds the span keys tracking set.
189
+ if sending fails.
234
190
  """
235
191
  with self.buffer_lock:
236
192
  self.buffer[:0] = spans
237
- # Rebuild span keys set from current buffer contents
238
- self.buffer_span_keys = {(span["traceId"], span["spanId"]) for span in self.buffer}
239
193
 
240
194
  def _clear_buffer(self) -> None:
241
195
  """Clear the buffer (thread-safe)."""
242
196
  with self.buffer_lock:
243
197
  self.buffer.clear()
244
- self.buffer_span_keys.clear()
245
198
 
246
199
  async def flush(self) -> None:
247
200
  """
@@ -265,8 +218,7 @@ class AIQASpanExporter(SpanExporter):
265
218
  logger.warning(
266
219
  f"Skipping flush: AIQA_SERVER_URL is not set. {len(spans_to_flush)} span(s) will not be sent."
267
220
  )
268
- # Spans already removed from buffer, clear their keys to free memory
269
- self._remove_span_keys_from_tracking(spans_to_flush)
221
+ # Spans already removed from buffer, nothing to clear
270
222
  return
271
223
 
272
224
  logger.info(f"flush() sending {len(spans_to_flush)} span(s) to server")
@@ -274,8 +226,6 @@ class AIQASpanExporter(SpanExporter):
274
226
  await self._send_spans(spans_to_flush)
275
227
  logger.info(f"flush() successfully sent {len(spans_to_flush)} span(s) to server")
276
228
  # Spans already removed from buffer during extraction
277
- # Now clear their keys from tracking set to free memory
278
- self._remove_span_keys_from_tracking(spans_to_flush)
279
229
  except RuntimeError as error:
280
230
  if self._is_interpreter_shutdown_error(error):
281
231
  if self.shutdown_requested:
@@ -287,12 +237,12 @@ class AIQASpanExporter(SpanExporter):
287
237
  # Put spans back for retry
288
238
  self._prepend_spans_to_buffer(spans_to_flush)
289
239
  raise
290
- logger.error(f"Error flushing spans to server: {error}")
240
+ logger.error(f"Error flushing spans to server: {error}", exc_info=True)
291
241
  # Put spans back for retry
292
242
  self._prepend_spans_to_buffer(spans_to_flush)
293
243
  raise
294
244
  except Exception as error:
295
- logger.error(f"Error flushing spans to server: {error}")
245
+ logger.error(f"Error flushing spans to server: {error}", exc_info=True)
296
246
  # Put spans back for retry
297
247
  self._prepend_spans_to_buffer(spans_to_flush)
298
248
  if self.shutdown_requested:
@@ -321,7 +271,7 @@ class AIQASpanExporter(SpanExporter):
321
271
  logger.debug(f"Auto-flush cycle #{cycle_count} completed, sleeping {self.flush_interval_ms / 1000.0}s")
322
272
  time.sleep(self.flush_interval_ms / 1000.0)
323
273
  except Exception as e:
324
- logger.error(f"Error in auto-flush cycle #{cycle_count}: {e}")
274
+ logger.error(f"Error in auto-flush cycle #{cycle_count}: {e}", exc_info=True)
325
275
  logger.debug(f"Auto-flush cycle #{cycle_count} error handled, sleeping {self.flush_interval_ms / 1000.0}s")
326
276
  time.sleep(self.flush_interval_ms / 1000.0)
327
277
 
@@ -357,13 +307,9 @@ class AIQASpanExporter(SpanExporter):
357
307
  logger.debug("_send_spans() no API key provided")
358
308
 
359
309
  try:
360
- # Pre-serialize JSON to bytes and wrap in BytesIO to avoid blocking event loop
361
- json_bytes = json.dumps(spans).encode('utf-8')
362
- data = io.BytesIO(json_bytes)
363
-
364
310
  async with aiohttp.ClientSession() as session:
365
311
  logger.debug(f"_send_spans() POST request starting to {url}")
366
- async with session.post(url, data=data, headers=headers) as response:
312
+ async with session.post(url, json=spans, headers=headers) as response:
367
313
  logger.debug(f"_send_spans() received response: status={response.status}")
368
314
  if not response.ok:
369
315
  error_text = await response.text()
@@ -382,10 +328,10 @@ class AIQASpanExporter(SpanExporter):
382
328
  else:
383
329
  logger.warning(f"_send_spans() interrupted by interpreter shutdown: {e}")
384
330
  raise
385
- logger.error(f"_send_spans() RuntimeError: {type(e).__name__}: {e}")
331
+ logger.error(f"_send_spans() RuntimeError: {type(e).__name__}: {e}", exc_info=True)
386
332
  raise
387
333
  except Exception as e:
388
- logger.error(f"_send_spans() exception: {type(e).__name__}: {e}")
334
+ logger.error(f"_send_spans() exception: {type(e).__name__}: {e}", exc_info=True)
389
335
  raise
390
336
 
391
337
  def _send_spans_sync(self, spans: List[Dict[str, Any]]) -> None:
@@ -414,7 +360,7 @@ class AIQASpanExporter(SpanExporter):
414
360
  )
415
361
  logger.debug(f"_send_spans_sync() successfully sent {len(spans)} spans")
416
362
  except Exception as e:
417
- logger.error(f"_send_spans_sync() exception: {type(e).__name__}: {e}")
363
+ logger.error(f"_send_spans_sync() exception: {type(e).__name__}: {e}", exc_info=True)
418
364
  raise
419
365
 
420
366
  def shutdown(self) -> None:
@@ -451,21 +397,17 @@ class AIQASpanExporter(SpanExporter):
451
397
  f"shutdown() skipping final flush: AIQA_SERVER_URL is not set. "
452
398
  f"{len(spans_to_flush)} span(s) will not be sent."
453
399
  )
454
- # Spans already removed from buffer, clear their keys to free memory
455
- self._remove_span_keys_from_tracking(spans_to_flush)
400
+ # Spans already removed from buffer
456
401
  else:
457
402
  logger.info(f"shutdown() sending {len(spans_to_flush)} span(s) to server (synchronous)")
458
403
  try:
459
404
  self._send_spans_sync(spans_to_flush)
460
405
  logger.info(f"shutdown() successfully sent {len(spans_to_flush)} span(s) to server")
461
406
  # Spans already removed from buffer during extraction
462
- # Clear their keys from tracking set to free memory
463
- self._remove_span_keys_from_tracking(spans_to_flush)
464
407
  except Exception as e:
465
- logger.error(f"shutdown() failed to send spans: {e}")
408
+ logger.error(f"shutdown() failed to send spans: {e}", exc_info=True)
466
409
  # Spans already removed, but process is exiting anyway
467
410
  logger.warning(f"shutdown() {len(spans_to_flush)} span(s) were not sent due to error")
468
- # Keys will remain in tracking set, but process is exiting so memory will be freed
469
411
  else:
470
412
  logger.debug("shutdown() no spans to flush")
471
413