diff --git a/azure/durable_functions/openai_agents/orchestrator_generator.py b/azure/durable_functions/openai_agents/orchestrator_generator.py index 7c2160b0..56d5aa01 100644 --- a/azure/durable_functions/openai_agents/orchestrator_generator.py +++ b/azure/durable_functions/openai_agents/orchestrator_generator.py @@ -9,6 +9,7 @@ from .runner import DurableOpenAIRunner from .context import DurableAIAgentContext from .event_loop import ensure_event_loop +from .usage_telemetry import UsageTelemetry async def durable_openai_agent_activity(input: str, model_provider: ModelProvider): @@ -29,6 +30,9 @@ def durable_openai_agent_orchestrator_generator( activity_name: str, ): """Adapts the synchronous OpenAI Agents function to an Durable orchestrator generator.""" + # Log versions the first time this generator is invoked + UsageTelemetry.log_usage_once() + ensure_event_loop() task_tracker = TaskTracker(durable_orchestration_context) durable_ai_agent_context = DurableAIAgentContext( diff --git a/azure/durable_functions/openai_agents/usage_telemetry.py b/azure/durable_functions/openai_agents/usage_telemetry.py new file mode 100644 index 00000000..3ae824ea --- /dev/null +++ b/azure/durable_functions/openai_agents/usage_telemetry.py @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class UsageTelemetry: + """Handles telemetry logging for OpenAI Agents SDK integration usage.""" + + # Class-level flag to ensure logging happens only once across all instances + _usage_logged = False + + @classmethod + def log_usage_once(cls): + """Log OpenAI Agents SDK integration usage exactly once. + + Fails gracefully if metadata cannot be retrieved. + """ + if cls._usage_logged: + return + + # NOTE: Any log line beginning with the special prefix defined below will be + # captured by the Azure Functions host as a Language Worker console log and + # forwarded to internal telemetry pipelines. + # Do not change this constant value without coordinating with the Functions + # host team. + LANGUAGE_WORKER_CONSOLE_LOG_PREFIX = "LanguageWorkerConsoleLog" + + package_versions = cls._collect_openai_agent_package_versions() + msg = ( + f"{LANGUAGE_WORKER_CONSOLE_LOG_PREFIX}" # Prefix captured by Azure Functions host + "Detected OpenAI Agents SDK integration with Durable Functions. " + f"Package versions: {package_versions}" + ) + print(msg) + + cls._usage_logged = True + + @classmethod + def _collect_openai_agent_package_versions(cls) -> str: + """Collect versions of relevant packages for telemetry logging. + + Returns + ------- + str + Comma-separated list of name=version entries or "(unavailable)" if + versions could not be determined. + """ + try: + try: + from importlib import metadata # Python 3.8+ + except ImportError: # pragma: no cover - legacy fallback + import importlib_metadata as metadata # type: ignore + + package_names = [ + "azure-functions-durable", + "openai", + "openai-agents", + ] + + versions = [] + for package_name in package_names: + try: + ver = metadata.version(package_name) + versions.append(f"{package_name}={ver}") + except Exception: # noqa: BLE001 - swallow and continue + versions.append(f"{package_name}=(not installed)") + + return ", ".join(versions) if versions else "(unavailable)" + except Exception: # noqa: BLE001 - never let version gathering break user code + return "(unavailable)" diff --git a/tests/openai_agents/test_usage_telemetry.py b/tests/openai_agents/test_usage_telemetry.py new file mode 100644 index 00000000..05f8f09d --- /dev/null +++ b/tests/openai_agents/test_usage_telemetry.py @@ -0,0 +1,97 @@ +import unittest.mock + + +class TestUsageTelemetry: + """Test cases for the UsageTelemetry class.""" + + def test_log_usage_once_logs_message_on_first_call(self, capsys): + """Test that log_usage_once logs the telemetry message.""" + # Reset any previous state by creating a fresh import + import importlib + from azure.durable_functions.openai_agents import usage_telemetry + importlib.reload(usage_telemetry) + UsageTelemetryFresh = usage_telemetry.UsageTelemetry + + def mock_version(package_name): + if package_name == "azure-functions-durable": + return "1.3.4" + elif package_name == "openai": + return "1.98.0" + elif package_name == "openai-agents": + return "0.2.5" + return "unknown" + + with unittest.mock.patch('importlib.metadata.version', side_effect=mock_version): + UsageTelemetryFresh.log_usage_once() + + captured = capsys.readouterr() + assert captured.out.startswith("LanguageWorkerConsoleLog") + assert "Detected OpenAI Agents SDK integration with Durable Functions." in captured.out + assert "azure-functions-durable=1.3.4" in captured.out + assert "openai=1.98.0" in captured.out + assert "openai-agents=0.2.5" in captured.out + + def test_log_usage_handles_package_version_errors(self, capsys): + """Test that log_usage_once handles package version lookup errors gracefully.""" + # Reset any previous state by creating a fresh import + import importlib + from azure.durable_functions.openai_agents import usage_telemetry + importlib.reload(usage_telemetry) + UsageTelemetryFresh = usage_telemetry.UsageTelemetry + + # Test with mixed success/failure scenario: some packages work, others fail + def mock_version(package_name): + if package_name == "azure-functions-durable": + return "1.3.4" + elif package_name == "openai": + raise Exception("Package not found") + elif package_name == "openai-agents": + return "0.2.5" + return "unknown" + + with unittest.mock.patch('importlib.metadata.version', side_effect=mock_version): + UsageTelemetryFresh.log_usage_once() + + captured = capsys.readouterr() + assert captured.out.startswith("LanguageWorkerConsoleLog") + assert "Detected OpenAI Agents SDK integration with Durable Functions." in captured.out + # Should handle errors gracefully: successful packages show versions, failed ones show "(not installed)" + assert "azure-functions-durable=1.3.4" in captured.out + assert "openai=(not installed)" in captured.out + assert "openai-agents=0.2.5" in captured.out + + def test_log_usage_works_with_real_packages(self, capsys): + """Test that log_usage_once works with real package versions.""" + # Reset any previous state by creating a fresh import + import importlib + from azure.durable_functions.openai_agents import usage_telemetry + importlib.reload(usage_telemetry) + UsageTelemetryFresh = usage_telemetry.UsageTelemetry + + # Test without mocking to see the real behavior + UsageTelemetryFresh.log_usage_once() + + captured = capsys.readouterr() + assert captured.out.startswith("LanguageWorkerConsoleLog") + assert "Detected OpenAI Agents SDK integration with Durable Functions." in captured.out + # Should contain some version information or (unavailable) + assert ("azure-functions-durable=" in captured.out or "(unavailable)" in captured.out) + + def test_log_usage_once_is_idempotent(self, capsys): + """Test that multiple calls to log_usage_once only log once.""" + # Reset any previous state by creating a fresh import + import importlib + from azure.durable_functions.openai_agents import usage_telemetry + importlib.reload(usage_telemetry) + UsageTelemetryFresh = usage_telemetry.UsageTelemetry + + with unittest.mock.patch('importlib.metadata.version', return_value="1.0.0"): + # Call multiple times + UsageTelemetryFresh.log_usage_once() + UsageTelemetryFresh.log_usage_once() + UsageTelemetryFresh.log_usage_once() + + captured = capsys.readouterr() + # Should only see one log message despite multiple calls + log_count = captured.out.count("LanguageWorkerConsoleLogDetected OpenAI Agents SDK integration") + assert log_count == 1 \ No newline at end of file