aiqa-client 0.1.0__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.
@@ -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,6 @@
1
+ include README.md
2
+ include LICENSE
3
+ include pyproject.toml
4
+ recursive-include aiqa *.py
5
+ recursive-include aiqa py.typed
6
+
@@ -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,120 @@
1
+ # A Python client for the AIQA server
2
+
3
+ OpenTelemetry-based client for tracing Python functions and sending traces to the AIQA server.
4
+
5
+ ## Installation
6
+
7
+ ### From PyPI (recommended)
8
+
9
+ ```bash
10
+ pip install aiqa-client
11
+ ```
12
+
13
+ ### From source
14
+
15
+ ```bash
16
+ python -m venv .venv
17
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
18
+ pip install -r requirements.txt
19
+ pip install -e .
20
+ ```
21
+
22
+ See [TESTING.md](TESTING.md) for detailed testing instructions.
23
+
24
+ ## Setup
25
+
26
+ Set the following environment variables:
27
+
28
+ ```bash
29
+ export AIQA_SERVER_URL="http://localhost:3000"
30
+ export AIQA_API_KEY="your-api-key"
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Basic Usage
36
+
37
+ ```python
38
+ from aiqa import WithTracing
39
+
40
+ @WithTracing
41
+ def my_function(x, y):
42
+ return x + y
43
+
44
+ @WithTracing
45
+ async def my_async_function(x, y):
46
+ await asyncio.sleep(0.1)
47
+ return x * y
48
+ ```
49
+
50
+ ### Custom Span Name
51
+
52
+ ```python
53
+ @WithTracing(name="custom_span_name")
54
+ def my_function():
55
+ pass
56
+ ```
57
+
58
+ ### Input/Output Filtering
59
+
60
+ ```python
61
+ @WithTracing(
62
+ filter_input=lambda x: {"filtered": str(x)},
63
+ filter_output=lambda x: {"result": x}
64
+ )
65
+ def my_function(data):
66
+ return {"processed": data}
67
+ ```
68
+
69
+ ### Flushing Spans
70
+
71
+ Spans are automatically flushed every 5 seconds. To flush immediately:
72
+
73
+ ```python
74
+ from aiqa import flush_spans
75
+ import asyncio
76
+
77
+ async def main():
78
+ # Your code here
79
+ await flush_spans()
80
+
81
+ asyncio.run(main())
82
+ ```
83
+
84
+ ### Shutting Down
85
+
86
+ To ensure all spans are sent before process exit:
87
+
88
+ ```python
89
+ from aiqa import shutdown_tracing
90
+ import asyncio
91
+
92
+ async def main():
93
+ # Your code here
94
+ await shutdown_tracing()
95
+
96
+ asyncio.run(main())
97
+ ```
98
+
99
+ ### Setting Span Attributes and Names
100
+
101
+ ```python
102
+ from aiqa import set_span_attribute, set_span_name
103
+
104
+ def my_function():
105
+ set_span_attribute("custom.attribute", "value")
106
+ set_span_name("custom_span_name")
107
+ # ... rest of function
108
+ ```
109
+
110
+ ## Features
111
+
112
+ - Automatic tracing of function calls (sync and async)
113
+ - Records function inputs and outputs as span attributes
114
+ - Automatic error tracking and exception recording
115
+ - Thread-safe span buffering and auto-flushing
116
+ - OpenTelemetry context propagation for nested spans
117
+
118
+ ## Example
119
+
120
+ See `example.py` for a complete working example.
@@ -0,0 +1,29 @@
1
+ """
2
+ Python client for AIQA server - OpenTelemetry tracing decorators.
3
+ """
4
+
5
+ from .tracing import (
6
+ WithTracing,
7
+ flush_spans,
8
+ shutdown_tracing,
9
+ set_span_attribute,
10
+ set_span_name,
11
+ get_active_span,
12
+ provider,
13
+ exporter,
14
+ )
15
+
16
+ __version__ = "0.1.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
+
@@ -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
+
File without changes
@@ -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,14 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ setup.py
6
+ aiqa/__init__.py
7
+ aiqa/aiqa_exporter.py
8
+ aiqa/py.typed
9
+ aiqa/tracing.py
10
+ aiqa_client.egg-info/PKG-INFO
11
+ aiqa_client.egg-info/SOURCES.txt
12
+ aiqa_client.egg-info/dependency_links.txt
13
+ aiqa_client.egg-info/requires.txt
14
+ aiqa_client.egg-info/top_level.txt
@@ -0,0 +1,10 @@
1
+ opentelemetry-api>=1.24.0
2
+ opentelemetry-sdk>=1.24.0
3
+ opentelemetry-semantic-conventions>=0.40b0
4
+ aiohttp>=3.9.0
5
+
6
+ [dev]
7
+ pytest>=7.0.0
8
+ pytest-asyncio>=0.21.0
9
+ black>=23.0.0
10
+ ruff>=0.1.0
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aiqa-client"
7
+ version = "0.1.0"
8
+ description = "OpenTelemetry-based Python client for tracing functions and sending traces to the AIQA server"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "AIQA", email = "info@aiqa.dev"}
14
+ ]
15
+ keywords = ["opentelemetry", "tracing", "observability", "aiqa", "monitoring"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.8",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Topic :: System :: Monitoring",
28
+ ]
29
+
30
+ dependencies = [
31
+ "opentelemetry-api>=1.24.0",
32
+ "opentelemetry-sdk>=1.24.0",
33
+ "opentelemetry-semantic-conventions>=0.40b0",
34
+ "aiohttp>=3.9.0",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "pytest>=7.0.0",
40
+ "pytest-asyncio>=0.21.0",
41
+ "black>=23.0.0",
42
+ "ruff>=0.1.0",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/winterstein/aiqa"
47
+ Documentation = "https://github.com/winterstein/aiqa/tree/main/client-python"
48
+ Repository = "https://github.com/winterstein/aiqa"
49
+ Issues = "https://github.com/winterstein/aiqa/issues"
50
+
51
+ [tool.setuptools]
52
+ packages = ["aiqa"]
53
+
54
+ [tool.setuptools.package-data]
55
+ aiqa = ["py.typed"]
56
+
57
+ [tool.black]
58
+ line-length = 100
59
+ target-version = ["py38", "py39", "py310", "py311", "py312"]
60
+
61
+ [tool.ruff]
62
+ line-length = 100
63
+ target-version = "py38"
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "I", "N", "W", "UP"]
67
+ ignore = ["E501"]
68
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """
2
+ Setup script for aiqa-client (kept for compatibility).
3
+ Use pyproject.toml for modern builds.
4
+ """
5
+
6
+ from setuptools import setup
7
+
8
+ setup()
9
+