diff --git a/AGENTS.md b/AGENTS.md index e828b2cc..cc66be8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,10 +123,11 @@ The client manages a singleton connection to LaunchDarkly and the associated tel | Export | Description | |---|---| -| `init_client(options?)` | Auto-discovers and initializes `launchdarkly-server-sdk` (optional dep, loaded via `importlib`). Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Accepts optional overrides for SDK key, base URIs, service name, environment, and OTLP endpoint. Returns `Awaitable[LDClientInterface]`. | -| `init_client(client=...)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface`. Stores it directly without calling the SDK. | +| `init_client(options?)` | Auto-discovers and initializes `launchdarkly-server-sdk` (optional dep, loaded via `importlib`). Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Accepts optional overrides for SDK key, base URIs, service name, environment, and OTLP endpoint. Returns `Awaitable[LDClientInterface]`. On every successful path, including the already-initialized path, flushes `$ld:ai:sdk:info` for any LaunchDarkly AI packages that have not yet reported. | +| `init_client(client=...)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface`. Stores it directly without calling the SDK. Flushes pending `$ld:ai:sdk:info` events. | | `get_client()` | Returns the initialized `LDClientInterface`. Throws if initialization has not completed. | -| `shutdown()` | Flushes all pending events and telemetry, then closes the client. Must be awaited before the process exits. | +| `shutdown()` | Flushes all pending events and telemetry, then closes the client. Must be awaited before the process exits. Clears sdk-info reporting so a later client reports again. | +| `register_ai_sdk_package(name, version)` | Records a LaunchDarkly AI package identity. Handler and convenience packages call this at import time. | ### Core Data Types diff --git a/packages/ai/src/launchdarkly_ai_python/__init__.py b/packages/ai/src/launchdarkly_ai_python/__init__.py index 2d5c43f7..f02d8411 100644 --- a/packages/ai/src/launchdarkly_ai_python/__init__.py +++ b/packages/ai/src/launchdarkly_ai_python/__init__.py @@ -3,3 +3,6 @@ __version__ = "0.1.3" # x-release-please-version from launchdarkly_ai_server import * # noqa: F403 +from launchdarkly_ai_server import register_ai_sdk_package + +register_ai_sdk_package("launchdarkly-ai-python", __version__) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py index 79eacb04..5b9bc1a1 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py @@ -2,6 +2,8 @@ __version__ = "0.1.4" # x-release-please-version +from launchdarkly_ai_server import register_ai_sdk_package + from . import native_graph # noqa: F401 from .builtins import ( ClaudeBash, @@ -44,3 +46,5 @@ "partition_tools", "to_claude_agents", ] + +register_ai_sdk_package("launchdarkly-ai-claude-agents", __version__) diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/__init__.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/__init__.py index 09572146..85351dcf 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/__init__.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/__init__.py @@ -2,6 +2,10 @@ __version__ = "0.1.4" # x-release-please-version +from launchdarkly_ai_server import register_ai_sdk_package + from .handler import claude_messages, create_claude_messages_handler __all__ = ["claude_messages", "create_claude_messages_handler"] + +register_ai_sdk_package("launchdarkly-ai-claude-messages", __version__) diff --git a/packages/client/agents.md b/packages/client/agents.md index 3ef0c16a..381adcbf 100644 --- a/packages/client/agents.md +++ b/packages/client/agents.md @@ -22,6 +22,7 @@ No other `launchdarkly-ai-*` package may define or duplicate these. They import | File | Responsibility | |---|---| | `src/launchdarkly_ai_server/conversation.py` | `conversation_id`, `ConversationIdSpanProcessor` — stamps `gen_ai.conversation.id` | +| `src/launchdarkly_ai_server/sdk_info.py` | `$ld:ai:sdk:info` package registry and flush | | `src/launchdarkly_ai_server/lifecycle.py` | `init_client`, `get_client`, `shutdown`, `extract_variation` | | `src/launchdarkly_ai_server/client.py` | `config()`, `ConfigInstance` | | `src/launchdarkly_ai_server/tracking.py` | `execute_and_track`, `execute_and_stream`, `wrap_tool_handlers`, `parse_usage` | @@ -42,7 +43,7 @@ Key symbols exported from `launchdarkly_ai_server`: ```python # Lifecycle -from launchdarkly_ai_server import init_client, get_client, shutdown, extract_variation +from launchdarkly_ai_server import init_client, get_client, shutdown, extract_variation, register_ai_sdk_package from launchdarkly_ai_server import conversation_id, set_conversation_id_if_absent, ConversationIdSpanProcessor # Types diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index 9a07b056..e444fa9a 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -45,6 +45,7 @@ resolve_handlers, resolve_tools, ) +from .sdk_info import SDK_INFO_CONTEXT, SDK_INFO_EVENT, register_ai_sdk_package from .tracking import execute_and_stream, execute_and_track, wrap_tool_handlers from .types import ( NATIVE_TOOL_KEY, @@ -199,6 +200,9 @@ "shutdown", "extract_variation", "inspect_config", + "SDK_INFO_CONTEXT", + "SDK_INFO_EVENT", + "register_ai_sdk_package", # judges "build_judge_tasks", "run_judge", @@ -215,3 +219,5 @@ "resolve_graph", "GraphInstance", ] + +register_ai_sdk_package("launchdarkly-ai-server", __version__) diff --git a/packages/client/src/launchdarkly_ai_server/lifecycle.py b/packages/client/src/launchdarkly_ai_server/lifecycle.py index 8969f2ea..1a37b25a 100644 --- a/packages/client/src/launchdarkly_ai_server/lifecycle.py +++ b/packages/client/src/launchdarkly_ai_server/lifecycle.py @@ -6,6 +6,7 @@ import os from typing import Any +from .sdk_info import flush_ai_sdk_info, reset_ai_sdk_info from .types import InitClientOptions logger = logging.getLogger(__name__) @@ -139,12 +140,14 @@ async def init_client( # Idempotent — if already initialized, return the existing client if _client is not None: + flush_ai_sdk_info(_client) return _client # BYOC path — pre-initialized client if client is not None: _client = client _setup_telemetry(opts.get("sdkKey", "byoc"), opts) + flush_ai_sdk_info(_client) return _client # Resolve SDK key @@ -191,6 +194,7 @@ async def init_client( _client = ld_client _setup_telemetry(sdk_key, opts) + flush_ai_sdk_info(_client) return _client @@ -207,6 +211,7 @@ async def shutdown() -> None: # Null the singleton before any awaits so a second call is a no-op _client = None _tracer_provider = None + reset_ai_sdk_info() if local_provider is not None: try: @@ -256,7 +261,7 @@ async def inspect_config( - Never raises — returns ``{"enabled": False, "config": None, "meta": None}`` on any error (unreachable LD, bad key, unparseable config, etc.) - - Does not emit any LaunchDarkly telemetry events + - Does not emit generation, duration, or token tracking events - Does not call any AI provider Returns a dict with keys: diff --git a/packages/client/src/launchdarkly_ai_server/sdk_info.py b/packages/client/src/launchdarkly_ai_server/sdk_info.py new file mode 100644 index 00000000..61cc6ac8 --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/sdk_info.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any + +from .types import LDContext + +SDK_INFO_EVENT = "$ld:ai:sdk:info" + +SDK_INFO_CONTEXT: LDContext = { + "kind": "ld_ai", + "key": "ld-internal-tracking", + "anonymous": True, +} + +_SDK_INFO_LANGUAGE = "python" + +_known: dict[str, tuple[str, str]] = {} +_reported: set[str] = set() + + +def register_ai_sdk_package(name: str, version: str) -> None: + """Record a LaunchDarkly AI package so it can report on the next flush.""" + package_id = f"{name}@{version}" + if package_id in _known: + return + _known[package_id] = (name, version) + + +def flush_ai_sdk_info(client: Any) -> None: + """Emit ``$ld:ai:sdk:info`` once per unreported registered package.""" + if len(_known) == len(_reported): + return + + from .utils import to_ld_context + + context = to_ld_context(client, SDK_INFO_CONTEXT) + for package_id, (name, version) in list(_known.items()): + if package_id in _reported: + continue + try: + client.track( + SDK_INFO_EVENT, + context, + { + "aiSdkName": name, + "aiSdkVersion": version, + "aiSdkLanguage": _SDK_INFO_LANGUAGE, + }, + 1, + ) + except Exception: + pass + _reported.add(package_id) + + +def reset_ai_sdk_info(*, clear_known: bool = False) -> None: + """Mark packages unreported. Optionally drop the registered set (tests).""" + _reported.clear() + if clear_known: + _known.clear() diff --git a/packages/client/tests/test_lifecycle.py b/packages/client/tests/test_lifecycle.py index e9f218f3..9f7f211d 100644 --- a/packages/client/tests/test_lifecycle.py +++ b/packages/client/tests/test_lifecycle.py @@ -12,14 +12,20 @@ import launchdarkly_ai_server.lifecycle as lifecycle_module from launchdarkly_ai_server import get_client, init_client, inspect_config, shutdown from launchdarkly_ai_server.lifecycle import _reset_for_testing +from launchdarkly_ai_server.sdk_info import ( + register_ai_sdk_package, + reset_ai_sdk_info, +) @pytest.fixture(autouse=True) def reset_singleton() -> None: """Ensure a fresh singleton for every test.""" _reset_for_testing() + reset_ai_sdk_info(clear_known=True) yield _reset_for_testing() + reset_ai_sdk_info(clear_known=True) def _make_stub_client() -> MagicMock: @@ -67,6 +73,39 @@ async def test_get_client_returns_passed_client(self) -> None: await init_client(client=stub) assert get_client() is stub + async def test_flushes_registered_ai_package_information(self) -> None: + stub = _make_stub_client() + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=stub) + + stub.track.assert_called_once_with( + "$ld:ai:sdk:info", + {"kind": "ld_ai", "key": "ld-internal-tracking", "anonymous": True}, + { + "aiSdkName": "launchdarkly-ai-server", + "aiSdkVersion": "0.1.3", + "aiSdkLanguage": "python", + }, + 1, + ) + + async def test_flushes_package_registered_after_initialization(self) -> None: + stub = _make_stub_client() + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=stub) + stub.track.reset_mock() + register_ai_sdk_package("launchdarkly-ai-openai-agents", "0.1.4") + await init_client() + + stub.track.assert_called_once() + assert ( + stub.track.call_args.args[2]["aiSdkName"] == "launchdarkly-ai-openai-agents" + ) + async def test_does_not_call_node_sdk(self) -> None: stub = _make_stub_client() mock_import = MagicMock() @@ -219,6 +258,19 @@ async def test_allows_reinitialization(self) -> None: await init_client(client=stub2) assert get_client() is stub2 + async def test_reemits_registered_packages_after_shutdown(self) -> None: + stub1 = _make_stub_client() + stub2 = _make_stub_client() + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=stub1) + await shutdown() + await init_client(client=stub2) + + stub1.track.assert_called_once() + stub2.track.assert_called_once() + async def test_idempotent_double_shutdown(self) -> None: stub = _make_stub_client() with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): diff --git a/packages/client/tests/test_sdk_info.py b/packages/client/tests/test_sdk_info.py new file mode 100644 index 00000000..dff75744 --- /dev/null +++ b/packages/client/tests/test_sdk_info.py @@ -0,0 +1,129 @@ +"""Tests for TESTING.md §3.9 AI SDK package information events.""" + +from unittest.mock import MagicMock + +import pytest + +from launchdarkly_ai_server.sdk_info import ( + SDK_INFO_CONTEXT, + SDK_INFO_EVENT, + flush_ai_sdk_info, + register_ai_sdk_package, + reset_ai_sdk_info, +) + + +@pytest.fixture(autouse=True) +def reset_sdk_info_state() -> None: + reset_ai_sdk_info(clear_known=True) + yield + reset_ai_sdk_info(clear_known=True) + + +def _client() -> MagicMock: + client = MagicMock() + client.track = MagicMock() + return client + + +def test_emits_one_event_per_registered_package() -> None: + client = _client() + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + register_ai_sdk_package("launchdarkly-ai-openai-agents", "0.1.4") + + flush_ai_sdk_info(client) + + assert client.track.call_count == 2 + client.track.assert_any_call( + "$ld:ai:sdk:info", + SDK_INFO_CONTEXT, + { + "aiSdkName": "launchdarkly-ai-server", + "aiSdkVersion": "0.1.3", + "aiSdkLanguage": "python", + }, + 1, + ) + client.track.assert_any_call( + "$ld:ai:sdk:info", + SDK_INFO_CONTEXT, + { + "aiSdkName": "launchdarkly-ai-openai-agents", + "aiSdkVersion": "0.1.4", + "aiSdkLanguage": "python", + }, + 1, + ) + + +def test_uses_anonymous_ld_ai_context() -> None: + assert SDK_INFO_EVENT == "$ld:ai:sdk:info" + assert SDK_INFO_CONTEXT == { + "kind": "ld_ai", + "key": "ld-internal-tracking", + "anonymous": True, + } + + +def test_duplicate_registration_and_flush_emit_once() -> None: + client = _client() + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + + flush_ai_sdk_info(client) + flush_ai_sdk_info(client) + + client.track.assert_called_once() + + +def test_two_versions_of_one_package_both_emit() -> None: + client = _client() + register_ai_sdk_package("launchdarkly-ai-server", "0.1.2") + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + + flush_ai_sdk_info(client) + + assert [call.args[2]["aiSdkVersion"] for call in client.track.call_args_list] == [ + "0.1.2", + "0.1.3", + ] + + +def test_late_registration_emits_on_next_flush() -> None: + client = _client() + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + flush_ai_sdk_info(client) + client.track.reset_mock() + + register_ai_sdk_package("launchdarkly-ai-claude-agents", "0.1.4") + flush_ai_sdk_info(client) + + client.track.assert_called_once() + assert client.track.call_args.args[2]["aiSdkName"] == ( + "launchdarkly-ai-claude-agents" + ) + + +def test_reset_reemits_known_packages() -> None: + client = _client() + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + flush_ai_sdk_info(client) + client.track.reset_mock() + + reset_ai_sdk_info() + flush_ai_sdk_info(client) + + client.track.assert_called_once() + + +def test_track_failure_is_non_fatal_and_not_retried() -> None: + client = _client() + client.track.side_effect = RuntimeError("client closed") + register_ai_sdk_package("launchdarkly-ai-server", "0.1.3") + + flush_ai_sdk_info(client) + client.track.reset_mock() + client.track.side_effect = None + flush_ai_sdk_info(client) + + client.track.assert_not_called() diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/__init__.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/__init__.py index ad260771..b358a47c 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/__init__.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/__init__.py @@ -2,6 +2,8 @@ __version__ = "0.1.4" # x-release-please-version +from launchdarkly_ai_server import register_ai_sdk_package + from .graph import langchain_graph from .handler import create_langchain_agents_handler, langchain_agents from .native_graph import to_lang_graph @@ -12,3 +14,5 @@ "langchain_graph", "to_lang_graph", ] + +register_ai_sdk_package("launchdarkly-ai-langchain-agents", __version__) diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/__init__.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/__init__.py index 0f661966..76c8b89d 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/__init__.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/__init__.py @@ -2,6 +2,10 @@ __version__ = "0.1.4" # x-release-please-version +from launchdarkly_ai_server import register_ai_sdk_package + from .handler import create_langchain_messages_handler, langchain_messages __all__ = ["create_langchain_messages_handler", "langchain_messages"] + +register_ai_sdk_package("launchdarkly-ai-langchain-messages", __version__) diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/__init__.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/__init__.py index 167aea69..fc0a2e02 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/__init__.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/__init__.py @@ -2,6 +2,8 @@ __version__ = "0.1.4" # x-release-please-version +from launchdarkly_ai_server import register_ai_sdk_package + from . import native_graph # noqa: F401 from .graph import openai_graph from .handler import create_openai_agent_handler, openai_agents @@ -15,3 +17,5 @@ "openai_graph", "to_openai_agents", ] + +register_ai_sdk_package("launchdarkly-ai-openai-agents", __version__) diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/__init__.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/__init__.py index df8776e1..82200a15 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/__init__.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/__init__.py @@ -2,6 +2,10 @@ __version__ = "0.1.4" # x-release-please-version +from launchdarkly_ai_server import register_ai_sdk_package + from .handler import create_openai_messages_handler, openai_messages __all__ = ["create_openai_messages_handler", "openai_messages"] + +register_ai_sdk_package("launchdarkly-ai-openai-messages", __version__)