Skip to content

Commit 6cd9c2d

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/otlp-trace-metrics-libdatadog
# Conflicts: # src/native/Cargo.toml # tests/tracer/test_otel_thread_context.py
2 parents 1edd0db + 3a825cc commit 6cd9c2d

201 files changed

Lines changed: 8170 additions & 1900 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/llmobs-integrations/references/implementation-guide.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,56 @@ Do not dump raw `kwargs` into LLMObs metadata. Prefer shared helpers such as `ge
135135

136136
Normalize `INPUT_TOKENS_METRIC_KEY` to the total input tokens sent to the model, including cached and non-cached tokens. Providers report this differently: Anthropic reports non-cached `input_tokens` separately from cache read/write input tokens, so add `input_tokens + cache_creation_input_tokens + cache_read_input_tokens`; OpenAI reports prompt/input tokens as the combined total and exposes cached tokens separately in details.
137137

138+
## Agent Integrations: Stamp Kind and Name at Span Start
139+
140+
Agent integrations have a critical ordering constraint. `_resolve_parent_agent()` in `ddtrace/llmobs/_utils.py` resolves agent attribution when a **child** span activates, not when the parent finishes. Under LIFO nesting the parent span is still open when the child starts — so any field written at span *finish* time is invisible to children that have already been attributed.
141+
142+
**Contract: any integration that produces `kind="agent"` LLMObs spans must stamp kind at span creation, not at finish.**
143+
144+
`BaseLLMIntegration.trace()` and `LlmTracingSubscriber.on_started` both call `_stamp_llmobs_span_kind_at_start()` automatically. Integrations should not call it directly; instead override the two hooks below.
145+
146+
### Hooks to implement for agent spans
147+
148+
**`_llmobs_span_kind(self, operation_id, span, **kwargs) -> Optional[str]`**
149+
150+
Return `"agent"` when the kwargs signal an agent span. The base-class default returns `"agent"` when `kwargs.get("kind") == "agent"`. Override only when the integration uses a different signal (e.g. `operation="agent"` for CrewAI, `interface_type="agent"` for Bedrock). Must be determinable at `trace()` call time — do NOT rely on data available only at finish.
151+
152+
**`_llmobs_agent_name_at_start(self, span, **kwargs) -> Optional[str]`**
153+
154+
Return the agent's LLMObs display name when it can be determined at `trace()` call time. Return `None` to fall back to the span resource name. Override when the integration can resolve the name from kwargs (e.g. Google ADK passes `_dd_agent=<agent_instance>` and the integration reads `agent.name`).
155+
156+
If the integration's `trace()` captures the instance reference before `**kwargs` (e.g. LangGraph), `_llmobs_agent_name_at_start` cannot reach it through `super().trace()`. In that case, stamp the name directly in the integration's `trace()` override after calling `super()`:
157+
158+
```python
159+
def trace(self, operation_id, submit_to_llmobs=False, **kwargs):
160+
span = super().trace(operation_id, submit_to_llmobs=submit_to_llmobs, **kwargs)
161+
# instance captured before **kwargs; stamp name after super() has written the kind
162+
if submit_to_llmobs and self.llmobs_enabled and kwargs.get("kind") == "agent":
163+
agent_name = getattr(instance, "name", None) if instance else None
164+
if agent_name:
165+
_annotate_llmobs_span_data(span, name=agent_name)
166+
return span
167+
```
168+
169+
### Example: Google ADK
170+
171+
Patch layer passes the agent object via a private kwarg:
172+
```python
173+
span = integration.trace(
174+
"%s.%s" % (instance.__class__.__name__, wrapped.__name__),
175+
kind="agent",
176+
submit_to_llmobs=True,
177+
_dd_agent=agent, # <-- agent instance available at trace() call time
178+
)
179+
```
180+
181+
Integration overrides the hook:
182+
```python
183+
def _llmobs_agent_name_at_start(self, span: Span, **kwargs: Any) -> Optional[str]:
184+
agent = kwargs.get("_dd_agent")
185+
return getattr(agent, "name", None) if agent else None
186+
```
187+
138188
## LLM-Specific Registration Checklist
139189

140190
In addition to the full checklist in the apm-integrations [Implementation Guide](../../apm-integrations/references/implementation-guide.md):

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ tests/test_sampling.py @DataDog/apm-sdk-capabilities
319319
tests/test_tracemethods.py @DataDog/apm-sdk-capabilities-python
320320
tests/opentelemetry/ @DataDog/apm-sdk-capabilities-python
321321
tests/tracer/ @DataDog/apm-sdk-capabilities-python
322+
tests/tracer/test_otel_thread_context.py @DataDog/apm-sdk-capabilities-python @DataDog/apm-core-python @DataDog/asm-python
322323
# Override because order matters
323324
tests/tracer/test_ci.py @DataDog/ci-app-libraries
324325
tests/tracer/test_ci_utils.py @DataDog/ci-app-libraries

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ vgcore.*
234234

235235
# Rust build artifacts
236236
src/native/target*
237+
src/native_heap_gotter/target*
238+
239+
# Profiling collector local CMake build dir
240+
ddtrace/profiling/collector/build-test/
237241

238242
# Fuzzing corpus, output and artifacts
239243
.fuzz/

.gitlab-ci.yml

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ variables:
2525
# Repository URL for CI Visibility
2626
DD_GIT_REPOSITORY_URL: "https://github.com/DataDog/dd-trace-py.git"
2727
# General purpose CI image (built from dd/images/dd-trace-py/ci-utils)
28-
CI_UTILS_IMAGE: "registry.ddbuild.io/dd-trace-py:v129724447-ccf4260-ci-utils@sha256:3ff0b19510872d740ee4bd3bddde9bb47567ccac7b6e4684bafb6b2bdaf62247"
28+
CI_UTILS_IMAGE: "registry.ddbuild.io/dd-trace-py:v130207166-153cbe2-ci-utils@sha256:816d1defebb9f049f3bc2e9987ce90d47f7a6c0316b7fc720c30f56376be63ed"
2929

3030
# One pipeline injection package size ratchet
3131
OCI_PACKAGE_MAX_SIZE_BYTES: 80_000_000
@@ -340,7 +340,12 @@ codeowners:
340340
stage: tests
341341
needs: []
342342
tags: ["arch:amd64"]
343+
variables:
344+
GIT_DEPTH: "0"
343345
image: ${CI_UTILS_IMAGE}
346+
id_tokens:
347+
DDOCTOSTS_ID_TOKEN:
348+
aud: dd-octo-sts
344349
script:
345350
# Validate that each file entry only exists once in CODEOWNERS to prevent confusion about ownership.
346351
# The file is evaluated from top to bottom where the last match wins, so duplicate entries can lead to unexpected owners being assigned.
@@ -368,20 +373,27 @@ codeowners:
368373
echo "Please remove duplicates from .github/CODEOWNERS."
369374
exit 1
370375
fi
371-
- git config --global --add safe.directory "${CI_PROJECT_DIR}"
372-
- git diff-tree --no-commit-id --name-only -r $CI_COMMIT_SHA > /tmp/changed_all.txt
373-
# Filter to only files that exist on disk (deleted files cause codeowners to crash)
374-
- |
375-
> /tmp/changed_files.txt
376-
while IFS= read -r f; do
377-
[ -f "$f" ] && echo "$f" >> /tmp/changed_files.txt
378-
done < /tmp/changed_all.txt
379376
- |
380-
if [ -s /tmp/changed_files.txt ]; then
381-
echo '```' > codeowners.txt
382-
xargs codeowners < /tmp/changed_files.txt | tee -a codeowners.txt
383-
echo '```' >> codeowners.txt
377+
if [ -z "${GH_TOKEN:-}" ]; then
378+
export GH_TOKEN=$(dd-octo-sts token --scope DataDog/dd-trace-py --policy gitlab.github-access.read)
384379
fi
380+
git config --global --add safe.directory "${CI_PROJECT_DIR}"
381+
BASE_BRANCH=$(.gitlab/scripts/resolve-base-branch.sh)
382+
git fetch origin "${BASE_BRANCH}"
383+
git show "FETCH_HEAD:.github/CODEOWNERS" > /tmp/base-CODEOWNERS
384+
git diff --name-only --diff-filter=ACMRTUXB -z "FETCH_HEAD...${CI_COMMIT_SHA}" > /tmp/changed_files.txt
385+
- |
386+
{
387+
printf 'Resolved from the full PR diff against `%s` using the target branch CODEOWNERS file.\n' "${BASE_BRANCH}"
388+
printf 'CODEOWNERS team requests not listed below are not required by the current file set.\n\n'
389+
if [ -s /tmp/changed_files.txt ]; then
390+
echo '```'
391+
xargs -0 codeowners --file /tmp/base-CODEOWNERS < /tmp/changed_files.txt
392+
echo '```'
393+
else
394+
echo 'No remaining files require a CODEOWNERS review.'
395+
fi
396+
} | tee codeowners.txt
385397
- .gitlab/scripts/post-pr-comment.sh "Codeowners resolved as" codeowners.txt
386398

387399
detect_circular_imports:
@@ -410,9 +422,10 @@ detect_circular_imports:
410422
fi
411423
BASE_BRANCH=$(.gitlab/scripts/resolve-base-branch.sh)
412424
git fetch origin "${BASE_BRANCH}"
413-
git checkout FETCH_HEAD
425+
MERGE_BASE=$(git merge-base FETCH_HEAD HEAD)
426+
git checkout "${MERGE_BASE}"
414427
cd ..
415-
# Analyse the base branch ddtrace using the PR version of cycles.py (has uv metadata)
428+
# Analyse the merge base ddtrace using the PR version of cycles.py (has uv metadata)
416429
- uv run --script cycles.py analyze --root dd-trace-py/ddtrace cycles-base.json
417430
- cat cycles-base.json
418431
- |
@@ -451,9 +464,10 @@ detect_layering_violations:
451464
fi
452465
BASE_BRANCH=$(.gitlab/scripts/resolve-base-branch.sh)
453466
git fetch origin "${BASE_BRANCH}"
454-
git checkout FETCH_HEAD
467+
MERGE_BASE=$(git merge-base FETCH_HEAD HEAD)
468+
git checkout "${MERGE_BASE}"
455469
cd ..
456-
# Analyse the base branch ddtrace using the PR version of layers.py/layers.json (has uv metadata)
470+
# Analyse the merge base using the PR version of layers.py/layers.json (has uv metadata)
457471
- uv run --script layers.py analyze --root dd-trace-py/ddtrace layers-base.json
458472
- cat layers-base.json
459473
- |
@@ -469,6 +483,11 @@ check_added_file_size:
469483
stage: tests
470484
needs: []
471485
extends: .testrunner
486+
variables:
487+
# Needs full history to reliably compute a merge-base against the fetched
488+
# base branch; a shallow clone can leave HEAD and FETCH_HEAD without a
489+
# common ancestor.
490+
GIT_DEPTH: "0"
472491
rules:
473492
- if: !reference [.is_main]
474493
when: never
@@ -666,6 +685,10 @@ profiling_native:
666685
- src/native/**/*.toml
667686
- src/native/**/*.txt
668687
- src/native/**/Cargo.lock
688+
# Standalone heap-gotter cdylib crate (opt-in build via setup.py)
689+
- src/native_heap_gotter/**/*.rs
690+
- src/native_heap_gotter/**/*.toml
691+
- src/native_heap_gotter/**/Cargo.lock
669692
# Top-level build config
670693
- setup.py
671694
- pyproject.toml

