From 35c46b129ac14d5691bea5ad576bf53dd54e3849 Mon Sep 17 00:00:00 2001 From: Eugenia G Quintana Date: Wed, 4 Nov 2026 11:39:38 +0000 Subject: [PATCH 1/2] fix: make ddtrace an optional dependency (datadog extra) ddtrace has been a required dependency of agentex-sdk since 0.4.19 (#127), and agentex.lib.utils.logging imported it at module level, so every agent built on the SDK installs Datadog's tracer even when it never uses it. Some deployments do not allow Datadog software at all. Move ddtrace to an optional `datadog` extra (agentex-sdk[datadog]) and guard the import in logging.py: the dd.trace_id/dd.span_id fields are only added when ddtrace is importable, and dd.service/env/version still fall back to DD_SERVICE/DD_ENV/DD_VERSION. The obs_ids/obs_span ddtrace helpers already return None/False on ImportError; tests now cover that. check-wheel-install now asserts a plain install does not pull in ddtrace. requirements-dev.lock is regenerated with the bootstrap export command, which also picks up drift already on main (azure-identity, scale-gp-beta). --- adk/README.md | 8 +++ adk/pyproject.toml | 9 ++- requirements-dev.lock | 34 +++++---- scripts/check-wheel-install | 7 ++ src/agentex/lib/utils/logging.py | 24 ++++--- tests/test_ddtrace_optional.py | 115 +++++++++++++++++++++++++++++++ uv.lock | 9 ++- 7 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 tests/test_ddtrace_optional.py 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..6b989eb77 --- /dev/null +++ b/tests/test_ddtrace_optional.py @@ -0,0 +1,115 @@ +"""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: + pytest.importorskip("ddtrace") + monkeypatch.setattr(agentex_logging, "_is_datadog_configured", True) + + payload = _format(agentex_logging.CustomJSONFormatter()) + + assert "dd.trace_id" in payload + assert "dd.span_id" in payload + + +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" From 4e7817506759a2b04dc9eb236158106195468b7d Mon Sep 17 00:00:00 2001 From: Eugenia G Quintana Date: Wed, 4 Nov 2026 11:46:27 +0000 Subject: [PATCH 2/2] test: assert the real span's trace ids reach the JSON log --- tests/test_ddtrace_optional.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_ddtrace_optional.py b/tests/test_ddtrace_optional.py index 6b989eb77..728df0022 100644 --- a/tests/test_ddtrace_optional.py +++ b/tests/test_ddtrace_optional.py @@ -84,13 +84,18 @@ def test_without_ddtrace_skips_trace_ids_and_keeps_env_fallbacks(self, monkeypat assert "dd.env" not in payload def test_with_ddtrace_adds_trace_ids(self, monkeypatch: pytest.MonkeyPatch) -> None: - pytest.importorskip("ddtrace") + ddtrace = pytest.importorskip("ddtrace") monkeypatch.setattr(agentex_logging, "_is_datadog_configured", True) - payload = _format(agentex_logging.CustomJSONFormatter()) + # 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 "dd.trace_id" in payload - assert "dd.span_id" in payload + 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: