Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions adk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions adk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
34 changes: 22 additions & 12 deletions requirements-dev.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -439,6 +449,8 @@ typing-extensions==4.15.0
# agentex-client
# aiosignal
# anyio
# azure-core
# azure-identity
# fastapi
# huggingface-hub
# langchain-core
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions scripts/check-wheel-install
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 13 additions & 11 deletions src/agentex/lib/utils/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand Down
120 changes: 120 additions & 0 deletions tests/test_ddtrace_optional.py
Original file line number Diff line number Diff line change
@@ -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")
9 changes: 7 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading