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/tracing.py CHANGED
@@ -1,53 +1,24 @@
1
1
  """
2
- OpenTelemetry tracing setup and utilities. Initializes tracer provider on import.
3
- Provides WithTracing decorator to automatically trace function calls.
2
+ OpenTelemetry tracing decorator. Provides WithTracing decorator to automatically trace function calls.
4
3
  """
5
4
 
6
- import json
7
5
  import logging
8
6
  import inspect
9
- import os
10
- import copy
7
+ import fnmatch
11
8
  from typing import Any, Callable, Optional, List
12
9
  from functools import wraps
13
10
  from opentelemetry import trace
14
- from opentelemetry.sdk.trace import TracerProvider
15
- from opentelemetry.trace import Status, StatusCode, SpanContext, TraceFlags
16
- from opentelemetry.propagate import inject, extract
17
- from .aiqa_exporter import AIQASpanExporter
18
- from .client import get_aiqa_client, get_component_tag, set_component_tag as _set_component_tag, get_aiqa_tracer
19
- from .constants import AIQA_TRACER_NAME, LOG_TAG
11
+ from opentelemetry.trace import Status, StatusCode
12
+
13
+ from .client import get_aiqa_client, get_component_tag, get_aiqa_tracer
14
+ from .constants import LOG_TAG
20
15
  from .object_serialiser import serialize_for_span
21
- from .http_utils import build_headers, get_server_url, get_api_key
22
16
  from .tracing_llm_utils import _extract_and_set_token_usage, _extract_and_set_provider_and_model
23
17
 
24
18
  logger = logging.getLogger(LOG_TAG)
25
19
 
26
20
 
27
- async def flush_tracing() -> None:
28
- """
29
- Flush all pending spans to the server.
30
- Flushes also happen automatically every few seconds. So you only need to call this function
31
- if you want to flush immediately, e.g. before exiting a process.
32
- A common use is if you are tracing unit tests or experiment runs.
33
-
34
- This flushes both the BatchSpanProcessor and the exporter buffer.
35
- """
36
- client = get_aiqa_client()
37
- if client.provider:
38
- client.provider.force_flush() # Synchronous method
39
- if client.exporter:
40
- await client.exporter.flush()
41
-
42
-
43
- # Export provider and exporter accessors for advanced usage
44
-
45
- __all__ = [
46
- "flush_tracing", "WithTracing",
47
- "set_span_attribute", "set_span_name", "get_active_span",
48
- "get_active_trace_id", "get_span_id", "create_span_from_trace_id", "inject_trace_context", "extract_trace_context",
49
- "set_conversation_id", "set_component_tag", "set_token_usage", "set_provider_and_model", "get_span", "submit_feedback"
50
- ]
21
+ __all__ = ["WithTracing"]
51
22
 
52
23
 
53
24
  class TracingOptions:
@@ -75,20 +46,25 @@ class TracingOptions:
75
46
  descriptive names.
76
47
 
77
48
  ignore_input: Iterable of keys (e.g., list, set) to exclude from
78
- input data when recording span attributes. Only applies when
79
- input is a dictionary. For example, use `["password", "api_key"]`
80
- to exclude sensitive fields from being traced.
49
+ input data when recording span attributes. Applies after filter_input if both are set.
50
+ Only applies when
51
+ input is a dictionary. Supports simple wildcards (e.g., `"_*"`
52
+ matches `"_apple"`, `"_fruit"`). For example, use `["password", "api_key"]`
53
+ or `["_*", "password"]` to exclude sensitive fields from being traced.
81
54
 
82
55
  ignore_output: Iterable of keys (e.g., list, set) to exclude from
83
56
  output data when recording span attributes. Only applies when
84
- output is a dictionary. Useful for excluding large or sensitive
57
+ output is a dictionary. Supports simple wildcards (e.g., `"_*"`
58
+ matches `"_apple"`, `"_fruit"`). Useful for excluding large or sensitive
85
59
  fields from traces.
86
60
 
87
- filter_input: Callable function that receives the prepared input data
88
- and returns a filtered/transformed version to be recorded in the
89
- span. The function should accept one argument (the input data)
90
- and return the transformed data. This is applied before
91
- ignore_input filtering.
61
+ filter_input: Callable function that receives the same arguments as the
62
+ decorated function (*args, **kwargs) and returns a filtered/transformed
63
+ version to be recorded in the span. This allows you to extract specific
64
+ properties from any kind of object, including `self` for methods.
65
+ The function receives the exact same inputs as the decorated function,
66
+ including `self` for bound methods. Returns a dict or any value that
67
+ will be converted to a dict. Applied before ignore_input filtering.
92
68
 
93
69
  filter_output: Callable function that receives the output data and
94
70
  returns a filtered/transformed version to be recorded in the span.
