diff --git a/examples/tracing/quotient_trace_openai.py b/examples/tracing/quotient_trace_openai.py index 2857a5e..d3f36d3 100644 --- a/examples/tracing/quotient_trace_openai.py +++ b/examples/tracing/quotient_trace_openai.py @@ -5,15 +5,15 @@ from quotientai import QuotientAI quotient = QuotientAI() + quotient.tracer.init( - app_name="openinference_test_openai", + app_name="quotient-trace-openai", environment="local", instruments=[OpenAIInstrumentor()], ) - @quotient.trace() -def test_openai(): +def main(): client = openai.OpenAI() response = client.chat.completions.create( model="gpt-3.5-turbo", @@ -29,4 +29,4 @@ def test_openai(): if __name__ == "__main__": - test_openai() + main() \ No newline at end of file diff --git a/examples/tracing/quotient_trace_openai_lazyinit.py b/examples/tracing/quotient_trace_openai_lazyinit.py new file mode 100644 index 0000000..e811fce --- /dev/null +++ b/examples/tracing/quotient_trace_openai_lazyinit.py @@ -0,0 +1,59 @@ +import os +import openai + +from openinference.instrumentation.openai import OpenAIInstrumentor + +from quotientai import QuotientAI + +# Initialize with lazy_init=True to avoid errors if API key is not available at build time +quotient = QuotientAI(lazy_init=True) + +# Apply decorator at module level - it will be a no-op until client is configured +@quotient.trace() +def test_openai(): + client = openai.OpenAI() + response = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Write a haiku."}], + max_tokens=20, + stream=True, + stream_options={"include_usage": True}, + ) + + for chunk in response: + if chunk.choices and (content := chunk.choices[0].delta.content): + print(content, end="") + + +def setup_quotient(): + """Configure QuotientAI at runtime when API key is available.""" + # Get API key from environment + quotient_api_key = os.environ.get("QUOTIENT_API_KEY") + + if not quotient_api_key: + print("Warning: QUOTIENT_API_KEY not found. Tracing will be disabled.") + return False + + # Configure the client with the API key + quotient.configure(quotient_api_key) + + # Initialize the tracer with instruments + quotient.tracer.init( + app_name="quotient-trace-openai-lazyinit", + environment="dev", + instruments=[OpenAIInstrumentor()], + ) + + print("QuotientAI tracing configured successfully.") + return True + + +if __name__ == "__main__": + tracing_enabled = setup_quotient() + print( + "Running OpenAI test with", + "tracing enabled" if tracing_enabled else "tracing disabled", + ) + print("=" * 50) + + test_openai() \ No newline at end of file diff --git a/examples/tracing/quotient_trace_qdrant.py b/examples/tracing/quotient_trace_qdrant.py index de8c13c..b06fe96 100644 --- a/examples/tracing/quotient_trace_qdrant.py +++ b/examples/tracing/quotient_trace_qdrant.py @@ -23,7 +23,7 @@ # Initialize QuotientAI client -quotient = QuotientAI() +quotient = QuotientAI(lazy=True) # Initialize tracing with Qdrant instrumentor quotient.tracer.init( diff --git a/quotientai/client.py b/quotientai/client.py index 0171bba..6c7788e 100644 --- a/quotientai/client.py +++ b/quotientai/client.py @@ -138,11 +138,15 @@ def _get( return self._handle_response(response) @handle_errors - def _post(self, path: str, data: dict = {}, timeout: int = None) -> dict: + def _post( + self, path: str, data: Optional[dict] = None, timeout: int = None + ) -> dict: """Send a POST request to the specified path.""" self._update_auth_header() - if isinstance(data, dict): + if data is None: + data = {} + elif isinstance(data, dict): data = {k: v for k, v in data.items() if v is not None} elif isinstance(data, list): data = [v for v in data if v is not None] @@ -155,11 +159,19 @@ def _post(self, path: str, data: dict = {}, timeout: int = None) -> dict: return self._handle_response(response) @handle_errors - def _patch(self, path: str, data: dict = {}, timeout: int = None) -> dict: + def _patch( + self, path: str, data: Optional[dict] = None, timeout: int = None + ) -> dict: """Send a PATCH request to the specified path.""" self._update_auth_header() - data = {k: v for k, v in data.items() if v is not None} + if data is None: + data = {} + elif isinstance(data, dict): + data = {k: v for k, v in data.items() if v is not None} + elif isinstance(data, list): + data = [v for v in data if v is not None] + response = self.patch( url=path, json=data, @@ -456,13 +468,17 @@ class QuotientTracer: This class handles both configuration (via init) and tracing. """ - def __init__(self, tracing_resource: TracingResource): + def __init__( + self, tracing_resource: Optional[TracingResource], lazy_init: bool = False + ): self.tracing_resource = tracing_resource self.app_name: Optional[str] = None self.environment: Optional[str] = None self.metadata: Optional[Dict[str, Any]] = None self.instruments: Optional[List[Any]] = None self.detections: Optional[List[str]] = None + self.lazy_init = lazy_init + self._configured = False def init( @@ -481,13 +497,14 @@ def init( self.environment = environment self.instruments = instruments self.detections = detections - # Configure the underlying tracing resource - self.tracing_resource.configure( - app_name=app_name, - environment=environment, - instruments=instruments, - detections=detections, - ) + + # Configure the underlying tracing resource (if available) + if self.tracing_resource: + self.tracing_resource.configure( + app_name=app_name, + environment=environment, + instruments=instruments, + ) self._configured = True @@ -508,21 +525,73 @@ def my_function(): async def my_async_function(): pass """ - if not self._configured: - logger.error( - f"tracer is not configured. Please call init() before tracing." - ) - return lambda func: func + # For lazy_init, return a lazy decorator that checks configuration at execution time + if self.lazy_init: + return self._create_lazy_decorator(name) + else: + # For non-lazy_init, use the original behavior + if not self.tracing_resource: + logger.error( + "tracer is not configured. Please call init() before tracing." + ) + return lambda func: func + # Warn if not configured but still allow tracing since resource is available + if not self._configured: + logger.warning( + "tracer is not explicitly configured. Consider calling tracer.init() for full configuration." + ) + + # Call the tracing resource without parameters since it's now configured + return self.tracing_resource.trace(name) - # Call the tracing resource without parameters since it's now configured - return self.tracing_resource.trace(name) + def _create_lazy_decorator(self, name: Optional[str] = None): + """ + Create a lazy decorator that defers the tracing decision until function execution. + This allows decorators to be applied before the client is configured. + """ + import functools + import inspect + + def decorator(func): + @functools.wraps(func) + def sync_wrapper(*args, **kwargs): + # Check configuration at execution time, not decoration time + if self._configured and self.tracing_resource: + # Get the actual decorator from the tracing resource + actual_decorator = self.tracing_resource.trace(name) + # Apply it to the function and call immediately + return actual_decorator(func)(*args, **kwargs) + else: + # No tracing - just call the function + return func(*args, **kwargs) + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + # Check configuration at execution time, not decoration time + if self._configured and self.tracing_resource: + # Get the actual decorator from the tracing resource + actual_decorator = self.tracing_resource.trace(name) + # Apply it to the function and call immediately + return await actual_decorator(func)(*args, **kwargs) + else: + # No tracing - just call the function + return await func(*args, **kwargs) + + # Return the appropriate wrapper based on whether the function is async + if inspect.iscoroutinefunction(func): + return async_wrapper + else: + return sync_wrapper + + return decorator def force_flush(self): """ Force flush all pending spans to the collector. This is useful for debugging and ensuring spans are sent immediately. """ - self.tracing_resource.force_flush() + if self.tracing_resource: + self.tracing_resource.force_flush() class QuotientAI: @@ -535,34 +604,96 @@ class QuotientAI: Args: api_key (Optional[str]): The API key to use for authentication. If not provided, will attempt to read from QUOTIENT_API_KEY environment variable. + lazy_init (bool): If True, defer authentication until first use. Useful for + environments where API keys aren't available at build time. """ - def __init__(self, api_key: Optional[str] = None): + def __init__(self, api_key: Optional[str] = None, lazy_init: bool = False): self.api_key = api_key or os.environ.get("QUOTIENT_API_KEY") - if not self.api_key: + self.lazy_init = lazy_init + self._initialized = False + self._initialization_error = None + + # Initialize resources as None initially + self.auth = None + self.logs = None + self.tracing = None + self.logger = None + + # Always create a tracer instance for lazy_init mode to avoid decorator errors + if lazy_init: + # Create a minimal tracer instance that can handle decorators + self.tracer = QuotientTracer(None, lazy_init=True) + else: + self.tracer = None + + if not lazy_init: + self._ensure_initialized() + + def _ensure_initialized(self): + """Ensure the client is properly initialized with resources and authentication.""" + if self._initialized: + return + + if self._initialization_error: logger.error( + f"Previous initialization failed: {self._initialization_error}" + ) + return + + # Try to get API key if not already set + if not self.api_key: + self.api_key = os.environ.get("QUOTIENT_API_KEY") + + if not self.api_key: + error_msg = ( "could not find API key. either pass api_key to QuotientAI() or " "set the QUOTIENT_API_KEY environment variable. " f"if you do not have an API key, you can create one at https://app.quotientai.co in your settings page" ) + logger.error(error_msg) + self._initialization_error = error_msg + return - _client = _BaseQuotientClient(self.api_key) - self.auth = resources.AuthResource(_client) - self.logs = resources.LogsResource(_client) - self.tracing = resources.TracingResource(_client) + try: + _client = _BaseQuotientClient(self.api_key) + self.auth = resources.AuthResource(_client) + self.logs = resources.LogsResource(_client) + self.tracing = resources.TracingResource(_client) - # Create an unconfigured logger instance. - self.logger = QuotientLogger(self.logs) - self.tracer = QuotientTracer(self.tracing) + # Create an unconfigured logger instance. + self.logger = QuotientLogger(self.logs) + + # Update tracer with the actual tracing resource if it was created in lazy mode + if self.lazy_init and self.tracer: + self.tracer.tracing_resource = self.tracing + else: + self.tracer = QuotientTracer(self.tracing, lazy_init=self.lazy_init) - try: self.auth.authenticate() + self._initialized = True except Exception as e: - logger.error( + error_msg = ( "If you are seeing this error, please check that your API key is correct.\n" f"If the issue persists, please contact support@quotientai.co\n{traceback.format_exc()}" ) - return None + logger.error(error_msg) + self._initialization_error = str(e) + return + + def configure(self, api_key: str): + """ + Configure the client with an API key at runtime. + This is useful for environments where the API key is not available at build time. + + Args: + api_key: The API key to use for authentication + """ + self.api_key = api_key + self._initialized = False + self._initialization_error = None + self._ensure_initialized() + return self def log( self, @@ -606,6 +737,11 @@ def log( Returns: Log ID if successful, None otherwise """ + self._ensure_initialized() + if not self._initialized or not self.logger: + logger.error("Client not properly initialized. Cannot log.") + return None + if not self.logger._configured: logger.error( "logger must be initialized with valid inputs before using log()." @@ -722,6 +858,13 @@ def log( def trace(self, name: Optional[str] = None): """Direct access to the tracer's trace decorator.""" + self._ensure_initialized() + if not self._initialized or not self.tracer: + # Return a no-op decorator if not initialized + def no_op_decorator(func): + return func + + return no_op_decorator return self.tracer.trace(name) def poll_for_detection( @@ -735,6 +878,11 @@ def poll_for_detection( timeout: Maximum time to wait for results in seconds (default: 300s/5min) poll_interval: How often to poll the API in seconds (default: 2s) """ + self._ensure_initialized() + if not self._initialized or not self.logger: + logger.error("Client not properly initialized. Cannot poll for detection.") + return None + if not self.logger._configured: logger.error( "Logger is not configured. Please call quotient.logger.init() before using poll_for_detection()." @@ -755,4 +903,8 @@ def force_flush(self): Force flush all pending spans to the collector. This is useful for debugging and ensuring spans are sent immediately. """ + self._ensure_initialized() + if not self._initialized or not self.tracer: + logger.warning("Client not properly initialized. Cannot force flush.") + return self.tracer.force_flush() diff --git a/quotientai/tracing/core.py b/quotientai/tracing/core.py index 3987dc1..b071fdc 100644 --- a/quotientai/tracing/core.py +++ b/quotientai/tracing/core.py @@ -4,16 +4,14 @@ import json import os import atexit -import weakref import time from enum import Enum from typing import Optional -from opentelemetry import context as otel_context from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.trace import TracerProvider, SpanProcessor, Span +from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.resources import Resource @@ -162,6 +160,13 @@ def _setup_auto_collector( Automatically setup OTLP exporter to send traces to collector """ try: + # Check if we have a valid API key + if not hasattr(self._client, "api_key") or not self._client.api_key: + logger.warning( + "No API key available - skipping tracing setup. This is normal at build time." + ) + return + # Check if tracer provider is already set up current_provider = get_tracer_provider() @@ -231,7 +236,9 @@ def _setup_auto_collector( ) except Exception as e: - logger.error(f"Failed to setup tracing: {str(e)}") + logger.warning( + f"Failed to setup tracing: {str(e)} - continuing without tracing" + ) # Fallback to no-op tracer self.tracer = None