aiqa-client 0.1.0__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 +29 -0
- aiqa/aiqa_exporter.py +248 -0
- aiqa/py.typed +0 -0
- aiqa/tracing.py +307 -0
- aiqa_client-0.1.0.dist-info/METADATA +156 -0
- aiqa_client-0.1.0.dist-info/RECORD +9 -0
- aiqa_client-0.1.0.dist-info/WHEEL +5 -0
- aiqa_client-0.1.0.dist-info/licenses/LICENSE +22 -0
- aiqa_client-0.1.0.dist-info/top_level.txt +1 -0
aiqa/__init__.py
ADDED
|
@@ -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.0"
|
|
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
|
+
|
aiqa/aiqa_exporter.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenTelemetry span exporter that sends spans to the AIQA server API.
|
|
3
|
+
Buffers spans and flushes them periodically or on shutdown. Thread-safe.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import List, Dict, Any, Optional
|
|
12
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
13
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AIQASpanExporter(SpanExporter):
|
|
19
|
+
"""
|
|
20
|
+
Exports spans to AIQA server. Buffers spans and auto-flushes every flush_interval_seconds.
|
|
21
|
+
Call shutdown() before process exit to flush remaining spans.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
server_url: Optional[str] = None,
|
|
27
|
+
api_key: Optional[str] = None,
|
|
28
|
+
flush_interval_seconds: float = 5.0,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Initialize the AIQA span exporter.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
server_url: URL of the AIQA server (defaults to AIQA_SERVER_URL env var)
|
|
35
|
+
api_key: API key for authentication (defaults to AIQA_API_KEY env var)
|
|
36
|
+
flush_interval_seconds: How often to flush spans to the server
|
|
37
|
+
"""
|
|
38
|
+
self._server_url = server_url
|
|
39
|
+
self._api_key = api_key
|
|
40
|
+
self.flush_interval_ms = flush_interval_seconds * 1000
|
|
41
|
+
self.buffer: List[Dict[str, Any]] = []
|
|
42
|
+
self.buffer_lock = threading.Lock()
|
|
43
|
+
self.flush_lock = threading.Lock()
|
|
44
|
+
self.shutdown_requested = False
|
|
45
|
+
self.flush_timer: Optional[threading.Thread] = None
|
|
46
|
+
self._start_auto_flush()
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def server_url(self) -> str:
|
|
50
|
+
return self._server_url or os.getenv("AIQA_SERVER_URL", "").rstrip("/")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def api_key(self) -> str:
|
|
54
|
+
return self._api_key or os.getenv("AIQA_API_KEY", "")
|
|
55
|
+
|
|
56
|
+
def export(self, spans: List[ReadableSpan]) -> SpanExportResult:
|
|
57
|
+
"""
|
|
58
|
+
Export spans to the AIQA server. Adds spans to buffer for async flushing.
|
|
59
|
+
"""
|
|
60
|
+
if not spans:
|
|
61
|
+
return SpanExportResult.SUCCESS
|
|
62
|
+
|
|
63
|
+
# Serialize and add to buffer
|
|
64
|
+
with self.buffer_lock:
|
|
65
|
+
serialized_spans = [self._serialize_span(span) for span in spans]
|
|
66
|
+
self.buffer.extend(serialized_spans)
|
|
67
|
+
|
|
68
|
+
return SpanExportResult.SUCCESS
|
|
69
|
+
|
|
70
|
+
def _serialize_span(self, span: ReadableSpan) -> Dict[str, Any]:
|
|
71
|
+
"""Convert ReadableSpan to a serializable format."""
|
|
72
|
+
span_context = span.get_span_context()
|
|
73
|
+
|
|
74
|
+
# Get parent span ID
|
|
75
|
+
parent_span_id = None
|
|
76
|
+
if hasattr(span, "parent") and span.parent:
|
|
77
|
+
parent_span_id = format(span.parent.span_id, "016x")
|
|
78
|
+
elif hasattr(span, "parent_span_id") and span.parent_span_id:
|
|
79
|
+
parent_span_id = format(span.parent_span_id, "016x")
|
|
80
|
+
|
|
81
|
+
# Get span kind (handle both enum and int)
|
|
82
|
+
span_kind = span.kind
|
|
83
|
+
if hasattr(span_kind, "value"):
|
|
84
|
+
span_kind = span_kind.value
|
|
85
|
+
|
|
86
|
+
# Get status code (handle both enum and int)
|
|
87
|
+
status_code = span.status.status_code
|
|
88
|
+
if hasattr(status_code, "value"):
|
|
89
|
+
status_code = status_code.value
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"name": span.name,
|
|
93
|
+
"kind": span_kind,
|
|
94
|
+
"parentSpanId": parent_span_id,
|
|
95
|
+
"startTime": self._time_to_tuple(span.start_time),
|
|
96
|
+
"endTime": self._time_to_tuple(span.end_time) if span.end_time else None,
|
|
97
|
+
"status": {
|
|
98
|
+
"code": status_code,
|
|
99
|
+
"message": getattr(span.status, "description", None),
|
|
100
|
+
},
|
|
101
|
+
"attributes": dict(span.attributes) if span.attributes else {},
|
|
102
|
+
"links": [
|
|
103
|
+
{
|
|
104
|
+
"context": {
|
|
105
|
+
"traceId": format(link.context.trace_id, "032x"),
|
|
106
|
+
"spanId": format(link.context.span_id, "016x"),
|
|
107
|
+
},
|
|
108
|
+
"attributes": dict(link.attributes) if link.attributes else {},
|
|
109
|
+
}
|
|
110
|
+
for link in (span.links or [])
|
|
111
|
+
],
|
|
112
|
+
"events": [
|
|
113
|
+
{
|
|
114
|
+
"name": event.name,
|
|
115
|
+
"time": self._time_to_tuple(event.timestamp),
|
|
116
|
+
"attributes": dict(event.attributes) if event.attributes else {},
|
|
117
|
+
}
|
|
118
|
+
for event in (span.events or [])
|
|
119
|
+
],
|
|
120
|
+
"resource": {
|
|
121
|
+
"attributes": dict(span.resource.attributes) if span.resource.attributes else {},
|
|
122
|
+
},
|
|
123
|
+
"traceId": format(span_context.trace_id, "032x"),
|
|
124
|
+
"spanId": format(span_context.span_id, "016x"),
|
|
125
|
+
"traceFlags": span_context.trace_flags,
|
|
126
|
+
"duration": self._time_to_tuple(span.end_time - span.start_time) if span.end_time else None,
|
|
127
|
+
"ended": span.end_time is not None,
|
|
128
|
+
"instrumentationLibrary": {
|
|
129
|
+
"name": span.instrumentation_info.name if hasattr(span, "instrumentation_info") else "",
|
|
130
|
+
"version": span.instrumentation_info.version if hasattr(span, "instrumentation_info") else None,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def _time_to_tuple(self, nanoseconds: int) -> tuple:
|
|
135
|
+
"""Convert nanoseconds to (seconds, nanoseconds) tuple."""
|
|
136
|
+
seconds = int(nanoseconds // 1_000_000_000)
|
|
137
|
+
nanos = int(nanoseconds % 1_000_000_000)
|
|
138
|
+
return (seconds, nanos)
|
|
139
|
+
|
|
140
|
+
async def flush(self) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Flush buffered spans to the server. Thread-safe: ensures only one flush operation runs at a time.
|
|
143
|
+
"""
|
|
144
|
+
with self.flush_lock:
|
|
145
|
+
# Get current buffer and clear it atomically
|
|
146
|
+
with self.buffer_lock:
|
|
147
|
+
spans_to_flush = self.buffer[:]
|
|
148
|
+
self.buffer.clear()
|
|
149
|
+
|
|
150
|
+
if not spans_to_flush:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Skip sending if server URL is not configured
|
|
154
|
+
if not self.server_url:
|
|
155
|
+
logger.warning(
|
|
156
|
+
f"Skipping flush: AIQA_SERVER_URL is not set. {len(spans_to_flush)} span(s) will not be sent."
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
await self._send_spans(spans_to_flush)
|
|
162
|
+
except Exception as error:
|
|
163
|
+
logger.error(f"Error flushing spans to server: {error}")
|
|
164
|
+
if self.shutdown_requested:
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
def _start_auto_flush(self) -> None:
|
|
168
|
+
"""Start the auto-flush timer."""
|
|
169
|
+
if self.shutdown_requested:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
def flush_worker():
|
|
173
|
+
import asyncio
|
|
174
|
+
loop = asyncio.new_event_loop()
|
|
175
|
+
asyncio.set_event_loop(loop)
|
|
176
|
+
|
|
177
|
+
while not self.shutdown_requested:
|
|
178
|
+
try:
|
|
179
|
+
loop.run_until_complete(self.flush())
|
|
180
|
+
time.sleep(self.flush_interval_ms / 1000.0)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Error in auto-flush: {e}")
|
|
183
|
+
time.sleep(self.flush_interval_ms / 1000.0)
|
|
184
|
+
|
|
185
|
+
# Final flush on shutdown
|
|
186
|
+
if self.shutdown_requested:
|
|
187
|
+
try:
|
|
188
|
+
loop.run_until_complete(self.flush())
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"Error in final flush: {e}")
|
|
191
|
+
finally:
|
|
192
|
+
loop.close()
|
|
193
|
+
|
|
194
|
+
flush_thread = threading.Thread(target=flush_worker, daemon=True)
|
|
195
|
+
flush_thread.start()
|
|
196
|
+
self.flush_timer = flush_thread
|
|
197
|
+
|
|
198
|
+
async def _send_spans(self, spans: List[Dict[str, Any]]) -> None:
|
|
199
|
+
"""Send spans to the server API."""
|
|
200
|
+
if not self.server_url:
|
|
201
|
+
raise ValueError("AIQA_SERVER_URL is not set. Cannot send spans to server.")
|
|
202
|
+
|
|
203
|
+
import aiohttp
|
|
204
|
+
|
|
205
|
+
logger.debug(f"Sending {len(spans)} spans to server: {self.server_url}")
|
|
206
|
+
|
|
207
|
+
headers = {
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
}
|
|
210
|
+
if self.api_key:
|
|
211
|
+
headers["Authorization"] = f"ApiKey {self.api_key}"
|
|
212
|
+
|
|
213
|
+
async with aiohttp.ClientSession() as session:
|
|
214
|
+
async with session.post(
|
|
215
|
+
f"{self.server_url}/span",
|
|
216
|
+
json=spans,
|
|
217
|
+
headers=headers,
|
|
218
|
+
) as response:
|
|
219
|
+
if not response.ok:
|
|
220
|
+
error_text = await response.text()
|
|
221
|
+
raise Exception(
|
|
222
|
+
f"Failed to send spans: {response.status} {response.reason} - {error_text}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def shutdown(self) -> None:
|
|
226
|
+
"""Shutdown the exporter, flushing any remaining spans. Call before process exit."""
|
|
227
|
+
self.shutdown_requested = True
|
|
228
|
+
|
|
229
|
+
# Wait for flush thread to finish (it will do final flush)
|
|
230
|
+
if self.flush_timer and self.flush_timer.is_alive():
|
|
231
|
+
self.flush_timer.join(timeout=10.0)
|
|
232
|
+
|
|
233
|
+
# Final flush attempt (synchronous)
|
|
234
|
+
import asyncio
|
|
235
|
+
try:
|
|
236
|
+
loop = asyncio.get_event_loop()
|
|
237
|
+
if loop.is_running():
|
|
238
|
+
# If loop is running, schedule flush
|
|
239
|
+
import concurrent.futures
|
|
240
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
241
|
+
future = executor.submit(asyncio.run, self.flush())
|
|
242
|
+
future.result(timeout=10.0)
|
|
243
|
+
else:
|
|
244
|
+
loop.run_until_complete(self.flush())
|
|
245
|
+
except RuntimeError:
|
|
246
|
+
# No event loop, create one
|
|
247
|
+
asyncio.run(self.flush())
|
|
248
|
+
|
aiqa/py.typed
ADDED
|
File without changes
|
aiqa/tracing.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenTelemetry tracing setup and utilities. Initializes tracer provider on import.
|
|
3
|
+
Provides WithTracing decorator to automatically trace function calls.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Any, Callable, Optional, Dict
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from opentelemetry import trace
|
|
13
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
14
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
15
|
+
from opentelemetry.sdk.resources import Resource
|
|
16
|
+
from opentelemetry.semconv.resource import ResourceAttributes
|
|
17
|
+
from opentelemetry.trace import Status, StatusCode
|
|
18
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
19
|
+
from .aiqa_exporter import AIQASpanExporter
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Load environment variables
|
|
24
|
+
exporter = AIQASpanExporter()
|
|
25
|
+
|
|
26
|
+
# Initialize OpenTelemetry
|
|
27
|
+
provider = TracerProvider(
|
|
28
|
+
resource=Resource.create(
|
|
29
|
+
{
|
|
30
|
+
ResourceAttributes.SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "aiqa-service"),
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
36
|
+
trace.set_tracer_provider(provider)
|
|
37
|
+
|
|
38
|
+
# Get a tracer instance
|
|
39
|
+
tracer = trace.get_tracer("aiqa-tracer")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def flush_spans() -> None:
|
|
43
|
+
"""
|
|
44
|
+
Flush all pending spans to the server.
|
|
45
|
+
Flushes also happen automatically every few seconds. So you only need to call this function
|
|
46
|
+
if you want to flush immediately, e.g. before exiting a process.
|
|
47
|
+
|
|
48
|
+
This flushes both the BatchSpanProcessor and the exporter buffer.
|
|
49
|
+
"""
|
|
50
|
+
provider.force_flush() # Synchronous method
|
|
51
|
+
await exporter.flush()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def shutdown_tracing() -> None:
|
|
55
|
+
"""
|
|
56
|
+
Shutdown the tracer provider and exporter.
|
|
57
|
+
It is not necessary to call this function.
|
|
58
|
+
"""
|
|
59
|
+
provider.shutdown() # Synchronous method
|
|
60
|
+
await exporter.shutdown()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Export provider and exporter for advanced usage
|
|
64
|
+
__all__ = ["provider", "exporter", "flush_spans", "shutdown_tracing", "WithTracing", "set_span_attribute", "set_span_name", "get_active_span"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TracingOptions:
|
|
68
|
+
"""Options for WithTracing decorator"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
name: Optional[str] = None,
|
|
73
|
+
ignore_input: Optional[Any] = None,
|
|
74
|
+
ignore_output: Optional[Any] = None,
|
|
75
|
+
filter_input: Optional[Callable[[Any], Any]] = None,
|
|
76
|
+
filter_output: Optional[Callable[[Any], Any]] = None,
|
|
77
|
+
):
|
|
78
|
+
self.name = name
|
|
79
|
+
self.ignore_input = ignore_input
|
|
80
|
+
self.ignore_output = ignore_output
|
|
81
|
+
self.filter_input = filter_input
|
|
82
|
+
self.filter_output = filter_output
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _serialize_for_span(value: Any) -> Any:
|
|
86
|
+
"""
|
|
87
|
+
Serialize a value for span attributes.
|
|
88
|
+
OpenTelemetry only accepts primitives (bool, str, bytes, int, float) or sequences of those.
|
|
89
|
+
Complex types (dicts, lists, objects) are converted to JSON strings.
|
|
90
|
+
"""
|
|
91
|
+
# Keep primitives as is (including None)
|
|
92
|
+
if value is None or isinstance(value, (str, int, float, bool, bytes)):
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
# For sequences, check if all elements are primitives
|
|
96
|
+
if isinstance(value, (list, tuple)):
|
|
97
|
+
# If all elements are primitives, return as list
|
|
98
|
+
if all(isinstance(item, (str, int, float, bool, bytes, type(None))) for item in value):
|
|
99
|
+
return list(value)
|
|
100
|
+
# Otherwise serialize to JSON string
|
|
101
|
+
try:
|
|
102
|
+
return json.dumps(value)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return str(value)
|
|
105
|
+
|
|
106
|
+
# For dicts and other complex types, serialize to JSON string
|
|
107
|
+
try:
|
|
108
|
+
return json.dumps(value)
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
# If JSON serialization fails, convert to string
|
|
111
|
+
return str(value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _prepare_input(args: tuple, kwargs: dict) -> Any:
|
|
115
|
+
"""Prepare input for span attributes."""
|
|
116
|
+
if not args and not kwargs:
|
|
117
|
+
return None
|
|
118
|
+
if len(args) == 1 and not kwargs:
|
|
119
|
+
return _serialize_for_span(args[0])
|
|
120
|
+
# Multiple args or kwargs - combine into dict
|
|
121
|
+
result = {}
|
|
122
|
+
if args:
|
|
123
|
+
result["args"] = [_serialize_for_span(arg) for arg in args]
|
|
124
|
+
if kwargs:
|
|
125
|
+
result["kwargs"] = {k: _serialize_for_span(v) for k, v in kwargs.items()}
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def WithTracing(
|
|
130
|
+
func: Optional[Callable] = None,
|
|
131
|
+
*,
|
|
132
|
+
name: Optional[str] = None,
|
|
133
|
+
ignore_input: Optional[Any] = None,
|
|
134
|
+
ignore_output: Optional[Any] = None,
|
|
135
|
+
filter_input: Optional[Callable[[Any], Any]] = None,
|
|
136
|
+
filter_output: Optional[Callable[[Any], Any]] = None,
|
|
137
|
+
):
|
|
138
|
+
"""
|
|
139
|
+
Decorator to automatically create spans for function calls.
|
|
140
|
+
Records input/output as span attributes. Spans are automatically linked via OpenTelemetry context.
|
|
141
|
+
|
|
142
|
+
Works with both synchronous and asynchronous functions.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
func: The function to trace (when used as @WithTracing)
|
|
146
|
+
name: Optional custom name for the span (defaults to function name)
|
|
147
|
+
ignore_input: Fields to ignore in input (not yet implemented)
|
|
148
|
+
ignore_output: Fields to ignore in output (not yet implemented)
|
|
149
|
+
filter_input: Function to filter/transform input before recording
|
|
150
|
+
filter_output: Function to filter/transform output before recording
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
@WithTracing
|
|
154
|
+
def my_function(x, y):
|
|
155
|
+
return x + y
|
|
156
|
+
|
|
157
|
+
@WithTracing
|
|
158
|
+
async def my_async_function(x, y):
|
|
159
|
+
return x + y
|
|
160
|
+
|
|
161
|
+
@WithTracing(name="custom_name")
|
|
162
|
+
def another_function():
|
|
163
|
+
pass
|
|
164
|
+
"""
|
|
165
|
+
def decorator(fn: Callable) -> Callable:
|
|
166
|
+
fn_name = name or fn.__name__ or "_"
|
|
167
|
+
|
|
168
|
+
# Check if already traced
|
|
169
|
+
if hasattr(fn, "_is_traced"):
|
|
170
|
+
logger.warning(f"Function {fn_name} is already traced, skipping tracing again")
|
|
171
|
+
return fn
|
|
172
|
+
|
|
173
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
174
|
+
|
|
175
|
+
if is_async:
|
|
176
|
+
@wraps(fn)
|
|
177
|
+
async def async_traced_fn(*args, **kwargs):
|
|
178
|
+
span = tracer.start_span(fn_name)
|
|
179
|
+
|
|
180
|
+
# Prepare input
|
|
181
|
+
input_data = _prepare_input(args, kwargs)
|
|
182
|
+
if filter_input:
|
|
183
|
+
input_data = filter_input(input_data)
|
|
184
|
+
if ignore_input and isinstance(input_data, dict):
|
|
185
|
+
# TODO: implement ignore_input logic
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
if input_data is not None:
|
|
189
|
+
# Serialize for span attributes (OpenTelemetry only accepts primitives or JSON strings)
|
|
190
|
+
serialized_input = _serialize_for_span(input_data)
|
|
191
|
+
span.set_attribute("input", serialized_input)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
# Call the function within the span context
|
|
195
|
+
trace_id = format(span.get_span_context().trace_id, "032x")
|
|
196
|
+
logger.debug(f"do traceable stuff {fn_name} {trace_id}")
|
|
197
|
+
|
|
198
|
+
with trace.use_span(span, end_on_exit=False):
|
|
199
|
+
result = await fn(*args, **kwargs)
|
|
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
|
+
# TODO: implement ignore_output logic
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
span.set_attribute("output", _serialize_for_span(output_data))
|
|
210
|
+
span.set_status(Status(StatusCode.OK))
|
|
211
|
+
|
|
212
|
+
return result
|
|
213
|
+
except Exception as exception:
|
|
214
|
+
error = exception if isinstance(exception, Exception) else Exception(str(exception))
|
|
215
|
+
span.record_exception(error)
|
|
216
|
+
span.set_status(Status(StatusCode.ERROR, str(error)))
|
|
217
|
+
raise
|
|
218
|
+
finally:
|
|
219
|
+
span.end()
|
|
220
|
+
|
|
221
|
+
async_traced_fn._is_traced = True
|
|
222
|
+
logger.debug(f"Function {fn_name} is now traced (async)")
|
|
223
|
+
return async_traced_fn
|
|
224
|
+
else:
|
|
225
|
+
@wraps(fn)
|
|
226
|
+
def sync_traced_fn(*args, **kwargs):
|
|
227
|
+
span = tracer.start_span(fn_name)
|
|
228
|
+
|
|
229
|
+
# Prepare input
|
|
230
|
+
input_data = _prepare_input(args, kwargs)
|
|
231
|
+
if filter_input:
|
|
232
|
+
input_data = filter_input(input_data)
|
|
233
|
+
if ignore_input and isinstance(input_data, dict):
|
|
234
|
+
# TODO: implement ignore_input logic
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
if input_data is not None:
|
|
238
|
+
# Serialize for span attributes (OpenTelemetry only accepts primitives or JSON strings)
|
|
239
|
+
serialized_input = _serialize_for_span(input_data)
|
|
240
|
+
span.set_attribute("input", serialized_input)
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
# Call the function within the span context
|
|
244
|
+
trace_id = format(span.get_span_context().trace_id, "032x")
|
|
245
|
+
logger.debug(f"do traceable stuff {fn_name} {trace_id}")
|
|
246
|
+
|
|
247
|
+
with trace.use_span(span, end_on_exit=False):
|
|
248
|
+
result = fn(*args, **kwargs)
|
|
249
|
+
|
|
250
|
+
# Prepare output
|
|
251
|
+
output_data = result
|
|
252
|
+
if filter_output:
|
|
253
|
+
output_data = filter_output(output_data)
|
|
254
|
+
if ignore_output and isinstance(output_data, dict):
|
|
255
|
+
# TODO: implement ignore_output logic
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
span.set_attribute("output", _serialize_for_span(output_data))
|
|
259
|
+
span.set_status(Status(StatusCode.OK))
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
except Exception as exception:
|
|
263
|
+
error = exception if isinstance(exception, Exception) else Exception(str(exception))
|
|
264
|
+
span.record_exception(error)
|
|
265
|
+
span.set_status(Status(StatusCode.ERROR, str(error)))
|
|
266
|
+
raise
|
|
267
|
+
finally:
|
|
268
|
+
span.end()
|
|
269
|
+
|
|
270
|
+
sync_traced_fn._is_traced = True
|
|
271
|
+
logger.debug(f"Function {fn_name} is now traced (sync)")
|
|
272
|
+
return sync_traced_fn
|
|
273
|
+
|
|
274
|
+
# Support both @WithTracing and @WithTracing(...) syntax
|
|
275
|
+
if func is None:
|
|
276
|
+
return decorator
|
|
277
|
+
else:
|
|
278
|
+
return decorator(func)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def set_span_attribute(attribute_name: str, attribute_value: Any) -> bool:
|
|
282
|
+
"""
|
|
283
|
+
Set an attribute on the active span.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
True if attribute was set, False if no active span found
|
|
287
|
+
"""
|
|
288
|
+
span = trace.get_current_span()
|
|
289
|
+
if span and span.is_recording():
|
|
290
|
+
span.set_attribute(attribute_name, _serialize_for_span(attribute_value))
|
|
291
|
+
return True
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
def set_span_name(span_name: str) -> bool:
|
|
295
|
+
"""
|
|
296
|
+
Set the name of the active span.
|
|
297
|
+
"""
|
|
298
|
+
span = trace.get_current_span()
|
|
299
|
+
if span and span.is_recording():
|
|
300
|
+
span.set_name(span_name)
|
|
301
|
+
return True
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
def get_active_span() -> Optional[trace.Span]:
|
|
305
|
+
"""Get the currently active span."""
|
|
306
|
+
return trace.get_current_span()
|
|
307
|
+
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiqa-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenTelemetry-based Python client for tracing functions and sending traces to the AIQA server
|
|
5
|
+
Author-email: AIQA <info@aiqa.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/winterstein/aiqa
|
|
8
|
+
Project-URL: Documentation, https://github.com/winterstein/aiqa/tree/main/client-python
|
|
9
|
+
Project-URL: Repository, https://github.com/winterstein/aiqa
|
|
10
|
+
Project-URL: Issues, https://github.com/winterstein/aiqa/issues
|
|
11
|
+
Keywords: opentelemetry,tracing,observability,aiqa,monitoring
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: opentelemetry-api>=1.24.0
|
|
27
|
+
Requires-Dist: opentelemetry-sdk>=1.24.0
|
|
28
|
+
Requires-Dist: opentelemetry-semantic-conventions>=0.40b0
|
|
29
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
33
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# A Python client for the AIQA server
|
|
38
|
+
|
|
39
|
+
OpenTelemetry-based client for tracing Python functions and sending traces to the AIQA server.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
### From PyPI (recommended)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install aiqa-client
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### From source
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m venv .venv
|
|
53
|
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
54
|
+
pip install -r requirements.txt
|
|
55
|
+
pip install -e .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See [TESTING.md](TESTING.md) for detailed testing instructions.
|
|
59
|
+
|
|
60
|
+
## Setup
|
|
61
|
+
|
|
62
|
+
Set the following environment variables:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export AIQA_SERVER_URL="http://localhost:3000"
|
|
66
|
+
export AIQA_API_KEY="your-api-key"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
### Basic Usage
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from aiqa import WithTracing
|
|
75
|
+
|
|
76
|
+
@WithTracing
|
|
77
|
+
def my_function(x, y):
|
|
78
|
+
return x + y
|
|
79
|
+
|
|
80
|
+
@WithTracing
|
|
81
|
+
async def my_async_function(x, y):
|
|
82
|
+
await asyncio.sleep(0.1)
|
|
83
|
+
return x * y
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Custom Span Name
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
@WithTracing(name="custom_span_name")
|
|
90
|
+
def my_function():
|
|
91
|
+
pass
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Input/Output Filtering
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
@WithTracing(
|
|
98
|
+
filter_input=lambda x: {"filtered": str(x)},
|
|
99
|
+
filter_output=lambda x: {"result": x}
|
|
100
|
+
)
|
|
101
|
+
def my_function(data):
|
|
102
|
+
return {"processed": data}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Flushing Spans
|
|
106
|
+
|
|
107
|
+
Spans are automatically flushed every 5 seconds. To flush immediately:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from aiqa import flush_spans
|
|
111
|
+
import asyncio
|
|
112
|
+
|
|
113
|
+
async def main():
|
|
114
|
+
# Your code here
|
|
115
|
+
await flush_spans()
|
|
116
|
+
|
|
117
|
+
asyncio.run(main())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Shutting Down
|
|
121
|
+
|
|
122
|
+
To ensure all spans are sent before process exit:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from aiqa import shutdown_tracing
|
|
126
|
+
import asyncio
|
|
127
|
+
|
|
128
|
+
async def main():
|
|
129
|
+
# Your code here
|
|
130
|
+
await shutdown_tracing()
|
|
131
|
+
|
|
132
|
+
asyncio.run(main())
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Setting Span Attributes and Names
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from aiqa import set_span_attribute, set_span_name
|
|
139
|
+
|
|
140
|
+
def my_function():
|
|
141
|
+
set_span_attribute("custom.attribute", "value")
|
|
142
|
+
set_span_name("custom_span_name")
|
|
143
|
+
# ... rest of function
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Features
|
|
147
|
+
|
|
148
|
+
- Automatic tracing of function calls (sync and async)
|
|
149
|
+
- Records function inputs and outputs as span attributes
|
|
150
|
+
- Automatic error tracking and exception recording
|
|
151
|
+
- Thread-safe span buffering and auto-flushing
|
|
152
|
+
- OpenTelemetry context propagation for nested spans
|
|
153
|
+
|
|
154
|
+
## Example
|
|
155
|
+
|
|
156
|
+
See `example.py` for a complete working example.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
aiqa/__init__.py,sha256=FMWyKfD9ZiEd1LtgIPaTSU1LTcvCFqHCw8oth2aO5js,470
|
|
2
|
+
aiqa/aiqa_exporter.py,sha256=hxnvdjCIebVFAqpeb9nXLYEi8A1oDysp7DebXcHA4po,9396
|
|
3
|
+
aiqa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
aiqa/tracing.py,sha256=Rk_XLVbBZqxiQRZVVi3Sedo9mkDTLUn9rZiUPdC7PTw,11325
|
|
5
|
+
aiqa_client-0.1.0.dist-info/licenses/LICENSE,sha256=kIzkzLuzG0HHaWYm4F4W5FeJ1Yxut3Ec6bhLWyw798A,1062
|
|
6
|
+
aiqa_client-0.1.0.dist-info/METADATA,sha256=gQgQZ6-LLbWucsVxSKQgR856JSgJWWtdUZA064pr-T0,3740
|
|
7
|
+
aiqa_client-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
aiqa_client-0.1.0.dist-info/top_level.txt,sha256=nwcsuVVSuWu27iLxZd4n1evVzv1W6FVTrSnCXCc-NQs,5
|
|
9
|
+
aiqa_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 AIQA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiqa
|