@@ -109,6 +85,22 @@ class TracingOptions:
109
85
  )
110
86
  def process_data(items):
111
87
  return items
88
+
89
+ # Extract properties from self in a method
90
+ class ExperimentRunner:
91
+ def __init__(self, dataset_id, experiment_id):
92
+ self.dataset_id = dataset_id
93
+ self.experiment_id = experiment_id
94
+
95
+ @WithTracing(
96
+ filter_input=lambda self, example: {
97
+ "dataset": self.dataset_id,
98
+ "experiment": self.experiment_id,
99
+ "example_id": example.id if hasattr(example, 'id') else None
100
+ }
101
+ )
102
+ def run_example(self, example):
103
+ return self.process(example)
112
104
  """
113
105
  self.name = name
114
106
  self.ignore_input = ignore_input
@@ -117,12 +109,27 @@ class TracingOptions:
117
109
  self.filter_output = filter_output
118
110
 
119
111
 
120
- def _prepare_input(args: tuple, kwargs: dict) -> Any:
112
+ def _matches_ignore_pattern(key: str, ignore_patterns: List[str]) -> bool:
113
+ """
114
+ Check if a key matches any pattern in the ignore list.
115
+ Supports simple wildcards (e.g., "_*" matches "_apple", "_fruit").
116
+ """
117
+ for pattern in ignore_patterns:
118
+ if "*" in pattern or "?" in pattern:
119
+ # Use fnmatch for wildcard matching
120
+ if fnmatch.fnmatch(key, pattern):
121
+ return True
122
+ else:
123
+ # Exact match for non-wildcard patterns
124
+ if key == pattern:
125
+ return True
126
+ return False
127
+
128
+
129
+ def _prepare_input(args: tuple, kwargs: dict, sig: Optional[inspect.Signature] = None) -> Any:
121
130
  """Prepare input for span attributes.
122
- Aims to produce nice span attributes for the input, since {args, kwargs} is not a natural way to read function input.
123
- So can "unwrap" the args, kwargs.
124
-
125
- For single-arg-dicts or kwargs-only, returns a shallow copy of the input data.
131
+ Converts args and kwargs into a unified dict structure using function signature when available.
132
+ Falls back to legacy behavior for functions without inspectable signatures.
126
133
 
127
134
  Note: This function does NOT serialize values - it just structures the data.
128
135
  Serialization happens later via serialize_for_span() to avoid double-encoding
@@ -130,6 +137,22 @@ def _prepare_input(args: tuple, kwargs: dict) -> Any:
130
137
  """
131
138
  if not args and not kwargs:
132
139
  return None
140
+
141
+ # Try to bind args to parameter names using function signature
142
+ if sig is not None:
143
+ try:
144
+ bound = sig.bind(*args, **kwargs)
145
+ bound.apply_defaults()
146
+ # Return dict of all arguments (positional args are now named)
147
+ result = bound.arguments.copy()
148
+ # Shallow copy to protect against mutating the input
149
+ return result
150
+ except (TypeError, ValueError):
151
+ # Binding failed (e.g., wrong number of args, *args/**kwargs issues)
152
+ # Fall through to legacy behavior
153
+ pass
154
+
155
+ # in case binding fails
133
156
  if not kwargs:
134
157
  if len(args) == 1:
135
158
  arg0 = args[0]
@@ -145,44 +168,99 @@ def _prepare_input(args: tuple, kwargs: dict) -> Any:
145
168
  return result
146
169
 
147
170
 
171
+ def _apply_ignore_patterns(data_dict: dict, ignore_patterns: Optional[List[str]]) -> dict:
172
+ """
173
+ Apply ignore patterns to a dict.
174
+ Supports string keys, wildcard patterns (*), and list of patterns.
175
+ Used for both ignore_input and ignore_output.
176
+
177
+ Args:
178
+ data_dict: Dictionary to filter
179
+ ignore_patterns: List of patterns to exclude (e.g., ["self", "_*", "password"])
180
+
181
+ Returns:
182
+ Filtered dictionary with matching keys removed
183
+ """
184
+ if not ignore_patterns or not isinstance(data_dict, dict):
185
+ return data_dict
186
+
187
+ result = data_dict.copy()
188
+ keys_to_delete = [
189
+ key for key in result.keys()
190
+ if _matches_ignore_pattern(key, ignore_patterns)
191
+ ]
192
+ for key in keys_to_delete:
193
+ del result[key]
194
+
195
+ return result
196
+
197
+
148
198
  def _prepare_and_filter_input(
149
199
  args: tuple,
150
200
  kwargs: dict,
151
201
  filter_input: Optional[Callable[[Any], Any]],
152
202
  ignore_input: Optional[List[str]],
203
+ sig: Optional[inspect.Signature] = None,
153
204
  ) -> Any:
154
205
  """
