From 64517d1ae24a31cb9592a6c2bc1c36b42abe3410 Mon Sep 17 00:00:00 2001 From: Gustavo Cid Date: Tue, 12 May 2026 14:41:18 -0300 Subject: [PATCH] chore(closes OPEN-10363): clean up trace configuration --- .../tracing/programmatic_configuration.py | 126 +++---- src/openlayer/lib/__init__.py | 4 + .../lib/integrations/langchain_callback.py | 12 +- src/openlayer/lib/tracing/__init__.py | 4 + .../lib/tracing/attachment_uploader.py | 4 +- src/openlayer/lib/tracing/tracer.py | 354 +++++++++++------- tests/test_offline_buffering.py | 64 ++-- tests/test_tracer_configuration.py | 339 ++++++++++------- tests/test_tracing_core.py | 64 +--- 9 files changed, 550 insertions(+), 421 deletions(-) diff --git a/examples/tracing/programmatic_configuration.py b/examples/tracing/programmatic_configuration.py index a8c22396..57ff9045 100644 --- a/examples/tracing/programmatic_configuration.py +++ b/examples/tracing/programmatic_configuration.py @@ -1,31 +1,33 @@ """ -Example: Programmatic Configuration for Openlayer Tracing +Example: Configuring the Openlayer Tracer -This example demonstrates how to configure Openlayer tracing programmatically -using the configure() function, instead of relying on environment variables. +Demonstrates the three ways to configure the tracer and how they compose: + 1. Environment variables (canonical for deployments) + 2. init() — programmatic, idempotent, merges on repeated calls + 3. Per-decorator override via @trace(inference_pipeline_id=...) + +Precedence (highest first): + decorator argument > init() > environment variable > default + +Also shows the deprecated configure() alias, kept for backward compatibility. """ import os import openai -from openlayer.lib import configure, trace, trace_openai +from openlayer.lib import init, configure, get_tracer_config, trace, trace_openai def example_environment_variables(): - """Traditional approach using environment variables.""" + """Canonical deployment path — env vars only, no code changes needed.""" print("=== Environment Variables Approach ===") - # Set environment variables (traditional approach) os.environ["OPENLAYER_API_KEY"] = "your_openlayer_api_key_here" os.environ["OPENLAYER_INFERENCE_PIPELINE_ID"] = "your_pipeline_id_here" os.environ["OPENAI_API_KEY"] = "your_openai_api_key_here" - # Use the @trace decorator @trace() def generate_response(query: str) -> str: - """Generate a response using OpenAI.""" - # Configure OpenAI client and trace it client = trace_openai(openai.OpenAI()) - response = client.chat.completions.create( model="gpt-3.5-turbo", messages=[{"role": "user", "content": query}], @@ -33,32 +35,24 @@ def generate_response(query: str) -> str: ) return response.choices[0].message.content - # Test the function - result = generate_response("What is machine learning?") - print(f"Response: {result}") + print(f"Response: {generate_response('What is machine learning?')}") -def example_programmatic_configuration(): - """New approach using programmatic configuration.""" - print("\n=== Programmatic Configuration Approach ===") +def example_programmatic_init(): + """Programmatic configuration via init() — preferred for notebooks/apps.""" + print("\n=== Programmatic init() ===") - # Configure Openlayer programmatically - configure( + init( api_key="your_openlayer_api_key_here", inference_pipeline_id="your_pipeline_id_here", - # base_url="https://api.openlayer.com/v1" # Optional: custom base URL + # base_url="https://onprem.example.com", # Optional, for on-prem deployments ) - # Set OpenAI API key os.environ["OPENAI_API_KEY"] = "your_openai_api_key_here" - # Use the @trace decorator (no environment variables needed for Openlayer) @trace() - def generate_response_programmatic(query: str) -> str: - """Generate a response using OpenAI with programmatic configuration.""" - # Configure OpenAI client and trace it + def generate_response(query: str) -> str: client = trace_openai(openai.OpenAI()) - response = client.chat.completions.create( model="gpt-3.5-turbo", messages=[{"role": "user", "content": query}], @@ -66,78 +60,82 @@ def generate_response_programmatic(query: str) -> str: ) return response.choices[0].message.content - # Test the function - result = generate_response_programmatic("What is deep learning?") - print(f"Response: {result}") + print(f"Response: {generate_response('What is deep learning?')}") + + +def example_init_merges_on_repeat(): + """init() is idempotent and merges — safe to call multiple times.""" + print("\n=== init() Merge Semantics ===") + + init(api_key="key-A", inference_pipeline_id="pipeline-A") + # Later in the program, override just one knob — the rest is preserved. + init(inference_pipeline_id="pipeline-B") + + cfg = get_tracer_config() # API key is redacted in the returned dict + print(f"Resolved config: api_key={cfg['api_key']}, pipeline_id={cfg['inference_pipeline_id']}") + # api_key=***, pipeline_id=pipeline-B def example_per_decorator_override(): - """Example showing how to override pipeline ID per decorator.""" - print("\n=== Per-Decorator Pipeline ID Override ===") + """The @trace decorator can override the configured pipeline per-call.""" + print("\n=== Per-Decorator Override ===") - # Configure default settings - configure( - api_key="your_openlayer_api_key_here", - inference_pipeline_id="default_pipeline_id", - ) + init(api_key="your_openlayer_api_key_here", inference_pipeline_id="default_pipeline_id") - # Function using default pipeline ID @trace() def default_pipeline_function(query: str) -> str: return f"Response to: {query}" - # Function using specific pipeline ID (overrides default) @trace(inference_pipeline_id="specific_pipeline_id") def specific_pipeline_function(query: str) -> str: return f"Specific response to: {query}" - # Test both functions - default_pipeline_function("Question 1") # Uses default_pipeline_id - specific_pipeline_function("Question 2") # Uses specific_pipeline_id + default_pipeline_function("Question 1") # default_pipeline_id + specific_pipeline_function("Question 2") # specific_pipeline_id - print("Both functions executed with different pipeline IDs") +def example_mixed_env_and_init(): + """Env var for API key, init() for pipeline — both honored via resolver.""" + print("\n=== Mixed (Env Var + init()) ===") -def example_mixed_configuration(): - """Example showing mixed environment and programmatic configuration.""" - print("\n=== Mixed Configuration Approach ===") - - # Set API key via environment variable os.environ["OPENLAYER_API_KEY"] = "your_openlayer_api_key_here" - - # Set pipeline ID programmatically - configure(inference_pipeline_id="programmatic_pipeline_id") + init(inference_pipeline_id="programmatic_pipeline_id") @trace() def mixed_config_function(query: str) -> str: - """Function using mixed configuration.""" return f"Mixed config response to: {query}" - # Test the function - result = mixed_config_function("What is the best approach?") - print(f"Response: {result}") + print(f"Response: {mixed_config_function('What is the best approach?')}") + + +def example_deprecated_configure(): + """configure() is kept for backward compatibility but is deprecated. + + It now merges (rather than replacing) state and emits a DeprecationWarning + on each call. New code should use init() instead. + """ + print("\n=== Deprecated configure() (still works) ===") + + configure( + api_key="your_openlayer_api_key_here", + inference_pipeline_id="your_pipeline_id_here", + ) if __name__ == "__main__": print("Openlayer Tracing Configuration Examples") print("=" * 50) - - # Note: Replace the placeholder API keys and IDs with real values - print( - "Note: Replace placeholder API keys and pipeline IDs with real values before running." - ) - print() + print("Note: Replace placeholder API keys and pipeline IDs with real values.\n") try: - # Run examples (these will fail without real API keys) example_environment_variables() - example_programmatic_configuration() + example_programmatic_init() + example_init_merges_on_repeat() example_per_decorator_override() - example_mixed_configuration() - + example_mixed_env_and_init() + example_deprecated_configure() except Exception as e: print(f"Example failed (expected with placeholder keys): {e}") print("\nTo run this example successfully:") print("1. Replace placeholder API keys with real values") print("2. Replace pipeline IDs with real Openlayer pipeline IDs") - print("3. Ensure you have valid OpenAI and Openlayer accounts") diff --git a/src/openlayer/lib/__init__.py b/src/openlayer/lib/__init__.py index f5f75bce..35ee2107 100644 --- a/src/openlayer/lib/__init__.py +++ b/src/openlayer/lib/__init__.py @@ -1,7 +1,9 @@ """Openlayer lib.""" __all__ = [ + "init", "configure", + "get_tracer_config", "trace", "trace_anthropic", "trace_openai", @@ -43,7 +45,9 @@ clear_user_session_context, ) +init = tracer.init configure = tracer.configure +get_tracer_config = tracer.get_tracer_config trace = tracer.trace trace_async = tracer.trace_async update_current_trace = tracer.update_current_trace diff --git a/src/openlayer/lib/integrations/langchain_callback.py b/src/openlayer/lib/integrations/langchain_callback.py index 2066b84c..63c1fab4 100644 --- a/src/openlayer/lib/integrations/langchain_callback.py +++ b/src/openlayer/lib/integrations/langchain_callback.py @@ -190,7 +190,7 @@ def _end_step( and tracer.get_current_step() is None ): trace = self._traces_by_root.pop(run_id) - if tracer._configured_background_publish_enabled: + if tracer._resolve("background_publish_enabled"): ctx = contextvars.copy_context() tracer._get_background_executor().submit( ctx.run, self._process_and_upload_trace, trace @@ -249,9 +249,7 @@ def _process_and_upload_trace(self, trace: traces.Trace) -> None: serialized_config = utils.json_serialize(config) client.inference_pipelines.data.stream( - inference_pipeline_id=utils.get_env_variable( - "OPENLAYER_INFERENCE_PIPELINE_ID" - ), + inference_pipeline_id=tracer.resolve_pipeline_id(), rows=[serialized_trace_data], config=serialized_config, ) @@ -1248,7 +1246,7 @@ def _end_step( # Only upload if: root step + has standalone trace + not part of external trace if is_root_step and has_standalone_trace and not self._has_external_trace: trace = self._traces_by_root.pop(run_id) - if tracer._configured_background_publish_enabled: + if tracer._resolve("background_publish_enabled"): ctx = contextvars.copy_context() tracer._get_background_executor().submit( ctx.run, self._process_and_upload_async_trace, trace @@ -1307,9 +1305,7 @@ def _process_and_upload_async_trace(self, trace: traces.Trace) -> None: serialized_config = utils.json_serialize(config) client.inference_pipelines.data.stream( - inference_pipeline_id=utils.get_env_variable( - "OPENLAYER_INFERENCE_PIPELINE_ID" - ), + inference_pipeline_id=tracer.resolve_pipeline_id(), rows=[serialized_trace_data], config=serialized_config, ) diff --git a/src/openlayer/lib/tracing/__init__.py b/src/openlayer/lib/tracing/__init__.py index e10c90dc..9a44e261 100644 --- a/src/openlayer/lib/tracing/__init__.py +++ b/src/openlayer/lib/tracing/__init__.py @@ -4,8 +4,10 @@ from .tracer import ( configure, create_step, + get_tracer_config, get_current_step, get_current_trace, + init, log_attachment, log_context, log_output, @@ -25,7 +27,9 @@ "log_context", "log_output", "log_question", + "init", "configure", + "get_tracer_config", "get_current_trace", "get_current_step", "create_step", diff --git a/src/openlayer/lib/tracing/attachment_uploader.py b/src/openlayer/lib/tracing/attachment_uploader.py index af2eefb3..2d790dd8 100644 --- a/src/openlayer/lib/tracing/attachment_uploader.py +++ b/src/openlayer/lib/tracing/attachment_uploader.py @@ -297,7 +297,7 @@ def get_uploader() -> Optional[AttachmentUploader]: global _uploader from . import tracer - if not tracer._configured_attachment_upload_enabled: + if not tracer._resolve("attachment_upload_enabled"): return None if _uploader is None: @@ -305,7 +305,7 @@ def get_uploader() -> Optional[AttachmentUploader]: if client: _uploader = AttachmentUploader( client, - url_upload_enabled=tracer._configured_url_upload_enabled, + url_upload_enabled=bool(tracer._resolve("url_upload_enabled")), ) return _uploader diff --git a/src/openlayer/lib/tracing/tracer.py b/src/openlayer/lib/tracing/tracer.py index bbbdb271..4dd34a0c 100644 --- a/src/openlayer/lib/tracing/tracer.py +++ b/src/openlayer/lib/tracing/tracer.py @@ -12,6 +12,7 @@ import time import traceback import uuid +import warnings from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from functools import wraps @@ -47,31 +48,65 @@ TRUE_LIST = ["true", "on", "1"] +# Internal/edge-case env vars read once at import time. _publish = utils.get_env_variable("OPENLAYER_DISABLE_PUBLISH") not in TRUE_LIST _verify_ssl = ( utils.get_env_variable("OPENLAYER_VERIFY_SSL") or "true" ).lower() in TRUE_LIST _client = None -# Configuration variables for programmatic setup -_configured_api_key: Optional[str] = None -_configured_pipeline_id: Optional[str] = None -_configured_base_url: Optional[str] = None -_configured_timeout: Optional[Union[int, float]] = None -_configured_max_retries: Optional[int] = None +# Sentinel used by init()/configure() to distinguish "not passed" from +# explicit None (the latter is the "clear configured value" path). +_UNSET: Any = object() -# Offline buffering and callback configuration -_configured_on_flush_failure: Optional[OnFlushFailureCallback] = None -_configured_offline_buffer_enabled: bool = False -_configured_offline_buffer_path: Optional[str] = None -_configured_max_buffer_size: Optional[int] = None +# Holds only what the user has explicitly set via init() (or the deprecated +# configure() alias). Keys present here win over env vars. Missing keys fall +# back to env vars via the resolver helpers below. +_tracer_config: Dict[str, Any] = {} -# Attachment upload configuration -_configured_attachment_upload_enabled: bool = False -_configured_url_upload_enabled: bool = False +# Defaults applied when a key is absent from both _tracer_config and the environment. +# Used only for advanced knobs that have no env-var fallback. +_DEFAULTS: Dict[str, Any] = { + "offline_buffer_enabled": False, + "attachment_upload_enabled": False, + "url_upload_enabled": False, + "background_publish_enabled": True, +} -# Background publishing configuration -_configured_background_publish_enabled: bool = True +# Defined later so _get_client() can emit one INFO log on first build. +_client_init_logged: bool = False + + +def _resolve(key: str, env_var: Optional[str] = None) -> Any: + """Resolve a configured value: _tracer_config > env var > default. + + `_tracer_config` is checked first so an explicit None (via init(api_key=None)) + takes precedence over the env var. Returns None if neither is set and + no default exists. + """ + if key in _tracer_config: + return _tracer_config[key] + if env_var: + env_val = utils.get_env_variable(env_var) + if env_val is not None: + return env_val + return _DEFAULTS.get(key) + + +# Public resolvers for the three user-facing knobs. Every code path that +# needs these (the SDK client, the @trace decorator, integrations like the +# LangChain callback) MUST go through these helpers — never read env vars +# directly. +def resolve_api_key() -> Optional[str]: + return _resolve("api_key", "OPENLAYER_API_KEY") + + +def resolve_pipeline_id() -> Optional[str]: + return _resolve("inference_pipeline_id", "OPENLAYER_INFERENCE_PIPELINE_ID") + + +def resolve_base_url() -> Optional[str]: + return _resolve("base_url", "OPENLAYER_BASE_URL") # Background executor for async trace publishing _background_executor: Optional[ThreadPoolExecutor] = None @@ -99,128 +134,188 @@ def _shutdown_background_executor() -> None: logger.debug("Background executor shutdown complete") -def configure( - api_key: Optional[str] = None, - inference_pipeline_id: Optional[str] = None, - base_url: Optional[str] = None, - timeout: Optional[Union[int, float]] = None, - max_retries: Optional[int] = None, - on_flush_failure: Optional[OnFlushFailureCallback] = None, - offline_buffer_enabled: bool = False, - offline_buffer_path: Optional[str] = None, - max_buffer_size: Optional[int] = None, - attachment_upload_enabled: bool = False, - url_upload_enabled: bool = False, - background_publish_enabled: bool = True, +def init( + api_key: Any = _UNSET, + inference_pipeline_id: Any = _UNSET, + base_url: Any = _UNSET, + timeout: Any = _UNSET, + max_retries: Any = _UNSET, + on_flush_failure: Any = _UNSET, + offline_buffer_enabled: Any = _UNSET, + offline_buffer_path: Any = _UNSET, + max_buffer_size: Any = _UNSET, + attachment_upload_enabled: Any = _UNSET, + url_upload_enabled: Any = _UNSET, + background_publish_enabled: Any = _UNSET, ) -> None: - """Configure the Openlayer tracer with custom settings. + """Initialize and configure the Openlayer tracer. - This function allows you to programmatically set the API key, inference pipeline ID, - base URL, timeout, retry settings, and offline buffering for the Openlayer client, - instead of relying on environment variables. + This is the canonical entry point for programmatic tracer configuration. It is + idempotent and merges with prior state, so it is safe to call multiple times — + only the arguments you pass are updated. Arguments you omit are left untouched. + + The three user-facing knobs (``api_key``, ``inference_pipeline_id``, ``base_url``) + can also be supplied via environment variables (``OPENLAYER_API_KEY``, + ``OPENLAYER_INFERENCE_PIPELINE_ID``, ``OPENLAYER_BASE_URL``). Precedence is: + + explicit value passed here > environment variable > built-in default + + Passing ``None`` explicitly for a key clears the env-var fallback for that key + (the resolver will then return ``None``); omitting an argument preserves the + previously configured value. Args: - api_key: The Openlayer API key. If not provided, falls back to OPENLAYER_API_KEY environment variable. - inference_pipeline_id: The default inference pipeline ID to use for tracing. - If not provided, falls back to OPENLAYER_INFERENCE_PIPELINE_ID environment variable. - base_url: The base URL for the Openlayer API. If not provided, falls back to - OPENLAYER_BASE_URL environment variable or the default. - timeout: The timeout for the Openlayer API in seconds (int or float). Defaults to 60 seconds. - max_retries: The maximum number of retries for failed API requests. Defaults to 2. - on_flush_failure: Optional callback function called when trace data fails to send to Openlayer. - Should accept (trace_data, config, error) as arguments. - offline_buffer_enabled: Enable offline buffering of failed traces. Defaults to False. - offline_buffer_path: Directory path for storing buffered traces. Defaults to ~/.openlayer/buffer. - max_buffer_size: Maximum number of trace files to store in buffer. Defaults to 1000. - attachment_upload_enabled: Enable uploading of attachments (images, audio, etc.) to - Openlayer storage. When enabled, attachments on steps will be uploaded during - trace completion. Defaults to False. - url_upload_enabled: Enable downloading and re-uploading of external URL - attachments to Openlayer storage. When enabled, attachments that reference - external URLs will be fetched and uploaded so the platform has a durable copy. - Requires attachment_upload_enabled to also be True. Defaults to False. - background_publish_enabled: Enable background publishing of traces. When enabled, - attachment uploads and trace publishing happen in a background thread, allowing - the main thread to return immediately. When disabled, tracing is synchronous. - Defaults to True. + api_key: Openlayer API key. Falls back to ``OPENLAYER_API_KEY``. + inference_pipeline_id: Default inference pipeline ID for tracing. Falls back + to ``OPENLAYER_INFERENCE_PIPELINE_ID``. + base_url: Base URL of the Openlayer API (useful for on-prem deployments). + Falls back to ``OPENLAYER_BASE_URL``. + timeout: API request timeout in seconds. Defaults to the SDK client default. + max_retries: Maximum retries for failed API requests. Defaults to the SDK + client default. + on_flush_failure: Optional callback invoked when a trace fails to publish. + Receives ``(trace_data, config, error)``. + offline_buffer_enabled: Buffer failed traces to disk for later replay. + Defaults to False. + offline_buffer_path: Directory for buffered traces. Defaults to + ``~/.openlayer/buffer``. + max_buffer_size: Maximum number of trace files to keep in the buffer. + Defaults to 1000. + attachment_upload_enabled: Upload attachments (images, audio, etc.) attached + to steps. Defaults to False. + url_upload_enabled: When attachment_upload_enabled is True, also fetch + external URL attachments and re-upload them to Openlayer storage so the + platform has a durable copy. Defaults to False. + background_publish_enabled: Publish traces from a background thread so the + main thread returns immediately. Defaults to True. Examples: - >>> import openlayer.lib.tracing.tracer as tracer - >>> # Configure with API key and pipeline ID - >>> tracer.configure(api_key="your_api_key_here", inference_pipeline_id="your_pipeline_id_here") - >>> # Configure with failure callback and offline buffering - >>> def on_failure(trace_data, config, error): - ... print(f"Failed to send trace: {error}") - ... # Could also log to monitoring system, send alerts, etc. - >>> tracer.configure( - ... api_key="your_api_key_here", - ... inference_pipeline_id="your_pipeline_id_here", - ... on_flush_failure=on_failure, - ... offline_buffer_enabled=True, - ... offline_buffer_path="/tmp/openlayer_buffer", - ... max_buffer_size=500, - ... ) - >>> # Configure with attachment uploads enabled - >>> tracer.configure( - ... api_key="your_api_key_here", - ... inference_pipeline_id="your_pipeline_id_here", - ... attachment_upload_enabled=True, - ... ) - >>> # Now use the decorators normally - >>> @tracer.trace() + >>> from openlayer.lib import init, trace + >>> # Minimal: pick up everything from env vars + >>> init() + >>> + >>> # Programmatic: override a couple of values + >>> init(api_key="...", inference_pipeline_id="...") + >>> + >>> # Idempotent / partial update — merges with prior state + >>> init(inference_pipeline_id="other-pipeline") # api_key preserved + >>> + >>> @trace() >>> def my_function(): ... return "result" """ - global _configured_api_key, _configured_pipeline_id, _configured_base_url, _configured_timeout, _configured_max_retries, _client - global _configured_on_flush_failure, _configured_offline_buffer_enabled, _configured_offline_buffer_path, _configured_max_buffer_size, _offline_buffer - global _configured_attachment_upload_enabled, _configured_url_upload_enabled, _configured_background_publish_enabled - - _configured_api_key = api_key - _configured_pipeline_id = inference_pipeline_id - _configured_base_url = base_url - _configured_timeout = timeout - _configured_max_retries = max_retries - _configured_on_flush_failure = on_flush_failure - _configured_offline_buffer_enabled = offline_buffer_enabled - _configured_offline_buffer_path = offline_buffer_path - _configured_max_buffer_size = max_buffer_size - _configured_attachment_upload_enabled = attachment_upload_enabled - _configured_url_upload_enabled = url_upload_enabled - _configured_background_publish_enabled = background_publish_enabled - - # Reset the client and buffer so they get recreated with new configuration + global _client, _offline_buffer + + for key, val in ( + ("api_key", api_key), + ("inference_pipeline_id", inference_pipeline_id), + ("base_url", base_url), + ("timeout", timeout), + ("max_retries", max_retries), + ("on_flush_failure", on_flush_failure), + ("offline_buffer_enabled", offline_buffer_enabled), + ("offline_buffer_path", offline_buffer_path), + ("max_buffer_size", max_buffer_size), + ("attachment_upload_enabled", attachment_upload_enabled), + ("url_upload_enabled", url_upload_enabled), + ("background_publish_enabled", background_publish_enabled), + ): + if val is not _UNSET: + _tracer_config[key] = val + + # Reset the lazily-built client, buffer, and attachment uploader so they + # get rebuilt with the new configuration on next use. _client = None _offline_buffer = None - - # Reset attachment uploader from .attachment_uploader import reset_uploader reset_uploader() +# Track that the configure() deprecation log has been emitted once per process +# so we don't spam logs on repeated calls. +_configure_deprecation_logged: bool = False + + +def configure(**kwargs: Any) -> None: + """Deprecated. Use :func:`openlayer.lib.init` instead. + + Kept as a thin wrapper that emits a ``DeprecationWarning`` and delegates to + :func:`init`. Behavior is identical to calling ``init(**kwargs)``. + + Note: unlike the previous implementation, this no longer resets unspecified + options — partial calls now merge with prior state. To clear a configured + value, pass it explicitly as ``None``. + """ + global _configure_deprecation_logged + warnings.warn( + "openlayer.lib.configure() is deprecated and will be removed in v1.0. " + "Use openlayer.lib.init() instead.", + DeprecationWarning, + stacklevel=2, + ) + if not _configure_deprecation_logged: + logger.info( + "openlayer.lib.configure() is deprecated; use openlayer.lib.init() instead." + ) + _configure_deprecation_logged = True + init(**kwargs) + + +def get_tracer_config() -> Dict[str, Any]: + """Return the currently-resolved tracer configuration. + + Useful for debugging "is my env var being picked up?" type questions. The + API key is redacted. Values are the result of running each knob through the + resolver (configured > env var > default), so what you see here is what the + tracer will actually use on the next request. + """ + api_key = resolve_api_key() + return { + "api_key": "***" if api_key else None, + "inference_pipeline_id": resolve_pipeline_id(), + "base_url": resolve_base_url(), + "timeout": _tracer_config.get("timeout"), + "max_retries": _tracer_config.get("max_retries"), + "on_flush_failure": _tracer_config.get("on_flush_failure"), + "offline_buffer_enabled": _resolve("offline_buffer_enabled"), + "offline_buffer_path": _tracer_config.get("offline_buffer_path"), + "max_buffer_size": _tracer_config.get("max_buffer_size"), + "attachment_upload_enabled": _resolve("attachment_upload_enabled"), + "url_upload_enabled": _resolve("url_upload_enabled"), + "background_publish_enabled": _resolve("background_publish_enabled"), + "publish": _publish, + "verify_ssl": _verify_ssl, + } + + def _get_client() -> Optional[Openlayer]: """Get or create the Openlayer client with lazy initialization.""" - global _client + global _client, _client_init_logged if not _publish: return None if _client is None: - # Lazy initialization - create client when first needed - client_kwargs = {} + # Lazy initialization — only pass through values that have been + # explicitly configured. Env vars (OPENLAYER_API_KEY, OPENLAYER_BASE_URL) + # are picked up by the SDK client itself when these are absent. + client_kwargs: Dict[str, Any] = {} - # Use configured API key if available, otherwise fall back to environment variable - if _configured_api_key is not None: - client_kwargs["api_key"] = _configured_api_key + api_key = resolve_api_key() + if api_key is not None: + client_kwargs["api_key"] = api_key - # Use configured base URL if available, otherwise fall back to environment variable - if _configured_base_url is not None: - client_kwargs["base_url"] = _configured_base_url + base_url = resolve_base_url() + if base_url is not None: + client_kwargs["base_url"] = base_url - if _configured_timeout is not None: - client_kwargs["timeout"] = _configured_timeout + timeout = _tracer_config.get("timeout") + if timeout is not None: + client_kwargs["timeout"] = timeout - if _configured_max_retries is not None: - client_kwargs["max_retries"] = _configured_max_retries + max_retries = _tracer_config.get("max_retries") + if max_retries is not None: + client_kwargs["max_retries"] = max_retries if _verify_ssl: _client = Openlayer(**client_kwargs) @@ -231,6 +326,14 @@ def _get_client() -> Optional[Openlayer]: ), **client_kwargs, ) + + if not _client_init_logged: + logger.info( + "Openlayer tracer initialized (pipeline_id=%s, base_url=%s)", + resolve_pipeline_id(), + base_url or "", + ) + _client_init_logged = True return _client @@ -434,13 +537,14 @@ def _get_offline_buffer() -> Optional[OfflineBuffer]: """Get or create the offline buffer instance.""" global _offline_buffer - if _configured_offline_buffer_enabled and _offline_buffer is None: + enabled = bool(_resolve("offline_buffer_enabled")) + if enabled and _offline_buffer is None: _offline_buffer = OfflineBuffer( - buffer_path=_configured_offline_buffer_path, - max_buffer_size=_configured_max_buffer_size, + buffer_path=_tracer_config.get("offline_buffer_path"), + max_buffer_size=_tracer_config.get("max_buffer_size"), ) - return _offline_buffer if _configured_offline_buffer_enabled else None + return _offline_buffer if enabled else None # ----------------------------- Public API functions ----------------------------- # @@ -588,7 +692,7 @@ def trace( >>> >>> # Set the environment variables >>> os.environ["OPENLAYER_API_KEY"] = "YOUR_OPENLAYER_API_KEY_HERE" - >>> os.environ["OPENLAYER_PROJECT_NAME"] = "YOUR_OPENLAYER_PROJECT_NAME_HERE" + >>> os.environ["OPENLAYER_INFERENCE_PIPELINE_ID"] = "YOUR_PIPELINE_ID_HERE" >>> >>> # Create guardrail instance >>> pii_guardrail = PIIGuardrail(name="PII Protection") @@ -1708,15 +1812,13 @@ def _handle_trace_completion( if isinstance(current_step, steps.ChatCompletionStep): prompt = current_step.inputs.get("prompt") - # Resolve inference_pipeline_id now (while we have access to config) - resolved_pipeline_id = ( - inference_pipeline_id - or _configured_pipeline_id - or utils.get_env_variable("OPENLAYER_INFERENCE_PIPELINE_ID") - ) + # Resolve inference_pipeline_id now (while we have access to config). + # Decorator argument takes priority; otherwise consult the resolver + # which checks init()/configure() state then the env var. + resolved_pipeline_id = inference_pipeline_id or resolve_pipeline_id() if _publish: - if _configured_background_publish_enabled: + if _resolve("background_publish_enabled"): # Submit to background thread pool, copying context so that # contextvars (user_id, session_id, etc.) are preserved. ctx = contextvars.copy_context() @@ -1761,7 +1863,7 @@ def _upload_and_publish_trace( """ try: # Upload attachments before processing trace data - if _configured_attachment_upload_enabled: + if _resolve("attachment_upload_enabled"): try: from .attachment_uploader import upload_trace_attachments @@ -1854,7 +1956,7 @@ def _handle_streaming_failure( """ try: # Call the failure callback if configured (per-trace takes priority over global) - failure_callback = on_flush_failure or _configured_on_flush_failure + failure_callback = on_flush_failure or _tracer_config.get("on_flush_failure") if failure_callback: try: failure_callback(trace_data, config, error) diff --git a/tests/test_offline_buffering.py b/tests/test_offline_buffering.py index 7f047e93..010ffdde 100644 --- a/tests/test_offline_buffering.py +++ b/tests/test_offline_buffering.py @@ -7,9 +7,10 @@ from pathlib import Path from unittest.mock import Mock, patch +from openlayer.lib.tracing import tracer from openlayer.lib.tracing.tracer import ( OfflineBuffer, - configure, + init, get_buffer_status, _get_offline_buffer, clear_offline_buffer, @@ -18,6 +19,14 @@ ) +def _reset_tracer_state() -> None: + """Clear tracer config and lazily-built state. Replacement for the old + bare init() reset call, which no longer resets under merge semantics.""" + tracer._tracer_config.clear() + tracer._client = None + tracer._offline_buffer = None + + class TestOfflineBuffer: """Test cases for the OfflineBuffer class.""" @@ -158,7 +167,7 @@ def teardown_method(self): shutil.rmtree(self.temp_dir, ignore_errors=True) # Reset configuration - configure() + _reset_tracer_state() def test_configure_offline_buffering(self): """Test configuring offline buffering.""" @@ -166,7 +175,7 @@ def test_configure_offline_buffering(self): def failure_callback(trace_data: Dict[str, Any], config: Dict[str, Any], error: Exception) -> None: pass - configure( + init( on_flush_failure=failure_callback, offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, @@ -174,27 +183,20 @@ def failure_callback(trace_data: Dict[str, Any], config: Dict[str, Any], error: ) # Test that configuration was set - from openlayer.lib.tracing.tracer import ( - _configured_max_buffer_size, - _configured_on_flush_failure, - _configured_offline_buffer_path, - _configured_offline_buffer_enabled, - ) - - assert _configured_on_flush_failure == failure_callback - assert _configured_offline_buffer_enabled is True - assert _configured_offline_buffer_path == self.temp_dir - assert _configured_max_buffer_size == 100 + assert tracer._tracer_config["on_flush_failure"] == failure_callback + assert tracer._tracer_config["offline_buffer_enabled"] is True + assert tracer._tracer_config["offline_buffer_path"] == self.temp_dir + assert tracer._tracer_config["max_buffer_size"] == 100 def test_get_offline_buffer_disabled(self): """Test that offline buffer returns None when disabled.""" - configure(offline_buffer_enabled=False) + init(offline_buffer_enabled=False) buffer = _get_offline_buffer() assert buffer is None def test_get_offline_buffer_enabled(self): """Test that offline buffer is created when enabled.""" - configure( + init( offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, ) @@ -221,11 +223,11 @@ def teardown_method(self): import shutil shutil.rmtree(self.temp_dir, ignore_errors=True) - configure() + _reset_tracer_state() def test_handle_streaming_failure_with_callback(self): """Test failure handling with callback only.""" - configure(on_flush_failure=self.mock_failure_callback) + init(on_flush_failure=self.mock_failure_callback) trace_data = {"inferenceId": "test-123"} config = {"output_column_name": "output"} @@ -240,7 +242,7 @@ def test_handle_streaming_failure_with_callback(self): def test_handle_streaming_failure_with_buffer(self): """Test failure handling with offline buffer.""" - configure( + init( offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, ) @@ -264,7 +266,7 @@ def test_handle_streaming_failure_callback_exception(self): def failing_callback(_trace_data: Dict[str, Any], _config: Dict[str, Any], _error: Exception) -> None: raise Exception("Callback error") - configure( + init( on_flush_failure=failing_callback, offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, @@ -296,11 +298,11 @@ def teardown_method(self): import shutil shutil.rmtree(self.temp_dir, ignore_errors=True) - configure() + _reset_tracer_state() def test_get_buffer_status_disabled(self): """Test buffer status when disabled.""" - configure(offline_buffer_enabled=False) + init(offline_buffer_enabled=False) status = get_buffer_status() assert status["enabled"] is False @@ -308,7 +310,7 @@ def test_get_buffer_status_disabled(self): def test_get_buffer_status_enabled(self): """Test buffer status when enabled.""" - configure( + init( offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, ) @@ -321,7 +323,7 @@ def test_get_buffer_status_enabled(self): def test_clear_offline_buffer_disabled(self): """Test clearing buffer when disabled.""" - configure(offline_buffer_enabled=False) + init(offline_buffer_enabled=False) result = clear_offline_buffer() assert result["traces_removed"] == 0 @@ -329,7 +331,7 @@ def test_clear_offline_buffer_disabled(self): def test_clear_offline_buffer_enabled(self): """Test clearing buffer when enabled.""" - configure( + init( offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, ) @@ -352,7 +354,7 @@ def test_replay_buffered_traces_success(self, mock_get_client: Mock) -> None: mock_client.inference_pipelines.data.stream.return_value = mock_response mock_get_client.return_value = mock_client - configure( + init( offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, ) @@ -390,7 +392,7 @@ def test_replay_buffered_traces_failure(self, mock_get_client: Mock) -> None: mock_client.inference_pipelines.data.stream.side_effect = Exception("API Error") mock_get_client.return_value = mock_client - configure( + init( offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, ) @@ -421,7 +423,7 @@ def on_failure(trace_data: Dict[str, Any], config: Dict[str, Any], error: Except def test_replay_buffered_traces_disabled(self): """Test replay when buffer is disabled.""" - configure(offline_buffer_enabled=False) + init(offline_buffer_enabled=False) result = replay_buffered_traces() assert result["total_traces"] == 0 @@ -434,7 +436,7 @@ def test_replay_buffered_traces_no_client(self, mock_get_client: Mock) -> None: """Test replay when no client is available.""" mock_get_client.return_value = None - configure( + init( offline_buffer_enabled=True, offline_buffer_path=self.temp_dir, ) @@ -459,7 +461,7 @@ def teardown_method(self): import shutil shutil.rmtree(self.temp_dir, ignore_errors=True) - configure() + _reset_tracer_state() @patch("openlayer.lib.tracing.tracer._get_client") @patch("openlayer.lib.tracing.tracer._publish", True) @@ -477,7 +479,7 @@ def test_trace_with_offline_buffering(self, mock_get_client: Mock) -> None: def on_failure(trace_data: Dict[str, Any], config: Dict[str, Any], error: Exception) -> None: failure_calls.append((trace_data, config, str(error))) - configure( + init( api_key="test-key", inference_pipeline_id="test-pipeline", on_flush_failure=on_failure, diff --git a/tests/test_tracer_configuration.py b/tests/test_tracer_configuration.py index a83745c4..e4b26f54 100644 --- a/tests/test_tracer_configuration.py +++ b/tests/test_tracer_configuration.py @@ -1,210 +1,265 @@ """Tests for the tracer configuration functionality.""" +import os +import warnings from typing import Any from unittest.mock import MagicMock, patch +import pytest + from openlayer.lib.tracing import tracer +def _reset_tracer_state() -> None: + """Clear tracer config and lazily-built state between tests.""" + tracer._tracer_config.clear() + tracer._client = None + tracer._client_init_logged = False + tracer._configure_deprecation_logged = False + + class TestTracerConfiguration: """Test cases for the tracer configuration functionality.""" def teardown_method(self): """Reset tracer configuration after each test.""" - # Reset the global configuration - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None - tracer._configured_timeout = None - tracer._configured_max_retries = None - tracer._client = None - - def test_configure_sets_global_variables(self): - """Test that configure() sets the global configuration variables.""" + _reset_tracer_state() + + def test_init_sets_config_values(self): + """init() stores explicitly-passed values in _tracer_config.""" api_key = "test_api_key" pipeline_id = "test_pipeline_id" base_url = "https://test.api.com" timeout = 30.5 max_retries = 5 - tracer.configure(api_key=api_key, inference_pipeline_id=pipeline_id, base_url=base_url, timeout=timeout, max_retries=max_retries) + tracer.init( + api_key=api_key, + inference_pipeline_id=pipeline_id, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + ) - assert tracer._configured_api_key == api_key - assert tracer._configured_pipeline_id == pipeline_id - assert tracer._configured_base_url == base_url - assert tracer._configured_timeout == timeout - assert tracer._configured_max_retries == max_retries + assert tracer._tracer_config["api_key"] == api_key + assert tracer._tracer_config["inference_pipeline_id"] == pipeline_id + assert tracer._tracer_config["base_url"] == base_url + assert tracer._tracer_config["timeout"] == timeout + assert tracer._tracer_config["max_retries"] == max_retries - def test_configure_resets_client(self): - """Test that configure() resets the client to force recreation.""" - # Create a mock client + def test_init_resets_client(self): + """init() resets the lazily-built client so it gets recreated.""" tracer._client = MagicMock() original_client = tracer._client - tracer.configure(api_key="test_key") + tracer.init(api_key="test_key") - # Client should be reset to None assert tracer._client is None assert tracer._client != original_client + def test_init_merges_partial_calls(self): + """Repeated init() calls merge; omitted args preserve prior state.""" + tracer.init(api_key="A", inference_pipeline_id="P", base_url="https://x") + tracer.init(api_key="B") # only api_key passed + + assert tracer._tracer_config["api_key"] == "B" + assert tracer._tracer_config["inference_pipeline_id"] == "P" + assert tracer._tracer_config["base_url"] == "https://x" + + def test_init_explicit_none_clears(self): + """Explicit None overrides env var; init(api_key=None) yields None from resolver.""" + tracer.init(api_key="A") + with patch.dict(os.environ, {"OPENLAYER_API_KEY": "env-value"}): + tracer.init(api_key=None) + assert tracer.resolve_api_key() is None + + def test_init_with_no_args_is_noop(self): + """init() with no args should not mutate _tracer_config (just resets client).""" + tracer.init(api_key="A", inference_pipeline_id="P") + snapshot = dict(tracer._tracer_config) + tracer.init() + assert tracer._tracer_config == snapshot + @patch("openlayer.lib.tracing.tracer.Openlayer") def test_get_client_uses_configured_api_key(self, mock_openlayer: Any) -> None: - """Test that _get_client() uses the configured API key.""" - # Enable publishing for this test + """_get_client() passes the configured API key through to Openlayer().""" with patch.object(tracer, "_publish", True): - api_key = "configured_api_key" - tracer.configure(api_key=api_key) - + tracer.init(api_key="configured_api_key") tracer._get_client() - - # Verify Openlayer was called with the configured API key - mock_openlayer.assert_called_once_with(api_key=api_key) + mock_openlayer.assert_called_once_with(api_key="configured_api_key") @patch("openlayer.lib.tracing.tracer.Openlayer") def test_get_client_uses_configured_base_url(self, mock_openlayer: Any) -> None: - """Test that _get_client() uses the configured base URL.""" with patch.object(tracer, "_publish", True): - base_url = "https://configured.api.com" - tracer.configure(base_url=base_url) - + tracer.init(base_url="https://configured.api.com") tracer._get_client() - - mock_openlayer.assert_called_once_with(base_url=base_url) + mock_openlayer.assert_called_once_with(base_url="https://configured.api.com") @patch("openlayer.lib.tracing.tracer.Openlayer") def test_get_client_uses_both_configured_values(self, mock_openlayer: Any) -> None: - """Test that _get_client() uses both configured API key and base URL.""" with patch.object(tracer, "_publish", True): - api_key = "configured_api_key" - base_url = "https://configured.api.com" - tracer.configure(api_key=api_key, base_url=base_url) - + tracer.init(api_key="k", base_url="https://b") tracer._get_client() - - mock_openlayer.assert_called_once_with(api_key=api_key, base_url=base_url) + mock_openlayer.assert_called_once_with(api_key="k", base_url="https://b") @patch("openlayer.lib.tracing.tracer.Openlayer") def test_get_client_uses_configured_timeout(self, mock_openlayer: Any) -> None: - """Test that _get_client() uses the configured timeout.""" with patch.object(tracer, "_publish", True): - timeout = 45.5 - tracer.configure(timeout=timeout) - + tracer.init(timeout=45.5) tracer._get_client() - - mock_openlayer.assert_called_once_with(timeout=timeout) + mock_openlayer.assert_called_once_with(timeout=45.5) @patch("openlayer.lib.tracing.tracer.Openlayer") def test_get_client_uses_configured_max_retries(self, mock_openlayer: Any) -> None: - """Test that _get_client() uses the configured max_retries.""" with patch.object(tracer, "_publish", True): - max_retries = 10 - tracer.configure(max_retries=max_retries) - + tracer.init(max_retries=10) tracer._get_client() - - mock_openlayer.assert_called_once_with(max_retries=max_retries) + mock_openlayer.assert_called_once_with(max_retries=10) @patch("openlayer.lib.tracing.tracer.Openlayer") def test_get_client_uses_all_configured_values(self, mock_openlayer: Any) -> None: - """Test that _get_client() uses all configured values together.""" with patch.object(tracer, "_publish", True): - api_key = "configured_api_key" - base_url = "https://configured.api.com" - timeout = 25 - max_retries = 3 - tracer.configure(api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries) - + tracer.init( + api_key="k", base_url="https://b", timeout=25, max_retries=3 + ) tracer._get_client() - - mock_openlayer.assert_called_once_with(api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries) + mock_openlayer.assert_called_once_with( + api_key="k", base_url="https://b", timeout=25, max_retries=3 + ) @patch("openlayer.lib.tracing.tracer.DefaultHttpxClient") @patch("openlayer.lib.tracing.tracer.Openlayer") - def test_get_client_with_ssl_disabled_and_config(self, mock_openlayer: Any, mock_http_client: Any) -> None: - """Test _get_client() with SSL disabled and custom configuration.""" + def test_get_client_with_ssl_disabled_and_config( + self, mock_openlayer: Any, mock_http_client: Any + ) -> None: with patch.object(tracer, "_publish", True), patch.object(tracer, "_verify_ssl", False): - api_key = "test_key" - tracer.configure(api_key=api_key) - + tracer.init(api_key="test_key") tracer._get_client() - - # Should create DefaultHttpxClient with verify=False mock_http_client.assert_called_once_with(verify=False) - - # Should create Openlayer with both http_client and configured values - mock_openlayer.assert_called_once_with(http_client=mock_http_client.return_value, api_key=api_key) - - @patch.object(tracer, "utils") - def test_handle_trace_completion_uses_configured_pipeline_id(self, mock_utils: Any) -> None: - """Test that _handle_trace_completion() uses configured pipeline ID.""" - with patch.object(tracer, "_publish", True), patch.object(tracer, "_get_client") as mock_get_client: - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_utils.get_env_variable.return_value = "env_pipeline_id" - - configured_pipeline_id = "configured_pipeline_id" - tracer.configure(inference_pipeline_id=configured_pipeline_id) - - # Mock the necessary objects for trace completion - with patch.object(tracer, "get_current_trace") as mock_get_trace, patch.object( - tracer, "post_process_trace" - ) as mock_post_process: - mock_trace = MagicMock() - mock_get_trace.return_value = mock_trace - mock_post_process.return_value = ({}, []) - - # Call the function - tracer._handle_trace_completion(is_root_step=True, step_name="test_step") - - # Verify the client.inference_pipelines.data.stream was called - # with the configured pipeline ID - mock_client.inference_pipelines.data.stream.assert_called_once() - call_kwargs = mock_client.inference_pipelines.data.stream.call_args[1] - assert call_kwargs["inference_pipeline_id"] == configured_pipeline_id - - @patch.object(tracer, "utils") - def test_pipeline_id_precedence(self, mock_utils: Any) -> None: - """Test pipeline ID precedence: provided > configured > environment.""" - with patch.object(tracer, "_publish", True), patch.object(tracer, "_get_client") as mock_get_client: - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_utils.get_env_variable.return_value = "env_pipeline_id" - - tracer.configure(inference_pipeline_id="configured_pipeline_id") - - with patch.object(tracer, "get_current_trace") as mock_get_trace, patch.object( - tracer, "post_process_trace" - ) as mock_post_process: - mock_trace = MagicMock() - mock_get_trace.return_value = mock_trace - mock_post_process.return_value = ({}, []) - - # Call with a provided pipeline ID (should have highest precedence) - tracer._handle_trace_completion( - is_root_step=True, step_name="test_step", inference_pipeline_id="provided_pipeline_id" - ) - - call_kwargs = mock_client.inference_pipelines.data.stream.call_args[1] - assert call_kwargs["inference_pipeline_id"] == "provided_pipeline_id" - - def test_configure_with_none_values(self): - """Test that configure() with None values doesn't overwrite existing config.""" - # Set initial configuration - tracer.configure( - api_key="initial_key", - inference_pipeline_id="initial_pipeline", + mock_openlayer.assert_called_once_with( + http_client=mock_http_client.return_value, api_key="test_key" + ) + + def test_pipeline_id_precedence(self) -> None: + """Resolver precedence: explicit arg > _tracer_config > env var.""" + with patch.dict(os.environ, {"OPENLAYER_INFERENCE_PIPELINE_ID": "env"}): + # No config: env wins. + assert tracer.resolve_pipeline_id() == "env" + # _tracer_config set: _tracer_config wins. + tracer.init(inference_pipeline_id="cfg") + assert tracer.resolve_pipeline_id() == "cfg" + # _tracer_config set to None: returns None (overrides env). + tracer.init(inference_pipeline_id=None) + assert tracer.resolve_pipeline_id() is None + + def test_init_preserves_none_values_explicit(self): + """Explicit None values are stored in _tracer_config and clear env fallback.""" + tracer.init( + api_key="initial_key", + inference_pipeline_id="initial_pipeline", base_url="https://initial.com", timeout=60.0, - max_retries=5 + max_retries=5, ) - # Configure with None values - tracer.configure(api_key=None, inference_pipeline_id=None, base_url=None, timeout=None, max_retries=None) + # Explicit None on every knob — under merge semantics, these are stored. + tracer.init( + api_key=None, + inference_pipeline_id=None, + base_url=None, + timeout=None, + max_retries=None, + ) - # Values should be set to None (this is the expected behavior) - assert tracer._configured_api_key is None - assert tracer._configured_pipeline_id is None - assert tracer._configured_base_url is None - assert tracer._configured_timeout is None - assert tracer._configured_max_retries is None + assert tracer._tracer_config["api_key"] is None + assert tracer._tracer_config["inference_pipeline_id"] is None + assert tracer._tracer_config["base_url"] is None + assert tracer._tracer_config["timeout"] is None + assert tracer._tracer_config["max_retries"] is None + + +class TestConfigureDeprecation: + """Tests for the deprecated configure() alias.""" + + def teardown_method(self): + _reset_tracer_state() + + def test_configure_emits_deprecation_warning(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + tracer.configure(api_key="x") + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_configure_delegates_to_init(self): + """configure(**kwargs) should produce the same _tracer_config as init(**kwargs).""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + tracer.configure(api_key="cfg", inference_pipeline_id="pipe") + assert tracer._tracer_config["api_key"] == "cfg" + assert tracer._tracer_config["inference_pipeline_id"] == "pipe" + + +class TestResolvers: + """Tests for the env-var resolver helpers.""" + + def teardown_method(self): + _reset_tracer_state() + + def test_resolver_falls_back_to_env(self): + with patch.dict( + os.environ, + { + "OPENLAYER_API_KEY": "env-key", + "OPENLAYER_INFERENCE_PIPELINE_ID": "env-pipe", + "OPENLAYER_BASE_URL": "https://env.example", + }, + ): + assert tracer.resolve_api_key() == "env-key" + assert tracer.resolve_pipeline_id() == "env-pipe" + assert tracer.resolve_base_url() == "https://env.example" + + def test_resolver_config_wins_over_env(self): + with patch.dict(os.environ, {"OPENLAYER_API_KEY": "env-key"}): + tracer.init(api_key="cfg-key") + assert tracer.resolve_api_key() == "cfg-key" + + def test_get_config_redacts_api_key(self): + tracer.init(api_key="super-secret", inference_pipeline_id="pipe") + cfg = tracer.get_tracer_config() + assert cfg["api_key"] == "***" + assert cfg["inference_pipeline_id"] == "pipe" + + def test_get_config_returns_none_when_unset(self): + cfg = tracer.get_tracer_config() + assert cfg["api_key"] is None + assert cfg["inference_pipeline_id"] is None + + +class TestLangchainCallbackResolver: + """Regression test: the LangChain callback must route through the resolver, + not read OPENLAYER_INFERENCE_PIPELINE_ID directly. Bug from the pre-resolver + code where init(inference_pipeline_id=...) was silently ignored by LangChain.""" + + def teardown_method(self): + _reset_tracer_state() + + def test_langchain_callback_uses_configured_pipeline(self): + try: + from openlayer.lib.integrations import langchain_callback # noqa: F401 + except ImportError: + pytest.skip("langchain-core not installed") + + # No env var set; only init() sets the pipeline. + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OPENLAYER_INFERENCE_PIPELINE_ID", None) + tracer.init(inference_pipeline_id="from-init") + assert tracer.resolve_pipeline_id() == "from-init" + + # The bug-fix is that the callback's stream call uses tracer.resolve_pipeline_id() + # at langchain_callback.py:252 and :1310. Verifying source-level routing here + # rather than mocking the full LangChain callback machinery. + import inspect + + source = inspect.getsource(langchain_callback) + assert "resolve_pipeline_id" in source + assert 'get_env_variable("OPENLAYER_INFERENCE_PIPELINE_ID")' not in source diff --git a/tests/test_tracing_core.py b/tests/test_tracing_core.py index 17717618..628d1f5b 100644 --- a/tests/test_tracing_core.py +++ b/tests/test_tracing_core.py @@ -22,16 +22,12 @@ class TestBasicTracing: def setup_method(self) -> None: """Setup before each test - reset global state.""" - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def teardown_method(self) -> None: """Cleanup after each test.""" - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None @patch.object(tracer, "_publish", False) @@ -90,15 +86,11 @@ class TestContextManagement: """Test context management functionality.""" def setup_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def teardown_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None @patch.object(tracer, "_publish", False) @@ -112,15 +104,11 @@ class TestTraceDataStructure: """Test trace data structure and content.""" def setup_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def teardown_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None @patch.object(tracer, "_publish", False) @@ -281,15 +269,11 @@ class TestTraceMetadata: """Test trace metadata functionality.""" def setup_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def teardown_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None @patch.object(tracer, "_publish", False) @@ -364,15 +348,11 @@ class TestTraceSerialization: """Test trace serialization and post-processing.""" def setup_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def teardown_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def test_step_to_dict_format(self) -> None: @@ -480,15 +460,11 @@ class TestStepTypes: """Test different step types and their specific behavior.""" def setup_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def teardown_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def test_step_factory_creates_correct_types(self) -> None: @@ -546,15 +522,11 @@ class TestErrorHandlingInTraces: """Test error handling and exception capture in traces.""" def setup_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def teardown_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None @patch.object(tracer, "_publish", False) @@ -612,15 +584,11 @@ class TestPromoteOutput: """Test promote parameter with output field extraction.""" def setup_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None def teardown_method(self) -> None: - tracer._configured_api_key = None - tracer._configured_pipeline_id = None - tracer._configured_base_url = None + tracer._tracer_config.clear() tracer._client = None @patch.object(tracer, "_publish", False)