From 071baa53798fa6636c0794ed29bc774c9adfd4bf Mon Sep 17 00:00:00 2001 From: Gary Huang Date: Mon, 11 Aug 2025 16:09:19 -0400 Subject: [PATCH 01/14] chore(llmobs): description should be optional when create_dataset (#14275) description should be optional when create_dataset ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- ddtrace/llmobs/_llmobs.py | 2 +- ddtrace/llmobs/_writer.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ddtrace/llmobs/_llmobs.py b/ddtrace/llmobs/_llmobs.py index 3468ad789b1..9b928c39b99 100644 --- a/ddtrace/llmobs/_llmobs.py +++ b/ddtrace/llmobs/_llmobs.py @@ -633,7 +633,7 @@ def pull_dataset(cls, name: str) -> Dataset: return ds @classmethod - def create_dataset(cls, name: str, description: str, records: Optional[List[DatasetRecord]] = None) -> Dataset: + def create_dataset(cls, name: str, description: str = "", records: Optional[List[DatasetRecord]] = None) -> Dataset: if records is None: records = [] ds = cls._instance._dne_client.dataset_create(name, description) diff --git a/ddtrace/llmobs/_writer.py b/ddtrace/llmobs/_writer.py index 53ae9ef81ca..0f98e0c9623 100644 --- a/ddtrace/llmobs/_writer.py +++ b/ddtrace/llmobs/_writer.py @@ -358,14 +358,13 @@ def dataset_create(self, name: str, description: str) -> Dataset: @staticmethod def _get_record_json(record: Union[UpdatableDatasetRecord, DatasetRecordRaw], is_update: bool) -> JSONType: - # for now, if a user wants to "erase" the value of expected_output, they are expected to - # set expected_output to None, and we serialize that as empty string to indicate this to BE + # for now, if a user wants to "erase" the value of expected_output or metadata, they are expected to + # set it to None, and we serialize an empty string (for expected_output) and empty dict (for metadata) + # to indicate this erasure to BE expected_output: JSONType = None if "expected_output" in record: expected_output = "" if record["expected_output"] is None else record["expected_output"] - # for now, if a user wants to "erase" the value of metadata, they are expected to - # set metadata to None, and we serialize that as an empty map to indicate this to BE metadata: JSONType = None if "metadata" in record: metadata = {} if record["metadata"] is None else record["metadata"] From a97bc82d82080202d34829102c77a365a42727d0 Mon Sep 17 00:00:00 2001 From: Nick Ripley Date: Mon, 11 Aug 2025 16:57:56 -0400 Subject: [PATCH 02/14] chore(profiling): re-accept interval for memory profile collector (#14276) In #14205 we removed the `_interval` parameter for the `MemoryCollector`. This was a mistake. Now the memory profile collector thread basically spins in a tight loop because the interval is 0. Add that parameter back. --- ddtrace/profiling/collector/memalloc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ddtrace/profiling/collector/memalloc.py b/ddtrace/profiling/collector/memalloc.py index ebfb5f0ef2c..72f01b6e842 100644 --- a/ddtrace/profiling/collector/memalloc.py +++ b/ddtrace/profiling/collector/memalloc.py @@ -30,13 +30,17 @@ class MemoryCollector(collector.PeriodicCollector): """Memory allocation collector.""" + _DEFAULT_INTERVAL = 0.5 + def __init__( self, + _interval: float = _DEFAULT_INTERVAL, max_nframe: Optional[int] = None, heap_sample_size: Optional[int] = None, ignore_profiler: Optional[bool] = None, ): super().__init__() + self._interval: float = _interval # TODO make this dynamic based on the 1. interval and 2. the max number of events allowed in the Recorder self.max_nframe: int = max_nframe if max_nframe is not None else config.max_frames self.heap_sample_size: int = heap_sample_size if heap_sample_size is not None else config.heap.sample_size From 0f8743608fd4a506271891ae2c2c48b6a2080d23 Mon Sep 17 00:00:00 2001 From: Taegyun Kim Date: Tue, 12 Aug 2025 23:19:31 +0900 Subject: [PATCH 03/14] chore(build): simplify build python 3 jobs (#14278) ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- .github/workflows/build_python_3.yml | 114 +++++++++------------------ 1 file changed, 37 insertions(+), 77 deletions(-) diff --git a/.github/workflows/build_python_3.yml b/.github/workflows/build_python_3.yml index 48da704a2fd..c1b2e70e882 100644 --- a/.github/workflows/build_python_3.yml +++ b/.github/workflows/build_python_3.yml @@ -24,8 +24,8 @@ jobs: persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: '3.8' - - run: pip install cibuildwheel==2.22.0 + python-version: "3.13" + - run: pip install cibuildwheel==2.23.3 - id: set-matrix env: CIBW_BUILD: ${{ inputs.cibw_build }} @@ -50,19 +50,51 @@ jobs: fail-fast: false matrix: include: ${{ fromJson(needs.build-wheels-matrix.outputs.include) }} + env: + CIBW_SKIP: ${{ inputs.cibw_skip }} + CIBW_PRERELEASE_PYTHONS: ${{ inputs.cibw_prerelease_pythons }} + CIBW_MUSLLINUX_I686_IMAGE: ghcr.io/datadog/dd-trace-py/pypa_musllinux_1_2_i686:latest + CIBW_BEFORE_ALL_WINDOWS: rustup target add i686-pc-windows-msvc + CIBW_BEFORE_ALL_MACOS: rustup target add aarch64-apple-darwin + CIBW_BEFORE_ALL_LINUX: | + if [[ "$(uname -m)-$(uname -i)-$(uname -o | tr '[:upper:]' '[:lower:]')-$(ldd --version 2>&1 | head -n 1 | awk '{print $1}')" != "i686-unknown-linux-musl" ]]; then + if command -v yum &> /dev/null; then + yum install -y libatomic.i686 + fi + curl -sSf https://sh.rustup.rs | sh -s -- -y; + fi + CIBW_ENVIRONMENT_LINUX: PATH=$HOME/.cargo/bin:$PATH CMAKE_BUILD_PARALLEL_LEVEL=24 CMAKE_ARGS="-DNATIVE_TESTING=OFF" + # SYSTEM_VERSION_COMPAT is a workaround for versioning issue, a.k.a. + # `platform.mac_ver()` reports incorrect MacOS version at 11.0 + # See: https://stackoverflow.com/a/65402241 + CIBW_ENVIRONMENT_MACOS: CMAKE_BUILD_PARALLEL_LEVEL=24 SYSTEM_VERSION_COMPAT=0 CMAKE_ARGS="-DNATIVE_TESTING=OFF" + CIBW_REPAIR_WHEEL_COMMAND_LINUX: | + mkdir ./tempwheelhouse && + unzip -l {wheel} | grep '\.so' && + auditwheel repair -w ./tempwheelhouse {wheel} && + for w in ./tempwheelhouse/*.whl; do + python scripts/zip_filter.py $w \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md + mv $w {dest_dir} + done && + rm -rf ./tempwheelhouse + CIBW_REPAIR_WHEEL_COMMAND_MACOS: | + zip -d {wheel} \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md && + MACOSX_DEPLOYMENT_TARGET=12.7 delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: choco install -y 7zip && + 7z d -r "{wheel}" *.c *.cpp *.cc *.h *.hpp *.pyx *.md && + move "{wheel}" "{dest_dir}" + CIBW_TEST_COMMAND: "python {project}/tests/smoke_test.py" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - # Include all history and tags with: persist-credentials: false fetch-depth: 0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - if: matrix.os != 'ubuntu-24.04-arm' name: Install Python with: - python-version: '3.8' + python-version: "3.13" - name: Set up QEMU if: runner.os == 'Linux' && matrix.os != 'ubuntu-24.04-arm' @@ -70,82 +102,10 @@ jobs: with: platforms: all - - name: Build wheels arm64 - if: always() && matrix.os == 'ubuntu-24.04-arm' - run: pipx run cibuildwheel==2.22.0 --only ${{ matrix.only }} - env: - CIBW_SKIP: ${{ inputs.cibw_skip }} - CIBW_PRERELEASE_PYTHONS: ${{ inputs.cibw_prerelease_pythons }} - CIBW_MUSLLINUX_I686_IMAGE: ghcr.io/datadog/dd-trace-py/pypa_musllinux_1_2_i686:latest - CIBW_BEFORE_ALL: > - if [[ "$(uname -m)-$(uname -i)-$(uname -o | tr '[:upper:]' '[:lower:]')-$(ldd --version 2>&1 | head -n 1 | awk '{print $1}')" != "i686-unknown-linux-musl" ]]; - then - curl -sSf https://sh.rustup.rs | sh -s -- -y; - fi - CIBW_BEFORE_ALL_WINDOWS: rustup target add i686-pc-windows-msvc - CIBW_BEFORE_ALL_MACOS: rustup target add aarch64-apple-darwin - CIBW_ENVIRONMENT_LINUX: PATH=$HOME/.cargo/bin:$PATH CMAKE_BUILD_PARALLEL_LEVEL=24 CMAKE_ARGS="-DNATIVE_TESTING=OFF" - CIBW_REPAIR_WHEEL_COMMAND_LINUX: | - mkdir ./tempwheelhouse && - unzip -l {wheel} | grep '\.so' && - auditwheel repair -w ./tempwheelhouse {wheel} && - for w in ./tempwheelhouse/*.whl; do - python scripts/zip_filter.py $w \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md - mv $w {dest_dir} - done && - rm -rf ./tempwheelhouse - CIBW_REPAIR_WHEEL_COMMAND_MACOS: | - zip -d {wheel} \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md && - MACOSX_DEPLOYMENT_TARGET=12.7 delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: - choco install -y 7zip && - 7z d -r "{wheel}" *.c *.cpp *.cc *.h *.hpp *.pyx *.md && - move "{wheel}" "{dest_dir}" - CIBW_TEST_COMMAND: "python {project}/tests/smoke_test.py" - # DEV: Uncomment to debug MacOS - # CIBW_BUILD_VERBOSITY_MACOS: 3 - - name: Build wheels - if: always() && matrix.os != 'ubuntu-24.04-arm' uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 with: only: ${{ matrix.only }} - env: - CIBW_SKIP: ${{ inputs.cibw_skip }} - CIBW_PRERELEASE_PYTHONS: ${{ inputs.cibw_prerelease_pythons }} - CIBW_MUSLLINUX_I686_IMAGE: ghcr.io/datadog/dd-trace-py/pypa_musllinux_1_2_i686:latest - CIBW_BEFORE_ALL: > - if [[ "$(uname -m)-$(uname -i)-$(uname -o | tr '[:upper:]' '[:lower:]')-$(ldd --version 2>&1 | head -n 1 | awk '{print $1}')" != "i686-unknown-linux-musl" ]]; - then - yum install -y libatomic.i686 - curl -sSf https://sh.rustup.rs | sh -s -- -y; - fi - CIBW_BEFORE_ALL_WINDOWS: rustup target add i686-pc-windows-msvc - CIBW_BEFORE_ALL_MACOS: rustup target add aarch64-apple-darwin - CIBW_ENVIRONMENT_LINUX: PATH=$HOME/.cargo/bin:$PATH CMAKE_BUILD_PARALLEL_LEVEL=24 CMAKE_ARGS="-DNATIVE_TESTING=OFF" - # SYSTEM_VERSION_COMPAT is a workaround for versioning issue, a.k.a. - # `platform.mac_ver()` reports incorrect MacOS version at 11.0 - # See: https://stackoverflow.com/a/65402241 - CIBW_ENVIRONMENT_MACOS: CMAKE_BUILD_PARALLEL_LEVEL=24 SYSTEM_VERSION_COMPAT=0 CMAKE_ARGS="-DNATIVE_TESTING=OFF" - CIBW_REPAIR_WHEEL_COMMAND_LINUX: | - mkdir ./tempwheelhouse && - unzip -l {wheel} | grep '\.so' && - auditwheel repair -w ./tempwheelhouse {wheel} && - for w in ./tempwheelhouse/*.whl; do - python scripts/zip_filter.py $w \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md - mv $w {dest_dir} - done && - rm -rf ./tempwheelhouse - CIBW_REPAIR_WHEEL_COMMAND_MACOS: | - zip -d {wheel} \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md && - MACOSX_DEPLOYMENT_TARGET=12.7 delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: - choco install -y 7zip && - 7z d -r "{wheel}" *.c *.cpp *.cc *.h *.hpp *.pyx *.md && - move "{wheel}" "{dest_dir}" - CIBW_TEST_COMMAND: "python {project}/tests/smoke_test.py" - # DEV: Uncomment to debug MacOS - # CIBW_BUILD_VERBOSITY_MACOS: 3 - if: runner.os != 'Windows' run: | From 5934a94844caf461cfcbd9976a76a99b67c862c4 Mon Sep 17 00:00:00 2001 From: Yun Kim <35776586+Yun-Kim@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:55:39 -0400 Subject: [PATCH 04/14] feat(crewai): support tracing crewAI flows (#14082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [MLOB-2806] This PR adds support for APM and LLMObs tracing of CrewAI flows, including the overall flow execution and individual start/listener method execution, as well as span linking of which methods triggered other methods in the flow (includes support for linking conditional AND/OR and router method triggers). (Due to some type hinting issues, I've removed `CrewAIIntegration` import from `llmobs/_integrations/__init__.py`. Python 3.8 has issues with subscripting WeakKeyDictionary, but CrewAI (and also LangGraph) only run on Python 3.9/3.10+ so we didn't know this was an issue until now) Note (assist for reading test assertions): span linking/event tests in `test_crewai_llmobs.py` are based on a complex flow that looks like this: Screenshot 2025-07-30 at 4 23 59 PM Additional note: tested [manually](https://dd.datad0g.com/llm/traces?query=%40ml_app%3Aml-app%20%40event_type%3Aspan%20%40parent_id%3Aundefined&agg_m=count&agg_m_source=base&agg_t=count&fromUser=false&llmPanels=%5B%7B%22t%22%3A%22sampleDetailPanel%22%2C%22rEID%22%3A%22AwAAAZhc-Jpa5Z3YfgAAABhBWmhjLUpwYUFBRHBkZlpaLTBvekFBQUEAAAAkMDE5ODVjZjktYTNkYS00ZGEzLWE3NGYtOTQ1NmI4YWU2Mjg1AAAAfQ%22%7D%5D&spanId=14559885504901134809&start=1753820708729&end=1753907108729&paused=false) and verified that crews are compatible with being run inside flows (as is a documented use case in CrewAI): Screenshot 2025-07-30 at 4 25 48 PM ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) [MLOB-2806]: https://datadoghq.atlassian.net/browse/MLOB-2806?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .riot/requirements/181e571.txt | 166 ----------------- .riot/requirements/1ce4995.txt | 160 ++++++++++++++++ .../requirements/{16628a6.txt => 8b7e1b6.txt} | 144 +++++++------- .../requirements/{158ac30.txt => e7249f1.txt} | 141 +++++++------- .../integration_registry/registry.yaml | 2 +- ddtrace/contrib/internal/crewai/patch.py | 93 +++++++-- ddtrace/llmobs/_integrations/__init__.py | 2 - ddtrace/llmobs/_integrations/crewai.py | 157 +++++++++++++++- .../feat-crewai-flow-d5cb250484f1d3c1.yaml | 4 + riotfile.py | 2 +- supported_versions_output.json | 2 +- supported_versions_table.csv | 2 +- tests/contrib/crewai/conftest.py | 176 ++++++++++++++++++ tests/contrib/crewai/test_crewai.py | 20 ++ tests/contrib/crewai/test_crewai_llmobs.py | 172 +++++++++++++---- tests/contrib/crewai/utils.py | 100 ++++++++++ ....crewai.test_crewai.test_complex_flow.json | 101 ++++++++++ ...b.crewai.test_crewai.test_simple_flow.json | 56 ++++++ 18 files changed, 1124 insertions(+), 376 deletions(-) delete mode 100644 .riot/requirements/181e571.txt create mode 100644 .riot/requirements/1ce4995.txt rename .riot/requirements/{16628a6.txt => 8b7e1b6.txt} (51%) rename .riot/requirements/{158ac30.txt => e7249f1.txt} (52%) create mode 100644 releasenotes/notes/feat-crewai-flow-d5cb250484f1d3c1.yaml create mode 100644 tests/contrib/crewai/utils.py create mode 100644 tests/snapshots/tests.contrib.crewai.test_crewai.test_complex_flow.json create mode 100644 tests/snapshots/tests.contrib.crewai.test_crewai.test_simple_flow.json diff --git a/.riot/requirements/181e571.txt b/.riot/requirements/181e571.txt deleted file mode 100644 index ddf6bf380c2..00000000000 --- a/.riot/requirements/181e571.txt +++ /dev/null @@ -1,166 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/181e571.in -# -aiohappyeyeballs==2.6.1 -aiohttp==3.12.7 -aiosignal==1.3.2 -annotated-types==0.7.0 -anyio==4.9.0 -appdirs==1.4.4 -asgiref==3.8.1 -asttokens==3.0.0 -attrs==25.3.0 -auth0-python==4.9.0 -backoff==2.2.1 -bcrypt==4.3.0 -blinker==1.9.0 -build==1.2.2.post1 -cachetools==5.5.2 -certifi==2025.4.26 -cffi==1.17.1 -charset-normalizer==3.4.2 -chromadb==1.0.12 -click==8.2.1 -coloredlogs==15.0.1 -coverage[toml]==7.8.2 -crewai==0.121.1 -cryptography==45.0.3 -decorator==5.2.1 -deprecated==1.2.18 -distro==1.9.0 -docstring-parser==0.16 -durationpy==0.10 -et-xmlfile==2.0.0 -executing==2.2.0 -fastapi==0.115.9 -filelock==3.18.0 -flatbuffers==25.2.10 -frozenlist==1.6.0 -fsspec==2025.5.1 -google-auth==2.40.2 -googleapis-common-protos==1.70.0 -grpcio==1.72.1 -h11==0.16.0 -hf-xet==1.1.2 -httpcore==1.0.9 -httptools==0.6.4 -httpx==0.28.1 -huggingface-hub==0.32.4 -humanfriendly==10.0 -hypothesis==6.45.0 -idna==3.10 -importlib-metadata==8.6.1 -importlib-resources==6.5.2 -iniconfig==2.1.0 -instructor==1.8.3 -ipython==9.3.0 -ipython-pygments-lexers==1.1.1 -jedi==0.19.2 -jinja2==3.1.6 -jiter==0.8.2 -json-repair==0.46.0 -json5==0.12.0 -jsonpickle==4.1.1 -jsonref==1.1.0 -jsonschema==4.24.0 -jsonschema-specifications==2025.4.1 -kubernetes==32.0.1 -litellm==1.68.0 -markdown-it-py==3.0.0 -markupsafe==3.0.2 -matplotlib-inline==0.1.7 -mdurl==0.1.2 -mmh3==5.1.0 -mock==5.2.0 -mpmath==1.3.0 -multidict==6.4.4 -networkx==3.5 -numpy==2.2.6 -oauthlib==3.2.2 -onnxruntime==1.22.0 -openai==1.75.0 -openpyxl==3.1.5 -opentelemetry-api==1.33.1 -opentelemetry-exporter-otlp-proto-common==1.33.1 -opentelemetry-exporter-otlp-proto-grpc==1.33.1 -opentelemetry-exporter-otlp-proto-http==1.33.1 -opentelemetry-instrumentation==0.54b1 -opentelemetry-instrumentation-asgi==0.54b1 -opentelemetry-instrumentation-fastapi==0.54b1 -opentelemetry-proto==1.33.1 -opentelemetry-sdk==1.33.1 -opentelemetry-semantic-conventions==0.54b1 -opentelemetry-util-http==0.54b1 -opentracing==2.4.0 -orjson==3.10.18 -overrides==7.7.0 -packaging==25.0 -parso==0.8.4 -pdfminer-six==20250327 -pdfplumber==0.11.6 -pexpect==4.9.0 -pillow==11.2.1 -pluggy==1.6.0 -posthog==4.2.0 -prompt-toolkit==3.0.51 -propcache==0.3.1 -protobuf==5.29.5 -ptyprocess==0.7.0 -pure-eval==0.2.3 -pyasn1==0.6.1 -pyasn1-modules==0.4.2 -pycparser==2.22 -pydantic==2.11.5 -pydantic-core==2.33.2 -pygments==2.19.1 -pyjwt==2.10.1 -pypdfium2==4.30.1 -pypika==0.48.9 -pyproject-hooks==1.2.0 -pytest==8.4.0 -pytest-asyncio==1.0.0 -pytest-cov==6.1.1 -pytest-mock==3.14.1 -python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 -pyvis==0.3.2 -pyyaml==6.0.2 -referencing==0.36.2 -regex==2024.11.6 -requests==2.32.3 -requests-oauthlib==2.0.0 -rich==13.9.4 -rpds-py==0.25.1 -rsa==4.9.1 -shellingham==1.5.4 -six==1.17.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -stack-data==0.6.3 -starlette==0.45.3 -sympy==1.14.0 -tenacity==9.1.2 -tiktoken==0.9.0 -tokenizers==0.21.1 -tomli==2.2.1 -tomli-w==1.2.0 -tqdm==4.67.1 -traitlets==5.14.3 -typer==0.16.0 -typing-extensions==4.14.0 -typing-inspection==0.4.1 -urllib3==2.4.0 -uv==0.7.9 -uvicorn[standard]==0.34.3 -uvloop==0.21.0 -vcrpy==7.0.0 -watchfiles==1.0.5 -wcwidth==0.2.13 -websocket-client==1.8.0 -websockets==15.0.1 -wrapt==1.17.2 -yarl==1.20.0 -zipp==3.22.0 diff --git a/.riot/requirements/1ce4995.txt b/.riot/requirements/1ce4995.txt new file mode 100644 index 00000000000..19a08990571 --- /dev/null +++ b/.riot/requirements/1ce4995.txt @@ -0,0 +1,160 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate --resolver=backtracking .riot/requirements/1ce4995.in +# +aiohappyeyeballs==2.6.1 +aiohttp==3.12.15 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.10.0 +appdirs==1.4.4 +asttokens==3.0.0 +attrs==25.3.0 +backoff==2.2.1 +bcrypt==4.3.0 +blinker==1.9.0 +build==1.3.0 +cachetools==5.5.2 +certifi==2025.8.3 +cffi==1.17.1 +charset-normalizer==3.4.3 +chromadb==1.0.16 +click==8.2.1 +coloredlogs==15.0.1 +coverage[toml]==7.10.3 +crewai==0.157.0 +cryptography==45.0.6 +decorator==5.2.1 +diskcache==5.6.3 +distro==1.9.0 +docstring-parser==0.17.0 +durationpy==0.10 +et-xmlfile==2.0.0 +executing==2.2.0 +filelock==3.18.0 +flatbuffers==25.2.10 +frozenlist==1.7.0 +fsspec==2025.7.0 +google-auth==2.40.3 +googleapis-common-protos==1.70.0 +grpcio==1.74.0 +h11==0.16.0 +hf-xet==1.1.7 +httpcore==1.0.9 +httptools==0.6.4 +httpx==0.28.1 +huggingface-hub==0.34.4 +humanfriendly==10.0 +hypothesis==6.45.0 +idna==3.10 +importlib-metadata==8.7.0 +importlib-resources==6.5.2 +iniconfig==2.1.0 +instructor==1.10.0 +ipython==9.4.0 +ipython-pygments-lexers==1.1.1 +jedi==0.19.2 +jinja2==3.1.6 +jiter==0.10.0 +json-repair==0.25.2 +json5==0.12.0 +jsonpickle==4.1.1 +jsonref==1.1.0 +jsonschema==4.25.0 +jsonschema-specifications==2025.4.1 +kubernetes==33.1.0 +litellm==1.74.9 +markdown-it-py==4.0.0 +markupsafe==3.0.2 +matplotlib-inline==0.1.7 +mdurl==0.1.2 +mmh3==5.2.0 +mock==5.2.0 +mpmath==1.3.0 +multidict==6.6.4 +networkx==3.5 +numpy==2.3.2 +oauthlib==3.3.1 +onnxruntime==1.22.0 +openai==1.99.8 +openpyxl==3.1.5 +opentelemetry-api==1.36.0 +opentelemetry-exporter-otlp-proto-common==1.36.0 +opentelemetry-exporter-otlp-proto-grpc==1.36.0 +opentelemetry-exporter-otlp-proto-http==1.36.0 +opentelemetry-proto==1.36.0 +opentelemetry-sdk==1.36.0 +opentelemetry-semantic-conventions==0.57b0 +opentracing==2.4.0 +orjson==3.11.1 +overrides==7.7.0 +packaging==25.0 +parso==0.8.4 +pdfminer-six==20250506 +pdfplumber==0.11.7 +pexpect==4.9.0 +pillow==11.3.0 +pluggy==1.6.0 +portalocker==2.7.0 +posthog==5.4.0 +prompt-toolkit==3.0.51 +propcache==0.3.2 +protobuf==6.31.1 +ptyprocess==0.7.0 +pure-eval==0.2.3 +pyasn1==0.6.1 +pyasn1-modules==0.4.2 +pybase64==1.4.2 +pycparser==2.22 +pydantic==2.11.7 +pydantic-core==2.33.2 +pygments==2.19.2 +pyjwt==2.10.1 +pypdfium2==4.30.0 +pypika==0.48.9 +pyproject-hooks==1.2.0 +pytest==8.4.1 +pytest-asyncio==1.1.0 +pytest-cov==6.2.1 +pytest-mock==3.14.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.1 +pyvis==0.3.2 +pyyaml==6.0.2 +referencing==0.36.2 +regex==2025.7.34 +requests==2.32.4 +requests-oauthlib==2.0.0 +rich==14.1.0 +rpds-py==0.27.0 +rsa==4.9.1 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +stack-data==0.6.3 +sympy==1.14.0 +tenacity==9.1.2 +tiktoken==0.11.0 +tokenizers==0.21.4 +tomli==2.2.1 +tomli-w==1.2.0 +tqdm==4.67.1 +traitlets==5.14.3 +typer==0.16.0 +typing-extensions==4.14.1 +typing-inspection==0.4.1 +urllib3==2.5.0 +uv==0.8.8 +uvicorn[standard]==0.35.0 +uvloop==0.21.0 +vcrpy==7.0.0 +watchfiles==1.1.0 +wcwidth==0.2.13 +websocket-client==1.8.0 +websockets==15.0.1 +wrapt==1.17.2 +yarl==1.20.1 +zipp==3.23.0 diff --git a/.riot/requirements/16628a6.txt b/.riot/requirements/8b7e1b6.txt similarity index 51% rename from .riot/requirements/16628a6.txt rename to .riot/requirements/8b7e1b6.txt index a570b0e1edf..58fb9cecab2 100644 --- a/.riot/requirements/16628a6.txt +++ b/.riot/requirements/8b7e1b6.txt @@ -2,165 +2,159 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --allow-unsafe --no-annotate .riot/requirements/16628a6.in +# pip-compile --allow-unsafe --no-annotate .riot/requirements/8b7e1b6.in # aiohappyeyeballs==2.6.1 -aiohttp==3.12.7 -aiosignal==1.3.2 +aiohttp==3.12.15 +aiosignal==1.4.0 annotated-types==0.7.0 -anyio==4.9.0 +anyio==4.10.0 appdirs==1.4.4 -asgiref==3.8.1 asttokens==3.0.0 attrs==25.3.0 -auth0-python==4.9.0 backoff==2.2.1 bcrypt==4.3.0 blinker==1.9.0 -build==1.2.2.post1 +build==1.3.0 cachetools==5.5.2 -certifi==2025.4.26 +certifi==2025.8.3 cffi==1.17.1 -charset-normalizer==3.4.2 -chromadb==1.0.12 +charset-normalizer==3.4.3 +chromadb==1.0.16 click==8.2.1 coloredlogs==15.0.1 -coverage[toml]==7.8.2 -crewai==0.121.1 -cryptography==45.0.3 +coverage[toml]==7.10.3 +crewai==0.157.0 +cryptography==45.0.6 decorator==5.2.1 -deprecated==1.2.18 +diskcache==5.6.3 distro==1.9.0 -docstring-parser==0.16 +docstring-parser==0.17.0 durationpy==0.10 et-xmlfile==2.0.0 executing==2.2.0 -fastapi==0.115.9 filelock==3.18.0 flatbuffers==25.2.10 -frozenlist==1.6.0 -fsspec==2025.5.1 -google-auth==2.40.2 +frozenlist==1.7.0 +fsspec==2025.7.0 +google-auth==2.40.3 googleapis-common-protos==1.70.0 -grpcio==1.72.1 +grpcio==1.74.0 h11==0.16.0 -hf-xet==1.1.2 +hf-xet==1.1.7 httpcore==1.0.9 httptools==0.6.4 httpx==0.28.1 -huggingface-hub==0.32.4 +huggingface-hub==0.34.4 humanfriendly==10.0 hypothesis==6.45.0 idna==3.10 -importlib-metadata==8.6.1 +importlib-metadata==8.7.0 importlib-resources==6.5.2 iniconfig==2.1.0 -instructor==1.8.3 -ipython==9.3.0 +instructor==1.10.0 +ipython==9.4.0 ipython-pygments-lexers==1.1.1 jedi==0.19.2 jinja2==3.1.6 -jiter==0.8.2 -json-repair==0.46.0 +jiter==0.10.0 +json-repair==0.25.2 json5==0.12.0 jsonpickle==4.1.1 jsonref==1.1.0 -jsonschema==4.24.0 +jsonschema==4.25.0 jsonschema-specifications==2025.4.1 -kubernetes==32.0.1 -litellm==1.68.0 -markdown-it-py==3.0.0 +kubernetes==33.1.0 +litellm==1.74.9 +markdown-it-py==4.0.0 markupsafe==3.0.2 matplotlib-inline==0.1.7 mdurl==0.1.2 -mmh3==5.1.0 +mmh3==5.2.0 mock==5.2.0 mpmath==1.3.0 -multidict==6.4.4 +multidict==6.6.4 networkx==3.5 -numpy==2.2.6 -oauthlib==3.2.2 +numpy==2.3.2 +oauthlib==3.3.1 onnxruntime==1.22.0 -openai==1.75.0 +openai==1.99.8 openpyxl==3.1.5 -opentelemetry-api==1.33.1 -opentelemetry-exporter-otlp-proto-common==1.33.1 -opentelemetry-exporter-otlp-proto-grpc==1.33.1 -opentelemetry-exporter-otlp-proto-http==1.33.1 -opentelemetry-instrumentation==0.54b1 -opentelemetry-instrumentation-asgi==0.54b1 -opentelemetry-instrumentation-fastapi==0.54b1 -opentelemetry-proto==1.33.1 -opentelemetry-sdk==1.33.1 -opentelemetry-semantic-conventions==0.54b1 -opentelemetry-util-http==0.54b1 +opentelemetry-api==1.36.0 +opentelemetry-exporter-otlp-proto-common==1.36.0 +opentelemetry-exporter-otlp-proto-grpc==1.36.0 +opentelemetry-exporter-otlp-proto-http==1.36.0 +opentelemetry-proto==1.36.0 +opentelemetry-sdk==1.36.0 +opentelemetry-semantic-conventions==0.57b0 opentracing==2.4.0 -orjson==3.10.18 +orjson==3.11.1 overrides==7.7.0 packaging==25.0 parso==0.8.4 -pdfminer-six==20250327 -pdfplumber==0.11.6 +pdfminer-six==20250506 +pdfplumber==0.11.7 pexpect==4.9.0 -pillow==11.2.1 +pillow==11.3.0 pluggy==1.6.0 -posthog==4.2.0 +portalocker==2.7.0 +posthog==5.4.0 prompt-toolkit==3.0.51 -propcache==0.3.1 -protobuf==5.29.5 +propcache==0.3.2 +protobuf==6.31.1 ptyprocess==0.7.0 pure-eval==0.2.3 pyasn1==0.6.1 pyasn1-modules==0.4.2 +pybase64==1.4.2 pycparser==2.22 -pydantic==2.11.5 +pydantic==2.11.7 pydantic-core==2.33.2 -pygments==2.19.1 +pygments==2.19.2 pyjwt==2.10.1 -pypdfium2==4.30.1 +pypdfium2==4.30.0 pypika==0.48.9 pyproject-hooks==1.2.0 -pytest==8.4.0 -pytest-asyncio==1.0.0 -pytest-cov==6.1.1 +pytest==8.4.1 +pytest-asyncio==1.1.0 +pytest-cov==6.2.1 pytest-mock==3.14.1 python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 +python-dotenv==1.1.1 pyvis==0.3.2 pyyaml==6.0.2 referencing==0.36.2 -regex==2024.11.6 -requests==2.32.3 +regex==2025.7.34 +requests==2.32.4 requests-oauthlib==2.0.0 -rich==13.9.4 -rpds-py==0.25.1 +rich==14.1.0 +rpds-py==0.27.0 rsa==4.9.1 shellingham==1.5.4 six==1.17.0 sniffio==1.3.1 sortedcontainers==2.4.0 stack-data==0.6.3 -starlette==0.45.3 sympy==1.14.0 tenacity==9.1.2 -tiktoken==0.9.0 -tokenizers==0.21.1 +tiktoken==0.11.0 +tokenizers==0.21.4 tomli==2.2.1 tomli-w==1.2.0 tqdm==4.67.1 traitlets==5.14.3 typer==0.16.0 -typing-extensions==4.14.0 +typing-extensions==4.14.1 typing-inspection==0.4.1 -urllib3==2.4.0 -uv==0.7.9 -uvicorn[standard]==0.34.3 +urllib3==2.5.0 +uv==0.8.8 +uvicorn[standard]==0.35.0 uvloop==0.21.0 vcrpy==7.0.0 -watchfiles==1.0.5 +watchfiles==1.1.0 wcwidth==0.2.13 websocket-client==1.8.0 websockets==15.0.1 wrapt==1.17.2 -yarl==1.20.0 -zipp==3.22.0 +yarl==1.20.1 +zipp==3.23.0 diff --git a/.riot/requirements/158ac30.txt b/.riot/requirements/e7249f1.txt similarity index 52% rename from .riot/requirements/158ac30.txt rename to .riot/requirements/e7249f1.txt index 39b978ecf98..88fc6f9b55e 100644 --- a/.riot/requirements/158ac30.txt +++ b/.riot/requirements/e7249f1.txt @@ -2,166 +2,161 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --allow-unsafe --no-annotate .riot/requirements/158ac30.in +# pip-compile --allow-unsafe --no-annotate .riot/requirements/e7249f1.in # aiohappyeyeballs==2.6.1 -aiohttp==3.12.7 -aiosignal==1.3.2 +aiohttp==3.12.15 +aiosignal==1.4.0 annotated-types==0.7.0 -anyio==4.9.0 +anyio==4.10.0 appdirs==1.4.4 -asgiref==3.8.1 asttokens==3.0.0 async-timeout==5.0.1 attrs==25.3.0 -auth0-python==4.9.0 backoff==2.2.1 +backports-asyncio-runner==1.2.0 bcrypt==4.3.0 blinker==1.9.0 -build==1.2.2.post1 +build==1.3.0 cachetools==5.5.2 -certifi==2025.4.26 +certifi==2025.8.3 cffi==1.17.1 -charset-normalizer==3.4.2 -chromadb==1.0.12 +charset-normalizer==3.4.3 +chromadb==1.0.16 click==8.2.1 coloredlogs==15.0.1 -coverage[toml]==7.8.2 -crewai==0.121.1 -cryptography==45.0.3 +coverage[toml]==7.10.3 +crewai==0.157.0 +cryptography==45.0.6 decorator==5.2.1 -deprecated==1.2.18 +diskcache==5.6.3 distro==1.9.0 -docstring-parser==0.16 +docstring-parser==0.17.0 durationpy==0.10 et-xmlfile==2.0.0 exceptiongroup==1.3.0 executing==2.2.0 -fastapi==0.115.9 filelock==3.18.0 flatbuffers==25.2.10 -frozenlist==1.6.0 -fsspec==2025.5.1 -google-auth==2.40.2 +frozenlist==1.7.0 +fsspec==2025.7.0 +google-auth==2.40.3 googleapis-common-protos==1.70.0 -grpcio==1.72.1 +grpcio==1.74.0 h11==0.16.0 -hf-xet==1.1.2 +hf-xet==1.1.7 httpcore==1.0.9 httptools==0.6.4 httpx==0.28.1 -huggingface-hub==0.32.4 +huggingface-hub==0.34.4 humanfriendly==10.0 hypothesis==6.45.0 idna==3.10 -importlib-metadata==8.6.1 +importlib-metadata==8.7.0 importlib-resources==6.5.2 iniconfig==2.1.0 -instructor==1.8.3 +instructor==1.10.0 ipython==8.37.0 jedi==0.19.2 jinja2==3.1.6 -jiter==0.8.2 -json-repair==0.46.0 +jiter==0.10.0 +json-repair==0.25.2 json5==0.12.0 jsonpickle==4.1.1 jsonref==1.1.0 -jsonschema==4.24.0 +jsonschema==4.25.0 jsonschema-specifications==2025.4.1 -kubernetes==32.0.1 -litellm==1.68.0 -markdown-it-py==3.0.0 +kubernetes==33.1.0 +litellm==1.74.9 +markdown-it-py==4.0.0 markupsafe==3.0.2 matplotlib-inline==0.1.7 mdurl==0.1.2 -mmh3==5.1.0 +mmh3==5.2.0 mock==5.2.0 mpmath==1.3.0 -multidict==6.4.4 +multidict==6.6.4 networkx==3.4.2 numpy==2.2.6 -oauthlib==3.2.2 +oauthlib==3.3.1 onnxruntime==1.22.0 -openai==1.75.0 +openai==1.99.8 openpyxl==3.1.5 -opentelemetry-api==1.33.1 -opentelemetry-exporter-otlp-proto-common==1.33.1 -opentelemetry-exporter-otlp-proto-grpc==1.33.1 -opentelemetry-exporter-otlp-proto-http==1.33.1 -opentelemetry-instrumentation==0.54b1 -opentelemetry-instrumentation-asgi==0.54b1 -opentelemetry-instrumentation-fastapi==0.54b1 -opentelemetry-proto==1.33.1 -opentelemetry-sdk==1.33.1 -opentelemetry-semantic-conventions==0.54b1 -opentelemetry-util-http==0.54b1 +opentelemetry-api==1.36.0 +opentelemetry-exporter-otlp-proto-common==1.36.0 +opentelemetry-exporter-otlp-proto-grpc==1.36.0 +opentelemetry-exporter-otlp-proto-http==1.36.0 +opentelemetry-proto==1.36.0 +opentelemetry-sdk==1.36.0 +opentelemetry-semantic-conventions==0.57b0 opentracing==2.4.0 -orjson==3.10.18 +orjson==3.11.1 overrides==7.7.0 packaging==25.0 parso==0.8.4 -pdfminer-six==20250327 -pdfplumber==0.11.6 +pdfminer-six==20250506 +pdfplumber==0.11.7 pexpect==4.9.0 -pillow==11.2.1 +pillow==11.3.0 pluggy==1.6.0 -posthog==4.2.0 +portalocker==2.7.0 +posthog==5.4.0 prompt-toolkit==3.0.51 -propcache==0.3.1 -protobuf==5.29.5 +propcache==0.3.2 +protobuf==6.31.1 ptyprocess==0.7.0 pure-eval==0.2.3 pyasn1==0.6.1 pyasn1-modules==0.4.2 +pybase64==1.4.2 pycparser==2.22 -pydantic==2.11.5 +pydantic==2.11.7 pydantic-core==2.33.2 -pygments==2.19.1 +pygments==2.19.2 pyjwt==2.10.1 -pypdfium2==4.30.1 +pypdfium2==4.30.0 pypika==0.48.9 pyproject-hooks==1.2.0 -pytest==8.4.0 -pytest-asyncio==1.0.0 -pytest-cov==6.1.1 +pytest==8.4.1 +pytest-asyncio==1.1.0 +pytest-cov==6.2.1 pytest-mock==3.14.1 python-dateutil==2.9.0.post0 -python-dotenv==1.1.0 +python-dotenv==1.1.1 pyvis==0.3.2 pyyaml==6.0.2 referencing==0.36.2 -regex==2024.11.6 -requests==2.32.3 +regex==2025.7.34 +requests==2.32.4 requests-oauthlib==2.0.0 -rich==13.9.4 -rpds-py==0.25.1 +rich==14.1.0 +rpds-py==0.27.0 rsa==4.9.1 shellingham==1.5.4 six==1.17.0 sniffio==1.3.1 sortedcontainers==2.4.0 stack-data==0.6.3 -starlette==0.45.3 sympy==1.14.0 tenacity==9.1.2 -tiktoken==0.9.0 -tokenizers==0.21.1 +tiktoken==0.11.0 +tokenizers==0.21.4 tomli==2.2.1 tomli-w==1.2.0 tqdm==4.67.1 traitlets==5.14.3 typer==0.16.0 -typing-extensions==4.14.0 +typing-extensions==4.14.1 typing-inspection==0.4.1 -urllib3==2.4.0 -uv==0.7.9 -uvicorn[standard]==0.34.3 +urllib3==2.5.0 +uv==0.8.8 +uvicorn[standard]==0.35.0 uvloop==0.21.0 vcrpy==7.0.0 -watchfiles==1.0.5 +watchfiles==1.1.0 wcwidth==0.2.13 websocket-client==1.8.0 websockets==15.0.1 wrapt==1.17.2 -yarl==1.20.0 -zipp==3.22.0 +yarl==1.20.1 +zipp==3.23.0 diff --git a/ddtrace/contrib/integration_registry/registry.yaml b/ddtrace/contrib/integration_registry/registry.yaml index 289f8ea7272..5be560b5846 100644 --- a/ddtrace/contrib/integration_registry/registry.yaml +++ b/ddtrace/contrib/integration_registry/registry.yaml @@ -243,7 +243,7 @@ integrations: tested_versions_by_dependency: crewai: min: 0.102.0 - max: 0.121.1 + max: 0.157.0 - integration_name: ddtrace_api is_external_package: false diff --git a/ddtrace/contrib/internal/crewai/patch.py b/ddtrace/contrib/internal/crewai/patch.py index bdb458408b7..8369c1148a5 100644 --- a/ddtrace/contrib/internal/crewai/patch.py +++ b/ddtrace/contrib/internal/crewai/patch.py @@ -7,7 +7,9 @@ from ddtrace.contrib.internal.trace_utils import unwrap from ddtrace.contrib.internal.trace_utils import with_traced_module from ddtrace.contrib.internal.trace_utils import wrap -from ddtrace.llmobs._integrations import CrewAIIntegration +from ddtrace.internal.logger import get_logger +from ddtrace.internal.utils import get_argument_value +from ddtrace.llmobs._integrations.crewai import CrewAIIntegration from ddtrace.trace import Pin @@ -15,6 +17,9 @@ def get_version() -> str: return getattr(crewai, "__version__", "") +logger = get_logger(__name__) + + config._add("crewai", {}) @@ -24,7 +29,7 @@ def _supported_versions() -> Dict[str, str]: @with_traced_module def traced_kickoff(crewai, pin, func, instance, args, kwargs): - integration = crewai._datadog_integration + integration: CrewAIIntegration = crewai._datadog_integration result = None instance_id = getattr(instance, "id", "") planning_enabled = getattr(instance, "planning", False) @@ -43,7 +48,7 @@ def traced_kickoff(crewai, pin, func, instance, args, kwargs): span.set_exc_info(*sys.exc_info()) raise finally: - kwargs["instance"] = instance + kwargs["_dd.instance"] = instance integration.llmobs_set_tags(span, args=args, kwargs=kwargs, response=result, operation="crew") span.finish() return result @@ -51,7 +56,7 @@ def traced_kickoff(crewai, pin, func, instance, args, kwargs): @with_traced_module def traced_task_execute(crewai, pin, func, instance, args, kwargs): - integration = crewai._datadog_integration + integration: CrewAIIntegration = crewai._datadog_integration result = None span = integration.trace( pin, @@ -70,7 +75,7 @@ def traced_task_execute(crewai, pin, func, instance, args, kwargs): finally: if getattr(instance, "_ddtrace_ctx", None): delattr(instance, "_ddtrace_ctx") - kwargs["instance"] = instance + kwargs["_dd.instance"] = instance integration.llmobs_set_tags(span, args=args, kwargs=kwargs, response=result, operation="task") span.finish() return result @@ -78,7 +83,7 @@ def traced_task_execute(crewai, pin, func, instance, args, kwargs): @with_traced_module def traced_task_execute_async(crewai, pin, func, instance, args, kwargs): - integration = crewai._datadog_integration + integration: CrewAIIntegration = crewai._datadog_integration _ddtrace_ctx = integration._get_current_ctx(pin) setattr(instance, "_ddtrace_ctx", _ddtrace_ctx) return func(*args, **kwargs) @@ -86,7 +91,7 @@ def traced_task_execute_async(crewai, pin, func, instance, args, kwargs): @with_traced_module def traced_task_get_context(crewai, pin, func, instance, args, kwargs): - integration = crewai._datadog_integration + integration: CrewAIIntegration = crewai._datadog_integration span = pin.tracer.current_span() result = func(*args, **kwargs) integration._llmobs_set_span_link_on_task(span, args, kwargs) @@ -95,7 +100,7 @@ def traced_task_get_context(crewai, pin, func, instance, args, kwargs): @with_traced_module def traced_agent_execute(crewai, pin, func, instance, args, kwargs): - integration = crewai._datadog_integration + integration: CrewAIIntegration = crewai._datadog_integration result = None span = integration.trace( pin, "CrewAI Agent", span_name=getattr(instance, "role", ""), operation="agent", submit_to_llmobs=True @@ -106,7 +111,7 @@ def traced_agent_execute(crewai, pin, func, instance, args, kwargs): span.set_exc_info(*sys.exc_info()) raise finally: - kwargs["instance"] = instance + kwargs["_dd.instance"] = instance integration.llmobs_set_tags(span, args=args, kwargs=kwargs, response=result, operation="agent") span.finish() return result @@ -114,7 +119,7 @@ def traced_agent_execute(crewai, pin, func, instance, args, kwargs): @with_traced_module def traced_tool_run(crewai, pin, func, instance, args, kwargs): - integration = crewai._datadog_integration + integration: CrewAIIntegration = crewai._datadog_integration result = None span = integration.trace( pin, "CrewAI Tool", span_name=getattr(instance, "name", ""), operation="tool", submit_to_llmobs=True @@ -125,12 +130,56 @@ def traced_tool_run(crewai, pin, func, instance, args, kwargs): span.set_exc_info(*sys.exc_info()) raise finally: - kwargs["instance"] = instance + kwargs["_dd.instance"] = instance integration.llmobs_set_tags(span, args=args, kwargs=kwargs, response=result, operation="tool") span.finish() return result +@with_traced_module +async def traced_flow_kickoff(crewai, pin, func, instance, args, kwargs): + integration: CrewAIIntegration = crewai._datadog_integration + span_name = getattr(type(instance), "__name__", "CrewAI Flow") + with integration.trace(pin, "CrewAI Flow", span_name=span_name, operation="flow", submit_to_llmobs=True) as span: + result = await func(*args, **kwargs) + integration.llmobs_set_tags(span, args=args, kwargs=kwargs, response=result, operation="flow") + return result + + +@with_traced_module +async def traced_flow_method(crewai, pin, func, instance, args, kwargs): + integration: CrewAIIntegration = crewai._datadog_integration + span_name = get_argument_value(args, kwargs, 0, "method_name", optional=True) or "Flow Method" + with integration.trace( + pin, + "CrewAI Flow Method", + span_name=span_name, + operation="flow_method", + submit_to_llmobs=True, + flow_instance=instance, + ) as span: + flow_state = getattr(instance, "state", {}) + initial_flow_state = {} + if isinstance(flow_state, dict): + initial_flow_state = {**flow_state} + elif hasattr(flow_state, "model_dump"): + initial_flow_state = flow_state.model_dump() + result = await func(*args, **kwargs) + kwargs["_dd.instance"] = instance + kwargs["_dd.initial_flow_state"] = initial_flow_state + integration.llmobs_set_tags(span, args=args, kwargs=kwargs, response=result, operation="flow_method") + return result + + +@with_traced_module +def patched_find_triggered_methods(crewai, pin, func, instance, args, kwargs): + integration: CrewAIIntegration = crewai._datadog_integration + result = func(*args, **kwargs) + current_span = pin.tracer.current_span() + integration.llmobs_set_span_links_on_flow(current_span, args, kwargs, instance) + return result + + def patch(): if getattr(crewai, "_datadog_patch", False): return @@ -138,15 +187,21 @@ def patch(): crewai._datadog_patch = True Pin().onto(crewai) - integration = CrewAIIntegration(integration_config=config.crewai) + integration: CrewAIIntegration = CrewAIIntegration(integration_config=config.crewai) crewai._datadog_integration = integration wrap(crewai, "Crew.kickoff", traced_kickoff(crewai)) - wrap(crewai, "Crew._get_context", traced_task_get_context(crewai)) - wrap(crewai, "Task._execute_core", traced_task_execute(crewai)) wrap(crewai, "Task.execute_async", traced_task_execute_async(crewai)) wrap(crewai, "Agent.execute_task", traced_agent_execute(crewai)) wrap(crewai.tools.structured_tool, "CrewStructuredTool.invoke", traced_tool_run(crewai)) + wrap(crewai, "Flow.kickoff_async", traced_flow_kickoff(crewai)) + try: + wrap(crewai, "Crew._get_context", traced_task_get_context(crewai)) + wrap(crewai, "Task._execute_core", traced_task_execute(crewai)) + wrap(crewai, "Flow._execute_method", traced_flow_method(crewai)) + wrap(crewai, "Flow._find_triggered_methods", patched_find_triggered_methods(crewai)) + except AttributeError: + logger.warning("Failed to patch internal CrewAI methods.") def unpatch(): @@ -156,10 +211,16 @@ def unpatch(): crewai._datadog_patch = False unwrap(crewai.Crew, "kickoff") - unwrap(crewai.Crew, "_get_context") - unwrap(crewai.Task, "_execute_core") unwrap(crewai.Task, "execute_async") unwrap(crewai.Agent, "execute_task") unwrap(crewai.tools.structured_tool.CrewStructuredTool, "invoke") + unwrap(crewai.Flow, "kickoff_async") + try: + unwrap(crewai.Crew, "_get_context") + unwrap(crewai.Task, "_execute_core") + unwrap(crewai.Flow, "_execute_method") + unwrap(crewai.Flow, "_find_triggered_methods") + except AttributeError: + pass delattr(crewai, "_datadog_integration") diff --git a/ddtrace/llmobs/_integrations/__init__.py b/ddtrace/llmobs/_integrations/__init__.py index 22b74a824e9..93ad8a9a62d 100644 --- a/ddtrace/llmobs/_integrations/__init__.py +++ b/ddtrace/llmobs/_integrations/__init__.py @@ -1,7 +1,6 @@ from .anthropic import AnthropicIntegration from .base import BaseLLMIntegration from .bedrock import BedrockIntegration -from .crewai import CrewAIIntegration from .gemini import GeminiIntegration from .google_genai import GoogleGenAIIntegration from .langchain import LangChainIntegration @@ -15,7 +14,6 @@ "AnthropicIntegration", "BaseLLMIntegration", "BedrockIntegration", - "CrewAIIntegration", "GeminiIntegration", "GoogleGenAIIntegration", "LangChainIntegration", diff --git a/ddtrace/llmobs/_integrations/crewai.py b/ddtrace/llmobs/_integrations/crewai.py index b73366d4e71..bbf39030109 100644 --- a/ddtrace/llmobs/_integrations/crewai.py +++ b/ddtrace/llmobs/_integrations/crewai.py @@ -3,6 +3,7 @@ from typing import Dict from typing import List from typing import Optional +from weakref import WeakKeyDictionary from ddtrace.internal import core from ddtrace.internal.logger import get_logger @@ -19,6 +20,7 @@ from ddtrace.llmobs._constants import SPAN_LINKS from ddtrace.llmobs._integrations.base import BaseLLMIntegration from ddtrace.llmobs._utils import _get_nearest_llmobs_ancestor +from ddtrace.llmobs._utils import safe_json from ddtrace.trace import Pin from ddtrace.trace import Span @@ -26,6 +28,16 @@ log = get_logger(__name__) +OP_NAMES_TO_SPAN_KIND = { + "crew": "workflow", + "task": "task", + "agent": "agent", + "tool": "tool", + "flow": "workflow", + "flow_method": "task", +} + + class CrewAIIntegration(BaseLLMIntegration): _integration_name = "crewai" # the CrewAI integration's task span linking relies on keeping track of an internal Datadog crew ID, @@ -33,6 +45,7 @@ class CrewAIIntegration(BaseLLMIntegration): _crews_to_task_span_ids: Dict[str, List[str]] = {} # maps crew ID to list of task span_ids _crews_to_tasks: Dict[str, Dict[str, Any]] = {} # maps crew ID to dictionary of task_id to span_id and span_links _planning_crew_ids: List[str] = [] # list of crew IDs that correspond to planning crew instances + _flow_span_to_method_to_span_dict: WeakKeyDictionary[Span, Dict[str, Dict[str, Any]]] = WeakKeyDictionary() def trace(self, pin: Pin, operation_id: str, submit_to_llmobs: bool = False, **kwargs: Dict[str, Any]) -> Span: if kwargs.get("_ddtrace_ctx"): @@ -56,6 +69,15 @@ def trace(self, pin: Pin, operation_id: str, submit_to_llmobs: bool = False, **k self._crews_to_task_span_ids.get(crew_id, []).append(str(span.span_id)) task_node = self._crews_to_tasks.get(crew_id, {}).setdefault(str(task_id), {}) task_node["span_id"] = str(span.span_id) + if kwargs.get("operation") == "flow": + self._flow_span_to_method_to_span_dict[span] = {} + if kwargs.get("operation") == "flow_method": + span_name = kwargs.get("span_name", "") + method_name: str = span_name if isinstance(span_name, str) else "" + if span._parent is None: + return span + span_dict = self._flow_span_to_method_to_span_dict.get(span._parent, {}).setdefault(method_name, {}) + span_dict.update({"span_id": str(span.span_id)}) return span def _get_current_ctx(self, pin): @@ -74,7 +96,7 @@ def _llmobs_set_tags( response: Optional[Any] = None, operation: str = "", ) -> None: - span._set_ctx_item(SPAN_KIND, "workflow" if operation == "crew" else operation) + span._set_ctx_item(SPAN_KIND, OP_NAMES_TO_SPAN_KIND.get(operation, "task")) if operation == "crew": crew_id = _get_crew_id(span, "crew") self._llmobs_set_tags_crew(span, args, kwargs, response) @@ -88,9 +110,13 @@ def _llmobs_set_tags( self._llmobs_set_tags_agent(span, args, kwargs, response) elif operation == "tool": self._llmobs_set_tags_tool(span, args, kwargs, response) + elif operation == "flow": + self._llmobs_set_tags_flow(span, args, kwargs, response) + elif operation == "flow_method": + self._llmobs_set_tags_flow_method(span, args, kwargs, response) def _llmobs_set_tags_crew(self, span, args, kwargs, response): - crew_instance = kwargs.get("instance") + crew_instance = kwargs.pop("_dd.instance", None) crew_id = _get_crew_id(span, "crew") task_span_ids = self._crews_to_task_span_ids.get(crew_id, []) if task_span_ids: @@ -117,7 +143,7 @@ def _llmobs_set_tags_crew(self, span, args, kwargs, response): def _llmobs_set_tags_task(self, span, args, kwargs, response): crew_id = _get_crew_id(span, "task") - task_instance = kwargs.get("instance") + task_instance = kwargs.pop("_dd.instance", None) task_id = getattr(task_instance, "id", None) task_name = getattr(task_instance, "name", "") task_description = getattr(task_instance, "description", "") @@ -151,7 +177,7 @@ def _llmobs_set_tags_agent(self, span, args, kwargs, response): """Set span links and metadata for agent spans. Agent spans are 1:1 with its parent (task/tool) span, so we link them directly here, even on the parent itself. """ - agent_instance = kwargs.get("instance") + agent_instance = kwargs.get("_dd.instance", None) self._tag_agent_manifest(span, agent_instance) agent_role = getattr(agent_instance, "role", "") task_description = getattr(kwargs.get("task"), "description", "") @@ -183,7 +209,7 @@ def _llmobs_set_tags_agent(self, span, args, kwargs, response): span._set_ctx_item(OUTPUT_VALUE, response) def _llmobs_set_tags_tool(self, span, args, kwargs, response): - tool_instance = kwargs.get("instance") + tool_instance = kwargs.pop("_dd.instance", None) tool_name = getattr(tool_instance, "name", "") description = _extract_tool_description_field(getattr(tool_instance, "description", "")) span._set_ctx_items( @@ -247,6 +273,127 @@ def _get_agent_tools(self, tools): formatted_tools.append(tool_dict) return formatted_tools + def _llmobs_set_tags_flow(self, span, args, kwargs, response): + inputs = get_argument_value(args, kwargs, 0, "inputs", optional=True) or {} + span._set_ctx_items({NAME: span.name or "CrewAI Flow", INPUT_VALUE: inputs, OUTPUT_VALUE: str(response)}) + return + + def _llmobs_set_tags_flow_method(self, span, args, kwargs, response): + flow_instance = kwargs.pop("_dd.instance", None) + initial_flow_state = kwargs.pop("_dd.initial_flow_state", {}) + input_dict = { + "args": [safe_json(arg) for arg in args[2:]], + "kwargs": {k: safe_json(v) for k, v in kwargs.items()}, + "flow_state": initial_flow_state, + } + span_links = ( + self._flow_span_to_method_to_span_dict.get(span._parent, {}).get(span.name, {}).get("span_links", []) + ) + if span.name in getattr(flow_instance, "_start_methods", []) and span._parent is not None: + span_links.append( + { + "span_id": str(span._parent.span_id), + "trace_id": format_trace_id(span.trace_id), + "attributes": {"from": "input", "to": "input"}, + } + ) + + if span.name in getattr(flow_instance, "_routers", []): + # For router methods the downstream trigger is the router's result, not the method name. + # We store the span info keyed by that result so it can be linked to future listener spans. + span_dict = self._flow_span_to_method_to_span_dict.get(span._parent, {}).setdefault(str(response), {}) + span_dict.update({"span_id": str(span.span_id), "trace_id": format_trace_id(span.trace_id)}) + + span._set_ctx_items( + { + NAME: span.name or "Flow Method", + INPUT_VALUE: input_dict, + OUTPUT_VALUE: str(response), + SPAN_LINKS: span_links, + } + ) + return + + def llmobs_set_span_links_on_flow(self, flow_span, args, kwargs, flow_instance): + if not self.llmobs_enabled: + return + try: + self._llmobs_set_span_link_on_flow(flow_span, args, kwargs, flow_instance) + except (KeyError, AttributeError): + pass + + def _llmobs_set_span_link_on_flow(self, flow_span, args, kwargs, flow_instance): + """ + Set span links for the next queued listener method(s) in a CrewAI flow. + + Notes: + - trigger_method is either a method name or router result, which trigger normal/router listeners respectively. + We skip if trigger_method is a router method name because we use the router result to link triggered listeners + - AND conditions: + - temporary output->output span links added by default for all trigger methods + - once all trigger methods have run for the listener, remove temporary output->output links and + add span links from trigger spans to listener span + """ + trigger_method = get_argument_value(args, kwargs, 0, "trigger_method", optional=True) + if not trigger_method: + return + flow_methods_to_spans = self._flow_span_to_method_to_span_dict.get(flow_span, {}) + trigger_span_dict = flow_methods_to_spans.get(trigger_method) + if not trigger_span_dict or trigger_method in getattr(flow_instance, "_routers", []): + # For router methods the downstream trigger listens for the router's result, not the router method name + # Skip if trigger_method represents a router method name instead of the router's results + return + listeners = getattr(flow_instance, "_listeners", {}) + triggered = False + for listener_name, (condition_type, listener_triggers) in listeners.items(): + if trigger_method not in listener_triggers: + continue + span_dict = flow_methods_to_spans.setdefault(listener_name, {}) + span_links = span_dict.setdefault("span_links", []) + if condition_type != "AND": + triggered = True + span_links.append( + { + "span_id": str(trigger_span_dict["span_id"]), + "trace_id": format_trace_id(flow_span.trace_id), + "attributes": {"from": "output", "to": "input"}, + } + ) + continue + if any( + flow_methods_to_spans.get(_trigger_method, {}).get("span_id") is None + for _trigger_method in listener_triggers + ): # skip if not all trigger methods have run (span ID must exist) for AND listener + continue + triggered = True + for method in listener_triggers: + method_span_dict = flow_methods_to_spans.get(method, {}) + span_links.append( + { + "span_id": str(method_span_dict["span_id"]), + "trace_id": format_trace_id(flow_span.trace_id), + "attributes": {"from": "output", "to": "input"}, + } + ) + flow_span_span_links = flow_span._get_ctx_item(SPAN_LINKS) or [] + # Remove temporary output->output link since the AND has been triggered + span_links_minus_tmp_output_links = [ + link for link in flow_span_span_links if link["span_id"] != str(method_span_dict["span_id"]) + ] + flow_span._set_ctx_item(SPAN_LINKS, span_links_minus_tmp_output_links) + + if triggered is False: + flow_span_span_links = flow_span._get_ctx_item(SPAN_LINKS) or [] + flow_span_span_links.append( + { + "span_id": str(trigger_span_dict["span_id"]), + "trace_id": format_trace_id(flow_span.trace_id), + "attributes": {"from": "output", "to": "output"}, + } + ) + flow_span._set_ctx_item(SPAN_LINKS, flow_span_span_links) + return + def _llmobs_set_span_link_on_task(self, span, args, kwargs): """Set span links for the next queued task in a CrewAI workflow. This happens between task executions, (the current span is the crew span and the task span hasn't started yet) diff --git a/releasenotes/notes/feat-crewai-flow-d5cb250484f1d3c1.yaml b/releasenotes/notes/feat-crewai-flow-d5cb250484f1d3c1.yaml new file mode 100644 index 00000000000..45f741d2492 --- /dev/null +++ b/releasenotes/notes/feat-crewai-flow-d5cb250484f1d3c1.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + crewai: Introduces APM and LLM Observability tracing support for CrewAI Flow ``kickoff/kickoff_async`` calls, including tracing internal flow method execution. diff --git a/riotfile.py b/riotfile.py index 185e0ae4448..848d9c6246f 100644 --- a/riotfile.py +++ b/riotfile.py @@ -2983,7 +2983,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT pkgs={ "pytest-asyncio": latest, "openai": latest, - "crewai": ["~=0.102.0", "~=0.121.0"], + "crewai": ["~=0.102.0", latest], "vcrpy": "==7.0.0", }, ), diff --git a/supported_versions_output.json b/supported_versions_output.json index ddee05ea1fd..70c9a01f7bc 100644 --- a/supported_versions_output.json +++ b/supported_versions_output.json @@ -171,7 +171,7 @@ "dependency": "crewai", "integration": "crewai", "minimum_tracer_supported": "0.102.0", - "max_tracer_supported": "0.121.1", + "max_tracer_supported": "0.157.0", "pinned": "true", "auto-instrumented": true }, diff --git a/supported_versions_table.csv b/supported_versions_table.csv index 88de94e844b..a6d3475c3cd 100644 --- a/supported_versions_table.csv +++ b/supported_versions_table.csv @@ -22,7 +22,7 @@ cassandra-driver,cassandra,3.24.0,3.28.0,True celery,celery,5.5.3,5.5.3,True cherrypy,cherrypy,17.0.0,18.10.0,False python-consul,consul,1.1.0,1.1.0,True -crewai,crewai *,0.102.0,0.121.1,True +crewai,crewai *,0.102.0,0.157.0,True django,django,2.2.28,5.2,True dogpile-cache,dogpile_cache,0.6.8,1.3.3,True dogpile.cache,dogpile_cache,0.6.8,1.3.3,True diff --git a/tests/contrib/crewai/conftest.py b/tests/contrib/crewai/conftest.py index 71f503a459a..47b6d3b6bc6 100644 --- a/tests/contrib/crewai/conftest.py +++ b/tests/contrib/crewai/conftest.py @@ -1,9 +1,15 @@ import os +import time from crewai import Agent from crewai import Crew +from crewai import Flow from crewai import Process from crewai import Task +from crewai.flow.flow import and_ +from crewai.flow.flow import listen +from crewai.flow.flow import router +from crewai.flow.flow import start from crewai.tasks.conditional_task import ConditionalTask from crewai.tools import tool import pytest @@ -13,6 +19,10 @@ from ddtrace.contrib.internal.crewai.patch import unpatch from ddtrace.llmobs import LLMObs as llmobs_service from ddtrace.trace import Pin +from tests.contrib.crewai.utils import budget_text +from tests.contrib.crewai.utils import fun_fact_text +from tests.contrib.crewai.utils import itinerary_text +from tests.contrib.crewai.utils import welcome_email_text from tests.llmobs._utils import TestLLMObsSpanWriter from tests.utils import DummyTracer from tests.utils import DummyWriter @@ -159,6 +169,172 @@ def hierarchical_crew(crewai): ) +@pytest.fixture +def simple_flow(crewai): + class ExFlow(Flow[dict]): + model = "gpt-4o-mini" + + @start() + def generate_city(self): + time.sleep(0.05) + return "New York City" + + @listen(generate_city) + def generate_fun_fact(self, random_city): + time.sleep(0.06) + return fun_fact_text + + yield ExFlow() + + +@pytest.fixture +def simple_flow_async(crewai): + class ExFlow(Flow[dict]): + model = "gpt-4o-mini" + + @start() + async def generate_city(self): + time.sleep(0.05) + return "New York City" + + @listen(generate_city) + async def generate_fun_fact(self, random_city): + time.sleep(0.06) + return fun_fact_text + + yield ExFlow() + + +@pytest.fixture +def complex_flow(crewai): + class ExFlow(Flow[dict]): + model = "gpt-4o-mini" + + @start() + def generate_city(self): + time.sleep(0.05) + return "New York City" + + @start() + def generate_welcome_email(self): + time.sleep(0.05) + return welcome_email_text + + @listen(generate_city) + def generate_fun_fact(self, random_city): + time.sleep(0.06) + return fun_fact_text + + @listen(generate_city) + def generate_budget(self, random_city): + time.sleep(0.04) + return budget_text + + @listen(and_(generate_budget, generate_city, generate_fun_fact, generate_welcome_email)) + def generate_itinerary(self): + time.sleep(0.05) + return itinerary_text + + yield ExFlow() + + +@pytest.fixture +def complex_flow_async(crewai): + class ExFlow(Flow[dict]): + model = "gpt-4o-mini" + + @start() + async def generate_city(self): + time.sleep(0.05) + return "New York City" + + @start() + async def generate_welcome_email(self): + time.sleep(0.05) + return welcome_email_text + + @listen(generate_city) + async def generate_fun_fact(self, random_city): + time.sleep(0.06) + return fun_fact_text + + @listen(generate_city) + async def generate_budget(self, random_city): + time.sleep(0.04) + return budget_text + + @listen(and_(generate_budget, generate_city, generate_fun_fact, generate_welcome_email)) + async def generate_itinerary(self): + time.sleep(0.05) + return itinerary_text + + yield ExFlow() + + +@pytest.fixture +def router_flow(crewai): + class ExFlow(Flow[dict]): + model = "gpt-4o-mini" + + @start() + def generate_city(self): + time.sleep(0.05) + random_city = "New York City" + self.state["city"] = random_city + return random_city + + @router(generate_city) + def discriminate_city(self): + time.sleep(0.05) + if self.state["city"] != "New York City": + return "YIKES" + return "LFG" + + @listen("YIKES") + def say_oop(self): + time.sleep(0.03) + return "Oop, have a fun trip!" + + @listen("LFG") + def generate_fun_fact(self): + time.sleep(0.06) + return fun_fact_text + + yield ExFlow() + + +@pytest.fixture +def router_flow_async(crewai): + class ExFlow(Flow[dict]): + model = "gpt-4o-mini" + + @start() + async def generate_city(self): + time.sleep(0.05) + random_city = "New York City" + self.state["city"] = random_city + return random_city + + @router(generate_city) + async def discriminate_city(self): + time.sleep(0.05) + if self.state["city"] != "New York City": + return "YIKES" + return "LFG" + + @listen("YIKES") + async def say_oop(self): + time.sleep(0.03) + return "Oop, have a fun trip!" + + @listen("LFG") + async def generate_fun_fact(self): + time.sleep(0.06) + return fun_fact_text + + yield ExFlow() + + @pytest.fixture def crewai(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "") diff --git a/tests/contrib/crewai/test_crewai.py b/tests/contrib/crewai/test_crewai.py index 589aa93cd2b..f9172b2b269 100644 --- a/tests/contrib/crewai/test_crewai.py +++ b/tests/contrib/crewai/test_crewai.py @@ -107,3 +107,23 @@ async def test_hierarchical_crew_async(crewai, hierarchical_crew, request_vcr): async def test_hierarchical_crew_async_for_each(crewai, hierarchical_crew, request_vcr): with request_vcr.use_cassette("test_hierarchical_crew.yaml"): await hierarchical_crew.kickoff_for_each_async(inputs=[{"ages": [10, 12, 14, 16, 18]}]) + + +@pytest.mark.snapshot(token="tests.contrib.crewai.test_crewai.test_simple_flow") +def test_simple_flow(crewai, simple_flow): + simple_flow.kickoff(inputs={"continent": "North America"}) + + +@pytest.mark.snapshot(token="tests.contrib.crewai.test_crewai.test_simple_flow") +async def test_simple_flow_async(crewai, simple_flow_async): + await simple_flow_async.kickoff_async(inputs={"continent": "North America"}) + + +@pytest.mark.snapshot(token="tests.contrib.crewai.test_crewai.test_complex_flow") +def test_complex_flow(crewai, complex_flow): + complex_flow.kickoff(inputs={"continent": "North America"}) + + +@pytest.mark.snapshot(token="tests.contrib.crewai.test_crewai.test_complex_flow") +async def test_complex_flow_async(crewai, complex_flow_async): + await complex_flow_async.kickoff_async(inputs={"continent": "North America"}) diff --git a/tests/contrib/crewai/test_crewai_llmobs.py b/tests/contrib/crewai/test_crewai_llmobs.py index 7ef83d00677..40adc0e9474 100644 --- a/tests/contrib/crewai/test_crewai_llmobs.py +++ b/tests/contrib/crewai/test_crewai_llmobs.py @@ -1,9 +1,17 @@ +import json + +import crewai import mock +from ddtrace.internal.utils.version import parse_version +from tests.contrib.crewai.utils import fun_fact_text from tests.llmobs._utils import _assert_span_link from tests.llmobs._utils import _expected_llmobs_non_llm_span_event +CREWAI_VERSION = parse_version(getattr(crewai, "__version__", "0.0.0")) + + AGENT_TO_EXPECTED_AGENT_MANIFEST = { "Senior Research Scientist": { "framework": "CrewAI", @@ -65,6 +73,14 @@ }, } +EXPECTED_SPAN_ARGS = { + "input_value": mock.ANY, + "output_value": mock.ANY, + "metadata": mock.ANY, + "tags": {"service": "tests.contrib.crewai", "ml_app": ""}, + "span_links": True, +} + def expected_agent_span_args(role): return { @@ -79,15 +95,8 @@ def expected_agent_span_args(role): def _assert_basic_crew_events(llmobs_events, spans): llmobs_events.sort(key=lambda span: span["start_ns"]) assert len(spans) == len(llmobs_events) == 5 - expected_span_args = { - "input_value": mock.ANY, - "output_value": mock.ANY, - "metadata": mock.ANY, - "tags": {"service": "tests.contrib.crewai", "ml_app": ""}, - "span_links": True, - } for llmobs_span, span, kind in zip(llmobs_events, spans, ("workflow", "task", "agent", "task", "agent")): - extra_args = expected_agent_span_args(llmobs_span["name"]) if kind == "agent" else expected_span_args + extra_args = expected_agent_span_args(llmobs_span["name"]) if kind == "agent" else EXPECTED_SPAN_ARGS assert llmobs_span == _expected_llmobs_non_llm_span_event(span, span_kind=kind, **extra_args) @@ -108,15 +117,8 @@ def _assert_basic_crew_links(llmobs_events): def _assert_tool_crew_events(llmobs_events, spans): llmobs_events.sort(key=lambda span: span["start_ns"]) assert len(spans) == len(llmobs_events) == 4 - expected_span_args = { - "input_value": mock.ANY, - "output_value": mock.ANY, - "metadata": mock.ANY, - "tags": {"service": "tests.contrib.crewai", "ml_app": ""}, - "span_links": True, - } - assert llmobs_events[0] == _expected_llmobs_non_llm_span_event(spans[0], span_kind="workflow", **expected_span_args) - assert llmobs_events[1] == _expected_llmobs_non_llm_span_event(spans[1], span_kind="task", **expected_span_args) + assert llmobs_events[0] == _expected_llmobs_non_llm_span_event(spans[0], span_kind="workflow", **EXPECTED_SPAN_ARGS) + assert llmobs_events[1] == _expected_llmobs_non_llm_span_event(spans[1], span_kind="task", **EXPECTED_SPAN_ARGS) assert llmobs_events[2] == _expected_llmobs_non_llm_span_event( spans[2], span_kind="agent", **expected_agent_span_args(llmobs_events[2]["name"]) ) @@ -144,15 +146,8 @@ def _assert_tool_crew_links(llmobs_events): def _assert_async_crew_events(llmobs_events, spans): llmobs_events.sort(key=lambda span: span["start_ns"]) assert len(spans) == len(llmobs_events) == 6 - expected_span_args = { - "input_value": mock.ANY, - "output_value": mock.ANY, - "metadata": mock.ANY, - "tags": {"service": "tests.contrib.crewai", "ml_app": ""}, - "span_links": True, - } - assert llmobs_events[0] == _expected_llmobs_non_llm_span_event(spans[0], span_kind="workflow", **expected_span_args) - assert llmobs_events[1] == _expected_llmobs_non_llm_span_event(spans[1], span_kind="task", **expected_span_args) + assert llmobs_events[0] == _expected_llmobs_non_llm_span_event(spans[0], span_kind="workflow", **EXPECTED_SPAN_ARGS) + assert llmobs_events[1] == _expected_llmobs_non_llm_span_event(spans[1], span_kind="task", **EXPECTED_SPAN_ARGS) assert llmobs_events[2] == _expected_llmobs_non_llm_span_event( spans[2], span_kind="agent", **expected_agent_span_args(llmobs_events[2]["name"]) ) @@ -164,7 +159,7 @@ def _assert_async_crew_events(llmobs_events, spans): metadata=mock.ANY, tags={"service": "tests.contrib.crewai", "ml_app": ""}, ) - assert llmobs_events[4] == _expected_llmobs_non_llm_span_event(spans[4], span_kind="task", **expected_span_args) + assert llmobs_events[4] == _expected_llmobs_non_llm_span_event(spans[4], span_kind="task", **EXPECTED_SPAN_ARGS) assert llmobs_events[5] == _expected_llmobs_non_llm_span_event( spans[5], span_kind="agent", **expected_agent_span_args(llmobs_events[5]["name"]) ) @@ -188,13 +183,6 @@ def _assert_async_crew_links(llmobs_events): def _assert_hierarchical_crew_events(llmobs_events, spans): llmobs_events.sort(key=lambda span: span["start_ns"]) assert len(spans) == len(llmobs_events) == 12 - expected_span_args = { - "input_value": mock.ANY, - "output_value": mock.ANY, - "metadata": mock.ANY, - "tags": {"service": "tests.contrib.crewai", "ml_app": ""}, - "span_links": True, - } expected_span_kinds = ( "workflow", "task", @@ -220,7 +208,7 @@ def _assert_hierarchical_crew_events(llmobs_events, spans): tags={"service": "tests.contrib.crewai", "ml_app": ""}, ) continue - assert llmobs_span == _expected_llmobs_non_llm_span_event(span, span_kind=kind, **expected_span_args) + assert llmobs_span == _expected_llmobs_non_llm_span_event(span, span_kind=kind, **EXPECTED_SPAN_ARGS) def _assert_hierarchical_crew_links(llmobs_events): @@ -244,6 +232,78 @@ def _assert_hierarchical_crew_links(llmobs_events): _assert_span_link(llmobs_events[11], llmobs_events[10], "output", "output") +def _assert_simple_flow_events(llmobs_events, spans): + llmobs_events.sort(key=lambda span: span["start_ns"]) + assert len(spans) == len(llmobs_events) == 3 + assert llmobs_events[0] == _expected_llmobs_non_llm_span_event(spans[0], span_kind="workflow", **EXPECTED_SPAN_ARGS) + assert llmobs_events[1] == _expected_llmobs_non_llm_span_event(spans[1], span_kind="task", **EXPECTED_SPAN_ARGS) + assert llmobs_events[1]["meta"]["output"]["value"] == "New York City" + assert llmobs_events[2] == _expected_llmobs_non_llm_span_event(spans[2], span_kind="task", **EXPECTED_SPAN_ARGS) + if CREWAI_VERSION >= (0, 119, 0): # Tracking I/O and state management only available CrewAI >=0.119.0 + input_val = json.loads(llmobs_events[0]["meta"]["input"]["value"]) + assert input_val == {"continent": "North America"} + assert llmobs_events[0]["meta"]["output"]["value"] == fun_fact_text + input_val = json.loads(llmobs_events[1]["meta"]["input"]["value"]) + assert input_val["args"] == [] + assert input_val["kwargs"] == {} + assert input_val["flow_state"] == {"id": mock.ANY, "continent": "North America"} + input_val = json.loads(llmobs_events[2]["meta"]["input"]["value"]) + assert input_val["args"] == ["New York City"] + assert input_val["kwargs"] == {} + assert input_val["flow_state"] == {"id": mock.ANY, "continent": "North America"} + assert llmobs_events[2]["meta"]["output"]["value"] == fun_fact_text + + +def _assert_simple_flow_links(llmobs_events): + llmobs_events.sort(key=lambda span: span["start_ns"]) + _assert_span_link(llmobs_events[0], llmobs_events[1], "input", "input") + _assert_span_link(llmobs_events[1], llmobs_events[2], "output", "input") + _assert_span_link(llmobs_events[2], llmobs_events[0], "output", "output") + + +def _assert_complex_flow_events(llmobs_events, spans): + llmobs_events.sort(key=lambda span: span["start_ns"]) + assert len(spans) == len(llmobs_events) == 6 + assert llmobs_events[0] == _expected_llmobs_non_llm_span_event(spans[0], span_kind="workflow", **EXPECTED_SPAN_ARGS) + assert llmobs_events[1] == _expected_llmobs_non_llm_span_event(spans[1], span_kind="task", **EXPECTED_SPAN_ARGS) + assert llmobs_events[2] == _expected_llmobs_non_llm_span_event(spans[2], span_kind="task", **EXPECTED_SPAN_ARGS) + assert llmobs_events[3] == _expected_llmobs_non_llm_span_event(spans[3], span_kind="task", **EXPECTED_SPAN_ARGS) + assert llmobs_events[4] == _expected_llmobs_non_llm_span_event(spans[4], span_kind="task", **EXPECTED_SPAN_ARGS) + assert llmobs_events[5] == _expected_llmobs_non_llm_span_event(spans[5], span_kind="task", **EXPECTED_SPAN_ARGS) + + +def _assert_complex_flow_links(llmobs_events): + llmobs_events.sort(key=lambda span: span["start_ns"]) + _assert_span_link(llmobs_events[0], llmobs_events[1], "input", "input") + _assert_span_link(llmobs_events[0], llmobs_events[2], "input", "input") + _assert_span_link(llmobs_events[5], llmobs_events[0], "output", "output") + + _assert_span_link(llmobs_events[1], llmobs_events[3], "output", "input") + _assert_span_link(llmobs_events[1], llmobs_events[4], "output", "input") + _assert_span_link(llmobs_events[1], llmobs_events[5], "output", "input") + + _assert_span_link(llmobs_events[2], llmobs_events[5], "output", "input") + _assert_span_link(llmobs_events[3], llmobs_events[5], "output", "input") + _assert_span_link(llmobs_events[4], llmobs_events[5], "output", "input") + + +def _assert_router_flow_events(llmobs_events, spans): + llmobs_events.sort(key=lambda span: span["start_ns"]) + assert len(spans) == len(llmobs_events) == 4 + assert llmobs_events[0] == _expected_llmobs_non_llm_span_event(spans[0], span_kind="workflow", **EXPECTED_SPAN_ARGS) + assert llmobs_events[1] == _expected_llmobs_non_llm_span_event(spans[1], span_kind="task", **EXPECTED_SPAN_ARGS) + assert llmobs_events[2] == _expected_llmobs_non_llm_span_event(spans[2], span_kind="task", **EXPECTED_SPAN_ARGS) + assert llmobs_events[3] == _expected_llmobs_non_llm_span_event(spans[3], span_kind="task", **EXPECTED_SPAN_ARGS) + + +def _assert_router_flow_links(llmobs_events): + llmobs_events.sort(key=lambda span: span["start_ns"]) + _assert_span_link(llmobs_events[0], llmobs_events[1], "input", "input") + _assert_span_link(llmobs_events[1], llmobs_events[2], "output", "input") + _assert_span_link(llmobs_events[2], llmobs_events[3], "output", "input") + _assert_span_link(llmobs_events[3], llmobs_events[0], "output", "output") + + def test_basic_crew(crewai, basic_crew, request_vcr, mock_tracer, llmobs_events): with request_vcr.use_cassette("test_basic_crew.yaml"): basic_crew.kickoff(inputs={"topic": "AI"}) @@ -386,3 +446,45 @@ async def test_hierarchical_crew_async_for_each(crewai, hierarchical_crew, reque spans = mock_tracer.pop_traces()[0] _assert_hierarchical_crew_events(llmobs_events, spans) _assert_hierarchical_crew_links(llmobs_events) + + +def test_simple_flow(crewai, simple_flow, mock_tracer, llmobs_events): + simple_flow.kickoff(inputs={"continent": "North America"}) + spans = mock_tracer.pop_traces()[0] + _assert_simple_flow_events(llmobs_events, spans) + _assert_simple_flow_links(llmobs_events) + + +async def test_simple_flow_async(crewai, simple_flow_async, mock_tracer, llmobs_events): + await simple_flow_async.kickoff_async(inputs={"continent": "North America"}) + spans = mock_tracer.pop_traces()[0] + _assert_simple_flow_events(llmobs_events, spans) + _assert_simple_flow_links(llmobs_events) + + +def test_complex_flow(crewai, complex_flow, mock_tracer, llmobs_events): + complex_flow.kickoff(inputs={"continent": "North America"}) + spans = mock_tracer.pop_traces()[0] + _assert_complex_flow_events(llmobs_events, spans) + _assert_complex_flow_links(llmobs_events) + + +async def test_complex_flow_async(crewai, complex_flow_async, mock_tracer, llmobs_events): + await complex_flow_async.kickoff_async(inputs={"continent": "North America"}) + spans = mock_tracer.pop_traces()[0] + _assert_complex_flow_events(llmobs_events, spans) + _assert_complex_flow_links(llmobs_events) + + +def test_router_flow(crewai, router_flow, mock_tracer, llmobs_events): + router_flow.kickoff() + spans = mock_tracer.pop_traces()[0] + _assert_router_flow_events(llmobs_events, spans) + _assert_router_flow_links(llmobs_events) + + +async def test_router_flow_async(crewai, router_flow_async, mock_tracer, llmobs_events): + await router_flow_async.kickoff_async() + spans = mock_tracer.pop_traces()[0] + _assert_router_flow_events(llmobs_events, spans) + _assert_router_flow_links(llmobs_events) diff --git a/tests/contrib/crewai/utils.py b/tests/contrib/crewai/utils.py new file mode 100644 index 00000000000..816132ba8e1 --- /dev/null +++ b/tests/contrib/crewai/utils.py @@ -0,0 +1,100 @@ +welcome_email_text = """ +Subject: Welcome to [Your Company Name] – Let’s Start Planning Your Perfect Trip! + +Dear [Prospective Client's Name], + +We’re thrilled to welcome you to the [Your Company Name] family! Thank you for considering us for your upcoming trip. +We’re excited to help you create unforgettable memories tailored just for you. + +At [Your Company Name], we believe that every journey should be unique and special. +Our team of experienced travel planners is dedicated to understanding your preferences and crafting an itinerary that +perfectly aligns with your vision. Whether you’re dreaming of a relaxing beach getaway, an adventurous mountain +expedition, or an immersive cultural experience, we’re here to turn your dreams into reality. + +To get started, we’d love to learn more about your travel interests, dates, and any specific destinations you have in +mind. Please feel free to reply to this email or give us a call at [Your Phone Number]. + +Once again, welcome aboard! We look forward to helping you plan an amazing trip that exceeds your expectations. + +Warm regards, + +[Your Name] +[Your Position] +[Your Company Name] +[Your Phone Number] +[Your Email Address] +[Your Website URL] +""" + +fun_fact_text = """ +Sure! Did you know that New York City has its own secret underground city? +Below the bustling streets, there are abandoned subway stations, old tunnels, and even a hidden speakeasy! +One famous example is the City Hall subway station, which closed in 1945 but still showcases stunning architecture +and vintage designs. It’s occasionally open for tours, giving a glimpse into this secret part of NYC's history! +""" + +budget_text = """ +Trip Budget for New York City + +Accommodation (3 nights): $600 +Food (3 days): $150 +Transportation (subway/taxis): $100 +Attractions (museums, shows): $200 +Miscellaneous (shopping, souvenirs): $100 +Total Budget: $1,250 +""" + +itinerary_text = f""" +3-Day New York City Itinerary on a $1,250 Budget +Day 1: Arrival and Exploring Manhattan +Accommodation Check-in + +Stay: Budget hotel or hostel (around $200/night). Suggestions include HI New York City Hostel or pod 51 Hotel. +Morning: + +Breakfast: Grab a bagel and coffee at Ess-a-Bagel (Approx. $10). +Activity: Visit the iconic Central Park. Walk, take photos, and enjoy the scenery. Free! +Afternoon: + +Lunch: Delicious slices at Joe's Pizza — a NYC classic (Approx. $10). +Activity: Explore the MoMA. Admission is free on Fridays from 5:30 PM to 9 PM, so plan your visit for that time! +Evening: + +Dinner: Try Shake Shack for some burgers and shakes (Approx. $15). +Attraction: End your day with a stroll through Times Square. Immerse yourself in the lights and energy. Free! +Day 2: Brooklyn and More Cultural Experiences +Morning: + +Breakfast: Head to Balthazar Bakery for pastries and coffee (Approx. $15). +Activity: Walk across the Brooklyn Bridge for stunning views of the skyline. Free! +Afternoon: + +Lunch: Try Juliana's Pizza in Brooklyn (Approx. $20). +Activity: Explore DUMBO for its arts scene and waterfront parks. Free to explore! +Evening: + +Dinner: Feast on ethnic food at Smorgasburg Brooklyn (Approx. $20) if visiting on a Saturday. +Attraction: Catch a Broadway show. Approx. $70 for a matinee show. +Day 3: Culture and Farewell +Morning: + +Breakfast: Enjoy breakfast at Cafe Mogador (Approx. $15). +Activity: Visit the American Museum of Natural History (Admission is $23, but pay what you wish). +Afternoon: + +Lunch: Grab lunch at a street vendor or food truck (Approx. $10). +Activity: Explore Guggenheim Museum (Admission is $25, but check for free admission days). +Evening: + +Dinner: Treat yourself to dinner at The Halal Guys for delicious street food (Approx. $10). +Attraction: Take a walk through The High Line, a beautiful elevated park (Free!). + +Budget breakdown: +{budget_text} + +Fun fact: +{fun_fact_text} + +Welcome email: +{welcome_email_text} +""" diff --git a/tests/snapshots/tests.contrib.crewai.test_crewai.test_complex_flow.json b/tests/snapshots/tests.contrib.crewai.test_crewai.test_complex_flow.json new file mode 100644 index 00000000000..2faa2eaf025 --- /dev/null +++ b/tests/snapshots/tests.contrib.crewai.test_crewai.test_complex_flow.json @@ -0,0 +1,101 @@ +[[ + { + "name": "ExFlow", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "688a751500000000", + "language": "python", + "runtime-id": "56162dabc987448fb1a8d3639a92aef8" + }, + "metrics": { + "_dd.measured": 1, + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 34543 + }, + "duration": 275395000, + "start": 1753904405969657000 + }, + { + "name": "generate_city", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow Method", + "trace_id": 0, + "span_id": 2, + "parent_id": 1, + "type": "", + "error": 0, + "metrics": { + "_dd.measured": 1 + }, + "duration": 56131000, + "start": 1753904405969837000 + }, + { + "name": "generate_welcome_email", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow Method", + "trace_id": 0, + "span_id": 3, + "parent_id": 1, + "type": "", + "error": 0, + "metrics": { + "_dd.measured": 1 + }, + "duration": 51731000, + "start": 1753904406026550000 + }, + { + "name": "generate_fun_fact", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow Method", + "trace_id": 0, + "span_id": 4, + "parent_id": 1, + "type": "", + "error": 0, + "metrics": { + "_dd.measured": 1 + }, + "duration": 67431000, + "start": 1753904406078962000 + }, + { + "name": "generate_budget", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow Method", + "trace_id": 0, + "span_id": 5, + "parent_id": 1, + "type": "", + "error": 0, + "metrics": { + "_dd.measured": 1 + }, + "duration": 42643000, + "start": 1753904406146863000 + }, + { + "name": "generate_itinerary", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow Method", + "trace_id": 0, + "span_id": 6, + "parent_id": 1, + "type": "", + "error": 0, + "metrics": { + "_dd.measured": 1 + }, + "duration": 53739000, + "start": 1753904406190596000 + }]] diff --git a/tests/snapshots/tests.contrib.crewai.test_crewai.test_simple_flow.json b/tests/snapshots/tests.contrib.crewai.test_crewai.test_simple_flow.json new file mode 100644 index 00000000000..5bd14c7ff9d --- /dev/null +++ b/tests/snapshots/tests.contrib.crewai.test_crewai.test_simple_flow.json @@ -0,0 +1,56 @@ +[[ + { + "name": "ExFlow", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "688a6cac00000000", + "language": "python", + "runtime-id": "5dd5230615214af6a668a6124e22e4b4" + }, + "metrics": { + "_dd.measured": 1, + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 26333 + }, + "duration": 120768000, + "start": 1753902252890209000 + }, + { + "name": "generate_city", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow Method", + "trace_id": 0, + "span_id": 2, + "parent_id": 1, + "type": "", + "error": 0, + "metrics": { + "_dd.measured": 1 + }, + "duration": 55178000, + "start": 1753902252890303000 + }, + { + "name": "generate_fun_fact", + "service": "tests.contrib.crewai", + "resource": "CrewAI Flow Method", + "trace_id": 0, + "span_id": 3, + "parent_id": 1, + "type": "", + "error": 0, + "metrics": { + "_dd.measured": 1 + }, + "duration": 65128000, + "start": 1753902252945642000 + }]] From 0b05b046f9766ec9f6189de62eec87066d10a15e Mon Sep 17 00:00:00 2001 From: Brett Langdon Date: Tue, 12 Aug 2025 13:06:42 -0400 Subject: [PATCH 05/14] chore(build): remove source files from wheel RECORD (#14279) ## Problem PyPI has issued a deprecation warning for our `ddtrace` package wheels. The wheel RECORD files list source files (`.c`, `.cpp`, `.h`, `.pyx`, etc.) that were removed during post-processing but the RECORD wasn't updated to reflect this. This mismatch between RECORD contents and actual wheel contents will become a hard error in future PyPI releases. ## Solution We've updated our wheel build pipeline to ensure RECORD file integrity: 1. **Enhanced `zip_filter.py`** - Our existing script for removing source files from wheels now also updates the RECORD file to maintain consistency between listed and actual contents. 2. **Unified build approach** - All platforms (Linux, macOS, Windows) now use the same `zip_filter.py` script for consistent source file removal and RECORD updating. 3. **Automated validation** - Added a new validation script and CI step that verifies every built wheel has a RECORD file that accurately reflects its contents, including proper SHA256 hashesand file sizes. This ensures our wheels comply with PyPI requirements while maintaining the same distribution content - compiled extensions without source files, but with accurate metadata. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [ ] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- .github/workflows/build_python_3.yml | 22 +++-- scripts/validate_wheel.py | 141 +++++++++++++++++++++++++++ scripts/zip_filter.py | 42 +++++++- 3 files changed, 196 insertions(+), 9 deletions(-) create mode 100755 scripts/validate_wheel.py diff --git a/.github/workflows/build_python_3.yml b/.github/workflows/build_python_3.yml index c1b2e70e882..b90c1f657e2 100644 --- a/.github/workflows/build_python_3.yml +++ b/.github/workflows/build_python_3.yml @@ -69,20 +69,16 @@ jobs: # See: https://stackoverflow.com/a/65402241 CIBW_ENVIRONMENT_MACOS: CMAKE_BUILD_PARALLEL_LEVEL=24 SYSTEM_VERSION_COMPAT=0 CMAKE_ARGS="-DNATIVE_TESTING=OFF" CIBW_REPAIR_WHEEL_COMMAND_LINUX: | + python scripts/zip_filter.py {wheel} \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md && mkdir ./tempwheelhouse && unzip -l {wheel} | grep '\.so' && auditwheel repair -w ./tempwheelhouse {wheel} && - for w in ./tempwheelhouse/*.whl; do - python scripts/zip_filter.py $w \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md - mv $w {dest_dir} - done && + mv ./tempwheelhouse/*.whl {dest_dir} && rm -rf ./tempwheelhouse CIBW_REPAIR_WHEEL_COMMAND_MACOS: | - zip -d {wheel} \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md && + python scripts/zip_filter.py {wheel} \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md && MACOSX_DEPLOYMENT_TARGET=12.7 delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: choco install -y 7zip && - 7z d -r "{wheel}" *.c *.cpp *.cc *.h *.hpp *.pyx *.md && - move "{wheel}" "{dest_dir}" + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: python scripts/zip_filter.py "{wheel}" "*.c" "*.cpp" "*.cc" "*.h" "*.hpp" "*.pyx" "*.md" && mv "{wheel}" "{dest_dir}" CIBW_TEST_COMMAND: "python {project}/tests/smoke_test.py" steps: @@ -107,6 +103,16 @@ jobs: with: only: ${{ matrix.only }} + - name: Validate wheel RECORD files + shell: bash + run: | + for wheel in ./wheelhouse/*.whl; do + if [ -f "$wheel" ]; then + echo "Validating $(basename $wheel)..." + python scripts/validate_wheel.py "$wheel" + fi + done + - if: runner.os != 'Windows' run: | echo "ARTIFACT_NAME=${{ matrix.only }}" >> $GITHUB_ENV diff --git a/scripts/validate_wheel.py b/scripts/validate_wheel.py new file mode 100755 index 00000000000..bb6c546c04e --- /dev/null +++ b/scripts/validate_wheel.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Validate that a wheel's contents match its RECORD file. + +This script checks: +1. All files in the wheel are listed in RECORD +2. All files in RECORD exist in the wheel +3. File hashes match (for files that have hashes in RECORD) +4. File sizes match +""" + +import argparse +import base64 +import csv +import hashlib +import io +from pathlib import Path +import sys +import zipfile + + +def compute_hash(data): + """Compute the urlsafe base64 encoded SHA256 hash of data.""" + hash_digest = hashlib.sha256(data).digest() + return base64.urlsafe_b64encode(hash_digest).rstrip(b"=").decode("ascii") + + +def validate_wheel(wheel_path): + """Validate that wheel contents match its RECORD file.""" + errors = [] + + with zipfile.ZipFile(wheel_path, "r") as wheel: + # Find the RECORD file + record_path = None + for name in wheel.namelist(): + if name.endswith(".dist-info/RECORD"): + record_path = name + break + + if not record_path: + errors.append("No RECORD file found in wheel") + return errors + + # Parse the RECORD file + record_content = wheel.read(record_path).decode("utf-8") + record_entries = {} + + reader = csv.reader(io.StringIO(record_content)) + for row in reader: + if not row or len(row) < 3: + continue + + file_path, hash_str, size_str = row[0], row[1], row[2] + record_entries[file_path] = {"hash": hash_str, "size": int(size_str) if size_str else None} + + # Get all files in the wheel (excluding directories) + wheel_files = set() + for name in wheel.namelist(): + # Skip directories (they end with /) + if not name.endswith("/"): + wheel_files.add(name) + + record_files = set(record_entries.keys()) + + # Check for files in wheel but not in RECORD + files_not_in_record = wheel_files - record_files + if files_not_in_record: + for f in sorted(files_not_in_record): + errors.append(f"File in wheel but not in RECORD: {f}") + + # Check for files in RECORD but not in wheel + files_not_in_wheel = record_files - wheel_files + if files_not_in_wheel: + for f in sorted(files_not_in_wheel): + errors.append(f"File in RECORD but not in wheel: {f}") + + # Validate hashes and sizes for files that exist in both + for file_path in record_files & wheel_files: + # Skip the RECORD file itself + if file_path == record_path: + continue + + record_entry = record_entries[file_path] + file_data = wheel.read(file_path) + + # Check size + if record_entry["size"] is not None: + actual_size = len(file_data) + if actual_size != record_entry["size"]: + errors.append( + f"Size mismatch for {file_path}: RECORD says {record_entry['size']}, actual is {actual_size}" + ) + + # Check hash + if record_entry["hash"]: + # Parse the hash format (algorithm=base64hash) + if "=" in record_entry["hash"]: + algo, expected_hash = record_entry["hash"].split("=", 1) + if algo == "sha256": + actual_hash = compute_hash(file_data) + if actual_hash != expected_hash: + errors.append( + f"Hash mismatch for {file_path}: RECORD says {expected_hash}, actual is {actual_hash}" + ) + else: + errors.append(f"Unknown hash algorithm {algo} for {file_path} (expected sha256)") + else: + errors.append(f"Invalid hash format for {file_path}: {record_entry['hash']}") + # The RECORD file itself should not have a hash + elif file_path != record_path: + errors.append(f"No hash recorded for {file_path}") + + return errors + + +def main(): + parser = argparse.ArgumentParser(description="Validate wheel RECORD file matches contents") + parser.add_argument("wheel", help="Path to wheel file to validate") + + args = parser.parse_args() + + wheel_path = Path(args.wheel) + if not wheel_path.exists(): + print(f"Error: Wheel file not found: {wheel_path}", file=sys.stderr) + sys.exit(1) + + print(f"Validating {wheel_path.name}...") + errors = validate_wheel(wheel_path) + + if errors: + print(f"\n[ERROR] Found {len(errors)} error(s):", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + sys.exit(1) + + print("[SUCCESS] Wheel validation passed!") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/zip_filter.py b/scripts/zip_filter.py index d930bacb8eb..0c9f8568460 100644 --- a/scripts/zip_filter.py +++ b/scripts/zip_filter.py @@ -1,11 +1,46 @@ import argparse +import csv import fnmatch +import io import os import zipfile +def update_record(record_content, patterns): + """Update the RECORD file to remove entries for deleted files.""" + # Parse the existing RECORD + records = [] + reader = csv.reader(io.StringIO(record_content)) + + for row in reader: + if not row: + continue + file_path = row[0] + # Skip files that match removal patterns + if not any(fnmatch.fnmatch(file_path, pattern) for pattern in patterns): + records.append(row) + + # Rebuild the RECORD content + output = io.StringIO() + writer = csv.writer(output, lineterminator="\n") + for record in records: + writer.writerow(record) + + return output.getvalue() + + def remove_from_zip(zip_filename, patterns): temp_zip_filename = f"{zip_filename}.tmp" + record_content = None + + # First pass: read RECORD file if it exists + with zipfile.ZipFile(zip_filename, "r") as source_zip: + for file in source_zip.infolist(): + if file.filename.endswith(".dist-info/RECORD"): + record_content = source_zip.read(file.filename).decode("utf-8") + break + + # Second pass: create new zip without removed files and with updated RECORD with zipfile.ZipFile(zip_filename, "r") as source_zip, zipfile.ZipFile( temp_zip_filename, "w", zipfile.ZIP_DEFLATED ) as temp_zip: @@ -13,7 +48,12 @@ def remove_from_zip(zip_filename, patterns): for file in source_zip.infolist(): if any(fnmatch.fnmatch(file.filename, pattern) for pattern in patterns): continue - temp_zip.writestr(file, source_zip.read(file.filename)) + elif file.filename.endswith(".dist-info/RECORD") and record_content: + # Update the RECORD file + updated_record = update_record(record_content, patterns) + temp_zip.writestr(file, updated_record) + else: + temp_zip.writestr(file, source_zip.read(file.filename)) os.replace(temp_zip_filename, zip_filename) From 2e4d8a7d9d6308b4c9eea0b0adc0611f0d2b5d86 Mon Sep 17 00:00:00 2001 From: Munir Abdinur Date: Tue, 12 Aug 2025 13:23:05 -0400 Subject: [PATCH 06/14] chore(telemetry): report telemetry for all config sources (#14170) ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- ddtrace/internal/telemetry/__init__.py | 116 ++++++++++++------------- ddtrace/settings/_core.py | 5 +- ddtrace/settings/_otel_remapper.py | 6 +- tests/conftest.py | 11 +-- tests/opentelemetry/test_config.py | 9 +- tests/telemetry/test_telemetry.py | 30 +++++++ tests/telemetry/test_writer.py | 84 ++++++++++++++---- 7 files changed, 173 insertions(+), 88 deletions(-) diff --git a/ddtrace/internal/telemetry/__init__.py b/ddtrace/internal/telemetry/__init__.py index 51061658308..a7eff2148eb 100644 --- a/ddtrace/internal/telemetry/__init__.py +++ b/ddtrace/internal/telemetry/__init__.py @@ -25,20 +25,6 @@ __all__ = ["telemetry_writer"] -def report_config_telemetry(effective_env, val, source, otel_env, config_id): - if effective_env == otel_env: - # We only report the raw value for OpenTelemetry configurations, we should make this consistent - raw_val = os.environ.get(effective_env, "").lower() - telemetry_writer.add_configuration(effective_env, raw_val, source) - else: - if otel_env is not None and otel_env in os.environ: - if source in ("fleet_stable_config", "env_var"): - _hiding_otel_config(otel_env, effective_env) - else: - _invalid_otel_config(otel_env) - telemetry_writer.add_configuration(effective_env, val, source, config_id) - - def get_config( envs: t.Union[str, t.List[str]], default: t.Any = None, @@ -47,63 +33,75 @@ def get_config( report_telemetry=True, ) -> t.Any: """Retrieve a configuration value in order of precedence: - 1. Fleet stable config + 1. Fleet stable config (highest) 2. Datadog env vars 3. OpenTelemetry env vars 4. Local stable config - 5. Default value + 5. Default value (lowest) + + Reports telemetry for every detected configuration source. """ if isinstance(envs, str): envs = [envs] - source = "" - effective_env = "" - val = None - config_id = None - # Get configurations from fleet stable config + + effective_val = default + telemetry_name = envs[0] + if report_telemetry: + telemetry_writer.add_configuration(telemetry_name, default, "default") + + for env in envs: + if env in LOCAL_CONFIG: + val = LOCAL_CONFIG[env] + if modifier: + val = modifier(val) + + if report_telemetry: + telemetry_writer.add_configuration(telemetry_name, val, "local_stable_config") + effective_val = val + break + + if otel_env is not None and otel_env in os.environ: + raw_val, parsed_val = parse_otel_env(otel_env) + if parsed_val is not None: + val = parsed_val + if modifier: + val = modifier(val) + + if report_telemetry: + # OpenTelemetry configurations always report the raw value + telemetry_writer.add_configuration(telemetry_name, raw_val, "otel_env_var") + effective_val = val + else: + _invalid_otel_config(otel_env) + + for env in envs: + if env in os.environ: + val = os.environ[env] + if modifier: + val = modifier(val) + + if report_telemetry: + telemetry_writer.add_configuration(telemetry_name, val, "env_var") + if otel_env is not None and otel_env in os.environ: + _hiding_otel_config(otel_env, env) + effective_val = val + break + for env in envs: if env in FLEET_CONFIG: - source = "fleet_stable_config" - effective_env = env val = FLEET_CONFIG[env] config_id = FLEET_CONFIG_IDS.get(env) + if modifier: + val = modifier(val) + + if report_telemetry: + telemetry_writer.add_configuration(telemetry_name, val, "fleet_stable_config", config_id) + if otel_env is not None and otel_env in os.environ: + _hiding_otel_config(otel_env, env) + effective_val = val break - # Get configurations from datadog env vars - if val is None: - for env in envs: - if env in os.environ: - source = "env_var" - effective_env = env - val = os.environ[env] - break - # Get configurations from otel env vars - if val is None: - if otel_env is not None and otel_env in os.environ: - parsed_val = parse_otel_env(otel_env) - if parsed_val is not None: - source = "env_var" - effective_env = otel_env - val = parsed_val - # Get configurations from local stable config - if val is None: - for env in envs: - if env in LOCAL_CONFIG: - source = "local_stable_config" - effective_env = env - val = LOCAL_CONFIG[env] - break - # Convert the raw value to expected format, if a modifier is provided - if val is not None and modifier: - val = modifier(val) - # If no value is found, use the default - if val is None: - effective_env = envs[0] - val = default - source = "default" - # Report telemetry - if report_telemetry: - report_config_telemetry(effective_env, val, source, otel_env, config_id) - return val + return effective_val telemetry_enabled = get_config("DD_INSTRUMENTATION_TELEMETRY_ENABLED", True, asbool, report_telemetry=False) diff --git a/ddtrace/settings/_core.py b/ddtrace/settings/_core.py index 69ddd1b2c62..39d169a3afc 100644 --- a/ddtrace/settings/_core.py +++ b/ddtrace/settings/_core.py @@ -20,6 +20,7 @@ class ValueSource(str, Enum): CODE = "code" DEFAULT = "default" UNKNOWN = "unknown" + OTEL_ENV_VAR = "otel_env_var" class DDConfig(Env): @@ -57,6 +58,8 @@ def __init__( if env_name in self.fleet_source: value_source = ValueSource.FLEET_STABLE_CONFIG + elif env_name in self.env_source and env_name.upper().startswith("OTEL_"): + value_source = ValueSource.OTEL_ENV_VAR elif env_name in self.env_source: value_source = ValueSource.ENV_VAR elif env_name in self.local_source: @@ -75,5 +78,5 @@ def __init__( else: self.config_id = None - def value_source(self, env_name: str) -> ValueSource: + def value_source(self, env_name: str) -> str: return self._value_source.get(env_name, ValueSource.UNKNOWN) diff --git a/ddtrace/settings/_otel_remapper.py b/ddtrace/settings/_otel_remapper.py index 44cb2212a94..86309874626 100644 --- a/ddtrace/settings/_otel_remapper.py +++ b/ddtrace/settings/_otel_remapper.py @@ -155,7 +155,7 @@ def _remap_default(otel_value: str) -> Optional[str]: } -def parse_otel_env(otel_env: str) -> Optional[str]: +def parse_otel_env(otel_env: str) -> Tuple[str, Optional[str]]: _, otel_config_validator = ENV_VAR_MAPPINGS[otel_env] raw_value = os.environ.get(otel_env, "") if otel_env not in ("OTEL_RESOURCE_ATTRIBUTES", "OTEL_SERVICE_NAME"): @@ -163,5 +163,5 @@ def parse_otel_env(otel_env: str) -> Optional[str]: raw_value = raw_value.lower() mapped_value = otel_config_validator(raw_value) if mapped_value is None: - return None - return mapped_value + return "", None + return raw_value, mapped_value diff --git a/tests/conftest.py b/tests/conftest.py index 399a4616517..48ef8521ddb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -638,20 +638,21 @@ def get_configurations(self, name=None, ignores=None, remove_seq_id=False, effec configurations = [] events_with_configs = self.get_events("app-started") + self.get_events("app-client-configuration-change") for event in events_with_configs: - for c in event["payload"]["configuration"]: - config = c.copy() - if remove_seq_id: - config.pop("seq_id") + for config in event["payload"]["configuration"]: if config["name"] == name or (name is None and config["name"] not in ignores): configurations.append(config) + configurations.sort(key=lambda x: x["seq_id"]) if effective: config_map = {} for c in configurations: config_map[c["name"]] = c configurations = list(config_map.values()) - configurations.sort(key=lambda x: x["name"]) + if remove_seq_id: + for c in configurations: + c.pop("seq_id") + return configurations diff --git a/tests/opentelemetry/test_config.py b/tests/opentelemetry/test_config.py index e96a893ecfa..699de61c7c2 100644 --- a/tests/opentelemetry/test_config.py +++ b/tests/opentelemetry/test_config.py @@ -37,7 +37,9 @@ def _global_sampling_rule(): "OTEL_SDK_DISABLED": "True", "DD_TRACE_OTEL_ENABLED": "True", }, - err=b"Setting OTEL_LOGS_EXPORTER to warning is not supported by ddtrace, this configuration will be ignored.\n", + err=b"Setting OTEL_LOGS_EXPORTER to warning is not supported by ddtrace, this configuration " + b"will be ignored.\nTrace sampler set from always_off to parentbased_always_off; only parent based " + b"sampling is supported.\nFollowing style not supported by ddtrace: jaegar.\n", ) def test_dd_otel_mixed_env_configuration(): from ddtrace import config @@ -69,8 +71,9 @@ def test_dd_otel_mixed_env_configuration(): "service.version=1.0,testtag1=random1,testtag2=random2,testtag3=random3,testtag4=random4", "OTEL_SDK_DISABLED": "False", }, - err=b"Setting OTEL_LOGS_EXPORTER to warning is not supported by ddtrace, " - b"this configuration will be ignored.\nFollowing style not supported by ddtrace: jaegar.\n", + err=b"Setting OTEL_LOGS_EXPORTER to warning is not supported by ddtrace, this configuration will be ignored.\n" + b"Trace sampler set from always_off to parentbased_always_off; only parent based sampling is supported.\n" + b"Following style not supported by ddtrace: jaegar.\n", ) def test_dd_otel_missing_dd_env_configuration(): from ddtrace import config diff --git a/tests/telemetry/test_telemetry.py b/tests/telemetry/test_telemetry.py index 67a39a69e15..623a23c47d1 100644 --- a/tests/telemetry/test_telemetry.py +++ b/tests/telemetry/test_telemetry.py @@ -353,3 +353,33 @@ def test_installed_excepthook(): assert telemetry_writer._enabled is True telemetry_writer.uninstall_excepthook() assert sys.excepthook.__name__ != "_telemetry_excepthook" + + +def test_telemetry_multiple_sources(test_agent_session, run_python_code_in_subprocess): + """Test that a config is submitted for multiple sources with increasing seq_id""" + + env = os.environ.copy() + env["OTEL_TRACES_EXPORTER"] = "none" + env["DD_TRACE_ENABLED"] = "false" + env["_DD_INSTRUMENTATION_TELEMETRY_TESTS_FORCE_APP_STARTED"] = "true" + + _, err, status, _ = run_python_code_in_subprocess( + "from ddtrace import config; config._tracing_enabled = True", env=env + ) + assert status == 0, err + + configs = test_agent_session.get_configurations(name="DD_TRACE_ENABLED", remove_seq_id=False, effective=False) + assert len(configs) == 4, configs + + sorted_configs = sorted(configs, key=lambda x: x["seq_id"]) + assert sorted_configs[0]["value"] is True + assert sorted_configs[0]["origin"] == "default" + + assert sorted_configs[1]["value"] == "none" + assert sorted_configs[1]["origin"] == "otel_env_var" + + assert sorted_configs[2]["value"] is False + assert sorted_configs[2]["origin"] == "env_var" + + assert sorted_configs[3]["value"] is True + assert sorted_configs[3]["origin"] == "code" diff --git a/tests/telemetry/test_writer.py b/tests/telemetry/test_writer.py index b6c804674f5..83f416e29cf 100644 --- a/tests/telemetry/test_writer.py +++ b/tests/telemetry/test_writer.py @@ -79,7 +79,7 @@ def test_app_started_event_configuration_override_asm( _, stderr, status, _ = run_python_code_in_subprocess("import ddtrace.auto", env=env) assert status == 0, stderr - configuration = test_agent_session.get_configurations(name=env_var, remove_seq_id=True) + configuration = test_agent_session.get_configurations(name=env_var, remove_seq_id=True, effective=True) assert len(configuration) == 1, configuration assert configuration[0] == {"name": env_var, "origin": "env_var", "value": expected_value} @@ -300,6 +300,7 @@ def test_app_started_event_configuration_override(test_agent_session, run_python ignores=["DD_TRACE_AGENT_URL", "DD_AGENT_PORT", "DD_TRACE_AGENT_PORT"], remove_seq_id=True, effective=True ) assert configurations + configurations.sort(key=lambda x: x["name"]) expected = [ {"name": "DD_AGENT_HOST", "origin": "default", "value": None}, @@ -994,32 +995,40 @@ def test_otel_config_telemetry(test_agent_session, run_python_code_in_subprocess _, stderr, status, _ = run_python_code_in_subprocess("import ddtrace", env=env) assert status == 0, stderr - configurations = {c["name"]: c for c in test_agent_session.get_configurations(remove_seq_id=True)} + configurations = {c["name"]: c for c in test_agent_session.get_configurations(remove_seq_id=True, effective=True)} assert configurations["DD_SERVICE"] == {"name": "DD_SERVICE", "origin": "env_var", "value": "dd_service"} - assert configurations["OTEL_LOG_LEVEL"] == {"name": "OTEL_LOG_LEVEL", "origin": "env_var", "value": "debug"} - assert configurations["OTEL_PROPAGATORS"] == { - "name": "OTEL_PROPAGATORS", - "origin": "env_var", + assert configurations["DD_TRACE_DEBUG"] == {"name": "DD_TRACE_DEBUG", "origin": "otel_env_var", "value": "debug"} + assert configurations["DD_TRACE_PROPAGATION_STYLE_INJECT"] == { + "name": "DD_TRACE_PROPAGATION_STYLE_INJECT", + "origin": "otel_env_var", "value": "tracecontext", } - assert configurations["OTEL_TRACES_SAMPLER"] == { - "name": "OTEL_TRACES_SAMPLER", - "origin": "env_var", + assert configurations["DD_TRACE_PROPAGATION_STYLE_EXTRACT"] == { + "name": "DD_TRACE_PROPAGATION_STYLE_EXTRACT", + "origin": "otel_env_var", + "value": "tracecontext", + } + assert configurations["DD_TRACE_SAMPLING_RULES"] == { + "name": "DD_TRACE_SAMPLING_RULES", + "origin": "otel_env_var", "value": "always_on", } - assert configurations["OTEL_TRACES_EXPORTER"] == { - "name": "OTEL_TRACES_EXPORTER", - "origin": "env_var", + assert configurations["DD_TRACE_ENABLED"] == { + "name": "DD_TRACE_ENABLED", + "origin": "otel_env_var", "value": "none", } - assert configurations["OTEL_LOGS_EXPORTER"] == {"name": "OTEL_LOGS_EXPORTER", "origin": "env_var", "value": "otlp"} - assert configurations["OTEL_RESOURCE_ATTRIBUTES"] == { - "name": "OTEL_RESOURCE_ATTRIBUTES", - "origin": "env_var", + assert configurations["DD_TAGS"] == { + "name": "DD_TAGS", + "origin": "otel_env_var", "value": "team=apm,component=web", } - assert configurations["OTEL_SDK_DISABLED"] == {"name": "OTEL_SDK_DISABLED", "origin": "env_var", "value": "true"} + assert configurations["DD_TRACE_OTEL_ENABLED"] == { + "name": "DD_TRACE_OTEL_ENABLED", + "origin": "otel_env_var", + "value": "true", + } env_hiding_metrics = test_agent_session.get_metrics("otel.env.hiding") tags = [m["tags"] for m in env_hiding_metrics] @@ -1093,3 +1102,44 @@ def test_redact_filename(filename, is_redacted): """Test file redaction logic""" writer = TelemetryWriter(is_periodic=False) assert writer._should_redact(filename) == is_redacted + + +def test_telemetry_writer_multiple_sources_config(telemetry_writer, test_agent_session): + """Test that telemetry data is submitted for multiple sources with increasing seq_id""" + + telemetry_writer.add_configuration("DD_SERVICE", "unamed_python_service", "default") + telemetry_writer.add_configuration("DD_SERVICE", "otel_service", "otel_env_var") + telemetry_writer.add_configuration("DD_SERVICE", "dd_service", "env_var") + telemetry_writer.add_configuration("DD_SERVICE", "monkey", "code") + telemetry_writer.add_configuration("DD_SERVICE", "baboon", "remote_config") + telemetry_writer.add_configuration("DD_SERVICE", "baboon", "fleet_stable_config") + + telemetry_writer.periodic(force_flush=True) + + configs = test_agent_session.get_configurations(name="DD_SERVICE", remove_seq_id=False, effective=False) + assert len(configs) == 6, configs + + sorted_configs = sorted(configs, key=lambda x: x["seq_id"]) + assert sorted_configs[0]["value"] == "unamed_python_service" + assert sorted_configs[0]["origin"] == "default" + assert sorted_configs[0]["seq_id"] == 1 + + assert sorted_configs[1]["value"] == "otel_service" + assert sorted_configs[1]["origin"] == "otel_env_var" + assert sorted_configs[1]["seq_id"] == 2 + + assert sorted_configs[2]["value"] == "dd_service" + assert sorted_configs[2]["origin"] == "env_var" + assert sorted_configs[2]["seq_id"] == 3 + + assert sorted_configs[3]["value"] == "monkey" + assert sorted_configs[3]["origin"] == "code" + assert sorted_configs[3]["seq_id"] == 4 + + assert sorted_configs[4]["value"] == "baboon" + assert sorted_configs[4]["origin"] == "remote_config" + assert sorted_configs[4]["seq_id"] == 5 + + assert sorted_configs[5]["value"] == "baboon" + assert sorted_configs[5]["origin"] == "fleet_stable_config" + assert sorted_configs[5]["seq_id"] == 6 From 10f4e44171bd3b1ee154f3b1ef2b42943b523e37 Mon Sep 17 00:00:00 2001 From: Emmett Butler <723615+emmettbutler@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:17:37 -0700 Subject: [PATCH 07/14] ci: improve GHA run detection in gitlab builds (#14285) This change fixes an issue in which the gitlab pipeline for a tagged release would sometimes start with the wrong wheel. The fix involves filtering the list of wheel candidates by the event that triggered their creation. Release pipelines should only consider wheels built during **tag** github actions. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- .gitlab/download-wheels-from-gh-actions.sh | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.gitlab/download-wheels-from-gh-actions.sh b/.gitlab/download-wheels-from-gh-actions.sh index f01afcccfd0..547c1cd92f2 100755 --- a/.gitlab/download-wheels-from-gh-actions.sh +++ b/.gitlab/download-wheels-from-gh-actions.sh @@ -1,12 +1,29 @@ #!/bin/bash set -eo pipefail +get_run_id() { + RUN_ID=$( + gh run ls \ + --repo DataDog/dd-trace-py \ + --commit="$CI_COMMIT_SHA" \ + $([ -z "$TRIGGERING_EVENT" ] && echo "" || echo "--event=$TRIGGERING_EVENT") \ + --workflow=build_deploy.yml \ + --json databaseId \ + --jq "first (.[]) | .databaseId" + ) +} + if [ -z "$CI_COMMIT_SHA" ]; then echo "Error: CI_COMMIT_SHA was not provided" exit 1 fi -RUN_ID=$(gh run ls --repo DataDog/dd-trace-py --commit=$CI_COMMIT_SHA --workflow=build_deploy.yml --json databaseId --jq "first (.[]) | .databaseId") +if [ -v "$CI_COMMIT_TAG" ]; then + TRIGGERING_EVENT="release" +fi + +get_run_id + if [ -z "$RUN_ID" ]; then echo "No RUN_ID found waiting for job to start" # The job has not started yet. Give it time to start @@ -19,7 +36,7 @@ if [ -z "$RUN_ID" ]; then end_time=$((start_time + timeout)) # Loop for 10 minutes waiting for run to appear in github while [ $(date +%s) -lt $end_time ]; do - RUN_ID=$(gh run ls --repo DataDog/dd-trace-py --commit=$CI_COMMIT_SHA --workflow=build_deploy.yml --json databaseId --jq "first (.[]) | .databaseId") + get_run_id if [ -n "$RUN_ID" ]; then break; fi From 7f557808ff9cdb5a8ce85da93b20bca977035774 Mon Sep 17 00:00:00 2001 From: Brett Langdon Date: Tue, 12 Aug 2025 15:32:59 -0400 Subject: [PATCH 08/14] ci: fix inaccurate detect baseline script for benchmarks (#14287) ls-remote will use the provided string as a pattern, so when checking if the '3.12' branch exists, it'll allow anything that contains '3.12' in the name. this change is more exacting by checking for a specific ref instead ``` UPSTREAM_BRANCH=v3.12.0rc1 + '[' v3.12.0rc1 == main ']' + [[ v3.12.0rc1 =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]] ++ echo 3.12.0rc1 ++ cut -d. -f1-2 + BASELINE_BRANCH=3.12 + git ls-remote --exit-code --heads origin 3.12 + echo 'Found remote branch origin/3.12' Found remote branch origin/3.12 ++ git describe --tags --abbrev=0 --exclude '*rc*' --exclude v3.12.0rc1 origin/3.12 fatal: Not a valid object name origin/3.12 ``` ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [ ] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- .gitlab/benchmarks/steps/detect-baseline.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/benchmarks/steps/detect-baseline.sh b/.gitlab/benchmarks/steps/detect-baseline.sh index 2a6f40699b5..66efab4b4cf 100755 --- a/.gitlab/benchmarks/steps/detect-baseline.sh +++ b/.gitlab/benchmarks/steps/detect-baseline.sh @@ -27,7 +27,7 @@ elif [[ "${UPSTREAM_BRANCH}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then BASELINE_BRANCH=$(echo "${UPSTREAM_BRANCH:1}" | cut -d. -f1-2) # Check if a release branch exists or not - if git ls-remote --exit-code --heads origin "${BASELINE_BRANCH}" > /dev/null; then + if git ls-remote --exit-code --heads origin "refs/heads/${BASELINE_BRANCH}" > /dev/null; then echo "Found remote branch origin/${BASELINE_BRANCH}" else echo "Remote branch origin/${BASELINE_BRANCH} not found. Falling back to main." From 2833098995aeb88935f0d1d01824df3005821522 Mon Sep 17 00:00:00 2001 From: Christophe Papazian <114495376+christophe-papazian@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:02:37 +0200 Subject: [PATCH 09/14] chore(aap): improvements for django endpoint discovery (#14280) - ensure no endpoint (route, method) is send twice in the same payload - add threat test suite support for endpoint discovery and check for resource compatibility between telemetry payload and span attributes - minor test improvements APPSEC-58374 ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- ddtrace/contrib/internal/django/patch.py | 2 +- ddtrace/internal/endpoints.py | 20 ++++++--- tests/appsec/contrib_appsec/conftest.py | 14 ++++++ .../appsec/contrib_appsec/django_app/urls.py | 4 +- tests/appsec/contrib_appsec/utils.py | 45 +++++++++++++++++++ 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/ddtrace/contrib/internal/django/patch.py b/ddtrace/contrib/internal/django/patch.py index 087186cb18a..0886c3d9bbf 100644 --- a/ddtrace/contrib/internal/django/patch.py +++ b/ddtrace/contrib/internal/django/patch.py @@ -456,7 +456,7 @@ def _gather_block_metadata(request, request_headers, ctx: core.ExecutionContext) if user_agent: metadata[http.USER_AGENT] = user_agent except Exception as e: - log.warning("Could not gather some metadata on blocked request: %s", str(e)) # noqa: G200 + log.warning("Could not gather some metadata on blocked request: %s", str(e)) core.dispatch("django.block_request_callback", (ctx, metadata, config_django, url, query)) diff --git a/ddtrace/internal/endpoints.py b/ddtrace/internal/endpoints.py index f21236eec5f..90725956988 100644 --- a/ddtrace/internal/endpoints.py +++ b/ddtrace/internal/endpoints.py @@ -1,6 +1,6 @@ import dataclasses from time import monotonic -from typing import List +from typing import Set @dataclasses.dataclass(frozen=True) @@ -9,11 +9,17 @@ class HttpEndPoint: path: str resource_name: str = dataclasses.field(default="") operation_name: str = dataclasses.field(default="http.request") + _hash: int = dataclasses.field(init=False, repr=False) def __post_init__(self) -> None: super().__setattr__("method", self.method.upper()) if not self.resource_name: super().__setattr__("resource_name", f"{self.method} {self.path}") + # cache hash result + super().__setattr__("_hash", hash((self.method, self.path))) + + def __hash__(self) -> int: + return self._hash @dataclasses.dataclass() @@ -24,7 +30,7 @@ class HttpEndPointsCollection: It maintains a maximum size and drops endpoints after a certain time period in case of a hot reload of the server. """ - endpoints: List[HttpEndPoint] = dataclasses.field(default_factory=list, init=False) + endpoints: Set[HttpEndPoint] = dataclasses.field(default_factory=set, init=False) is_first: bool = dataclasses.field(default=True, init=False) drop_time_seconds: float = dataclasses.field(default=90.0, init=False) last_modification_time: float = dataclasses.field(default_factory=monotonic, init=False) @@ -45,12 +51,12 @@ def add_endpoint( current_time = monotonic() if current_time - self.last_modification_time > self.drop_time_seconds: self.reset() - self.endpoints.append( + self.endpoints.add( HttpEndPoint(method=method, path=path, resource_name=resource_name, operation_name=operation_name) ) elif len(self.endpoints) < self.max_size_length: self.last_modification_time = current_time - self.endpoints.append( + self.endpoints.add( HttpEndPoint(method=method, path=path, resource_name=resource_name, operation_name=operation_name) ) @@ -61,16 +67,16 @@ def flush(self, max_length: int) -> dict: if max_length >= len(self.endpoints): res = { "is_first": self.is_first, - "endpoints": [dataclasses.asdict(ep) for ep in self.endpoints], + "endpoints": list(map(dataclasses.asdict, self.endpoints)), } self.reset() return res else: + batch = [self.endpoints.pop() for _ in range(max_length)] res = { "is_first": self.is_first, - "endpoints": [dataclasses.asdict(ep) for ep in self.endpoints[:max_length]], + "endpoints": [dataclasses.asdict(ep) for ep in batch], } - self.endpoints = self.endpoints[max_length:] self.is_first = False self.last_modification_time = monotonic() return res diff --git a/tests/appsec/contrib_appsec/conftest.py b/tests/appsec/contrib_appsec/conftest.py index 2df68072b9d..4fe2c8f5e62 100644 --- a/tests/appsec/contrib_appsec/conftest.py +++ b/tests/appsec/contrib_appsec/conftest.py @@ -64,6 +64,20 @@ def get(name): yield get +@pytest.fixture +def find_resource(test_spans, root_span): + # checking both root spans and web spans for the tag + def find(resource_name): + for span in test_spans.spans: + if span.parent_id is None or span.span_type == "web": + res = span._resource[0] + if res == resource_name: + return True + return False + + yield find + + @pytest.fixture def get_metric(root_span): yield lambda name: root_span().get_metric(name) diff --git a/tests/appsec/contrib_appsec/django_app/urls.py b/tests/appsec/contrib_appsec/django_app/urls.py index 1ac7f0e03fa..2e4de06b7a0 100644 --- a/tests/appsec/contrib_appsec/django_app/urls.py +++ b/tests/appsec/contrib_appsec/django_app/urls.py @@ -154,13 +154,13 @@ def rasp(request, endpoint: str): if param.startswith("cmda"): cmd = query_params[param] try: - res.append(f'cmd stdout: {subprocess.run([cmd, "-c", "3", "localhost"])}') + res.append(f'cmd stdout: {subprocess.run([cmd, "-c", "3", "localhost"], timeout=0.5)}') except Exception as e: res.append(f"Error: {e}") elif param.startswith("cmds"): cmd = query_params[param] try: - res.append(f"cmd stdout: {subprocess.run(cmd)}") + res.append(f"cmd stdout: {subprocess.run(cmd, timeout=0.5)}") except Exception as e: res.append(f"Error: {e}") tracer.current_span()._local_root.set_tag("rasp.request.done", endpoint) diff --git a/tests/appsec/contrib_appsec/utils.py b/tests/appsec/contrib_appsec/utils.py index a254d34fcea..ff95d4fde73 100644 --- a/tests/appsec/contrib_appsec/utils.py +++ b/tests/appsec/contrib_appsec/utils.py @@ -181,6 +181,51 @@ def test_simple_attack_timeout(self, interface: Interface, root_span, get_metric assert len(args_list) == 1 assert ("waf_timeout", "true") in args_list[0][4] + def test_api_endpoint_discovery(self, interface: Interface, find_resource): + """Check that API endpoint discovery works in the framework. + + Also ensure the resource name is set correctly. + """ + if interface.name != "django": + pytest.skip("API endpoint discovery is only supported in Django") + from ddtrace.settings.asm import endpoint_collection + + def parse(path: str) -> str: + import re + + # django substitutions to make a url path from route + if re.match(r"^\^.*\$$", path): + path = path[1:-1] + path = re.sub(r"", "123", path) + path = re.sub(r"", "abc", path) + if path.endswith("/?"): + path = path[:-2] + return "/" + path + + with override_global_config(dict(_asm_enabled=True)): + self.update_tracer(interface) + # required to load the endpoints + interface.client.get("/") + collection = endpoint_collection.endpoints + assert collection + for ep in collection: + assert ep.method + # path could be empty, but must be a string + assert isinstance(ep.path, str) + assert ep.resource_name + assert ep.operation_name + if ep.method not in ("GET", "*", "POST"): + continue + path = parse(ep.path) + response = ( + interface.client.post(path, {"data": "content"}) + if ep.method == "POST" + else interface.client.get(path) + ) + assert self.status(response) in (200, 401), f"ep.path failed: {ep.path} -> {path}" + resource = "GET" + ep.resource_name[1:] if ep.resource_name.startswith("* ") else ep.resource_name + assert find_resource(resource) + @pytest.mark.parametrize("asm_enabled", [True, False]) @pytest.mark.parametrize( ("user_agent", "priority"), From 1ff527bdd9f7e1565997e5534547be6f13a1b357 Mon Sep 17 00:00:00 2001 From: Juanjo Alvarez Martinez Date: Wed, 13 Aug 2025 17:26:04 +0200 Subject: [PATCH 10/14] chore: add the traceback to the integration failed to enable errors (#14291) ## Checklist - [X] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --------- Signed-off-by: Juanjo Alvarez Co-authored-by: Gabriele N. Tornetta --- ddtrace/_monkey.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ddtrace/_monkey.py b/ddtrace/_monkey.py index b633413081c..d1559df9cf7 100644 --- a/ddtrace/_monkey.py +++ b/ddtrace/_monkey.py @@ -293,6 +293,7 @@ def on_import(hook): "failed to enable ddtrace support for %s: %s", module, str(e), + exc_info=True, ) telemetry.telemetry_writer.add_integration(module, False, PATCH_MODULES.get(module) is True, str(e)) telemetry.telemetry_writer.add_count_metric( From 1901448b7b49783c24e9388e099a702b00d23159 Mon Sep 17 00:00:00 2001 From: Brett Langdon Date: Wed, 13 Aug 2025 12:01:21 -0400 Subject: [PATCH 11/14] chore(codeowners): widen permissions for trace_handlers.py (#14294) Right now updating an integration which uses the core api may also be blocked on the sdk team since the integration handlers to convert core events into spans lives in `ddtrace/_trace/`. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fd5b245e58b..80a3055f438 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -193,6 +193,8 @@ tests/internal/remoteconfig @DataDog/remote-config @DataDog/apm-core-pyt # API SDK ddtrace/trace/ @DataDog/apm-sdk-api-python ddtrace/_trace/ @DataDog/apm-sdk-api-python +# File commonly updated for integrations, widen ownership to help with PR review +ddtrace/_trace/trace_handlers.py @DataDog/apm-sdk-api-python @DataDog/apm-core-python @DataDog/apm-idm-python ddtrace/opentelemetry/ @DataDog/apm-sdk-api-python ddtrace/internal/opentelemetry @DataDog/apm-sdk-api-python ddtrace/opentracer/ @DataDog/apm-sdk-api-python From 5370faa684c12448044e7a871bea326ab487f478 Mon Sep 17 00:00:00 2001 From: Brett Langdon Date: Wed, 13 Aug 2025 13:02:13 -0400 Subject: [PATCH 12/14] chore(django): remove django.process_exception event/handler (#14293) We can remove the need for `with ctx.span` and calling an additional `django.process_exception` event by adding support for `should_set_traceback` to the `_finish_span` trace handler. This will help to further remove span references/usage in the django integration. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [ ] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- ddtrace/_trace/trace_handlers.py | 10 +++------- ddtrace/contrib/internal/django/patch.py | 8 ++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/ddtrace/_trace/trace_handlers.py b/ddtrace/_trace/trace_handlers.py index ffc4bffd6fc..e990d209e14 100644 --- a/ddtrace/_trace/trace_handlers.py +++ b/ddtrace/_trace/trace_handlers.py @@ -161,6 +161,8 @@ def _finish_span( exc_type, exc_value, exc_traceback = exc_info if exc_type and exc_value and exc_traceback: span.set_exc_info(exc_type, exc_value, exc_traceback) + elif ctx.get_item("should_set_traceback", False): + span.set_traceback() span.finish() @@ -546,11 +548,6 @@ def _on_django_func_wrapped(_unused1, _unused2, _unused3, ctx, ignored_excs): ctx.span._ignore_exception(exc) -def _on_django_process_exception(ctx: core.ExecutionContext, should_set_traceback: bool): - if should_set_traceback: - ctx.span.set_traceback() - - def _on_django_block_request(ctx: core.ExecutionContext, metadata: Dict[str, str], django_config, url: str, query: str): for tk, tv in metadata.items(): ctx.span.set_tag_str(tk, tv) @@ -890,7 +887,6 @@ def listen(): core.on("django.start_response", _on_django_start_response) core.on("django.cache", _on_django_cache) core.on("django.func.wrapped", _on_django_func_wrapped) - core.on("django.process_exception", _on_django_process_exception) core.on("django.block_request_callback", _on_django_block_request) core.on("django.after_request_headers.post", _on_django_after_request_headers_post) core.on("botocore.patched_api_call.exception", _on_botocore_patched_api_call_exception) @@ -989,7 +985,7 @@ def listen(): ): core.on(f"context.started.{context_name}", _start_span) - for name in ("django.template.render",): + for name in ("django.template.render", "django.process_exception"): core.on(f"context.ended.{name}", _finish_span) diff --git a/ddtrace/contrib/internal/django/patch.py b/ddtrace/contrib/internal/django/patch.py index 0886c3d9bbf..2abe3958938 100644 --- a/ddtrace/contrib/internal/django/patch.py +++ b/ddtrace/contrib/internal/django/patch.py @@ -360,11 +360,11 @@ def wrapped(django, pin, func, instance, args, kwargs): tags = {COMPONENT: config_django.integration_name} with core.context_with_data( "django.process_exception", span_name=name, resource=resource, tags=tags, pin=pin - ) as ctx, ctx.span: + ) as ctx: resp = func(*args, **kwargs) - core.dispatch( - "django.process_exception", (ctx, hasattr(resp, "status_code") and 500 <= resp.status_code < 600) - ) + + # Tell finish span that we should collect the traceback + ctx.set_item("should_set_traceback", hasattr(resp, "status_code") and 500 <= resp.status_code < 600) return resp return trace_utils.with_traced_module(wrapped)(django) From 3b3f2ec195fdd68e5afed92fc1c0f55ed630dab4 Mon Sep 17 00:00:00 2001 From: Brett Langdon Date: Wed, 13 Aug 2025 13:03:30 -0400 Subject: [PATCH 13/14] chore(internal): add is_wrapped and is_wrapped_with helpers (#14295) Useful helpers to ensure we can check if something is already wrapped to avoid re-wrapping it, and we can check if it is already wrapped with a specific function. Useful both for testing and patching. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [ ] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- ddtrace/internal/wrapping/__init__.py | 32 +++++++++++++ tests/internal/test_wrapping.py | 67 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/ddtrace/internal/wrapping/__init__.py b/ddtrace/internal/wrapping/__init__.py index c534edb660d..55ba9afd5e4 100644 --- a/ddtrace/internal/wrapping/__init__.py +++ b/ddtrace/internal/wrapping/__init__.py @@ -269,6 +269,38 @@ def wrap(f, wrapper): return wf +def is_wrapped(f: FunctionType) -> bool: + """Check if a function is wrapped with any wrapper.""" + try: + wf = cast(WrappedFunction, f) + inner = cast(FunctionType, wf.__dd_wrapped__) + + # Sanity check + assert inner.__name__ == "", "Wrapper has wrapped function" # nosec + return True + except AttributeError: + return False + + +def is_wrapped_with(f: FunctionType, wrapper: Wrapper) -> bool: + """Check if a function is wrapped with a specific wrapper.""" + try: + wf = cast(WrappedFunction, f) + inner = cast(FunctionType, wf.__dd_wrapped__) + + # Sanity check + assert inner.__name__ == "", "Wrapper has wrapped function" # nosec + + if wrapper in f.__code__.co_consts: + return True + + # This is not the correct wrapping layer. Try with the next one. + return is_wrapped_with(inner, wrapper) + + except AttributeError: + return False + + def unwrap(wf, wrapper): # type: (WrappedFunction, Wrapper) -> FunctionType """Unwrap a wrapped function. diff --git a/tests/internal/test_wrapping.py b/tests/internal/test_wrapping.py index 88707ea6485..3610f0d452a 100644 --- a/tests/internal/test_wrapping.py +++ b/tests/internal/test_wrapping.py @@ -6,6 +6,8 @@ import pytest +from ddtrace.internal.wrapping import is_wrapped +from ddtrace.internal.wrapping import is_wrapped_with from ddtrace.internal.wrapping import unwrap from ddtrace.internal.wrapping import wrap from ddtrace.internal.wrapping.context import WrappingContext @@ -95,6 +97,71 @@ def f(a, b, c=None): assert not channel1 and not channel2 +def test_is_wrapped(): + """Test that `is_wrapped` and `is_wrapped_with` work as expected.""" + + def first_wrapper(f, args, kwargs): + return f(*args, **kwargs) + + def second_wrapper(f, args, kwargs): + return f(*args, **kwargs) + + def f(a, b, c=None): + return (a, b, c) + + # Function works + assert f(1, 2) == (1, 2, None) + + # Not wrapped yet + assert not is_wrapped(f) + assert not is_wrapped_with(f, first_wrapper) + assert not is_wrapped_with(f, second_wrapper) + + # Wrap with first wrapper + wrap(f, first_wrapper) + + # Function still works + assert f(1, 2) == (1, 2, None) + + # Only wrapped with first_wrapper + assert is_wrapped(f) + assert is_wrapped_with(f, first_wrapper) + assert not is_wrapped_with(f, second_wrapper) + + # Wrap with second wrapper + wrap(f, second_wrapper) + + # Function still works + assert f(1, 2) == (1, 2, None) + + # Wrapped with everything + assert is_wrapped(f) + assert is_wrapped_with(f, first_wrapper) + assert is_wrapped_with(f, second_wrapper) + + # Unwrap first wrapper + unwrap(f, first_wrapper) + + # Function still works + assert f(1, 2) == (1, 2, None) + + # Still wrapped with second_wrapper + assert is_wrapped(f) + assert not is_wrapped_with(f, first_wrapper) + assert is_wrapped_with(f, second_wrapper) + + # Unwrap second wrapper + unwrap(f, second_wrapper) + + # Function still works + assert f(1, 2) == (1, 2, None) + + # Not wrapped anymore + assert not is_wrapped(f) + assert not is_wrapped_with(f, first_wrapper) + assert not is_wrapped_with(f, second_wrapper) + + @pytest.mark.skipif(sys.version_info > (3, 12), reason="segfault on 3.13") def test_wrap_generator(): channel = [] From 201388146dc00678d8f046604ff32ade4d1eab4c Mon Sep 17 00:00:00 2001 From: Brett Langdon Date: Wed, 13 Aug 2025 13:46:18 -0400 Subject: [PATCH 14/14] ci(benchmarks): increase slos for telemetry add metric (#14301) We've seen some flakiness in a few telemetry add metric benchmarks, increasing the SLOs slightly to give some head room to try and avoid false positives. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --- .../bp-runner.microbenchmarks.fail-on-breach.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitlab/benchmarks/bp-runner.microbenchmarks.fail-on-breach.yml b/.gitlab/benchmarks/bp-runner.microbenchmarks.fail-on-breach.yml index ef0cb9d9452..79beea72f34 100644 --- a/.gitlab/benchmarks/bp-runner.microbenchmarks.fail-on-breach.yml +++ b/.gitlab/benchmarks/bp-runner.microbenchmarks.fail-on-breach.yml @@ -1094,19 +1094,19 @@ experiments: - max_rss_usage < 34.00 MB - name: telemetryaddmetric-100-count-metrics-100-times thresholds: - - execution_time < 22.50 ms + - execution_time < 23.00 ms - max_rss_usage < 34.00 MB - name: telemetryaddmetric-100-distribution-metrics-100-times thresholds: - - execution_time < 2.10 ms + - execution_time < 2.20 ms - max_rss_usage < 34.00 MB - name: telemetryaddmetric-100-gauge-metrics-100-times thresholds: - - execution_time < 1.40 ms + - execution_time < 1.50 ms - max_rss_usage < 34.00 MB - name: telemetryaddmetric-100-rate-metrics-100-times thresholds: - - execution_time < 2.40 ms + - execution_time < 2.50 ms - max_rss_usage < 34.00 MB - name: telemetryaddmetric-flush-1-metric thresholds: @@ -1118,7 +1118,7 @@ experiments: - max_rss_usage < 34.00 MB - name: telemetryaddmetric-flush-1000-metrics thresholds: - - execution_time < 2.35 ms + - execution_time < 2.45 ms - max_rss_usage < 34.50 MB # tracer