diff --git a/adk/README.md b/adk/README.md index ef7c553d9..0aa5bc493 100644 --- a/adk/README.md +++ b/adk/README.md @@ -20,6 +20,14 @@ pip install agentex-sdk This automatically pulls in [`agentex-client`](../) (the slim Stainless-generated REST client) so `from agentex import Agentex, AsyncAgentex` works the same as before. +Datadog support is optional. To use it (for example `ddtrace-run`, or `dd.trace_id` / `dd.span_id` in JSON logs), install the `datadog` extra or add `ddtrace` to your own dependencies: + +```sh +pip install 'agentex-sdk[datadog]' +``` + +Without it, the SDK does not install or import `ddtrace`. JSON logs still carry `dd.service`, `dd.env` and `dd.version` from `DD_SERVICE`, `DD_ENV` and `DD_VERSION`. + ## When to use this vs `agentex-client` - **`agentex-sdk`** — you're authoring agents. Pulls everything: ACP server, Temporal, MCP, LLM providers, observability, CLI. ~37 deps. diff --git a/adk/pyproject.toml b/adk/pyproject.toml index a5f393374..44f684211 100644 --- a/adk/pyproject.toml +++ b/adk/pyproject.toml @@ -56,8 +56,7 @@ dependencies = [ "scale-gp>=0.1.0a59", "scale-gp-beta>=0.5.0", "mcp>=1.4.1", - # Observability - "ddtrace>=3.13.0", + # Observability (ddtrace is opt-in via the `datadog` extra below) "opentelemetry-api>=1.20.0", "opentelemetry-sdk>=1.20.0", "json_log_formatter>=1.1.1", @@ -78,6 +77,12 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", ] +[project.optional-dependencies] +# Some deployments do not allow Datadog software to be installed, so ddtrace is +# opt-in. Without it, logs skip the dd.trace_id/dd.span_id fields and the +# dd_only obs helpers return no ids. +datadog = ["ddtrace>=3.13.0"] + # No `obs` extra, deliberately — do not add one for sgp-obs. # # sgp-obs is not on public PyPI (it is served from Scale's curated CodeArtifact diff --git a/requirements-dev.lock b/requirements-dev.lock index ff876c6b5..c5e7eb9cf 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -35,8 +35,10 @@ attrs==25.4.0 # aiohttp # jsonschema # referencing -bytecode==0.17.0 - # via ddtrace +azure-core==1.41.0 + # via azure-identity +azure-identity==1.27.0 + # via agentex-sdk certifi==2026.1.4 # via # httpcore @@ -65,9 +67,10 @@ colorama==0.4.6 ; sys_platform == 'win32' comm==0.2.3 # via ipywidgets cryptography==48.0.0 - # via pyjwt -ddtrace==4.10.1 - # via agentex-sdk + # via + # azure-identity + # msal + # pyjwt debugpy==1.8.21 decorator==5.3.1 # via ipython @@ -80,8 +83,6 @@ distro==1.9.0 # scale-gp-beta durationpy==0.10 # via kubernetes -envier==0.6.1 - # via ddtrace execnet==2.1.2 # via pytest-xdist executing==2.2.1 @@ -207,6 +208,12 @@ mcp==1.27.2 # openai-agents mdurl==0.1.2 # via markdown-it-py +msal==1.39.0 + # via + # azure-identity + # msal-extensions +msal-extensions==1.3.1 + # via azure-identity multidict==6.7.0 # via # aiohttp @@ -234,7 +241,6 @@ openai-agents==0.14.8 opentelemetry-api==1.42.1 # via # agentex-sdk - # ddtrace # opentelemetry-sdk # opentelemetry-semantic-conventions # pydantic-ai-slim @@ -312,7 +318,9 @@ pygments==2.19.2 # pytest # rich pyjwt==2.13.0 - # via mcp + # via + # mcp + # msal pyright==1.1.399 pytest==9.0.2 # via @@ -350,8 +358,10 @@ regex==2026.5.9 # via tiktoken requests==2.34.2 # via + # azure-core # kubernetes # langsmith + # msal # openai-agents # python-on-whales # requests-oauthlib @@ -373,7 +383,7 @@ rpds-py==2026.5.1 ruff==0.14.13 scale-gp==0.1.0a62 # via agentex-sdk -scale-gp-beta==0.2.0 +scale-gp-beta==0.5.0 # via agentex-sdk shellingham==1.5.4 # via typer @@ -439,6 +449,8 @@ typing-extensions==4.15.0 # agentex-client # aiosignal # anyio + # azure-core + # azure-identity # fastapi # huggingface-hub # langchain-core @@ -496,8 +508,6 @@ websockets==16.0 # openai-agents widgetsnbextension==4.0.15 # via ipywidgets -wrapt==2.2.1 - # via ddtrace xxhash==3.7.0 # via langsmith yarl==1.22.0 diff --git a/scripts/check-wheel-install b/scripts/check-wheel-install index b80aed2d2..6b98fe112 100755 --- a/scripts/check-wheel-install +++ b/scripts/check-wheel-install @@ -22,4 +22,11 @@ from agentex.types import Event # client surface — ships in agentex-client from agentex.resources import states # client surface that "didn't land" in the incident print("agentex namespace OK:", Event.__name__, states.__name__) + +# ddtrace is opt-in (agentex-sdk[datadog]); a plain install must not pull it in. +import importlib.util +assert importlib.util.find_spec("ddtrace") is None, "ddtrace installed without the datadog extra" +from agentex.lib.utils.logging import make_logger # top-level ddtrace import used to live here + +make_logger("agentex.check_wheel_install").info("logging OK without ddtrace") PY diff --git a/src/agentex/lib/utils/logging.py b/src/agentex/lib/utils/logging.py index e2d5d5cb4..87eb4dd62 100644 --- a/src/agentex/lib/utils/logging.py +++ b/src/agentex/lib/utils/logging.py @@ -2,11 +2,15 @@ import logging import contextvars -import ddtrace import json_log_formatter from rich.console import Console from rich.logging import RichHandler +try: + import ddtrace +except ImportError: # ddtrace ships only with the `datadog` extra + ddtrace = None # type: ignore[assignment] + _is_datadog_configured = bool(os.environ.get("DD_AGENT_HOST")) ctx_var_request_id = contextvars.ContextVar[str]("request_id") @@ -73,24 +77,22 @@ def json_record(self, message: str, extra: dict, record: logging.LogRecord) -> d extra["lineno"] = record.lineno extra["pathname"] = record.pathname extra["request_id"] = ctx_var_request_id.get(None) - if _is_datadog_configured: - extra["dd.trace_id"] = ddtrace.tracer.get_log_correlation_context().get("dd.trace_id", None) or getattr( # type: ignore[attr-defined] - record, "dd.trace_id", 0 - ) - extra["dd.span_id"] = ddtrace.tracer.get_log_correlation_context().get("dd.span_id", None) or getattr( # type: ignore[attr-defined] - record, "dd.span_id", 0 - ) + if _is_datadog_configured and ddtrace is not None: + dd_context = ddtrace.tracer.get_log_correlation_context() # type: ignore[attr-defined] + extra["dd.trace_id"] = dd_context.get("dd.trace_id", None) or getattr(record, "dd.trace_id", 0) + extra["dd.span_id"] = dd_context.get("dd.span_id", None) or getattr(record, "dd.span_id", 0) # add the env, service, and version configured for the tracer # If tracing is not set up, then this should pull values from DD_ENV, DD_SERVICE, and DD_VERSION. - service_override = ddtrace.config.service or os.getenv("DD_SERVICE") + dd_config = ddtrace.config if ddtrace is not None else None + service_override = (dd_config.service if dd_config is not None else None) or os.getenv("DD_SERVICE") if service_override: extra["dd.service"] = service_override - env_override = ddtrace.config.env or os.getenv("DD_ENV") + env_override = (dd_config.env if dd_config is not None else None) or os.getenv("DD_ENV") if env_override: extra["dd.env"] = env_override - version_override = ddtrace.config.version or os.getenv("DD_VERSION") + version_override = (dd_config.version if dd_config is not None else None) or os.getenv("DD_VERSION") if version_override: extra["dd.version"] = version_override diff --git a/tests/test_ddtrace_optional.py b/tests/test_ddtrace_optional.py new file mode 100644 index 000000000..728df0022 --- /dev/null +++ b/tests/test_ddtrace_optional.py @@ -0,0 +1,120 @@ +"""ddtrace is an optional dependency (the ``datadog`` extra). + +Some deployments do not allow Datadog software to be installed, so the SDK has to +import and log cleanly without it. These tests hide ddtrace via ``sys.modules`` (a +``None`` entry makes the import raise ImportError) and check that nothing breaks. +""" + +from __future__ import annotations + +import os +import sys +import json +import logging +import subprocess + +import pytest + +from agentex.lib.utils import logging as agentex_logging +from agentex.lib.core.tracing import obs_ids, obs_span + + +def _hide_ddtrace(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "ddtrace", None) + monkeypatch.setitem(sys.modules, "ddtrace.trace", None) + + +def _format(formatter: logging.Formatter) -> dict: + record = logging.LogRecord("agentex.test", logging.INFO, __file__, 1, "hello", None, None) + return json.loads(formatter.format(record)) + + +# A fresh interpreter, so the import-time `import ddtrace` in agentex modules runs +# with ddtrace hidden rather than reusing the copy this test process already loaded. +_IMPORT_WITHOUT_DDTRACE = """ +import sys, logging +sys.modules["ddtrace"] = None + +import agentex +from agentex.lib.utils import logging as agentex_logging +import agentex.lib.adk +import agentex.lib.sdk.fastacp.fastacp +import agentex.lib.core.tracing.trace +import agentex.lib.cli.commands.main + +assert agentex_logging.ddtrace is None +record = logging.LogRecord("agentex.test", logging.INFO, "test.py", 1, "hello", None, None) +print(agentex_logging.CustomJSONFormatter().format(record)) +""" + + +def test_agentex_imports_and_logs_without_ddtrace() -> None: + env = {"DD_AGENT_HOST": "localhost", "DD_SERVICE": "svc", "DD_ENV": "test", "DD_VERSION": "1.2.3"} + result = subprocess.run( + [sys.executable, "-c", _IMPORT_WITHOUT_DDTRACE], + capture_output=True, + text=True, + env={**{k: v for k, v in os.environ.items() if not k.startswith("DD_")}, **env}, + check=False, + ) + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout.strip().splitlines()[-1]) + assert payload["message"] == "hello" + assert "dd.trace_id" not in payload + assert "dd.span_id" not in payload + # The env-var fallbacks still apply without ddtrace. + assert payload["dd.service"] == "svc" + assert payload["dd.env"] == "test" + assert payload["dd.version"] == "1.2.3" + + +class TestJsonFormatter: + def test_without_ddtrace_skips_trace_ids_and_keeps_env_fallbacks(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(agentex_logging, "ddtrace", None) + monkeypatch.setattr(agentex_logging, "_is_datadog_configured", True) + monkeypatch.setenv("DD_SERVICE", "svc") + monkeypatch.delenv("DD_ENV", raising=False) + monkeypatch.delenv("DD_VERSION", raising=False) + + payload = _format(agentex_logging.CustomJSONFormatter()) + + assert "dd.trace_id" not in payload + assert "dd.span_id" not in payload + assert payload["dd.service"] == "svc" + assert "dd.env" not in payload + + def test_with_ddtrace_adds_trace_ids(self, monkeypatch: pytest.MonkeyPatch) -> None: + ddtrace = pytest.importorskip("ddtrace") + monkeypatch.setattr(agentex_logging, "_is_datadog_configured", True) + + # Inside a real span, so the log must carry that span's ids, not placeholders. + with ddtrace.tracer.trace("agentex.test"): + expected = ddtrace.tracer.get_log_correlation_context() + payload = _format(agentex_logging.CustomJSONFormatter()) + + assert expected["dd.trace_id"] not in (None, 0, "0") + assert expected["dd.span_id"] not in (None, 0, "0") + assert payload["dd.trace_id"] == expected["dd.trace_id"] + assert payload["dd.span_id"] == expected["dd.span_id"] + + +class TestObsHelpersWithoutDdtrace: + def test_ddtrace_ids_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + _hide_ddtrace(monkeypatch) + assert obs_ids._ddtrace_ids() is None + + def test_default_mode_correlation_is_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SGP_OBS_MODE", raising=False) # default is dd_only + _hide_ddtrace(monkeypatch) + assert obs_ids.obs_correlation() == {} + + def test_default_mode_open_span_is_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SGP_OBS_MODE", raising=False) + _hide_ddtrace(monkeypatch) + assert obs_span.open_obs_span("step", "biz-span", "biz-trace") is None + + def test_tag_ambient_is_false_and_never_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SGP_OBS_MODE", raising=False) + _hide_ddtrace(monkeypatch) + assert obs_span._tag_ddtrace_ambient("biz-span", "biz-trace") is False + obs_span.tag_ambient_obs_span("biz-span", "biz-trace") diff --git a/uv.lock b/uv.lock index 41c325f25..1dc2fdeda 100644 --- a/uv.lock +++ b/uv.lock @@ -99,7 +99,6 @@ dependencies = [ { name = "azure-identity" }, { name = "claude-agent-sdk" }, { name = "cloudpickle" }, - { name = "ddtrace" }, { name = "fastapi" }, { name = "jinja2" }, { name = "json-log-formatter" }, @@ -129,6 +128,11 @@ dependencies = [ { name = "yaspin" }, ] +[package.optional-dependencies] +datadog = [ + { name = "ddtrace" }, +] + [package.metadata] requires-dist = [ { name = "agentex-client", editable = "." }, @@ -136,7 +140,7 @@ requires-dist = [ { name = "azure-identity", specifier = ">=1.16.1,<2.0.0" }, { name = "claude-agent-sdk", specifier = ">=0.1.0" }, { name = "cloudpickle", specifier = ">=3.1.1" }, - { name = "ddtrace", specifier = ">=3.13.0" }, + { name = "ddtrace", marker = "extra == 'datadog'", specifier = ">=3.13.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "jinja2", specifier = ">=3.1.3,<4" }, { name = "json-log-formatter", specifier = ">=1.1.1" }, @@ -165,6 +169,7 @@ requires-dist = [ { name = "watchfiles", specifier = ">=0.24.0,<1.0" }, { name = "yaspin", specifier = ">=3.1.0" }, ] +provides-extras = ["datadog"] [[package]] name = "aiohappyeyeballs"