feat(otlp/export): offline OTLP trace/metric/log export#4994
feat(otlp/export): offline OTLP trace/metric/log export#4994khanayan123 wants to merge 1 commit into
Conversation
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 5e29095 | Docs | Datadog PR Page | Give us feedback! |
BenchmarksBenchmark execution time: 2026-07-24 18:50:27 Comparing candidate commit 5e29095 in PR branch Found 0 performance improvements and 0 performance regressions! Performance is the same for 326 metrics, 0 unstable metrics, 1 flaky benchmarks without significant changes.
|
195f2e6 to
9111a00
Compare
bbd3993 to
41cdd9b
Compare
33dd489 to
8a0f97b
Compare
8a0f97b to
99af138
Compare
2236774 to
46ab716
Compare
99af138 to
d26661f
Compare
| // newRawTransport resolves the endpoint and headers for a signal and builds a | ||
| // transport. signalPath is one of the /v1/<signal> constants; extraHeaders are | ||
| // signal-specific headers (e.g. the metric config) applied before Config.Headers. | ||
| func newRawTransport(cfg Config, signalPath string, extraHeaders map[string]string) (*rawTransport, error) { |
There was a problem hiding this comment.
For RFC Open Question #1: ddtrace/tracer already has an OTLP writer and transport (otlp_writer.go, span_to_otlp.go). A separate transport here is a reasonable ownership choice; recording the owner in CODEOWNERS and closing OQ#1 with that decision would keep the repo from carrying parallel OTLP transports without a clear owner keeping them coherent.
| // expected OTLP response, is surfaced as a per-request error via partial. It | ||
| // returns a non-nil error if any request failed; per-request detail is in the | ||
| // result. | ||
| func exportEach[T proto.Message](ctx context.Context, t *rawTransport, reqs []T, partial partialSuccessFunc) (*ExportResult, error) { |
There was a problem hiding this comment.
exportEach is sequential (one POST at a time) and holds each full request plus its marshaled bytes in memory, so a slow destination serializes the whole batch. Fine for v1 since Client is concurrency safe and the caller can fan out, but documenting that throughput is caller-parallelized, and offering a bounded-concurrency option if Trajectory needs it, would help. A benchmark for ExportTraces (allocs/op + bytes) fits the package-benchmark ask on #4936.
46ab716 to
403c017
Compare
37584a9 to
7ec69a1
Compare
403c017 to
373b34e
Compare
7ec69a1 to
29fc187
Compare
373b34e to
cc21b62
Compare
2bbde9e to
4244095
Compare
Adds a public llmobs/export package: an offline client for exporting
already-built LLM Observability spans and evaluations to Datadog, direct to
intake or through the Agent EVP proxy. It is for tools that reconstruct
telemetry offline (e.g. Trajectory) and need the SDK to own endpoint derivation,
auth, HTTP transport, retry, size handling, and structured results.
- Offline only: never starts the tracer or calls StartSpan/Finish. Caller-
assigned IDs are opaque strings, never routed into APM IDs or sampling.
Multi-destination = one isolated Client per destination.
- Wire shape matches the LLM Obs intake and Trajectory's production payload:
{_dd.stage:"raw", event_type:"span", spans:[...]}; span kind in both nested
(meta.span.kind) and flat (meta."span.kind") forms; string span-link IDs;
service as both the top-level field and a service: tag; ml_app required.
- Batch-bounded single-encode export (scanned in SpanBatchSize/EvalBatchSize
windows); row-level validation with per-row error attribution; SpanMetrics
Extra escape hatch; session_id/service tags are authoritative.
- Reuses the internal LLM Obs transport (new exported transport.Post) for
auth/routing and retry (5xx/408/425/429; Retry-After honored on 429 and any
retriable status that advertises it; caller-cancel not retriable). Adds shared
internal/exportutil helpers (Snippet, Aggregate, Retry).
- Benchmarks, a wire-shape contract test, and CODEOWNERS (@DataDog/trajectory).
Implements the RFC "Raw LLM Observability Export, Multi-Destination Routing, and
Caller-Assigned IDs in dd-trace-go" (PR 1 of a stacked pair; otlp/export is #4994).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2a7fa4f to
28bdac1
Compare
1801234 to
631cea5
Compare
28bdac1 to
2a7fa4f
Compare
631cea5 to
1801234
Compare
Adds a public otlp/export package: offline clients that POST already-built OTLP collector-proto requests as raw protobuf to Datadog's agentless OTLP intake (otlp.<site>) or a caller-provided collector/Agent endpoint. The OTLP half of the RFC "Raw LLM Observability Export, Multi-Destination Routing, and Caller-Assigned IDs in dd-trace-go" (stacked on #4936). - Accepts go.opentelemetry.io/proto/otlp/collector/{trace,metrics,logs}/v1 Export*ServiceRequest values (no new deps); one request -> one POST -> one indexed RequestResult; exported sequentially, throughput caller-parallelized. - Routes: Datadog (Site+APIKey -> otlp.<site>/v1/<signal>, dd-api-key; metrics add dd-otel-metric-config) or collector/Agent (Endpoint, no auth). - Strict success: only HTTP 200 with a decodable Export*ServiceResponse; partial-success and undecodable bodies surface as failed exports; unknown protobuf fields ignored (forward-compatible). No redirects (default and caller-provided clients) so the body isn't dropped and dd-api-key isn't leaked. - OTLP/HTTP-spec retry (429/502/503/504 + network); Retry-After honored (int64/HTTP-date, clamped). Configurable Config.RequestTimeout that does not shorten a caller's own deadline. google.rpc.Status messages decoded into a bounded ResponseSnippet. - Promotes internal/llmobs/exportutil back to a shared internal/exportutil (its original "shared by both offline export clients" intent): otlp/export needs the same bounded Snippet/Aggregate helpers as llmobs/export without importing internal/llmobs. Adds the generic Retry engine (pluggable classifier) there; updates llmobs/export's import. The live llmobs path is unaffected. (#4936 scoped exportutil under internal/llmobs when llmobs/export was its only consumer; this second consumer promotes it to shared.) - Benchmark; CODEOWNERS (@DataDog/trajectory @DataDog/ml-observability @DataDog/apm-go). Closes RFC Open Question #1 (a deliberately separate raw-proto transport with a recorded owner). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1801234 to
5e29095
Compare
What
Adds a public
otlp/exportpackage: offline clients that POST already-built OTLP collector-proto requests as raw protobuf to Datadog's agentless OTLP intake or a caller-provided collector/Agent endpoint.The OTLP half of the RFC "Raw LLM Observability Export, Multi-Destination Routing, and Caller-Assigned IDs in dd-trace-go."
Design
go.opentelemetry.io/proto/otlp/collector/{trace,metrics,logs}/v1Export*ServiceRequestvalues (no new deps —proto/otlp+protobufare already direct deps).*Export*ServiceRequest→ one POST → one indexedRequestResultrow (inExportResult.Requests). The SDK never merges or splits requests. Exported sequentially; aClientis concurrency-safe and stateless, so throughput is caller-parallelized.Site+APIKey→https://otlp.<site>/v1/<signal>withdd-api-key) or collector/Agent (Endpoint, no auth unlessAPIKeyis also set). Metrics on the Datadog route adddd-otel-metric-config(metrics-only, and only whenEndpointis empty).Export*ServiceResponseis success. Any other 2xx (202/204/206) or a not-followed redirect is a failed export, and a 200 that reportsrejected_* > 0(partial success) is surfaced as a per-request error. A 200 whose body reads but does not proto-decode is a permanent per-request failure, whereas a 200 whose body cannot be read at all is surfaced as a retryable transport-class failure (status 0) so dropped records are not hidden behind a false success. Unknown protobuf fields are ignored (forward-compatible with newer collectors).dd-api-keyis never forwarded to a redirect target.429/502/503/504(+ network errors, status 0) retry; other 4xx/5xx are permanent; caller-cancelled context is not retriable. HonorsRetry-After(delta-seconds parsed as int64, or HTTP-date; clamped to 60s).Config.RequestTimeout; the 10s default applies only when the caller's context has no deadline, so a longer caller deadline (large export / slow collector) is not silently shortened.google.rpc.Statusmessage intoRequestResult.ResponseSnippet(bounded/UTF-8-safe) instead of raw protobuf bytes.ddtrace/opentelemetry; retry mechanics live in the sharedinternal/exportutil.Retry(pluggable classifier).Performance
BenchmarkExportTraces(allocations/op + bytes/op); see the packagePerformancedoc for the sequential/caller-parallelized model and per-request memory profile.Ownership
CODEOWNERS:
/otlp/export→@DataDog/trajectory @DataDog/ml-observability @DataDog/apm-go.Tests
go test -race ./otlp/export/... ./internal/exportutil/...— endpoint derivation (Datadog vs collector, trailing-slash trim, schemeless/non-HTTP rejection, APIKey-or-Endpoint requirement), header injection, endpoint-override-with-APIKey(no metric config), protobuf round-trip, nil-request handling, per-request result rows, OTLP retry classification with exact attempt counts, context-cancel non-retriable, partial-success for all three signals, decoded-status snippet, strict-200 / non-200 failure, undecodable-body failure, read-error-on-2xx surfaced as a retryable failure, forward-compatible-response success, no-redirect (default + custom client),Retry-Afterparsing incl. overflow clamp, configurable/deadline-respecting per-request timeout, and theRetryengine.golangci-lintclean.Review status
Squashed to a single commit; Codex-clean on the current HEAD. For #4936, the only outstanding note is a doc-placement item, intentionally handled in the package
doc.goper maintainer review (not CONTRIBUTING.md). For #4994, no outstanding findings.Contract testing: OTLP intake-shape has a cross-language guard in system-tests via
Test_Otel_Tracing_OTLP(tracer OTLP path; Go passes). DataDog/system-tests#7314 (draft) additionally asserts the OTelgen_aiLLM-Obs-over-OTLP wire shape — the same schema anotlp/exportcaller emits for LLM Obs — but hermetically and via the tracer's OTLP producer, so it does not import or exercise this package. This package's own emission is covered by its unit tests (proto round-trip); the only thing not covered is validation against the real OTLP intake, which would reuse the existingotel_tracing_e2ereal-backend pattern — a separate E2E effort, not a quick add.Scope note: metrics coverage here is OTLP-protobuf only. Trajectory's native
dd_metrics_v2lane (JSONPOST /api/v2/series+/api/v1/distribution_points) is intentionally out of scope — it has no path in dd-trace-go and is a deprecation-window fallback that OTLP replaces.RFC Open Question #1 — resolved
Whether to reuse/generalize
ddtrace/tracer's OTLP writer vs. a separate transport: this PR ships a deliberately separate raw-proto transport, and the owning teams are now recorded in CODEOWNERS (@DataDog/trajectory @DataDog/ml-observability @DataDog/apm-go) so the two OTLP paths have a clear owner keeping them coherent.🤖 Generated with Claude Code