155
- Prepare and filter input for span attributes - applies the user's filter_input and ignore_input.
156
- For single-arg-dicts or kwargs-only, returns a shallow copy of the input data.
206
+ Prepare and filter input for span attributes.
207
+
208
+ Process flow:
209
+ 1. Apply filter_input to args, kwargs (receives same inputs as decorated function, including self)
210
+ 2. Convert into dict ready for span.attributes.input
211
+ 3. Apply ignore_input to the dict (supports string, wildcard, and list patterns)
212
+
213
+ Args:
214
+ args: Positional arguments (including self for bound methods)
215
+ kwargs: Keyword arguments
216
+ filter_input: Optional function to filter/transform args and kwargs before conversion.
217
+ Receives *args, **kwargs with the same signature as the function being decorated,
218
+ including `self` for bound methods. This allows extracting properties from any object.
219
+ ignore_input: Optional list of keys/patterns to exclude from the final dict.
220
+ If "self" is in ignore_input, it will be removed from the final dict but filter_input
221
+ still receives it.
222
+ sig: Optional function signature for proper arg name resolution
223
+
224
+ Returns:
225
+ Prepared input data (dict, list, or other) ready for span.attributes.input
157
226
  """
158
- # Handle "self" in ignore_input by skipping the first argument
159
- filtered_args = args
160
- filtered_kwargs = kwargs.copy() if kwargs else {}
161
- filtered_ignore_input = ignore_input
162
- if ignore_input and "self" in ignore_input:
163
- # Remove "self" from ignore_input list (we'll handle it specially)
164
- filtered_ignore_input = [key for key in ignore_input if key != "self"]
165
- # Skip first arg if it exists (typically self for bound methods)
166
- if args:
167
- filtered_args = args[1:]
168
- # Also remove "self" from kwargs if present
169
- if "self" in filtered_kwargs:
170
- del filtered_kwargs["self"]
171
- # turn args, kwargs into one "nice" object
172
- input_data = _prepare_input(filtered_args, filtered_kwargs)
173
- if filter_input and input_data is not None:
174
- input_data = filter_input(input_data)
175
- if filtered_ignore_input and len(filtered_ignore_input) > 0:
176
- if not isinstance(input_data, dict):
177
- logger.warning(f"_prepare_and_filter_input: skip: ignore_input is set beyond 'self': {filtered_ignore_input} but input_data is not a dict: {type(input_data)}")
227
+ # Step 1: Apply filter_input to args, kwargs (same inputs as decorated function, including self)
228
+ if filter_input:
229
+ # filter_input receives the exact same args/kwargs as the decorated function
230
+ # This allows it to access self and extract properties from any object
231
+ try:
232
+ filtered_result = filter_input(*args, **kwargs)
233
+ except TypeError:
234
+ # Fallback: backward compatibility - convert to dict first
235
+ temp_dict = _prepare_input(args, kwargs, sig)
236
+ filtered_result = filter_input(temp_dict)
237
+
238
+ # Step 2: Convert filter_input result into dict ready for span.attributes.input
239
+ if isinstance(filtered_result, dict):
240
+ input_data = filtered_result
178
241
  else:
179
- for key in filtered_ignore_input:
180
- if key in input_data:
181
- del input_data[key]
182
- # Also handle case where input_data is just self (single value, not dict)
183
- # If we filtered out self and there are no remaining args/kwargs, return None
184
- if ignore_input and "self" in ignore_input and not filtered_args and not filtered_kwargs:
185
- return None
242
+ # Convert filter_input result to dict using signature
243
+ # Use original sig (not filtered) since filter_input received all args including self
244
+ input_data = _prepare_input(
245
+ (filtered_result,) if not isinstance(filtered_result, tuple) else filtered_result,
246
+ {},
247
+ sig
248
+ )
249
+ else:
250
+ # Step 2: Convert into dict ready for span.attributes.input
251
+ input_data = _prepare_input(args, kwargs, sig)
252
+
253
+ # Step 3: Apply ignore_input to the dict (removes "self" from final dict if specified)
254
+ should_ignore_self = ignore_input and "self" in ignore_input
255
+ if isinstance(input_data, dict):
256
+ input_data = _apply_ignore_patterns(input_data, ignore_input)
257
+ # Handle case where we removed self and there are no remaining args/kwargs
258
+ if should_ignore_self and not input_data:
259
+ return None
260
+ elif ignore_input:
261
+ # Warn if ignore_input is set but input_data is not a dict
262
+ logger.warning(f"_prepare_and_filter_input: skip: ignore_input is set but input_data is not a dict: {type(input_data)}")
263
+
186
264
  return input_data
187
265
 
188
266
 
@@ -197,11 +275,13 @@ def _filter_and_serialize_output(
197
275
  if isinstance(output_data, dict):
198
276
  output_data = output_data.copy() # copy to provide shallow protection against the user accidentally mutating the output with filter_output
199
277
  output_data = filter_output(output_data)
200
- if ignore_output and isinstance(output_data, dict):
201
- output_data = output_data.copy()
202
- for key in ignore_output:
203
- if key in output_data:
204
- del output_data[key]
278
+
279
+ # Apply ignore_output patterns (supports key, wildcard, and list patterns)
280
+ if isinstance(output_data, dict):
281
+ output_data = _apply_ignore_patterns(output_data, ignore_output)
282
+ elif ignore_output:
283
+ # Warn if ignore_output is set but output_data is not a dict
284
+ logger.warning(f"_filter_and_serialize_output: skip: ignore_output is set but output_data is not a dict: {type(output_data)}")
205
285
 
206
286
  # Serialize immediately to create immutable result (removes mutable structures)
207
287
  return serialize_for_span(output_data)
@@ -418,13 +498,22 @@ def WithTracing(
418
498
  func: The function to trace (when used as @WithTracing)
419
499
  name: Optional custom name for the span (defaults to function name)
420
500
  ignore_input: List of keys to exclude from input data when recording span attributes.
421
- Only applies when input is a dictionary. For example, use ["password", "api_key"]
422
- to exclude sensitive fields from being traced.
501
+ self is handled as "self"
502
+ Supports simple wildcards (e.g., "_*"
503
+ matches "_apple", "_fruit"). For example, use ["password", "api_key"] or
504
+ ["_*", "password"] to exclude sensitive fields from being traced.
423
505
  ignore_output: List of keys to exclude from output data when recording span attributes.
424
- Only applies when output is a dictionary. Useful for excluding large or sensitive
506
+ Only applies when output is a dictionary. Supports simple wildcards (e.g., "_*"
507
+ matches "_apple", "_fruit"). Useful for excluding large or sensitive
425
508
  fields from traces.
426
- filter_input: Function to filter/transform input before recording
427
- filter_output: Function to filter/transform output before recording
509
+ filter_input: Function to filter/transform input before recording.
510
+ Receives the same arguments as the decorated function (*args, **kwargs),
511
+ including `self` for bound methods. This allows you to extract specific
512
+ properties from any kind of object. For example, to extract `dataset_id`
513
+ from `self` in a method: `filter_input=lambda self, x: {"dataset": self.dataset_id, "x": x}`.
514
+ Returns a dict or any value (will be converted to dict). Applied before ignore_input.
515
+ filter_output: Function to filter/transform output before recording.
516
+ Receives the output value and returns a filtered/transformed version.
428
517
 
429
518
  Example:
430
519
  @WithTracing
@@ -434,6 +523,17 @@ def WithTracing(
434
523
  @WithTracing(name="custom_name")
435
524
  def another_function():
436
525
  pass
526
+
527
+ # Extract properties from self in a method
528
+ class MyClass:
529
+ def __init__(self, dataset_id):
530
+ self.dataset_id = dataset_id
531
+
532
+ @WithTracing(
533
+ filter_input=lambda self, x: {"dataset": self.dataset_id, "x": x}
534
+ )
535
+ def process(self, x):
536
+ return x * 2
437
537
  """
