aiqa-client 0.1.1__py3-none-any.whl → 0.1.3__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.
aiqa/__init__.py CHANGED
@@ -13,7 +13,7 @@ from .tracing import (
13
13
  exporter,
14
14
  )
15
15
 
16
- __version__ = "0.1.1"
16
+ __version__ = "0.1.3"
17
17
 
18
18
  __all__ = [
19
19
  "WithTracing",
aiqa/aiqa_exporter.py CHANGED
@@ -149,18 +149,65 @@ class AIQASpanExporter(SpanExporter):
149
149
  nanos = int(nanoseconds % 1_000_000_000)
150
150
  return (seconds, nanos)
151
151
 
152
+ def _build_request_headers(self) -> Dict[str, str]:
153
+ """Build HTTP headers for span requests."""
154
+ headers = {"Content-Type": "application/json"}
155
+ if self.api_key:
156
+ headers["Authorization"] = f"ApiKey {self.api_key}"
157
+ return headers
158
+
159
+ def _get_span_url(self) -> str:
160
+ """Get the URL for sending spans."""
161
+ if not self.server_url:
162
+ raise ValueError("AIQA_SERVER_URL is not set. Cannot send spans to server.")
163
+ return f"{self.server_url}/span"
164
+
165
+ def _is_interpreter_shutdown_error(self, error: Exception) -> bool:
166
+ """Check if error is due to interpreter shutdown."""
167
+ error_str = str(error)
168
+ return "cannot schedule new futures after" in error_str or "interpreter shutdown" in error_str
169
+
170
+ def _extract_spans_from_buffer(self) -> List[Dict[str, Any]]:
171
+ """Extract spans from buffer (thread-safe). Returns copy of buffer."""
172
+ with self.buffer_lock:
173
+ return self.buffer[:]
174
+
175
+ def _extract_and_remove_spans_from_buffer(self) -> List[Dict[str, Any]]:
176
+ """
177
+ Atomically extract and remove all spans from buffer (thread-safe).
178
+ Returns the extracted spans. This prevents race conditions where spans
179
+ are added between extraction and clearing.
180
+ """
181
+ with self.buffer_lock:
182
+ spans = self.buffer[:]
183
+ self.buffer.clear()
184
+ return spans
185
+
186
+ def _prepend_spans_to_buffer(self, spans: List[Dict[str, Any]]) -> None:
187
+ """
188
+ Prepend spans back to buffer (thread-safe). Used to restore spans
189
+ if sending fails.
190
+ """
191
+ with self.buffer_lock:
192
+ self.buffer[:0] = spans
193
+
194
+ def _clear_buffer(self) -> None:
195
+ """Clear the buffer (thread-safe)."""
196
+ with self.buffer_lock:
197
+ self.buffer.clear()
198
+
152
199
  async def flush(self) -> None:
153
200
  """
154
201
  Flush buffered spans to the server. Thread-safe: ensures only one flush operation runs at a time.
202
+ Atomically extracts spans to prevent race conditions with concurrent export() calls.
155
203
  """
156
204
  logger.debug("flush() called - attempting to acquire flush lock")
157
205
  with self.flush_lock:
158
206
  logger.debug("flush() acquired flush lock")
159
- # Get current buffer and clear it atomically
160
- with self.buffer_lock:
161
- spans_to_flush = self.buffer[:]
162
- self.buffer.clear()
163
- logger.debug(f"flush() extracted {len(spans_to_flush)} span(s) from buffer")
207
+ # Atomically extract and remove spans to prevent race conditions
208
+ # where export() adds spans between extraction and clearing
209
+ spans_to_flush = self._extract_and_remove_spans_from_buffer()
210
+ logger.debug(f"flush() extracted {len(spans_to_flush)} span(s) from buffer")
164
211
 
165
212
  if not spans_to_flush:
166
213
  logger.debug("flush() completed: no spans to flush")
@@ -171,14 +218,33 @@ class AIQASpanExporter(SpanExporter):
171
218
  logger.warning(
172
219
  f"Skipping flush: AIQA_SERVER_URL is not set. {len(spans_to_flush)} span(s) will not be sent."
173
220
  )
221
+ # Spans already removed from buffer, nothing to clear
174
222
  return
175
223
 
176
224
  logger.info(f"flush() sending {len(spans_to_flush)} span(s) to server")
177
225
  try:
178
226
  await self._send_spans(spans_to_flush)
179
227
  logger.info(f"flush() successfully sent {len(spans_to_flush)} span(s) to server")
228
+ # Spans already removed from buffer during extraction
229
+ except RuntimeError as error:
230
+ if self._is_interpreter_shutdown_error(error):
231
+ if self.shutdown_requested:
232
+ logger.debug(f"flush() skipped due to interpreter shutdown: {error}")
233
+ # Put spans back for retry with sync send during shutdown
234
+ self._prepend_spans_to_buffer(spans_to_flush)
235
+ else:
236
+ logger.warning(f"flush() interrupted by interpreter shutdown: {error}")
237
+ # Put spans back for retry
238
+ self._prepend_spans_to_buffer(spans_to_flush)
239
+ raise
240
+ logger.error(f"Error flushing spans to server: {error}", exc_info=True)
241
+ # Put spans back for retry
242
+ self._prepend_spans_to_buffer(spans_to_flush)
243
+ raise
180
244
  except Exception as error:
181
245
  logger.error(f"Error flushing spans to server: {error}", exc_info=True)
246
+ # Put spans back for retry
247
+ self._prepend_spans_to_buffer(spans_to_flush)
182
248
  if self.shutdown_requested:
183
249
  raise
184
250
 
@@ -211,17 +277,17 @@ class AIQASpanExporter(SpanExporter):
211
277
 
212
278
  logger.info(f"Auto-flush worker thread stopping (shutdown requested). Completed {cycle_count} cycles.")
213
279
 
214
- # Final flush on shutdown
215
- if self.shutdown_requested:
216
- logger.info("Performing final flush on shutdown")
217
- try:
218
- loop.run_until_complete(self.flush())
219
- logger.info("Final flush completed successfully")
220
- except Exception as e:
221
- logger.error(f"Error in final flush: {e}", exc_info=True)
222
- finally:
280
+ # Don't do final flush here - shutdown() will handle it with synchronous send
281
+ # This avoids event loop shutdown issues
282
+ logger.debug("Auto-flush thread skipping final flush (will be handled by shutdown() with sync send)")
283
+
284
+ # Close the event loop
285
+ try:
286
+ if not loop.is_closed():
223
287
  loop.close()
224
- logger.debug("Auto-flush worker thread event loop closed")
288
+ logger.debug("Auto-flush worker thread event loop closed")
289
+ except Exception:
290
+ pass # Ignore errors during cleanup
225
291
 
226
292
  flush_thread = threading.Thread(target=flush_worker, daemon=True, name="AIQA-AutoFlush")
227
293
  flush_thread.start()
@@ -229,20 +295,13 @@ class AIQASpanExporter(SpanExporter):
229
295
  logger.info(f"Auto-flush thread started: {flush_thread.name} (daemon={flush_thread.daemon})")
230
296
 
231
297
  async def _send_spans(self, spans: List[Dict[str, Any]]) -> None:
232
- """Send spans to the server API."""
233
- if not self.server_url:
234
- raise ValueError("AIQA_SERVER_URL is not set. Cannot send spans to server.")
235
-
298
+ """Send spans to the server API (async)."""
236
299
  import aiohttp
237
300
 
238
- url = f"{self.server_url}/span"
301
+ url = self._get_span_url()
302
+ headers = self._build_request_headers()
239
303
  logger.debug(f"_send_spans() sending {len(spans)} spans to {url}")
240
-
241
- headers = {
242
- "Content-Type": "application/json",
243
- }
244
304
  if self.api_key:
245
- headers["Authorization"] = f"ApiKey {self.api_key[:10]}..." # Log partial key for security
246
305
  logger.debug("_send_spans() using API key authentication")
247
306
  else:
248
307
  logger.debug("_send_spans() no API key provided")
@@ -250,11 +309,7 @@ class AIQASpanExporter(SpanExporter):
250
309
  try:
251
310
  async with aiohttp.ClientSession() as session:
252
311
  logger.debug(f"_send_spans() POST request starting to {url}")
253
- async with session.post(
254
- url,
255
- json=spans,
256
- headers=headers,
257
- ) as response:
312
+ async with session.post(url, json=spans, headers=headers) as response:
258
313
  logger.debug(f"_send_spans() received response: status={response.status}")
259
314
  if not response.ok:
260
315
  error_text = await response.text()
@@ -266,10 +321,48 @@ class AIQASpanExporter(SpanExporter):
266
321
  f"Failed to send spans: {response.status} {response.reason} - {error_text}"
267
322
  )
268
323
  logger.debug(f"_send_spans() successfully sent {len(spans)} spans")
324
+ except RuntimeError as e:
325
+ if self._is_interpreter_shutdown_error(e):
326
+ if self.shutdown_requested:
327
+ logger.debug(f"_send_spans() skipped due to interpreter shutdown: {e}")
328
+ else:
329
+ logger.warning(f"_send_spans() interrupted by interpreter shutdown: {e}")
330
+ raise
331
+ logger.error(f"_send_spans() RuntimeError: {type(e).__name__}: {e}", exc_info=True)
332
+ raise
269
333
  except Exception as e:
270
334
  logger.error(f"_send_spans() exception: {type(e).__name__}: {e}", exc_info=True)
271
335
  raise
272
336
 
337
+ def _send_spans_sync(self, spans: List[Dict[str, Any]]) -> None:
338
+ """Send spans to the server API (synchronous, for shutdown scenarios)."""
339
+ import requests
340
+
341
+ url = self._get_span_url()
342
+ headers = self._build_request_headers()
343
+ logger.debug(f"_send_spans_sync() sending {len(spans)} spans to {url}")
344
+ if self.api_key:
345
+ logger.debug("_send_spans_sync() using API key authentication")
346
+ else:
347
+ logger.debug("_send_spans_sync() no API key provided")
348
+
349
+ try:
350
+ response = requests.post(url, json=spans, headers=headers, timeout=10.0)
351
+ logger.debug(f"_send_spans_sync() received response: status={response.status_code}")
352
+ if not response.ok:
353
+ error_text = response.text[:200] if response.text else ""
354
+ logger.error(
355
+ f"_send_spans_sync() failed: status={response.status_code}, "
356
+ f"reason={response.reason}, error={error_text}"
357
+ )
358
+ raise Exception(
359
+ f"Failed to send spans: {response.status_code} {response.reason} - {error_text}"
360
+ )
361
+ logger.debug(f"_send_spans_sync() successfully sent {len(spans)} spans")
362
+ except Exception as e:
363
+ logger.error(f"_send_spans_sync() exception: {type(e).__name__}: {e}", exc_info=True)
364
+ raise
365
+
273
366
  def shutdown(self) -> None:
274
367
  """Shutdown the exporter, flushing any remaining spans. Call before process exit."""
275
368
  logger.info("shutdown() called - initiating exporter shutdown")
@@ -291,24 +384,32 @@ class AIQASpanExporter(SpanExporter):
291
384
  else:
292
385
  logger.debug("shutdown() no active auto-flush thread to wait for")
293
386
 
294
- # Final flush attempt (synchronous)
295
- import asyncio
296
- try:
297
- loop = asyncio.get_event_loop()
298
- if loop.is_running():
299
- logger.debug("shutdown() event loop is running, using ThreadPoolExecutor for final flush")
300
- # If loop is running, schedule flush
301
- import concurrent.futures
302
- with concurrent.futures.ThreadPoolExecutor() as executor:
303
- future = executor.submit(asyncio.run, self.flush())
304
- future.result(timeout=10.0)
387
+ # Final flush attempt (use synchronous send to avoid event loop issues)
388
+ with self.flush_lock:
389
+ logger.debug("shutdown() performing final flush with synchronous send")
390
+ # Atomically extract and remove spans to prevent race conditions
391
+ spans_to_flush = self._extract_and_remove_spans_from_buffer()
392
+ logger.debug(f"shutdown() extracted {len(spans_to_flush)} span(s) from buffer for final flush")
393
+
394
+ if spans_to_flush:
395
+ if not self.server_url:
396
+ logger.warning(
397
+ f"shutdown() skipping final flush: AIQA_SERVER_URL is not set. "
398
+ f"{len(spans_to_flush)} span(s) will not be sent."
399
+ )
400
+ # Spans already removed from buffer
401
+ else:
402
+ logger.info(f"shutdown() sending {len(spans_to_flush)} span(s) to server (synchronous)")
403
+ try:
404
+ self._send_spans_sync(spans_to_flush)
405
+ logger.info(f"shutdown() successfully sent {len(spans_to_flush)} span(s) to server")
406
+ # Spans already removed from buffer during extraction
407
+ except Exception as e:
408
+ logger.error(f"shutdown() failed to send spans: {e}", exc_info=True)
409
+ # Spans already removed, but process is exiting anyway
410
+ logger.warning(f"shutdown() {len(spans_to_flush)} span(s) were not sent due to error")
305
411
  else:
306
- logger.debug("shutdown() event loop exists but not running, using run_until_complete")
307
- loop.run_until_complete(self.flush())
308
- except RuntimeError:
309
- # No event loop, create one
310
- logger.debug("shutdown() no event loop found, creating new one for final flush")
311
- asyncio.run(self.flush())
412
+ logger.debug("shutdown() no spans to flush")
312
413
 
313
414
  # Check buffer state after shutdown
314
415
  with self.buffer_lock:
aiqa/tracing.py CHANGED
@@ -175,8 +175,6 @@ def WithTracing(
175
175
  if is_async:
176
176
  @wraps(fn)
177
177
  async def async_traced_fn(*args, **kwargs):
178
- span = tracer.start_span(fn_name)
179
-
180
178
  # Prepare input
181
179
  input_data = _prepare_input(args, kwargs)
182
180
  if filter_input:
@@ -186,41 +184,40 @@ def WithTracing(
186
184
  if key in input_data:
187
185
  del input_data[key]
188
186
 
189
- if input_data is not None:
190
- # Serialize for span attributes (OpenTelemetry only accepts primitives or JSON strings)
191
- serialized_input = _serialize_for_span(input_data)
192
- span.set_attribute("input", serialized_input)
193
-
194
- try:
195
- # Call the function within the span context
196
- trace_id = format(span.get_span_context().trace_id, "032x")
197
- logger.debug(f"do traceable stuff {fn_name} {trace_id}")
187
+ # Use start_as_current_span to ensure span is recorded by BatchSpanProcessor
188
+ with tracer.start_as_current_span(fn_name) as span:
189
+ if input_data is not None:
190
+ # Serialize for span attributes (OpenTelemetry only accepts primitives or JSON strings)
191
+ serialized_input = _serialize_for_span(input_data)
192
+ span.set_attribute("input", serialized_input)
198
193
 
199
- with trace.use_span(span, end_on_exit=False):
194
+ try:
195
+ # Call the function within the span context
196
+ trace_id = format(span.get_span_context().trace_id, "032x")
197
+ logger.debug(f"do traceable stuff {fn_name} {trace_id}")
198
+
200
199
  result = await fn(*args, **kwargs)
201
-
202
- # Prepare output
203
- output_data = result
204
- if filter_output:
205
- output_data = filter_output(output_data)
206
- if ignore_output and isinstance(output_data, dict):
207
- # Make a copy of output_data to avoid modifying the original
208
- output_data = output_data.copy()
209
- for key in ignore_output:
210
- if key in output_data:
211
- del output_data[key]
212
-
213
- span.set_attribute("output", _serialize_for_span(output_data))
214
- span.set_status(Status(StatusCode.OK))
215
-
216
- return result
217
- except Exception as exception:
218
- error = exception if isinstance(exception, Exception) else Exception(str(exception))
219
- span.record_exception(error)
220
- span.set_status(Status(StatusCode.ERROR, str(error)))
221
- raise
222
- finally:
223
- span.end()
200
+
201
+ # Prepare output
202
+ output_data = result
203
+ if filter_output:
204
+ output_data = filter_output(output_data)
205
+ if ignore_output and isinstance(output_data, dict):
206
+ # Make a copy of output_data to avoid modifying the original
207
+ output_data = output_data.copy()
208
+ for key in ignore_output:
209
+ if key in output_data:
210
+ del output_data[key]
211
+
212
+ span.set_attribute("output", _serialize_for_span(output_data))
213
+ span.set_status(Status(StatusCode.OK))
214
+
215
+ return result
216
+ except Exception as exception:
217
+ error = exception if isinstance(exception, Exception) else Exception(str(exception))
218
+ span.record_exception(error)
219
+ span.set_status(Status(StatusCode.ERROR, str(error)))
220
+ raise
224
221
 
225
222
  async_traced_fn._is_traced = True
226
223
  logger.debug(f"Function {fn_name} is now traced (async)")
@@ -228,8 +225,6 @@ def WithTracing(
228
225
  else:
229
226
  @wraps(fn)
230
227
  def sync_traced_fn(*args, **kwargs):
231
- span = tracer.start_span(fn_name)
232
-
233
228
  # Prepare input
234
229
  input_data = _prepare_input(args, kwargs)
235
230
  if filter_input:
@@ -239,41 +234,40 @@ def WithTracing(
239
234
  if key in input_data:
240
235
  del input_data[key]
241
236
 
242
- if input_data is not None:
243
- # Serialize for span attributes (OpenTelemetry only accepts primitives or JSON strings)
244
- serialized_input = _serialize_for_span(input_data)
245
- span.set_attribute("input", serialized_input)
246
-
247
- try:
248
- # Call the function within the span context
249
- trace_id = format(span.get_span_context().trace_id, "032x")
250
- logger.debug(f"do traceable stuff {fn_name} {trace_id}")
237
+ # Use start_as_current_span to ensure span is recorded by BatchSpanProcessor
238
+ with tracer.start_as_current_span(fn_name) as span:
239
+ if input_data is not None:
240
+ # Serialize for span attributes (OpenTelemetry only accepts primitives or JSON strings)
241
+ serialized_input = _serialize_for_span(input_data)
242
+ span.set_attribute("input", serialized_input)
251
243
 
252
- with trace.use_span(span, end_on_exit=False):
244
+ try:
245
+ # Call the function within the span context
246
+ trace_id = format(span.get_span_context().trace_id, "032x")
247
+ logger.debug(f"do traceable stuff {fn_name} {trace_id}")
248
+
253
249
  result = fn(*args, **kwargs)
254
-
255
- # Prepare output
256
- output_data = result
257
- if filter_output:
258
- output_data = filter_output(output_data)
259
- if ignore_output and isinstance(output_data, dict):
260
- # Make a copy of output_data to avoid modifying the original
261
- output_data = output_data.copy()
262
- for key in ignore_output:
263
- if key in output_data:
264
- del output_data[key]
265
-
266
- span.set_attribute("output", _serialize_for_span(output_data))
267
- span.set_status(Status(StatusCode.OK))
268
-
269
- return result
270
- except Exception as exception:
271
- error = exception if isinstance(exception, Exception) else Exception(str(exception))
272
- span.record_exception(error)
273
- span.set_status(Status(StatusCode.ERROR, str(error)))
274
- raise
275
- finally:
276
- span.end()
250
+
251
+ # Prepare output
252
+ output_data = result
253
+ if filter_output:
254
+ output_data = filter_output(output_data)
255
+ if ignore_output and isinstance(output_data, dict):
256
+ # Make a copy of output_data to avoid modifying the original
257
+ output_data = output_data.copy()
258
+ for key in ignore_output:
259
+ if key in output_data:
260
+ del output_data[key]
261
+
262
+ span.set_attribute("output", _serialize_for_span(output_data))
263
+ span.set_status(Status(StatusCode.OK))
264
+
265
+ return result
266
+ except Exception as exception:
267
+ error = exception if isinstance(exception, Exception) else Exception(str(exception))
268
+ span.record_exception(error)
269
+ span.set_status(Status(StatusCode.ERROR, str(error)))
270
+ raise
277
271
 
278
272
  sync_traced_fn._is_traced = True
279
273
  logger.debug(f"Function {fn_name} is now traced (sync)")
@@ -305,7 +299,7 @@ def set_span_name(span_name: str) -> bool:
305
299
  """
306
300
  span = trace.get_current_span()
307
301
  if span and span.is_recording():
308
- span.set_name(span_name)
302
+ span.update_name(span_name)
309
303
  return True
310
304
  return False
311
305
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiqa-client
3
- Version: 0.1.1
3
+ Version: 0.1.3
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
@@ -27,6 +27,7 @@ Requires-Dist: opentelemetry-api>=1.24.0
27
27
  Requires-Dist: opentelemetry-sdk>=1.24.0
28
28
  Requires-Dist: opentelemetry-semantic-conventions>=0.40b0
29
29
  Requires-Dist: aiohttp>=3.9.0
30
+ Requires-Dist: requests>=2.31.0
30
31
  Provides-Extra: dev
31
32
  Requires-Dist: pytest>=7.0.0; extra == "dev"
32
33
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
@@ -0,0 +1,9 @@
1
+ aiqa/__init__.py,sha256=mQWkldjxytAru66UBU5aRiENpR9hPsqxY5FBSqLwS60,470
2
+ aiqa/aiqa_exporter.py,sha256=vXyX6Q_iOjrDz3tCPOMXuBTQg7ocACdOOqzpkUqhy9g,19131
3
+ aiqa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ aiqa/tracing.py,sha256=TnKkasn9ESofNMzztTMAOhs_OfD6WZj1KS1U2Ikbypk,12084
5
+ aiqa_client-0.1.3.dist-info/licenses/LICENSE,sha256=kIzkzLuzG0HHaWYm4F4W5FeJ1Yxut3Ec6bhLWyw798A,1062
6
+ aiqa_client-0.1.3.dist-info/METADATA,sha256=lqRVBC0F7vbpeC0akkvRcn_c84vkwTLuwCzX6FTzpDs,3772
7
+ aiqa_client-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ aiqa_client-0.1.3.dist-info/top_level.txt,sha256=nwcsuVVSuWu27iLxZd4n1evVzv1W6FVTrSnCXCc-NQs,5
9
+ aiqa_client-0.1.3.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- aiqa/__init__.py,sha256=LEONMsfGaQXePRZN9XxdULWsufbfJnTJ1t1-LU9c-9o,470
2
- aiqa/aiqa_exporter.py,sha256=Y0VrZqnb3LG4WSb9XQYct9ABwunG04T9aM8I6EH0qQQ,13781
3
- aiqa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- aiqa/tracing.py,sha256=RwcHk9P_dKoQUv545VVHqnJ4x8jaZkqA4YhdVUXGywc,11889
5
- aiqa_client-0.1.1.dist-info/licenses/LICENSE,sha256=kIzkzLuzG0HHaWYm4F4W5FeJ1Yxut3Ec6bhLWyw798A,1062
6
- aiqa_client-0.1.1.dist-info/METADATA,sha256=NcmuXu5XyQsq2X-MRn_qaq4pCgnwH-1khBuZruYAwKQ,3740
7
- aiqa_client-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- aiqa_client-0.1.1.dist-info/top_level.txt,sha256=nwcsuVVSuWu27iLxZd4n1evVzv1W6FVTrSnCXCc-NQs,5
9
- aiqa_client-0.1.1.dist-info/RECORD,,