diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a974052b..b200bc7a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.2.0-alpha.92" + ".": "0.2.0-alpha.93" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2b09528b..0b70a4d7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,3 +1,3 @@ configured_endpoints: 18 -openapi_spec_hash: 20f058101a252f7500803d66aff58eb3 +openapi_spec_hash: 153617b7252b1b12f21043b2a1246f8b config_hash: 30422a4611d93ca69e4f1aff60b9ddb5 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7501349..d3b9b79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 0.2.0-alpha.93 (2025-09-10) + +Full Changelog: [v0.2.0-alpha.92...v0.2.0-alpha.93](https://github.com/openlayer-ai/openlayer-python/compare/v0.2.0-alpha.92...v0.2.0-alpha.93) + +### Features + +* Add user_id and session_id in traces to track users and sessions ([b7cd032](https://github.com/openlayer-ai/openlayer-python/commit/b7cd032d7bc0ad80d5d025decc7db46720b81349)) +* **api:** api update ([e65c84e](https://github.com/openlayer-ai/openlayer-python/commit/e65c84e5420874636c405ddd5cce8dd2f4e69bbf)) + + +### Chores + +* closes OPEN-7337 Exclude examples from linting action ([9730dc0](https://github.com/openlayer-ai/openlayer-python/commit/9730dc0d7e9ccbd5db08a9188caa3bff337a0d20)) +* exclude custom lib from linting action ([89f0d21](https://github.com/openlayer-ai/openlayer-python/commit/89f0d2117df3ab07da397877cd24f5fbd0bca14d)) +* **internal:** move mypy configurations to `pyproject.toml` file ([49b32b4](https://github.com/openlayer-ai/openlayer-python/commit/49b32b4095ad06abda2ef43fc04edd3ba9620c52)) +* **internal:** version bump ([cb69e2e](https://github.com/openlayer-ai/openlayer-python/commit/cb69e2e0a1765615efda7a7ff7af4badbb3b7c68)) +* **internal:** version bump ([59e1a98](https://github.com/openlayer-ai/openlayer-python/commit/59e1a983a9b7e9975a34520e26055e7ef6b236cb)) +* **tests:** simplify `get_platform` test ([d30a165](https://github.com/openlayer-ai/openlayer-python/commit/d30a16505f86135c3f5980307a9ae073de1a4708)) + + +### Styles + +* reverted back to previous version for data_stream_params.py ([89d6a5d](https://github.com/openlayer-ai/openlayer-python/commit/89d6a5d4c1e47f13662dc95feee4dde27371318a)) + + +### Refactors + +* import fixes ([bd78a73](https://github.com/openlayer-ai/openlayer-python/commit/bd78a73b6d692d04c06f862a934767d87653ad8c)) + ## 0.2.0-alpha.92 (2025-09-09) Full Changelog: [v0.2.0-alpha.91...v0.2.0-alpha.92](https://github.com/openlayer-ai/openlayer-python/compare/v0.2.0-alpha.91...v0.2.0-alpha.92) diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 7d5e61da..00000000 --- a/mypy.ini +++ /dev/null @@ -1,50 +0,0 @@ -[mypy] -pretty = True -show_error_codes = True - -# Exclude _files.py because mypy isn't smart enough to apply -# the correct type narrowing and as this is an internal module -# it's fine to just use Pyright. -# -# We also exclude our `tests` as mypy doesn't always infer -# types correctly and Pyright will still catch any type errors. -exclude = ^(src/openlayer/_files\.py|_dev/.*\.py|src/openlayer/lib/.*\.py|examples/.*\.py|tests/.*)$ - -strict_equality = True -implicit_reexport = True -check_untyped_defs = True -no_implicit_optional = True - -warn_return_any = True -warn_unreachable = True -warn_unused_configs = True - -# Turn these options off as it could cause conflicts -# with the Pyright options. -warn_unused_ignores = False -warn_redundant_casts = False - -disallow_any_generics = True -disallow_untyped_defs = True -disallow_untyped_calls = True -disallow_subclassing_any = True -disallow_incomplete_defs = True -disallow_untyped_decorators = True -cache_fine_grained = True - -# By default, mypy reports an error if you assign a value to the result -# of a function call that doesn't return anything. We do this in our test -# cases: -# ``` -# result = ... -# assert result is None -# ``` -# Changing this codegen to make mypy happy would increase complexity -# and would not be worth it. -disable_error_code = func-returns-value,overload-cannot-match - -# https://github.com/python/mypy/issues/12162 -[mypy.overrides] -module = "black.files.*" -ignore_errors = true -ignore_missing_imports = true diff --git a/pyproject.toml b/pyproject.toml index 3e8206ba..0020ba8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openlayer" -version = "0.2.0-alpha.92" +version = "0.2.0-alpha.93" description = "The official Python library for the openlayer API" dynamic = ["readme"] license = "Apache-2.0" @@ -62,7 +62,6 @@ dev-dependencies = [ "dirty-equals>=0.6.0", "importlib-metadata>=6.7.0", "rich>=13.7.1", - "nest_asyncio==1.6.0", "pytest-xdist>=3.6.1", ] @@ -165,6 +164,58 @@ reportOverlappingOverload = false reportImportCycles = false reportPrivateUsage = false +[tool.mypy] +pretty = true +show_error_codes = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ['src/openlayer/_files.py', '_dev/.*.py', 'tests/.*', 'examples/.*', 'src/openlayer/lib/.*'] + +strict_equality = true +implicit_reexport = true +check_untyped_defs = true +no_implicit_optional = true + +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = false +warn_redundant_casts = false + +disallow_any_generics = true +disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +cache_fine_grained = true + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = "func-returns-value,overload-cannot-match" + +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + + [tool.ruff] line-length = 120 output-format = "grouped" diff --git a/requirements-dev.lock b/requirements-dev.lock index 8cddda44..157f47cb 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -77,7 +77,6 @@ multidict==6.5.0 mypy==1.14.1 mypy-extensions==1.0.0 # via mypy -nest-asyncio==1.6.0 nodeenv==1.8.0 # via pyright nox==2023.4.22 diff --git a/src/openlayer/_version.py b/src/openlayer/_version.py index 14d3ed6b..f821148a 100644 --- a/src/openlayer/_version.py +++ b/src/openlayer/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "openlayer" -__version__ = "0.2.0-alpha.92" # x-release-please-version +__version__ = "0.2.0-alpha.93" # x-release-please-version diff --git a/src/openlayer/lib/__init__.py b/src/openlayer/lib/__init__.py index 00075bf2..abfab729 100644 --- a/src/openlayer/lib/__init__.py +++ b/src/openlayer/lib/__init__.py @@ -3,7 +3,7 @@ __all__ = [ "configure", "trace", - "trace_anthropic", + "trace_anthropic", "trace_openai", "trace_openai_assistant_thread_run", "trace_mistral", @@ -14,11 +14,24 @@ "trace_oci_genai", "trace_oci", # Alias for backward compatibility "update_current_trace", - "update_current_step" + "update_current_step", + # User and session context functions + "set_user_session_context", + "update_trace_user_session", + "get_current_user_id", + "get_current_session_id", + "clear_user_session_context", ] # ---------------------------------- Tracing --------------------------------- # from .tracing import tracer +from .tracing.context import ( + set_user_session_context, + update_trace_user_session, + get_current_user_id, + get_current_session_id, + clear_user_session_context, +) configure = tracer.configure trace = tracer.trace diff --git a/src/openlayer/lib/tracing/__init__.py b/src/openlayer/lib/tracing/__init__.py index e69de29b..ffa19284 100644 --- a/src/openlayer/lib/tracing/__init__.py +++ b/src/openlayer/lib/tracing/__init__.py @@ -0,0 +1,30 @@ +"""Openlayer tracing module.""" + +from .tracer import ( + trace, + trace_async, + update_current_trace, + update_current_step, + log_context, + log_output, + configure, + get_current_trace, + get_current_step, + create_step, +) + + +__all__ = [ + # Core tracing functions + "trace", + "trace_async", + "update_current_trace", + "update_current_step", + "log_context", + "log_output", + "configure", + "get_current_trace", + "get_current_step", + "create_step", +] + diff --git a/src/openlayer/lib/tracing/context.py b/src/openlayer/lib/tracing/context.py new file mode 100644 index 00000000..0f011735 --- /dev/null +++ b/src/openlayer/lib/tracing/context.py @@ -0,0 +1,167 @@ +""" +Streamlined user and session context management for Openlayer tracing. + +This module provides simple functions to set user_id and session_id in middleware +and override them anywhere in your traced code. +""" + +import contextvars +import threading +from typing import Optional, Union + +# Sentinel object to distinguish between "not provided" and "explicitly None" +_NOT_PROVIDED = object() + +# Context variables for user and session tracking +_user_id_context = contextvars.ContextVar("openlayer_user_id", default=None) +_session_id_context = contextvars.ContextVar("openlayer_session_id", default=None) + +# Thread-local fallback for environments where contextvars don't work well +_thread_local = threading.local() + + +class UserSessionContext: + """Internal class to manage user and session context.""" + + @staticmethod + def set_user_id(user_id: Union[str, int, None]) -> None: + """Set the user ID for the current context.""" + user_id_str = str(user_id) if user_id is not None else None + _user_id_context.set(user_id_str) + + # Thread-local fallback + _thread_local.user_id = user_id_str + + @staticmethod + def get_user_id() -> Optional[str]: + """Get the current user ID.""" + try: + return _user_id_context.get(None) + except LookupError: + # Fallback to thread-local + return getattr(_thread_local, 'user_id', None) + + @staticmethod + def set_session_id(session_id: Union[str, None]) -> None: + """Set the session ID for the current context.""" + _session_id_context.set(session_id) + + # Thread-local fallback + _thread_local.session_id = session_id + + @staticmethod + def get_session_id() -> Optional[str]: + """Get the current session ID.""" + try: + return _session_id_context.get(None) + except LookupError: + # Fallback to thread-local + return getattr(_thread_local, 'session_id', None) + + @staticmethod + def clear_context() -> None: + """Clear all user and session context.""" + _user_id_context.set(None) + _session_id_context.set(None) + + # Clear thread-local + for attr in ['user_id', 'session_id']: + if hasattr(_thread_local, attr): + delattr(_thread_local, attr) + + +# ----------------------------- Public API Functions ----------------------------- # + +def set_user_session_context( + user_id: Union[str, int, None] = None, + session_id: Union[str, None] = None, +) -> None: + """Set user and session context for tracing (typically called in middleware). + + This function should be called once per request in your middleware to establish + default user_id and session_id values that will be automatically included in all traces. + + Args: + user_id: The user identifier + session_id: The session identifier + + Example: + >>> from openlayer.lib.tracing import set_user_session_context + >>> + >>> # In your middleware or request handler + >>> def middleware(request): + ... set_user_session_context( + ... user_id=request.user.id, + ... session_id=request.session.session_key + ... ) + ... # Now all traced functions will automatically include these values + """ + if user_id is not None: + UserSessionContext.set_user_id(user_id) + if session_id is not None: + UserSessionContext.set_session_id(session_id) + + +def update_trace_user_session( + user_id: Union[str, int, None] = _NOT_PROVIDED, + session_id: Union[str, None] = _NOT_PROVIDED, +) -> None: + """Update user_id and/or session_id for the current trace context. + + This can be called anywhere in your traced code to override the user_id + and/or session_id set in middleware. Inspired by Langfuse's updateActiveTrace pattern. + + Args: + user_id: The user identifier to set (optional). Pass None to clear. + session_id: The session identifier to set (optional). Pass None to clear. + + Example: + >>> from openlayer.lib.tracing import update_trace_user_session + >>> + >>> @trace() + >>> def process_request(): + ... # Override user_id for this specific trace + ... update_trace_user_session(user_id="different_user_123") + ... return "result" + >>> + >>> @trace() + >>> def start_new_session(): + ... # Start a new session for this trace + ... update_trace_user_session(session_id="new_session_456") + ... return "result" + >>> + >>> @trace() + >>> def switch_user_and_session(): + ... # Update both at once + ... update_trace_user_session( + ... user_id="admin_user_789", + ... session_id="admin_session_abc" + ... ) + ... return "result" + >>> + >>> @trace() + >>> def clear_user(): + ... # Clear user_id (set to None) + ... update_trace_user_session(user_id=None) + ... return "result" + """ + # Use sentinel object to distinguish between "not provided" and "explicitly None" + if user_id is not _NOT_PROVIDED: + UserSessionContext.set_user_id(user_id) + if session_id is not _NOT_PROVIDED: + UserSessionContext.set_session_id(session_id) + + +def get_current_user_id() -> Optional[str]: + """Get the current user ID from context.""" + return UserSessionContext.get_user_id() + + +def get_current_session_id() -> Optional[str]: + """Get the current session ID from context.""" + return UserSessionContext.get_session_id() + + +def clear_user_session_context() -> None: + """Clear all user and session context.""" + UserSessionContext.clear_context() diff --git a/src/openlayer/lib/tracing/tracer.py b/src/openlayer/lib/tracing/tracer.py index 5a15a243..1471c758 100644 --- a/src/openlayer/lib/tracing/tracer.py +++ b/src/openlayer/lib/tracing/tracer.py @@ -16,6 +16,7 @@ from .. import utils from . import enums, steps, traces from ..guardrails.base import GuardrailResult, GuardrailAction +from .context import UserSessionContext logger = logging.getLogger(__name__) @@ -923,6 +924,12 @@ def _handle_trace_completion( num_of_token_column_name="tokens", ) ) + + # Add reserved column configurations for user context + if "user_id" in trace_data: + config.update({"user_id_column_name": "user_id"}) + if "session_id" in trace_data: + config.update({"session_id_column_name": "session_id"}) if "groundTruth" in trace_data: config.update({"ground_truth_column_name": "groundTruth"}) if "context" in trace_data: @@ -1184,7 +1191,16 @@ def post_process_trace( if trace_obj.metadata is not None: # Add each trace metadata key directly to the row/record level trace_data.update(trace_obj.metadata) - + + # Add reserved columns for user and session context + user_id = UserSessionContext.get_user_id() + if user_id is not None: + trace_data["user_id"] = user_id + + session_id = UserSessionContext.get_session_id() + if session_id is not None: + trace_data["session_id"] = session_id + if root_step.ground_truth: trace_data["groundTruth"] = root_step.ground_truth if input_variables: diff --git a/src/openlayer/types/inference_pipelines/data_stream_params.py b/src/openlayer/types/inference_pipelines/data_stream_params.py index a897b34a..fd3a0cac 100644 --- a/src/openlayer/types/inference_pipelines/data_stream_params.py +++ b/src/openlayer/types/inference_pipelines/data_stream_params.py @@ -82,6 +82,9 @@ class ConfigLlmData(TypedDict, total=False): Applies to RAG use cases. Providing the question enables RAG-specific metrics. """ + session_id_column_name: Annotated[Optional[str], PropertyInfo(alias="sessionIdColumnName")] + """Name of the column with the session id.""" + timestamp_column_name: Annotated[str, PropertyInfo(alias="timestampColumnName")] """Name of the column with the timestamps. @@ -89,6 +92,9 @@ class ConfigLlmData(TypedDict, total=False): used. """ + user_id_column_name: Annotated[Optional[str], PropertyInfo(alias="userIdColumnName")] + """Name of the column with the user id.""" + class ConfigTabularClassificationData(TypedDict, total=False): class_names: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="classNames")]] diff --git a/tests/api_resources/inference_pipelines/test_data.py b/tests/api_resources/inference_pipelines/test_data.py index 7c29f492..60727f56 100644 --- a/tests/api_resources/inference_pipelines/test_data.py +++ b/tests/api_resources/inference_pipelines/test_data.py @@ -55,7 +55,9 @@ def test_method_stream_with_all_params(self, client: Openlayer) -> None: } ], "question_column_name": "question", + "session_id_column_name": "session_id", "timestamp_column_name": "timestamp", + "user_id_column_name": "user_id", }, rows=[ { @@ -174,7 +176,9 @@ async def test_method_stream_with_all_params(self, async_client: AsyncOpenlayer) } ], "question_column_name": "question", + "session_id_column_name": "session_id", "timestamp_column_name": "timestamp", + "user_id_column_name": "user_id", }, rows=[ { diff --git a/tests/test_client.py b/tests/test_client.py index 00de783e..2cb1bfd6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,13 +6,10 @@ import os import sys import json -import time import asyncio import inspect -import subprocess import tracemalloc from typing import Any, Union, cast -from textwrap import dedent from unittest import mock from typing_extensions import Literal @@ -23,14 +20,17 @@ from openlayer import Openlayer, AsyncOpenlayer, APIResponseValidationError from openlayer._types import Omit +from openlayer._utils import asyncify from openlayer._models import BaseModel, FinalRequestOptions from openlayer._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError from openlayer._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + OtherPlatform, DefaultHttpxClient, DefaultAsyncHttpxClient, + get_platform, make_request_options, ) @@ -1791,50 +1791,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" - def test_get_platform(self) -> None: - # A previous implementation of asyncify could leave threads unterminated when - # used with nest_asyncio. - # - # Since nest_asyncio.apply() is global and cannot be un-applied, this - # test is run in a separate process to avoid affecting other tests. - test_code = dedent(""" - import asyncio - import nest_asyncio - import threading - - from openlayer._utils import asyncify - from openlayer._base_client import get_platform - - async def test_main() -> None: - result = await asyncify(get_platform)() - print(result) - for thread in threading.enumerate(): - print(thread.name) - - nest_asyncio.apply() - asyncio.run(test_main()) - """) - with subprocess.Popen( - [sys.executable, "-c", test_code], - text=True, - ) as process: - timeout = 10 # seconds - - start_time = time.monotonic() - while True: - return_code = process.poll() - if return_code is not None: - if return_code != 0: - raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") - - # success - break - - if time.monotonic() - start_time > timeout: - process.kill() - raise AssertionError("calling get_platform using asyncify resulted in a hung process") - - time.sleep(0.1) + async def test_get_platform(self) -> None: + platform = await asyncify(get_platform)() + assert isinstance(platform, (str, OtherPlatform)) async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly