Skip to content

feat(llmobs/export): offline LLM Obs span & evaluation export#4936

Draft
khanayan123 wants to merge 11 commits into
mainfrom
ayan.khan/llmobs-explicit-span-ids
Draft

feat(llmobs/export): offline LLM Obs span & evaluation export#4936
khanayan123 wants to merge 11 commits into
mainfrom
ayan.khan/llmobs-explicit-span-ids

Conversation

@khanayan123

@khanayan123 khanayan123 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What

Adds a public llmobs/export package: an offline client for exporting already-built LLM Observability spans and evaluations to Datadog, either direct to intake or through the Agent EVP proxy.

c, _ := export.New(export.Config{
    Site:    "datadoghq.com",
    APIKey:  key,
    MLApp:   "my-llm-app", // required
    Service: "my-svc",
})
res, err := c.ExportSpans(ctx, spans)       // []export.SpanEvent
res, err  = c.ExportEvaluations(ctx, evals) // []export.EvaluationMetric

This is PR 1 of a stacked pair implementing the RFC "Raw LLM Observability Export, Multi-Destination Routing, and Caller-Assigned IDs in dd-trace-go." The sibling otlp/export package is the stacked follow-up (#4994).

Replaces the earlier draft on this branch (live WithSpanID/WithTraceID/WithParentID options), scrapped in favor of the raw-export design the RFC settled on.

Why

Offline reconstruction tools (e.g. Trajectory) rebuild finished agent sessions and need to export the results without replaying them through live instrumentation. This moves the export mechanics into the SDK — endpoint derivation, auth, HTTP, retry classification, size handling, and structured per-request results — while reconstruction, projection, privacy filtering, deterministic IDs, dedup, and durable retry stay in the caller.

Design

  • Offline, not live: never calls StartSpan/Finish, never starts the tracer.
  • Caller-assigned IDs (trace_id/span_id/parent_id) are preserved verbatim as opaque string payload fields (decimal uint64 strings are valid; hex not required) and are never routed into APM span IDs, APM trace IDs, or sampling. Empty parent → "undefined".
  • Multi-destination = N isolated clients (one per destination); no global-tracer fan-out or de-singletonization.
  • Wire shape matches the LLM Obs intake: {_dd.stage:"raw", event_type:"span", spans:[…]}; flat meta."span.kind"; top-level service; string span-link IDs; optional _dd.apm_trace_id (omitted when unset). Eval join by span_id+trace_id or tag; exactly one of categorical/score/boolean/json_value; optional assessment/reasoning/metadata.
  • ml_app required: New rejects an empty MLApp (matching the live client); every exported span/eval carries an ml_app (client default, or per-span/metric override).
  • Row-level validation, batch-safe: each span requires span_id/trace_id/kind; each eval requires a valid join and exactly one finite value. Invalid rows are dropped and reported in ExportResult.ValidationErrors — never failing a whole batch. Each row is trial-encoded, so a single non-JSON-encodable value (a non-finite metric cost, an unmarshalable json_value/metadata) becomes a row-level error rather than a chunk failure.
  • Metric parity: SpanMetrics carries the standard LLM Obs token/count/cost keys as named fields plus an Extra map[string]float64 escape hatch, so a reconstructed span never silently loses a metric (mirrors the internal transport's flat map[string]float64).
  • session_id: the structured SessionID is the source of truth and overrides any stale session_id: tag, matching the live path (so tag-joined evals attach to the right session).
  • Transport reuse: a new exported transport.Post primitive on the internal LLM Obs transport provides auth/routing (agentless direct vs. Agent EVP) and retry (5xx/408/425/429 retriable, other 4xx permanent, Retry-After honored on 429); the client owns chunking (50 spans, 1000 evals/req) and a best-effort 5 MB size guard (splits oversized batches; truncates input/output only as a last resort), and returns per-request ExportResult with validation attribution.

Tests

go test -race ./llmobs/export/... ./internal/exportutil/... — wire-shape/auth for both routes, chunking, size guard + batch-split, validation attribution (missing IDs/kind, non-finite metric, empty/unmarshalable json), ml_app requirement + stamping, SpanMetrics Extra/named-field collision, session_id override, retry vs. permanent classification, context-cancellation, and a -race test guarding caller-slice immutability; plus internal/exportutil unit tests (Snippet truncation/rune-boundary, Aggregate). Internal internal/llmobs suites still pass. golangci-lint (incl. the modernize analyzer) is clean.

Review status

Reviewed clean by Codex on the current HEAD (no findings).

Open question (RFC §4)

Intake-contract re-confirm: the fields exercised here (top-level service, string span-link IDs, opaque trace_id, eval assessment/reasoning/metadata/json_value) are what Trajectory already sends in production — flagging for intake owners to confirm as a long-term public contract.

🤖 Generated with Claude Code

@datadog-prod-us1-4

datadog-prod-us1-4 Bot commented Jun 23, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🔄 Datadog auto-retried 3 jobs - 2 passed on retry View in Datadog

🎯 Code Coverage (details)
Patch Coverage: 79.25%
Overall Coverage: 62.84% (+11.41%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 46ab716 | Docs | Datadog PR Page | Give us feedback!

@pr-commenter

pr-commenter Bot commented Jun 23, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-07-09 17:11:35

Comparing candidate commit 46ab716 in PR branch ayan.khan/llmobs-explicit-span-ids with baseline commit 6ffabb5 in branch main.

Found 0 performance improvements and 0 performance regressions! Performance is the same for 326 metrics, 0 unstable metrics, 1 flaky benchmarks without significant changes.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

Known flaky benchmarks

These benchmarks are marked as flaky and will not trigger a failure. Modify FLAKY_BENCHMARKS_REGEX to control which benchmarks are marked as flaky.

Known flaky benchmarks without significant changes:

  • scenario:BenchmarkOTLPTraceWriterFlush

@khanayan123 khanayan123 force-pushed the ayan.khan/llmobs-explicit-span-ids branch from ae6ce8e to a9279fb Compare July 7, 2026 18:44
@khanayan123 khanayan123 changed the title feat(llmobs): allow explicit span/trace/parent IDs for offline reconstruction feat(llmobs/export): offline LLM Obs span & evaluation export Jul 7, 2026
Comment thread llmobs/export/export.go Outdated
Comment thread llmobs/export/export.go Outdated
Comment thread llmobs/export/export_test.go Outdated
Comment thread llmobs/export/export_test.go Outdated
@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 347bc287b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread llmobs/export/export.go Outdated
Comment thread llmobs/export/eval_metric.go Outdated
Comment thread llmobs/export/eval_metric.go Outdated
@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80bc414ae0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +6 to +9
// Package exportutil holds small helpers shared by the offline export clients
// (llmobs/export and otlp/export): bounded response-body snippets and
// per-request failure aggregation.
package exportutil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new internal package

The internal/AGENTS.md instructions require updating internal/README.md whenever a new internal package is introduced, including its name and functionality. This commit adds internal/exportutil, but internal/README.md still has no exportutil entry, so the new shared helper package is not documented for maintainers.

Useful? React with 👍 / 👎.

DD: transport.DDAttributes{
SpanID: e.SpanID,
TraceID: e.TraceID,
APMTraceID: e.APMTraceID,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Omit empty APM trace IDs

When APMTraceID is not set, this still populates transport.DDAttributes with the zero value, and that shared type serializes apm_trace_id without omitempty. Offline spans that are not correlated to APM therefore send _dd.apm_trace_id: "" instead of omitting the optional field, which is distinguishable from absence and can make intake treat the correlation ID as malformed; use an export-specific _dd wire type or omit this field when empty.

Useful? React with 👍 / 👎.

Comment thread llmobs/export/client.go
var agentURL *url.URL
if cfg.AgentURL != "" {
// Trim a trailing slash so the EVP proxy path is not doubled.
u, err := url.Parse(strings.TrimRight(cfg.AgentURL, "/"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Reject relative AgentURL values

If a caller sets AgentURL to a common agent address like localhost:8126, url.Parse succeeds here, so New returns a client that later builds localhost:8126/evp_proxy/...; net/http then treats localhost as the scheme and every export fails with unsupported protocol scheme "localhost". Validate that the parsed Agent URL is an accepted absolute URL (or unix socket URL) and return the existing invalid-AgentURL error before constructing the client.

Useful? React with 👍 / 👎.

Comment thread llmobs/export/span_event.go Outdated
Input string
Output string
// Metadata is free-form span metadata.
Metadata map[string]string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve structured span metadata

For offline spans reconstructed from the live LLM Obs API, numeric or nested metadata cannot be exported through this field because the live annotation path accepts arbitrary structured map[string]any metadata, while this new raw exporter narrows it to map[string]string. Those values must either be stringified or dropped before calling ExportSpans, so reconstructed spans no longer round-trip the metadata users can already record with live instrumentation.

Useful? React with 👍 / 👎.

@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c58770331

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread llmobs/export/eval_metric.go Outdated
MetricType: metricType,
TimestampMS: m.TimestampMS,
MLApp: mlApp,
Tags: m.Tags,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stamp the SDK version on exported evaluations

When ExportEvaluations is used without a caller-supplied ddtrace.version tag, or with a stale one, the metric is sent as-is because this path copies m.Tags directly. The live evaluation path explicitly removes any caller ddtrace.version tag and appends the current tracer version in internal/llmobs/llmobs.go (lines 956-962); evaluation payloads have no _dd.tracer_version field, so offline exports otherwise lose or misreport SDK-version attribution for these metrics.

Useful? React with 👍 / 👎.

Comment thread internal/README.md Outdated

### Export Utilities

Contains small helpers shared by the offline export clients ([llmobs/export](../llmobs/export/) and [otlp/export](../otlp/export/)): bounded, UTF-8-safe response-body snippets (`Snippet`), per-request failure aggregation (`Aggregate`), transient-vs-permanent retry classification (`Retriable`), and a bounded-retry engine over a POST closure (`Retry`). See [exportutil](./exportutil/).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Remove nonexistent exportutil APIs from the README

This line documents Retriable and Retry as part of internal/exportutil and links to otlp/export, but this commit's internal/exportutil/exportutil.go only defines Snippet and Aggregate, and there is no otlp/export package in the tree. Anyone following the new internal package docs will look for helpers and a package that do not exist, so the README should either describe only the current API or land those helpers/package together.

Useful? React with 👍 / 👎.

Comment thread llmobs/export/client.go Outdated
defaultSite = "datadoghq.com"
defaultSpanBatchSize = 50
defaultEvalBatchSize = 1000
defaultMaxSpanBytes = 5 << 20 // 5 MiB

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the span size guard below the EVP limit

With this default, encodeSpans only truncates input/output when the encoded chunk exceeds 5,242,880 bytes, but the LLM Obs sender uses sizeLimitEVPEvent = 5_000_000 and its size-based flushing test asserts HTTP payloads stay at or below that 5 MB limit. A default offline span chunk in the 5,000,001–5,242,880 byte range will therefore skip truncation and be posted over the Agent/EVP limit, causing large exports to be rejected instead of having IO dropped.

Useful? React with 👍 / 👎.

@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 776bb5d47d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread llmobs/export/export.go Outdated
Comment on lines +125 to +128
if len(body) <= c.maxSpanBytes {
return body, nil
}
for i := range p.Spans {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Split oversized batches before dropping span I/O

When the encoded body exceeds maxSpanBytes, this immediately drops input/output from every span in the batch. With the default 50-span batch size, a set of individually acceptable spans can exceed 5 MB only because they were grouped together; for example, 50 spans with ~120 KB inputs would all be sent with dropped_io even though smaller chunks would preserve the data. That permanently corrupts offline reconstructions, so oversized batches should be split before falling back to per-span I/O truncation.

Useful? React with 👍 / 👎.

Comment thread internal/llmobs/config/config.go Outdated
Comment on lines +50 to +52
// Reuse the shared factory (proxy/pool/dialer). timeout=0 keeps the
// no-global-timeout behavior; per-request deadlines are set via context.
return internal.DefaultHTTPClient(0, false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Preserve HTTP/2 on the default LLM Obs client

In the default agentless path this now returns internal.DefaultHTTPClient(0, false), whose transport installs a custom DialContext but leaves ForceAttemptHTTP2 unset. The net/http transport docs state that a custom dialer disables HTTP/2 unless ForceAttemptHTTP2 is true, so HTTPS LLM Obs intake traffic regresses from the previous HTTP/2-enabled transport to HTTP/1.1, which can hurt high-volume flushes. Preserve ForceAttemptHTTP2 when reusing the shared client.

Useful? React with 👍 / 👎.

Comment thread llmobs/export/client.go Outdated
Comment on lines +50 to +51
// MLApp is the default ml_app for evaluations when a metric omits it.
MLApp string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require or stamp ml_app for offline payloads

The live LLM Obs client rejects startup when MLApp is empty and stamps ml_app onto every span, but this offline config makes MLApp optional and only treats it as an evaluation default. A caller can therefore construct the documented Config{Site, APIKey, Service} and export spans without any ml_app: tag (or evaluations without ml_app), which produces payloads missing the ML app required for LLM Obs attribution. Make MLApp required here or use it to stamp spans and metrics by default.

Useful? React with 👍 / 👎.

@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a05f790a14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread llmobs/export/client.go Outdated
Comment on lines +103 to +105
if u.Scheme == "" || (u.Scheme != "unix" && u.Host == "") {
return nil, fmt.Errorf("llmobs/export: invalid AgentURL %q: must be an absolute URL (scheme://host) or a unix socket URL", cfg.AgentURL)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unsupported AgentURL schemes

When AgentURL is a typo such as htt://localhost:8126 or another unsupported absolute URL like ftp://host, this check accepts it because Host is non-empty; every export then fails later in Post with unsupported protocol scheme after being retried as a transport error. The tracer's agent URL handling only supports http, https, and unix, so this constructor should reject other schemes up front.

Useful? React with 👍 / 👎.

Comment thread llmobs/export/eval_metric.go
Comment thread llmobs/export/export.go Outdated
@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2826a98e49

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread llmobs/export/export.go
Comment thread llmobs/export/export.go
Comment thread llmobs/export/export.go
Comment thread llmobs/export/export.go Outdated
@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2826a98e49

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread llmobs/export/export.go Outdated
Comment thread llmobs/export/export.go
Comment thread llmobs/export/span_event.go Outdated
Comment thread llmobs/export/span_event.go
@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 2ef4063de9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 2236774fdd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

khanayan123 and others added 4 commits July 9, 2026 12:41
Add a public llmobs/export package: an offline client for exporting
already-built LLM Observability spans and evaluations to Datadog intake
(direct) or through the Agent EVP proxy. This is an export/transport API,
not live instrumentation — it never starts the tracer, depends on the live
LLM Obs lifecycle, or replays payloads through StartSpan/Finish.

Implements the "Raw LLM Observability Export" part of the RFC. It lets
offline reconstruction tools (e.g. Trajectory) depend on the SDK for the
export mechanics — endpoint derivation, auth, HTTP, retry classification,
size handling, and structured per-request results — while keeping
reconstruction, projection, privacy, dedup and durable retry above the SDK.

- Client.ExportSpans / ExportEvaluations over caller-built SpanEvent /
  EvaluationMetric payloads, lowered to the LLM Obs wire shape.
- Caller-assigned trace/span/parent IDs preserved verbatim as opaque
  string payload fields; never routed into APM span IDs or sampling.
- Chunking (50 spans, 1000 evals per request), best-effort 5MB size guard
  (truncates input/output), and per-request ExportResult with validation
  attribution.
- Datadog route (DD-API-KEY) and Agent EVP route; reuses the internal LLM
  Obs transport for auth/routing/retry via a new transport.Post primitive.

Multi-destination is modeled as one isolated Client per destination. A
sibling otlp/export package is a stacked follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address reviewdog/golangci `modernize` findings (no behavior change):
- chunk(): compute the slice end bound with min() (minmax)
- appendUnique(): use slices.Contains (slicescontains)
- export_test concurrency loop: range-over-int + sync.WaitGroup.Go
  (rangeint, waitgroup)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ExportSpans: wrap the span batch in a JSON array on the wire. The
  /api/v2/llmobs intake expects []PushSpanEventsRequest (see
  internal/llmobs/transport.PushSpanEvents); posting a bare object was the
  wrong top-level shape and would be rejected. (P1)
- eval lower(): validate an explicit MetricType is one of
  categorical/score/boolean and matches the provided value kind, rejecting
  typos and mismatches as row-level ValidationErrors instead of letting the
  intake reject the whole chunk. (P2)
- eval lower(): reject a non-nil but empty JSONValue, which json:omitempty
  would drop from the wire, producing a value-less metric. (P2)

Tests updated for the array span shape and the new eval validations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reduce net-new duplication surfaced by a codebase reuse audit:

- Reuse internal/llmobs/transport.DDAttributes for the span _dd block and
  transport.LLMObsMetric (+ PushMetricsRequest envelope, EvaluationSpanJoin/
  EvaluationTagJoin) for evaluations, instead of parallel wire structs. Extends
  the shared LLMObsMetric with the offline-only omitempty fields (json_value,
  assessment, reasoning, metadata) so there is one eval-wire source of truth;
  additive and safe for the live PushEvalMetrics path.
- Extract internal/exportutil {Snippet, Aggregate} and use it here (shared with
  otlp/export in the stacked PR) to drop the byte-identical helpers.
- Point internal/llmobs/config.newHTTPClient at the shared internal.DefaultHTTPClient
  (timeout=0 preserves the no-global-timeout behavior; per-request deadlines are
  set via context), removing a 3rd copy of the HTTP client factory.

String-ID span links, the typed public SpanEvent, top-level service, the flat
meta."span.kind", chunking and the size guard stay custom (genuine shape
divergence from the live path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
khanayan123 and others added 7 commits July 9, 2026 12:41
- Omit empty _dd.apm_trace_id: offline spans not correlated to APM must not
  send apm_trace_id:"" (distinct from absence for the optional field). Use an
  export-specific _dd wire type with omitempty instead of the shared
  transport.DDAttributes (which is non-omitempty to match the live path); the
  eval-metric reuse of transport.LLMObsMetric is unaffected. (P2)
- Validate AgentURL is an absolute (or unix) URL in New, so a value like
  "localhost:8126" is rejected up front instead of failing every export at
  POST time with "unsupported protocol scheme". (P3)
- Widen SpanEvent.Metadata to map[string]any so reconstructed spans can
  round-trip the structured metadata the live annotation path supports. (P2)
- Document the new internal/exportutil package in internal/README.md as
  required by internal/AGENTS.md. (P2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Stamp ddtrace.version on exported evaluations: strip any caller-provided
  ddtrace.version tag and append the SDK's, matching the live eval path so
  intake can attribute the emitting SDK version. (P2)
- Cap the default span-payload size at 5,000,000 bytes (the EVP event size
  limit, internal/llmobs sizeLimitEVPEvent) instead of 5 MiB, so the size
  guard cannot pass a chunk the intake would reject. (P2)
- Trim the internal/README.md exportutil entry to the APIs that exist on this
  branch (Snippet, Aggregate) and reference only llmobs/export. (P3)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Split oversized span batches instead of dropping I/O from every span: when an
  encoded batch exceeds the size limit and has >1 span, bisect and recurse so
  individually-acceptable spans keep their input/output; only a lone span still
  over the limit has its I/O truncated. (P2)
- Restore ForceAttemptHTTP2 on the LLM Obs HTTP client: reverts the reuse of
  internal.DefaultHTTPClient, whose custom dialer disabled HTTP/2 (the shared
  factory leaves ForceAttemptHTTP2 unset). (P3)
- Stamp ml_app on exported spans from the client default when the caller did not
  supply an ml_app tag, matching the live client. (P2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Restrict AgentURL to http/https/unix schemes so a typo like "htt://host" or
  an unsupported scheme (ftp://) is rejected in New instead of failing every
  export at POST time. (P2)
- Reject non-finite evaluation ScoreValue (NaN/Inf) as a row-level validation
  error; encoding/json cannot marshal it, so it would otherwise fail the whole
  chunk. (P2)
- Stamp session_id as a span tag (from SpanEvent.SessionID) so evaluations can
  tag-join on session_id, matching the live LLM Obs path. (P2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reject non-encodable rows per-item instead of failing whole batches, and
require ml_app + span kind up front:

- ExportSpans now lowers, stamps, and trial-marshals each span; a span with a
  non-finite metric cost (or otherwise non-encodable value) is dropped as a
  ValidationError rather than poisoning the chunk's marshal. sendSpanBatch
  works on already-validated []wireSpan.
- ExportSpans drops spans with an empty Kind (validateSpan).
- ExportEvaluations trial-marshals each lowered metric so an unmarshalable
  JSONValue/Metadata is a row-level error, not a chunk failure.
- New requires a non-empty MLApp, matching the live client and guaranteeing
  every exported span/evaluation carries an ml_app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address two further Codex findings:

- SpanMetrics dropped standard LLM Obs metric keys that had no field
  (ephemeral_1h/5m_input_tokens, billable_character_count, time_to_first_token)
  and could not carry custom keys at all. Add the missing standard fields plus
  an Extra map[string]float64 escape hatch with a merging MarshalJSON, so a
  reconstructed span never silently loses a metric. This mirrors the internal
  transport, which models span metrics as a flat key -> number map.
- When a caller set SpanEvent.SessionID but also passed a stale session_id tag,
  the top-level session_id and the tag could disagree, so tag-joined
  evaluations attached to the wrong session. SessionID is now the source of
  truth and replaces any existing session_id tag (replaceTag), matching the
  live path which overwrites the session tag from the resolved session ID.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ics godoc

From an adversarial self-review of the diff:

- Add internal/exportutil unit tests for Snippet (pass-through, ASCII
  truncation, UTF-8 rune-boundary backoff) and Aggregate, which previously had
  no direct coverage.
- Fix the SpanMetrics godoc: the flat wire type is
  internal/llmobs/transport.LLMObsSpanEvent.Metrics (there is no transport.Span),
  and clarify that only the token/count fields map to MetricKey* constants while
  the estimated_* fields carry cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@khanayan123 khanayan123 force-pushed the ayan.khan/llmobs-explicit-span-ids branch from 2236774 to 46ab716 Compare July 9, 2026 16:42
@khanayan123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 46ab7161ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant