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