.gitlab/benchmarks/microbenchmarks.yml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,24 +138,26 @@ baseline:build:
138138
# and clamps the build container, causing OOMKills during parallel compilation.
139139
DD_DISABLE_VPA: "true"
140140
script: |
141-
CACHED_WHL=$(ls *.whl | head -n 1) 2>/dev/null || echo ""
141+
mkdir -p baseline-wheel
142+
CACHED_WHL=$(ls baseline-wheel/*.whl | head -n 1) 2>/dev/null || echo ""
142143
if [ ! -f "${CACHED_WHL}" ];
143144
then
144145
.gitlab/benchmarks/steps/build-baseline.sh
146+
mv *.whl baseline-wheel/
145147
else
146148
echo "Using wheel from cache for ${BASELINE_BRANCH}:${BASELINE_COMMIT_SHA}:${CACHED_WHL}"
147149
fi
148150
149-
echo "BASELINE_WHL=$(ls *.whl | head -n 1)" | tee -a baseline.env
151+
echo "BASELINE_WHL=$(ls baseline-wheel/*.whl | head -n 1)" | tee -a baseline.env
150152
cache:
151153
- key: v0-microbenchmarks-baseline-build-${BASELINE_COMMIT_SHA}
152154
paths:
153-
- "*.whl"
155+
- "baseline-wheel/*.whl"
154156
artifacts:
155157
reports:
156158
dotenv: baseline.env
157159
paths:
158-
- "*.whl"
160+
- "baseline-wheel/*.whl"
159161

160162
candidate:
161163
image: $PACKAGE_IMAGE
@@ -166,16 +168,17 @@ candidate:
166168
job: "build linux: [amd64, cp39-cp39, v113741238-d2b8243-manylinux2014_x86_64]"
167169
artifacts: true
168170
script: |
169-
cp pywheels/*-cp39-cp39-manylinux*_x86_64*.whl ./
170-
echo "CANDIDATE_WHL=$(ls *.whl | head -n 1)" | tee candidate.env
171+
mkdir -p candidate-wheel
172+
cp pywheels/*-cp39-cp39-manylinux*_x86_64*.whl candidate-wheel/
173+
echo "CANDIDATE_WHL=$(ls candidate-wheel/*.whl | head -n 1)" | tee candidate.env
171174
echo "CANDIDATE_BRANCH=${CI_COMMIT_REF_NAME}" | tee -a candidate.env
172175
echo "CANDIDATE_COMMIT_SHA=${CI_COMMIT_SHA}" | tee -a candidate.env
173176
echo "CANDIDATE_COMMIT_DATE=$(git show -s --format=%ct $CI_COMMIT_SHA)" | tee -a candidate.env
174177
artifacts:
175178
reports:
176179
dotenv: candidate.env
177180
paths:
178-
- "*.whl"
181+
- "candidate-wheel/*.whl"
179182

180183
benchmarks-pr-comment:
181184
image: $MICROBENCHMARKS_CI_IMAGE

.gitlab/native.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ include:
2121
echo -e "\e[0Ksection_start:`date +%s`:cargo_test[collapsed=true]\r\e[0Kcargo test"
2222
cargo test --no-fail-fast --locked
2323
echo -e "\e[0Ksection_end:`date +%s`:cargo_test\r\e[0K"
24+
# The standalone heap-gotter cdylib crate lives outside src/native, so run
25+
# the same fmt/clippy/test gate against it here to keep it under native CI.
26+
- |
27+
cd ../native_heap_gotter
28+
echo -e "\e[0Ksection_start:`date +%s`:gotter_cargo_fmt[collapsed=true]\r\e[0Kheap-gotter cargo fmt"
29+
cargo fmt --all -- --check
30+
echo -e "\e[0Ksection_end:`date +%s`:gotter_cargo_fmt\r\e[0K"
31+
- |
32+
echo -e "\e[0Ksection_start:`date +%s`:gotter_cargo_clippy[collapsed=true]\r\e[0Kheap-gotter cargo clippy"
33+
cargo clippy --locked --all-features -- -D warnings
34+
echo -e "\e[0Ksection_end:`date +%s`:gotter_cargo_clippy\r\e[0K"
35+
- |
36+
echo -e "\e[0Ksection_start:`date +%s`:gotter_cargo_test[collapsed=true]\r\e[0Kheap-gotter cargo test"
37+
cargo test --no-fail-fast --locked
38+
echo -e "\e[0Ksection_end:`date +%s`:gotter_cargo_test\r\e[0K"
2439
2540
"clang-tidy profiling":
2641
stage: tests

.gitlab/one-pipeline.locked.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# DO NOT EDIT THIS FILE MANUALLY
22
# This file is auto-generated by automation.
33
include:
4-
- remote: https://gitlab-templates.ddbuild.io/libdatadog/include/versions/1.2.0/one-pipeline.yml
4+
- remote: https://gitlab-templates.ddbuild.io/libdatadog/include/versions/1.3.0/one-pipeline.yml

.gitlab/scripts/build-wheel-helpers.sh

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,45 @@ build_wheel() {
9999
repair_wheel() {
100100
# Extract debug symbols
101101
section_start "extract_debug_symbols" "Extracting debug symbols"
102-
uv run --no-project scripts/extract_debug_symbols.py "${BUILT_WHEEL_FILE}" --output-dir "${DEBUG_WHEEL_DIR}"
102+
uv run --no-project scripts/extract_debug_symbols.py "${BUILT_WHEEL_FILE}" \
103+
--output-dir "${DEBUG_WHEEL_DIR}" \
104+
--ignore-patterns "libddwaf*,libdd_heap_gotter*"
103105
section_end "extract_debug_symbols"
104106

107+
# Heap-gotter cdylib debug symbols are extracted in setup.py (build_heap_gotter);
108+
# merge any staged .debug sidecars into the debug-symbols package.
109+
section_start "merge_heap_gotter_debug_symbols" "Merging heap-gotter debug symbols"
110+
uv run --no-project python - <<'PY'
111+
import glob
112+
import os
113+
import zipfile
114+
from pathlib import Path
115+
116+
project_dir = os.environ["PROJECT_DIR"]
117+
debug_dir = os.environ["DEBUG_WHEEL_DIR"]
118+
sidecars = sorted(Path(project_dir, "build").rglob("libdd_heap_gotter*.debug"))
119+
if not sidecars:
120+
print("No heap-gotter debug sidecars found")
121+
raise SystemExit(0)
122+
packages = glob.glob(os.path.join(debug_dir, "*-debug-symbols.zip"))
123+
if not packages:
124+
print("WARNING: no debug-symbols package to merge heap-gotter sidecars into")
125+
raise SystemExit(0)
126+
pkg = packages[0]
127+
with zipfile.ZipFile(pkg, "a", zipfile.ZIP_DEFLATED) as zf:
128+
existing = set(zf.namelist())
129+
for sidecar in sidecars:
130+
parts = sidecar.parts
131+
try:
132+
arc = str(Path(*parts[parts.index("ddtrace") :]))
133+
except ValueError:
134+
arc = sidecar.name
135+
if arc not in existing:
136+
zf.write(sidecar, arc)
137+
print(f"Added heap-gotter debug symbols: {arc}")
138+
PY
139+
section_end "merge_heap_gotter_debug_symbols"
140+
105141
# Strip wheel
106142
section_start "strip_wheel" "Stripping unneeded files"
107143
uv run --no-project scripts/zip_filter.py "${BUILT_WHEEL_FILE}" \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md
@@ -115,7 +151,69 @@ repair_wheel() {
115151
# Repair wheel (ONLY PLATFORM-SPECIFIC CODE)
116152
section_start "repair_wheel" "Repairing wheel"
117153
if [[ "$(uname -s)" == "Linux" ]]; then
154+
# The opt-in heap-gotter cdylib (DD_PROFILING_NATIVE_HEAP_BUILD=1) has
155+
# non-standard ELF versioning sections that trip auditwheel's iter_versions
156+
# parser. --exclude does not help: it only drops a SONAME from dependency
157+
# grafting, while repair still parses every ELF listed in the wheel's RECORD.
158+
# So the cdylib has to leave the wheel entirely and be reinserted after.
159+
GOTTER_STASH_DIR="${WORK_DIR}/heap_gotter_stash"
160+
GOTTER_PATTERN='*libdd_heap_gotter*.so'
161+
if unzip -l "${BUILT_WHEEL_FILE}" | grep -q 'libdd_heap_gotter.*\.so$'; then
162+
mkdir -p "${GOTTER_STASH_DIR}"
163+
unzip -q "${BUILT_WHEEL_FILE}" "${GOTTER_PATTERN}" -d "${GOTTER_STASH_DIR}"
164+
uv run --no-project scripts/zip_filter.py "${BUILT_WHEEL_FILE}" "${GOTTER_PATTERN}"
165+
fi
166+
118167
auditwheel repair -w "${TMP_WHEEL_DIR}" "${BUILT_WHEEL_FILE}"
168+
169+
if [[ -d "${GOTTER_STASH_DIR}" ]]; then
170+
REPAIRED_WHEEL_FILE=$(ls "${TMP_WHEEL_DIR}"/*.whl | head -n 1)
171+
GOTTER_STASH_DIR="${GOTTER_STASH_DIR}" REPAIRED_WHEEL_FILE="${REPAIRED_WHEEL_FILE}" \
172+
uv run --no-project python - <<'PY'
173+
import base64
174+
import csv
175+
import hashlib
176+
import io
177+
import os
178+
import zipfile
179+
from pathlib import Path
180+
181+
wheel = Path(os.environ["REPAIRED_WHEEL_FILE"])
182+
stash = Path(os.environ["GOTTER_STASH_DIR"])
183+
184+
additions = {str(p.relative_to(stash)): p for p in sorted(stash.rglob("*")) if p.is_file()}
185+
if not additions:
186+
print("No stashed heap-gotter cdylib to reinsert")
187+
raise SystemExit(0)
188+
189+
tmp_wheel = Path(f"{wheel}.tmp")
190+
with (
191+
zipfile.ZipFile(wheel, "r") as source_zip,
192+
zipfile.ZipFile(tmp_wheel, "w", zipfile.ZIP_DEFLATED) as temp_zip,
193+
):
194+
record = next((f for f in source_zip.infolist() if f.filename.endswith(".dist-info/RECORD")), None)
195+
if record is None:
196+
raise SystemExit(f"no RECORD found in {wheel}")
197+
# DEV: Use ZipInfo objects to ensure original file attributes are preserved
198+
for file in source_zip.infolist():
199+
if file.filename == record.filename or file.filename in additions:
200+
continue
201+
temp_zip.writestr(file, source_zip.read(file.filename))
202+
rows = [r for r in csv.reader(io.StringIO(source_zip.read(record.filename).decode("utf-8"))) if r]
203+
rows = [r for r in rows if r[0] != record.filename and r[0] not in additions]
204+
for arcname, path in additions.items():
205+
data = path.read_bytes()
206+
temp_zip.writestr(arcname, data)
207+
digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode("ascii")
208+
rows.append([arcname, f"sha256={digest}", str(len(data))])
209+
print(f"Reinserted heap-gotter cdylib: {arcname}")
210+
rows.append([record.filename, "", ""])
211+
output = io.StringIO()
212+
csv.writer(output, lineterminator="\n").writerows(rows)
213+
temp_zip.writestr(record, output.getvalue())
214+
os.replace(tmp_wheel, wheel)
215+
PY
216+
fi
119217
else
120218
# macOS
121219
MACOSX_DEPLOYMENT_TARGET=14.7 uvx --from="delocate" delocate-wheel \

0 commit comments

Comments
 (0)