438
538
  def decorator(fn: Callable) -> Callable:
439
539
  fn_name = name or fn.__name__ or "_"
@@ -447,6 +547,15 @@ def WithTracing(
447
547
  is_generator = inspect.isgeneratorfunction(fn)
448
548
  is_async_generator = inspect.isasyncgenfunction(fn) if hasattr(inspect, 'isasyncgenfunction') else False
449
549
 
550
+ # Get function signature once at decoration time for efficient arg name resolution
551
+ fn_sig: Optional[inspect.Signature] = None
552
+ try:
553
+ fn_sig = inspect.signature(fn)
554
+ except (ValueError, TypeError):
555
+ # Some callables (e.g., builtins, C extensions) don't have inspectable signatures
556
+ # Will fall back to legacy behavior
557
+ pass
558
+
450
559
  # Don't get tracer here - get it lazily when function is called
451
560
  # This ensures initialization only happens when tracing is actually used
452
561
 
@@ -595,7 +704,7 @@ def WithTracing(
595
704
  if is_async_generator:
596
705
  @wraps(fn)
597
706
  async def async_gen_traced_fn(*args, **kwargs):
598
- input_data = _prepare_and_filter_input(args, kwargs, filter_input, ignore_input)
707
+ input_data = _prepare_and_filter_input(args, kwargs, filter_input, ignore_input, fn_sig)
599
708
  return await _execute_generator_async(
600
709
  lambda: fn(*args, **kwargs),
601
710
  input_data
@@ -607,7 +716,7 @@ def WithTracing(
607
716
  elif is_generator:
608
717
  @wraps(fn)
609
718
  def gen_traced_fn(*args, **kwargs):
610
- input_data = _prepare_and_filter_input(args, kwargs, filter_input, ignore_input)
719
+ input_data = _prepare_and_filter_input(args, kwargs, filter_input, ignore_input, fn_sig)
611
720
  return _execute_generator_sync(
612
721
  lambda: fn(*args, **kwargs),
613
722
  input_data
@@ -619,7 +728,7 @@ def WithTracing(
619
728
  elif is_async:
620
729
  @wraps(fn)
621
730
  async def async_traced_fn(*args, **kwargs):
622
- input_data = _prepare_and_filter_input(args, kwargs, filter_input, ignore_input)
731
+ input_data = _prepare_and_filter_input(args, kwargs, filter_input, ignore_input, fn_sig)
623
732
  return await _execute_with_span_async(
624
733
  lambda: fn(*args, **kwargs),
625
734
  input_data
@@ -631,7 +740,7 @@ def WithTracing(
631
740
  else:
632
741
  @wraps(fn)
633
742
  def sync_traced_fn(*args, **kwargs):
634
- input_data = _prepare_and_filter_input(args, kwargs, filter_input, ignore_input)
743
+ input_data = _prepare_and_filter_input(args, kwargs, filter_input, ignore_input, fn_sig)
635
744
  return _execute_with_span_sync(
636
745
  lambda: fn(*args, **kwargs),
637
746
  input_data
@@ -648,477 +757,4 @@ def WithTracing(
648
757
  return decorator(func)
649
758
 
650
759
 
651
- def set_span_attribute(attribute_name: str, attribute_value: Any) -> bool:
652
- """
653
- Set an attribute on the active span.
654
-
655
- Returns:
656
- True if attribute was set, False if no active span found
657
- """
658
- span = trace.get_current_span()
659
- if span and span.is_recording():
660
- span.set_attribute(attribute_name, serialize_for_span(attribute_value))
661
- return True
662
- return False
663
-
664
- def set_span_name(span_name: str) -> bool:
665
- """
666
- Set the name of the active span.
667
- """
668
- span = trace.get_current_span()
669
- if span and span.is_recording():
670
- span.update_name(span_name)
671
- return True
672
- return False
673
-
674
- def get_active_span() -> Optional[trace.Span]:
675
- """Get the currently active span."""
676
- return trace.get_current_span()
677
-
678
-
679
- def set_conversation_id(conversation_id: str) -> bool:
680
- """
681
- Set the gen_ai.conversation.id attribute on the active span.
682
- This allows you to group multiple traces together that are part of the same conversation.
683
- See https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-events/ for more details.
684
-
685
- Args:
686
- conversation_id: A unique identifier for the conversation (e.g., user session ID, chat ID, etc.)
687
-
688
- Returns:
689
- True if gen_ai.conversation.id was set, False if no active span found
690
-
691
- Example:
692
- from aiqa import WithTracing, set_conversation_id
693
-
694
- @WithTracing
695
- def handle_user_request(user_id: str, request: dict):
696
- # Set conversation ID to group all traces for this user session
697
- set_conversation_id(f"user_{user_id}_session_{request.get('session_id')}")
698
- # ... rest of function
699
- """
700
- return set_span_attribute("gen_ai.conversation.id", conversation_id)
701
-
702
-
703
- def set_token_usage(
704
- input_tokens: Optional[int] = None,
705
- output_tokens: Optional[int] = None,
706
- total_tokens: Optional[int] = None,
707
- ) -> bool:
708
- """
709
- Set token usage attributes on the active span using OpenTelemetry semantic conventions for gen_ai.
710
- This allows you to explicitly record token usage information.
711
- AIQA tracing will automatically detect and set token usage from standard OpenAI-like API responses.
712
- See https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ for more details.
713
-
714
- Args:
715
- input_tokens: Number of input tokens used (maps to gen_ai.usage.input_tokens)
716
- output_tokens: Number of output tokens generated (maps to gen_ai.usage.output_tokens)
717
- total_tokens: Total number of tokens used (maps to gen_ai.usage.total_tokens)
718
-
719
- Returns:
720
- True if at least one token usage attribute was set, False if no active span found
721
-
722
- Example:
723
- from aiqa import WithTracing, set_token_usage
724
-
725
- @WithTracing
726
- def call_llm(prompt: str):
727
- response = openai_client.chat.completions.create(...)
728
- # Explicitly set token usage
729
- set_token_usage(
730
- input_tokens=response.usage.prompt_tokens,
731
- output_tokens=response.usage.completion_tokens,
732
- total_tokens=response.usage.total_tokens
733
- )
734
- return response
735
- """
736
- span = trace.get_current_span()
737
- if not span or not span.is_recording():
738
- return False
739
-
740
- set_count = 0
741
- try:
742
- if input_tokens is not None:
743
- span.set_attribute("gen_ai.usage.input_tokens", input_tokens)
744
- set_count += 1
745
- if output_tokens is not None:
746
- span.set_attribute("gen_ai.usage.output_tokens", output_tokens)
747
- set_count += 1
748
- if total_tokens is not None:
749
- span.set_attribute("gen_ai.usage.total_tokens", total_tokens)
750
- set_count += 1
751
- except Exception as e:
752
- logger.warning(f"Failed to set token usage attributes: {e}")
753
- return False
754
-
755
- return set_count > 0
756
-
757
-
758
- def set_provider_and_model(
759
- provider: Optional[str] = None,
760
- model: Optional[str] = None,
761
- ) -> bool:
762
- """
763
- Set provider and model attributes on the active span using OpenTelemetry semantic conventions for gen_ai.
764
- This allows you to explicitly record provider and model information.
765
- AIQA tracing will automatically detect and set provider/model from standard API responses.
766
- See https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ for more details.
767
-
768
- Args:
769
- provider: Name of the AI provider (e.g., "openai", "anthropic", "google") (maps to gen_ai.provider.name)
770
- model: Name of the model used (e.g., "gpt-4", "claude-3-5-sonnet") (maps to gen_ai.request.model)
771
-
772
- Returns:
773
- True if at least one attribute was set, False if no active span found
774
-
775
- Example:
776
- from aiqa import WithTracing, set_provider_and_model
777
-
778
- @WithTracing
779
- def call_llm(prompt: str):
780
- response = openai_client.chat.completions.create(...)
781
- # Explicitly set provider and model
782
- set_provider_and_model(
783
- provider="openai",
784
- model=response.model
785
- )
786
- return response
787
- """
788
- span = trace.get_current_span()
789
- if not span or not span.is_recording():
790
- return False
791
-
792
- set_count = 0
793
- try:
794
- if provider is not None:
795
- span.set_attribute("gen_ai.provider.name", str(provider))
796
- set_count += 1
797
- if model is not None:
798
- span.set_attribute("gen_ai.request.model", str(model))
799
- set_count += 1
800
- except Exception as e:
801
- logger.warning(f"Failed to set provider/model attributes: {e}")
802
- return False
803
-
804
- return set_count > 0
805
-
806
-
807
- def set_component_tag(tag: str) -> None:
808
- """
809
- Set the component tag that will be added to all spans created by AIQA.
810
- This can also be set via the AIQA_COMPONENT_TAG environment variable.
811
- The component tag allows you to identify which component/system generated the spans.
812
-
813
- Note: Initialization is automatic when WithTracing is first used. You can also call
814
- get_aiqa_client() explicitly if needed.
815
- the client and load environment variables.
816
-
817
- Args:
818
- tag: A component identifier (e.g., "mynamespace.mysystem", "backend.api", etc.)
819
-
820
- Example:
821
- from aiqa import get_aiqa_client, set_component_tag, WithTracing
822
-
823
- # Initialize client (loads env vars including AIQA_COMPONENT_TAG)
824
- get_aiqa_client()
825
-
826
- # Or set component tag programmatically (overrides env var)
827
- set_component_tag("mynamespace.mysystem")
828
-
829
- @WithTracing
830
- def my_function():
831
- pass
832
- """
833
- _set_component_tag(tag)
834
-
835
-
836
- def get_active_trace_id() -> Optional[str]:
837
- """
838
- Get the current trace ID as a hexadecimal string (32 characters).
839
-
840
- Returns:
841
- The trace ID as a hex string, or None if no active span exists.
842
-
843
- Example:
844
- trace_id = get_active_trace_id()
845
- # Pass trace_id to another service/agent
846
- # e.g., include in HTTP headers, message queue metadata, etc.
847
- # Within a single thread, OpenTelemetry normally does this for you.
848
- """
849
- span = trace.get_current_span()
850
- if span and span.get_span_context().is_valid:
851
- return format(span.get_span_context().trace_id, "032x")
852
- return None
853
-
854
-
855
- def get_span_id() -> Optional[str]:
856
- """
857
- Get the current span ID as a hexadecimal string (16 characters).
858
-
859
- Returns:
860
- The span ID as a hex string, or None if no active span exists.
861
-
862
- Example:
863
- span_id = get_span_id()
864
- # Can be used to create child spans in other services
865
- """
866
- span = trace.get_current_span()
867
- if span and span.get_span_context().is_valid:
868
- return format(span.get_span_context().span_id, "016x")
869
- return None
870
-
871
-
872
- def create_span_from_trace_id(
873
- trace_id: str,
874
- parent_span_id: Optional[str] = None,
875
- span_name: str = "continued_span",
876
- ) -> trace.Span:
877
- """
878
- Create a new span that continues from an existing trace ID.
879
- This is useful for linking traces across different services or agents.
880
-
881
- Args:
882
- trace_id: The trace ID as a hexadecimal string (32 characters)
883
- parent_span_id: Optional parent span ID as a hexadecimal string (16 characters).
884
- If provided, the new span will be a child of this span.
885
- span_name: Name for the new span (default: "continued_span")
886
-
887
- Returns:
888
- A new span that continues the trace. Use it in a context manager or call end() manually.
889
-
890
- Example:
891
- # In service A: get trace ID
892
- trace_id = get_active_trace_id()
893
- span_id = get_span_id()
894
-
895
- # Send to service B (e.g., via HTTP, message queue, etc.)
896
- # ...
897
-
898
- # In service B: continue the trace
899
- with create_span_from_trace_id(trace_id, parent_span_id=span_id, span_name="service_b_operation"):
900
- # Your code here
901
- pass
902
- """
903
- try:
904
- # Parse trace ID from hex string
905
- trace_id_int = int(trace_id, 16)
906
-
907
- # Parse parent span ID if provided
908
- parent_span_id_int = None
909
- if parent_span_id:
910
- parent_span_id_int = int(parent_span_id, 16)
911
-
912
- # Create a parent span context
913
- parent_span_context = SpanContext(
914
- trace_id=trace_id_int,
915
- span_id=parent_span_id_int if parent_span_id_int else 0,
916
- is_remote=True,
917
- trace_flags=TraceFlags(0x01), # SAMPLED flag
918
- )
919
-
920
- # Create a context with this span context as the parent
921
- from opentelemetry.trace import set_span_in_context
922
- parent_context = set_span_in_context(trace.NonRecordingSpan(parent_span_context))
923
-
924
- # Ensure initialization before creating span
925
- get_aiqa_client()
926
- # Start a new span in this context (it will be a child of the parent span)
927
- tracer = get_aiqa_tracer()
928
- span = tracer.start_span(span_name, context=parent_context)
929
-
930
- # Set component tag if configured
931
- component_tag = get_component_tag()
932
- if component_tag:
933
- span.set_attribute("gen_ai.component.id", component_tag)
934
-
935
- return span
936
- except (ValueError, AttributeError) as e:
937
- logger.error(f"Error creating span from trace_id: {e}")
938
- # Ensure initialization before creating span
939
- get_aiqa_client()
940
- # Fallback: create a new span
941
- tracer = get_aiqa_tracer()
942
- span = tracer.start_span(span_name)
943
- component_tag = get_component_tag()
944
- if component_tag:
945
- span.set_attribute("gen_ai.component.id", component_tag)
946
- return span
947
-
948
-
949
- def inject_trace_context(carrier: dict) -> None:
950
- """
951
- Inject the current trace context into a carrier (e.g., HTTP headers).
952
- This allows you to pass trace context to another service.
953
-
954
- Args:
955
- carrier: Dictionary to inject trace context into (e.g., HTTP headers dict)
956
-
957
- Example:
958
- import requests
959
-
960
- headers = {}
961
- inject_trace_context(headers)
962
- response = requests.get("http://other-service/api", headers=headers)
963
- """
964
- try:
965
- inject(carrier)
966
- except Exception as e:
967
- logger.warning(f"Error injecting trace context: {e}")
968
-
969
-
970
- def extract_trace_context(carrier: dict) -> Any:
971
- """
972
- Extract trace context from a carrier (e.g., HTTP headers).
973
- Use this to continue a trace that was started in another service.
974
-
975
- Args:
976
- carrier: Dictionary containing trace context (e.g., HTTP headers dict)
977
-
978
- Returns:
979
- A context object that can be used with trace.use_span() or tracer.start_span()
980
-
981
- Example:
982
- from opentelemetry.trace import use_span
983
-
984
- # Extract context from incoming request headers
985
- ctx = extract_trace_context(request.headers)
986
-
987
- # Use the context to create a span
988
- with use_span(ctx):
989
- # Your code here
990
- pass
991
-
992
- # Or create a span with the context
993
- tracer = get_aiqa_tracer()
994
- with tracer.start_as_current_span("operation", context=ctx):
995
- # Your code here
996
- pass
997
- """
998
- try:
999
- return extract(carrier)
1000
- except Exception as e:
1001
- logger.warning(f"Error extracting trace context: {e}")
1002
- return None
1003
-
1004
-
1005
- def get_span(span_id: str, organisation_id: Optional[str] = None, exclude: Optional[List[str]] = None) -> Optional[dict]:
1006
- """
1007
- Get a span by its ID from the AIQA server.
1008
-
1009
- 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).
1010
-
1011
- Args:
1012
- span_id: The span ID as a hexadecimal string (16 characters) or client span ID
1013
- organisation_id: Optional organisation ID. If not provided, will try to get from
1014
- AIQA_ORGANISATION_ID environment variable. The organisation is typically
1015
- extracted from the API key during authentication, but the API requires it
1016
- as a query parameter.
1017
- exclude: Optional list of fields to exclude from the span data. By default this function WILL return 'attributes' (often large).
1018
-
1019
- Returns:
1020
- The span data as a dictionary, or None if not found
1021
-
1022
- Example:
1023
- from aiqa import get_span
1024
-
1025
- span = get_span('abc123...')
1026
- if span:
1027
- print(f"Found span: {span['name']}")
1028
- my_function(**span['input'])
1029
- """
1030
- import os
1031
- import requests
1032
-
1033
- server_url = get_server_url()
1034
- api_key = get_api_key()
1035
- org_id = organisation_id or os.getenv("AIQA_ORGANISATION_ID", "")
1036
-
1037
- if not server_url:
1038
- raise ValueError("AIQA_SERVER_URL is not set. Cannot retrieve span.")
1039
- if not org_id:
1040
- raise ValueError("Organisation ID is required. Provide it as parameter or set AIQA_ORGANISATION_ID environment variable.")
1041
- if not api_key:
1042
- raise ValueError("API key is required. Set AIQA_API_KEY environment variable.")
1043
-
1044
- # Try both spanId and clientSpanId queries
1045
- for query_field in ["spanId", "clientSpanId"]:
1046
- url = f"{server_url}/span"
1047
- params = {
1048
- "q": f"{query_field}:{span_id}",
1049
- "organisation": org_id,
1050
- "limit": "1",
1051
- "exclude": ",".join(exclude) if exclude else None,
1052
- "fields": "*" if not exclude else None,
1053
- }
1054
-
1055
- headers = build_headers(api_key)
1056
-
1057
- response = requests.get(url, params=params, headers=headers)
1058
- if response.status_code == 200:
1059
- result = response.json()
1060
- hits = result.get("hits", [])
1061
- if hits and len(hits) > 0:
1062
- return hits[0]
1063
- elif response.status_code == 404:
1064
- # Try next query field
1065
- continue
1066
- else:
1067
- error_text = response.text
1068
- raise ValueError(f"Failed to get span: {response.status_code} - {error_text[:500]}")
1069
- # not found
1070
- return None
1071
-
1072
-
1073
- async def submit_feedback(
1074
- trace_id: str,
1075
- thumbs_up: Optional[bool] = None,
1076
- comment: Optional[str] = None,
1077
- ) -> None:
1078
- """
1079
- Submit feedback for a trace by creating a new span with the same trace ID.
1080
- This allows you to add feedback (thumbs-up, thumbs-down, comment) to a trace after it has completed.
1081
-
1082
- Args:
1083
- trace_id: The trace ID as a hexadecimal string (32 characters)
1084
- thumbs_up: True for positive feedback, False for negative feedback, None for neutral
1085
- comment: Optional text comment
1086
-
1087
- Example:
1088
- from aiqa import submit_feedback
1089
-
1090
- # Submit positive feedback
1091
- await submit_feedback('abc123...', thumbs_up=True, comment='Great response!')
1092
-
1093
- # Submit negative feedback
1094
- await submit_feedback('abc123...', thumbs_up=False, comment='Incorrect answer')
1095
- """
1096
- if not trace_id or len(trace_id) != 32:
1097
- raise ValueError('Invalid trace ID: must be 32 hexadecimal characters')
1098
-
1099
- # Create a span for feedback with the same trace ID
1100
- span = create_span_from_trace_id(trace_id, span_name='feedback')
1101
-
1102
- try:
1103
- # Set feedback attributes
1104
- if thumbs_up is not None:
1105
- span.set_attribute('feedback.thumbs_up', thumbs_up)
1106
- span.set_attribute('feedback.type', 'positive' if thumbs_up else 'negative')
1107
- else:
1108
- span.set_attribute('feedback.type', 'neutral')
1109
-
1110
- if comment:
1111
- span.set_attribute('feedback.comment', comment)
1112
-
1113
- # Mark as feedback span
1114
- span.set_attribute('aiqa.span_type', 'feedback')
1115
-
1116
- # End the span
1117
- span.end()
1118
-
1119
- # Flush to ensure it's sent immediately
1120
- await flush_tracing()
1121
- except Exception as e:
1122
- span.end()
1123
- raise e
1124
760