aiqa-client 0.2.0__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.
- {aiqa_client-0.2.0/aiqa_client.egg-info → aiqa_client-0.3.1}/PKG-INFO +94 -4
- aiqa_client-0.3.1/README.md +210 -0
- aiqa_client-0.3.1/aiqa/__init__.py +66 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/aiqa/aiqa_exporter.py +78 -20
- aiqa_client-0.3.1/aiqa/client.py +170 -0
- aiqa_client-0.3.1/aiqa/experiment_runner.py +336 -0
- aiqa_client-0.3.1/aiqa/object_serialiser.py +361 -0
- aiqa_client-0.3.1/aiqa/test_experiment_runner.py +176 -0
- aiqa_client-0.3.1/aiqa/test_tracing.py +230 -0
- aiqa_client-0.3.1/aiqa/tracing.py +1256 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1/aiqa_client.egg-info}/PKG-INFO +94 -4
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/aiqa_client.egg-info/SOURCES.txt +4 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/pyproject.toml +1 -1
- aiqa_client-0.2.0/README.md +0 -120
- aiqa_client-0.2.0/aiqa/__init__.py +0 -29
- aiqa_client-0.2.0/aiqa/client.py +0 -49
- aiqa_client-0.2.0/aiqa/tracing.py +0 -543
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/LICENSE +0 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/MANIFEST.in +0 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/aiqa/py.typed +0 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/aiqa_client.egg-info/dependency_links.txt +0 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/aiqa_client.egg-info/requires.txt +0 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/aiqa_client.egg-info/top_level.txt +0 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/setup.cfg +0 -0
- {aiqa_client-0.2.0 → aiqa_client-0.3.1}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiqa-client
|
|
3
|
-
Version: 0.
|
|
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
|
|
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
|
|
119
|
+
from aiqa import flush_tracing
|
|
112
120
|
import asyncio
|
|
113
121
|
|
|
114
122
|
async def main():
|
|
115
123
|
# Your code here
|
|
116
|
-
await
|
|
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(
|
|
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 = [
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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":
|
|
142
|
-
"version":
|
|
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,
|
|
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}"
|
|
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}"
|
|
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}"
|
|
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,
|
|
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}"
|
|
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}"
|
|
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}"
|
|
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}"
|
|
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
|
|