aiqa-client 0.4.7__py3-none-any.whl → 0.6.1__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/span_helpers.py ADDED
@@ -0,0 +1,511 @@
1
+ """
2
+ Span manipulation utilities, trace context management, and server API functions.
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ import requests
8
+ from typing import Any, Optional, List
9
+ from opentelemetry import trace
10
+ from opentelemetry.trace import Status, StatusCode, SpanContext, TraceFlags
11
+
12
+ from .client import get_aiqa_client, get_component_tag, set_component_tag as _set_component_tag, get_aiqa_tracer
13
+ from .constants import LOG_TAG
14
+ from .object_serialiser import serialize_for_span
15
+ from .http_utils import build_headers, get_server_url, get_api_key
16
+
17
+ logger = logging.getLogger(LOG_TAG)
18
+
19
+
20
+ async def flush_tracing() -> None:
21
+ """
22
+ Flush all pending spans to the server.
23
+ Flushes also happen automatically every few seconds. So you only need to call this function
24
+ if you want to flush immediately, e.g. before exiting a process.
25
+ A common use is if you are tracing unit tests or experiment runs.
26
+
27
+ This flushes the BatchSpanProcessor (OTLP exporter doesn't have a separate flush method).
28
+ """
29
+ client = get_aiqa_client()
30
+ if client.provider:
31
+ client.provider.force_flush() # Synchronous method
32
+
33
+
34
+ def set_span_attribute(attribute_name: str, attribute_value: Any) -> bool:
35
+ """
36
+ Set an attribute on the active span.
37
+
38
+ Returns:
39
+ True if attribute was set, False if no active span found
40
+ """
41
+ span = trace.get_current_span()
42
+ if span and span.is_recording():
43
+ span.set_attribute(attribute_name, serialize_for_span(attribute_value))
44
+ return True
45
+ return False
46
+
47
+
48
+ def set_span_name(span_name: str) -> bool:
49
+ """
50
+ Set the name of the active span.
51
+ """
52
+ span = trace.get_current_span()
53
+ if span and span.is_recording():
54
+ span.update_name(span_name)
55
+ return True
56
+ return False
57
+
58
+
59
+ def get_active_span() -> Optional[trace.Span]:
60
+ """Get the currently active span."""
61
+ return trace.get_current_span()
62
+
63
+
64
+ def set_conversation_id(conversation_id: str) -> bool:
65
+ """
66
+ Naturally a conversation might span several traces.
67
+ Set the gen_ai.conversation.id attribute on the active span.
68
+ This allows you to group multiple traces together that are part of the same conversation.
69
+ See https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-events/ for more details.
70
+
71
+ Args:
72
+ conversation_id: A unique identifier for the conversation (e.g., user session ID, chat ID, etc.)
73
+
74
+ Returns:
75
+ True if gen_ai.conversation.id was set, False if no active span found
76
+
77
+ Example:
78
+ from aiqa import WithTracing, set_conversation_id
79
+
80
+ @WithTracing
81
+ def handle_user_request(user_id: str, request: dict):
82
+ # Set conversation ID to group all traces for this user session
83
+ set_conversation_id(f"user_{user_id}_session_{request.get('session_id')}")
84
+ # ... rest of function
85
+ """
86
+ return set_span_attribute("gen_ai.conversation.id", conversation_id)
87
+
88
+
89
+ def set_token_usage(
90
+ input_tokens: Optional[int] = None,
91
+ output_tokens: Optional[int] = None,
92
+ total_tokens: Optional[int] = None,
93
+ ) -> bool:
94
+ """
95
+ Set token usage attributes on the active span using OpenTelemetry semantic conventions for gen_ai.
96
+ This allows you to explicitly record token usage information.
97
+ AIQA tracing will automatically detect and set token usage from standard OpenAI-like API responses.
98
+ See https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ for more details.
99
+
100
+ Args:
101
+ input_tokens: Number of input tokens used (maps to gen_ai.usage.input_tokens)
102
+ output_tokens: Number of output tokens generated (maps to gen_ai.usage.output_tokens)
103
+ total_tokens: Total number of tokens used (maps to gen_ai.usage.total_tokens)
104
+
105
+ Returns:
106
+ True if at least one token usage attribute was set, False if no active span found
107
+
108
+ Example:
109
+ from aiqa import WithTracing, set_token_usage
110
+
111
+ @WithTracing
112
+ def call_llm(prompt: str):
113
+ response = openai_client.chat.completions.create(...)
114
+ # Explicitly set token usage
115
+ set_token_usage(
116
+ input_tokens=response.usage.prompt_tokens,
117
+ output_tokens=response.usage.completion_tokens,
118
+ total_tokens=response.usage.total_tokens
119
+ )
120
+ return response
121
+ """
122
+ span = trace.get_current_span()
123
+ if not span or not span.is_recording():
124
+ return False
125
+
126
+ set_count = 0
127
+ try:
128
+ if input_tokens is not None:
129
+ span.set_attribute("gen_ai.usage.input_tokens", input_tokens)
130
+ set_count += 1
131
+ if output_tokens is not None:
132
+ span.set_attribute("gen_ai.usage.output_tokens", output_tokens)
133
+ set_count += 1
134
+ if total_tokens is not None:
135
+ span.set_attribute("gen_ai.usage.total_tokens", total_tokens)
136
+ set_count += 1
137
+ except Exception as e:
138
+ logger.warning(f"Failed to set token usage attributes: {e}")
139
+ return False
140
+
141
+ return set_count > 0
142
+
143
+
144
+ def set_provider_and_model(
145
+ provider: Optional[str] = None,
146
+ model: Optional[str] = None,
147
+ ) -> bool:
148
+ """
149
+ Set provider and model attributes on the active span using OpenTelemetry semantic conventions for gen_ai.
150
+ This allows you to explicitly record provider and model information.
151
+ AIQA tracing will automatically detect and set provider/model from standard API responses.
152
+ See https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ for more details.
153
+
154
+ Args:
155
+ provider: Name of the AI provider (e.g., "openai", "anthropic", "google") (maps to gen_ai.provider.name)
156
+ model: Name of the model used (e.g., "gpt-4", "claude-3-5-sonnet") (maps to gen_ai.request.model)
157
+
158
+ Returns:
159
+ True if at least one attribute was set, False if no active span found
160
+
161
+ Example:
162
+ from aiqa import WithTracing, set_provider_and_model
163
+
164
+ @WithTracing
165
+ def call_llm(prompt: str):
166
+ response = openai_client.chat.completions.create(...)
167
+ # Explicitly set provider and model
168
+ set_provider_and_model(
169
+ provider="openai",
170
+ model=response.model
171
+ )
172
+ return response
173
+ """
174
+ span = trace.get_current_span()
175
+ if not span or not span.is_recording():
176
+ return False
177
+
178
+ set_count = 0
179
+ try:
180
+ if provider is not None:
181
+ span.set_attribute("gen_ai.provider.name", str(provider))
182
+ set_count += 1
183
+ if model is not None:
184
+ span.set_attribute("gen_ai.request.model", str(model))
185
+ set_count += 1
186
+ except Exception as e:
187
+ logger.warning(f"Failed to set provider/model attributes: {e}")
188
+ return False
189
+
190
+ return set_count > 0
191
+
192
+
193
+ def set_component_tag(tag: str) -> None:
194
+ """
195
+ Set the component tag that will be added to all spans created by AIQA.
196
+ This can also be set via the AIQA_COMPONENT_TAG environment variable.
197
+ The component tag allows you to identify which component/system generated the spans.
198
+
199
+ Note: Initialization is automatic when WithTracing is first used. You can also call
200
+ get_aiqa_client() explicitly if needed.
201
+ the client and load environment variables.
202
+
203
+ Args:
204
+ tag: A component identifier (e.g., "mynamespace.mysystem", "backend.api", etc.)
205
+
206
+ Example:
207
+ from aiqa import get_aiqa_client, set_component_tag, WithTracing
208
+
209
+ # Initialize client (loads env vars including AIQA_COMPONENT_TAG)
210
+ get_aiqa_client()
211
+
212
+ # Or set component tag programmatically (overrides env var)
213
+ set_component_tag("mynamespace.mysystem")
214
+
215
+ @WithTracing
216
+ def my_function():
217
+ pass
218
+ """
219
+ _set_component_tag(tag)
220
+
221
+
222
+ def get_active_trace_id() -> Optional[str]:
223
+ """
224
+ Get the current trace ID as a hexadecimal string (32 characters).
225
+
226
+ Returns:
227
+ The trace ID as a hex string, or None if no active span exists.
228
+
229
+ Example:
230
+ trace_id = get_active_trace_id()
231
+ # Pass trace_id to another service/agent
232
+ # e.g., include in HTTP headers, message queue metadata, etc.
233
+ # Within a single thread, OpenTelemetry normally does this for you.
234
+ """
235
+ span = trace.get_current_span()
236
+ if span and span.get_span_context().is_valid:
237
+ return format(span.get_span_context().trace_id, "032x")
238
+ return None
239
+
240
+
241
+ def get_span_id() -> Optional[str]:
242
+ """
243
+ Get the current span ID as a hexadecimal string (16 characters).
244
+
245
+ Returns:
246
+ The span ID as a hex string, or None if no active span exists.
247
+
248
+ Example:
249
+ span_id = get_span_id()
250
+ # Can be used to create child spans in other services
251
+ """
252
+ span = trace.get_current_span()
253
+ if span and span.get_span_context().is_valid:
254
+ return format(span.get_span_context().span_id, "016x")
255
+ return None
256
+
257
+
258
+ def create_span_from_trace_id(
259
+ trace_id: str,
260
+ parent_span_id: Optional[str] = None,
261
+ span_name: str = "continued_span",
262
+ ) -> trace.Span:
263
+ """
264
+ Create a new span that continues from an existing trace ID.
265
+ This is useful for linking traces across different services or agents.
266
+
267
+ Args:
268
+ trace_id: The trace ID as a hexadecimal string (32 characters)
269
+ parent_span_id: Optional parent span ID as a hexadecimal string (16 characters).
270
+ If provided, the new span will be a child of this span.
271
+ span_name: Name for the new span (default: "continued_span")
272
+
273
+ Returns:
274
+ A new span that continues the trace. Use it in a context manager or call end() manually.
275
+
276
+ Example:
277
+ # In service A: get trace ID
278
+ trace_id = get_active_trace_id()
279
+ span_id = get_span_id()
280
+
281
+ # Send to service B (e.g., via HTTP, message queue, etc.)
282
+ # ...
283
+
284
+ # In service B: continue the trace
285
+ with create_span_from_trace_id(trace_id, parent_span_id=span_id, span_name="service_b_operation"):
286
+ # Your code here
287
+ pass
288
+ """
289
+ try:
290
+ # Parse trace ID from hex string
291
+ trace_id_int = int(trace_id, 16)
292
+
293
+ # Parse parent span ID if provided
294
+ parent_span_id_int = None
295
+ if parent_span_id:
296
+ parent_span_id_int = int(parent_span_id, 16)
297
+
298
+ # Create a parent span context
299
+ parent_span_context = SpanContext(
300
+ trace_id=trace_id_int,
301
+ span_id=parent_span_id_int if parent_span_id_int else 0,
302
+ is_remote=True,
303
+ trace_flags=TraceFlags(0x01), # SAMPLED flag
304
+ )
305
+
306
+ # Create a context with this span context as the parent
307
+ from opentelemetry.trace import set_span_in_context
308
+ parent_context = set_span_in_context(trace.NonRecordingSpan(parent_span_context))
309
+
310
+ # Ensure initialization before creating span
311
+ get_aiqa_client()
312
+ # Start a new span in this context (it will be a child of the parent span)
313
+ tracer = get_aiqa_tracer()
314
+ span = tracer.start_span(span_name, context=parent_context)
315
+
316
+ # Set component tag if configured
317
+ component_tag = get_component_tag()
318
+ if component_tag:
319
+ span.set_attribute("gen_ai.component.id", component_tag)
320
+
321
+ return span
322
+ except (ValueError, AttributeError) as e:
323
+ logger.error(f"Error creating span from trace_id: {e}")
324
+ # Ensure initialization before creating span
325
+ get_aiqa_client()
326
+ # Fallback: create a new span
327
+ tracer = get_aiqa_tracer()
328
+ span = tracer.start_span(span_name)
329
+ component_tag = get_component_tag()
330
+ if component_tag:
331
+ span.set_attribute("gen_ai.component.id", component_tag)
332
+ return span
333
+
334
+
335
+ def inject_trace_context(carrier: dict) -> None:
336
+ """
337
+ Inject the current trace context into a carrier (e.g., HTTP headers).
338
+ This allows you to pass trace context to another service.
339
+
340
+ Args:
341
+ carrier: Dictionary to inject trace context into (e.g., HTTP headers dict)
342
+
343
+ Example:
344
+ import requests
345
+
346
+ headers = {}
347
+ inject_trace_context(headers)
348
+ response = requests.get("http://other-service/api", headers=headers)
349
+ """
350
+ try:
351
+ from opentelemetry.propagate import inject
352
+ inject(carrier)
353
+ except Exception as e:
354
+ logger.warning(f"Error injecting trace context: {e}")
355
+
356
+
357
+ def extract_trace_context(carrier: dict) -> Any:
358
+ """
359
+ Extract trace context from a carrier (e.g., HTTP headers).
360
+ Use this to continue a trace that was started in another service.
361
+
362
+ Args:
363
+ carrier: Dictionary containing trace context (e.g., HTTP headers dict)
364
+
365
+ Returns:
366
+ A context object that can be used with trace.use_span() or tracer.start_span()
367
+
368
+ Example:
369
+ from opentelemetry.trace import use_span
370
+
371
+ # Extract context from incoming request headers
372
+ ctx = extract_trace_context(request.headers)
373
+
374
+ # Use the context to create a span
375
+ with use_span(ctx):
376
+ # Your code here
377
+ pass
378
+
379
+ # Or create a span with the context
380
+ tracer = get_aiqa_tracer()
381
+ with tracer.start_as_current_span("operation", context=ctx):
382
+ # Your code here
383
+ pass
384
+ """
385
+ try:
386
+ from opentelemetry.propagate import extract
387
+ return extract(carrier)
388
+ except Exception as e:
389
+ logger.warning(f"Error extracting trace context: {e}")
390
+ return None
391
+
392
+
393
+ def get_span(span_id: str, organisation_id: Optional[str] = None, exclude: Optional[List[str]] = None) -> Optional[dict]:
394
+ """
395
+ Get a span by its ID from the AIQA server.
396
+
397
+ Expected usage is: re-playing a specific function call in a unit test (either a developer debugging an issue, or as part of a test suite).
398
+
399
+ Args:
400
+ span_id: The span ID as a hexadecimal string (16 characters) or client span ID
401
+ organisation_id: Optional organisation ID. If not provided, will try to get from
402
+ AIQA_ORGANISATION_ID environment variable. The organisation is typically
403
+ extracted from the API key during authentication, but the API requires it
404
+ as a query parameter.
405
+ exclude: Optional list of fields to exclude from the span data. By default this function WILL return 'attributes' (often large).
406
+
407
+ Returns:
408
+ The span data as a dictionary, or None if not found
409
+
410
+ Example:
411
+ from aiqa import get_span
412
+
413
+ span = get_span('abc123...')
414
+ if span:
415
+ print(f"Found span: {span['name']}")
416
+ my_function(**span['input'])
417
+ """
418
+ server_url = get_server_url()
419
+ api_key = get_api_key()
420
+ org_id = organisation_id or os.getenv("AIQA_ORGANISATION_ID", "")
421
+
422
+ # Check if server_url is the default (meaning AIQA_SERVER_URL was not set)
423
+ if not os.getenv("AIQA_SERVER_URL"):
424
+ raise ValueError("AIQA_SERVER_URL is not set. Cannot retrieve span.")
425
+ if not org_id:
426
+ raise ValueError("Organisation ID is required. Provide it as parameter or set AIQA_ORGANISATION_ID environment variable.")
427
+ if not api_key:
428
+ raise ValueError("API key is required. Set AIQA_API_KEY environment variable.")
429
+
430
+ # Try both spanId and clientSpanId queries
431
+ for query_field in ["spanId", "clientSpanId"]:
432
+ url = f"{server_url}/span"
433
+ params = {
434
+ "q": f"{query_field}:{span_id}",
435
+ "organisation": org_id,
436
+ "limit": "1",
437
+ "exclude": ",".join(exclude) if exclude else None,
438
+ "fields": "*" if not exclude else None,
439
+ }
440
+
441
+ headers = build_headers(api_key)
442
+
443
+ response = requests.get(url, params=params, headers=headers)
444
+ if response.status_code == 200:
445
+ result = response.json()
446
+ hits = result.get("hits", [])
447
+ if hits and len(hits) > 0:
448
+ return hits[0]
449
+ elif response.status_code == 404:
450
+ # Try next query field
451
+ continue
452
+ else:
453
+ error_text = response.text
454
+ raise ValueError(f"Failed to get span: {response.status_code} - {error_text[:500]}")
455
+ # not found
456
+ return None
457
+
458
+
459
+ async def submit_feedback(
460
+ trace_id: str,
461
+ thumbs_up: Optional[bool] = None,
462
+ comment: Optional[str] = None,
463
+ ) -> None:
464
+ """
465
+ Submit feedback for a trace by creating a new span with the same trace ID.
466
+ This allows you to add feedback (thumbs-up, thumbs-down, comment) to a trace after it has completed.
467
+
468
+ Args:
469
+ trace_id: The trace ID as a hexadecimal string (32 characters)
470
+ thumbs_up: True for positive feedback, False for negative feedback, None for neutral
471
+ comment: Optional text comment
472
+
473
+ Example:
474
+ from aiqa import submit_feedback
475
+
476
+ # Submit positive feedback
477
+ await submit_feedback('abc123...', thumbs_up=True, comment='Great response!')
478
+
479
+ # Submit negative feedback
480
+ await submit_feedback('abc123...', thumbs_up=False, comment='Incorrect answer')
481
+ """
482
+ if not trace_id or len(trace_id) != 32:
483
+ raise ValueError('Invalid trace ID: must be 32 hexadecimal characters')
484
+
485
+ # Create a span for feedback with the same trace ID
486
+ span = create_span_from_trace_id(trace_id, span_name='feedback')
487
+
488
+ try:
489
+ # Set feedback attributes
490
+ if thumbs_up is not None:
491
+ span.set_attribute('feedback.thumbs_up', thumbs_up)
492
+ span.set_attribute('feedback.type', 'positive' if thumbs_up else 'negative')
493
+ else:
494
+ span.set_attribute('feedback.type', 'neutral')
495
+
496
+ if comment:
497
+ span.set_attribute('feedback.comment', comment)
498
+
499
+ # Mark as feedback span
500
+ span.set_attribute('aiqa.span_type', 'feedback')
501
+
502
+ # End the span
503
+ span.end()
504
+
505
+ # Flush to ensure it's sent immediately
506
+ await flush_tracing()
507
+ except Exception as e:
508
+ span.end()
509
+ raise e
510
+
511
+