Skip to content

feat(#6458): export eval measurement scores via OTLP - #6459

Open
ascerra wants to merge 9 commits into
mainfrom
feat/otlp-score-export
Open

feat(#6458): export eval measurement scores via OTLP#6459
ascerra wants to merge 9 commits into
mainfrom
feat/otlp-score-export

Conversation

@ascerra

@ascerra ascerra commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Implements ADR 0087 portable remote score export: after writing eval-measurements.jsonl, newly scored rows also emit gen_ai.evaluation.result span events on the same TraceID when OTEL_EXPORTER_OTLP_* is set (same path as ADR 0050 agent traces).
  • Fail-open: OTLP export errors warn via CLI; local JSONL/ledger always win. Does not rewrite run-telemetry.jsonl.
  • No vendor score adapters (MLFLOW_* / Assessments) in core — MLflow Assessments UI can be a separate consumer of the OTLP event.

Closes #6458

Test plan

  • Unit tests: go test ./internal/evalmeasure/ ./internal/telemetry/
  • Local httptest OTLP sink proof against Review artifact (run 32482721216 / trace 84d470ba…)
  • Live dogfood MLflow OTLP: score span + event attached to tr-84d470ba2451ffeccfe09022d9b2aebd
  • CI green on this PR
  • Optional: after merge, confirm dogfood eval-measure posts scores when OTEL is set

Made with Cursor

Wire MeasureAndExport to emit gen_ai.evaluation.result span events on the
same TraceID when OTEL_EXPORTER_OTLP_* is set, matching ADR 0087 / 0050.
Local JSONL stays source of truth; remote export is fail-open.

Signed-off-by: Adam Scerra <ascerra@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ascerra
ascerra requested a review from a team as a code owner August 21, 2026 19:37
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Export eval measurement scores as OTLP GenAI evaluation span events

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Export newly written eval measurement rows as OTLP GenAI evaluation span events when OTEL is
 configured.
• Keep local eval-measurements.jsonl/ledger as source of truth; remote export is fail-open.
• Add unit tests and update ADRs/docs to reflect implemented remote score export.
Diagram

graph TD
  A["run-telemetry.jsonl"] --> B["MeasureAndExport"] --> C["eval-measurements.jsonl + ledger"]
  B --> D["ExportOTLPScores"] --> E["OTLP HTTP exporter"] --> F{{"OTLP backend"}}
  subgraph Legend
    direction LR
    _file["File"] ~~~ _mod["Module"] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Attach events directly to existing spans (no child span)
  • ➕ Avoids creating an extra span per score
  • ➕ Keeps score data physically on the scored span
  • ➖ Not feasible when scoring offline from JSONL (no live span handle)
  • ➖ Would require rewriting run-telemetry.jsonl or reconstructing full span payloads, violating 'primary facts' immutability
2. Export scores as OTEL Logs or Metrics signals
  • ➕ Potentially lower overhead than spans for high-volume scoring
  • ➕ Some backends may have better UX for log/metric-based scoring
  • ➖ GenAI semantic conventions and common vendor UIs often expect evaluation results on trace spans/events
  • ➖ Harder to guarantee correlation to the exact parent span across backends compared to trace events

Recommendation: The chosen approach (child span remote-parented to the scored span + gen_ai.evaluation.result event) is the best fit for offline scoring: it preserves strict local-source-of-truth semantics (no mutation of run-telemetry.jsonl), keeps vendor neutrality, and guarantees cross-backend correlation via shared TraceID/parent SpanID. Alternatives either require mutating primary telemetry artifacts or weaken correlation/compatibility.

Files changed (11) +662 / -18

Enhancement (5) +241 / -4
evalmeasure.goSurface OTLP score export behavior and warnings in CLI help/output +10/-2

Surface OTLP score export behavior and warnings in CLI help/output

• Extends the eval-measure command help text to describe optional OTLP score export and fail-open semantics. Adds CLI warnings when remote export fails while keeping local JSONL persistence.

internal/cli/evalmeasure.go

export_otlp.goImplement OTLP score export as GenAI evaluation span events +196/-0

Implement OTLP score export as GenAI evaluation span events

• Introduces ExportOTLPScores to emit a short child span (fullsend.eval_measure) remote-parented to the scored span and add gen_ai.evaluation.result events with semconv attributes. Validates OTLP endpoint configuration, respects OTEL_SDK_DISABLED, and captures exporter errors for fail-open warnings.

internal/evalmeasure/export_otlp.go

parse.goTrack remote export warning in ParseStats +3/-0

Track remote export warning in ParseStats

• Extends ParseStats with RemoteExportWarning for propagating OTLP export failures to the CLI without failing scoring. Keeps the existing model where scoring remains successful even with partial parse issues.

internal/evalmeasure/parse.go

run.goWire MeasureAndExport to perform optional OTLP score export +9/-2

Wire MeasureAndExport to perform optional OTLP score export

• Updates MeasureAndExport documentation to reflect implemented OTLP exporting. Calls ExportOTLPScores after local persistence and records any failure as ParseStats.RemoteExportWarning to preserve fail-open semantics.

internal/evalmeasure/run.go

telemetry.goExpose OTLPEnabled and endpoint validation/exporter construction helpers +23/-0

Expose OTLPEnabled and endpoint validation/exporter construction helpers

• Adds OTLPEnabled and ValidateOTLPEndpoints helpers and a NewOTLPExporter constructor so other packages can reuse the same OTLP export path as agent traces. Keeps endpoint validation behavior explicit for callers.

internal/telemetry/telemetry.go

Tests (2) +241 / -0
export_otlp_test.goAdd unit tests for OTLP score event emission and fail-open behavior +227/-0

Add unit tests for OTLP score event emission and fail-open behavior

• Adds a local httptest OTLP sink to assert emitted spans/events and verify correct trace/parent correlation and event attributes. Covers noop behavior when OTLP is unset/disabled, invalid IDs, and MeasureAndExport fail-open warning propagation.

internal/evalmeasure/export_otlp_test.go

telemetry_test.goTest OTLPEnabled and endpoint validation helper behavior +14/-0

Test OTLPEnabled and endpoint validation helper behavior

• Adds a focused unit test for OTLPEnabled and ValidateOTLPEndpoints covering unset, valid URL, and invalid URL cases.

internal/telemetry/telemetry_test.go

Documentation (3) +14 / -14
0087-eval-measurements-online-trace-scoring.mdClarify OTLP score export semantics and event format +5/-4

Clarify OTLP score export semantics and event format

• Updates ADR 0087 to specify that remote score export is implemented via gen_ai.evaluation.result span events using the same OTEL_EXPORTER_OTLP_* configuration as agent traces. Reinforces fail-open behavior and lack of vendor-specific adapters in core.

docs/ADRs/0087-eval-measurements-online-trace-scoring.md

architecture.mdDocument implemented remote score export on shared OTLP path +2/-4

Document implemented remote score export on shared OTLP path

• Removes the 'planned' note and documents that scores can now export over OTLP when configured. Clarifies that eval scores are derived products stored in eval-measurements.jsonl and optionally exported as correlated span events.

docs/architecture.md

eval-measurements.mdUpdate eval measurements guide with OTLP score export details +7/-6

Update eval measurements guide with OTLP score export details

• Updates the guide to reflect that remote score export is now available and describes the correlation model (child span + gen_ai.evaluation.result event). Reiterates that run-telemetry.jsonl is not rewritten and local JSONL remains authoritative.

docs/guides/infrastructure/eval-measurements.md

Other (1) +166 / -0
main.goAdd local OTLP sink proof tool for score export +166/-0

Add local OTLP sink proof tool for score export

• Adds a standalone program that scores a real telemetry file and asserts OTLP requests contain gen_ai.evaluation.result events. Uses an httptest server as an OTLP endpoint and outputs a structured JSON report for inspection.

hack/prove-otlp-scores/main.go

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Site preview

Preview: https://48da3ef3-site.fullsend-ai.workers.dev

Commit: 2e029bf909805b91aa11b510272801be06defe8e

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 21, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 7:39 PM UTC · Ended 7:50 PM UTC

Commit: 7260ca8 · View workflow run →

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.66667% with 26 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/evalmeasure/export_otlp.go 89.40% 8 Missing and 8 partials ⚠️
internal/telemetry/telemetry.go 66.66% 8 Missing ⚠️
internal/evalmeasure/run.go 81.81% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@qodo-code-review

qodo-code-review Bot commented Aug 21, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (1)

Grey Divider


Action required

1. Unbounded OTLP flush ✓ Resolved 🐞 Bug ☼ Reliability
Description
ExportOTLPScores calls tp.ForceFlush(ctx) using a context that commonly has no deadline (CLI
passes cmd.Context() and MeasureFile uses context.Background()), so eval-measure can hang
indefinitely on OTLP network/retry issues. This contradicts the stated fail-open behavior by
potentially stalling the run even though local JSONL was already persisted.
Code

internal/evalmeasure/export_otlp.go[R85-87]

+	if err := tp.ForceFlush(ctx); err != nil && firstErr == nil {
+		firstErr = err
+	}
Relevance

●●● Strong

Recent reliability precedent accepted making blocking operations respect cancellation or bounded
time.

PR-#6437

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new code flushes using the caller context, and upstream callers provide contexts without
deadlines (background contexts). That combination means flush duration is unbounded from this code’s
perspective, so OTLP export can stall the CLI/run even though export is supposed to be best-effort.

internal/evalmeasure/export_otlp.go[85-87]
internal/evalmeasure/run.go[20-24]
internal/cli/evalmeasure.go[118-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ExportOTLPScores` performs `tp.ForceFlush(ctx)` with the caller-provided context. In typical usage that context has no deadline (`cmd.Context()` from cobra, or `context.Background()` via `MeasureFile`). If the OTLP exporter blocks due to network issues/retries, `ForceFlush` can block indefinitely, stalling `eval-measure` despite the feature being documented as fail-open.

### Issue Context
- `MeasureFile` calls `MeasureAndExport(context.Background(), ...)`.
- The CLI passes `cmd.Context()` to `MeasureAndExport` without adding a timeout.
- `ExportOTLPScores` uses that same context for `ForceFlush`.

### Fix Focus Areas
- internal/evalmeasure/export_otlp.go[68-95]
- internal/evalmeasure/run.go[20-24]
- internal/cli/evalmeasure.go[118-123]

### Suggested approach
- Wrap `ForceFlush` in a bounded context, e.g.:
 - `flushCtx, cancel := context.WithTimeout(ctx, otlpFlushTimeout)` (this will respect earlier deadlines if present)
 - `defer cancel()`
 - `tp.ForceFlush(flushCtx)`
- Optionally use the same bounded context when constructing the exporter if exporter creation can block.
- Add a regression test that uses a context with a very short timeout and a non-routable/blocked endpoint, and assert `ExportOTLPScores` returns within the timeout and surfaces a warning (not a hang).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. ADR 0087 decision rewritten ✓ Resolved 📜 Skill insight ≡ Correctness
Description
The PR edits an already Accepted ADR’s Decision content to incorporate new implementation details,
which violates the rule against substantively rewriting accepted ADRs. This risks changing
historical decision records instead of adding an additive note or superseding ADR.
Code

docs/ADRs/0087-eval-measurements-online-trace-scoring.md[R88-91]

+one new measurement row is produced (including `label: skip`). Remote score
+export uses the same `OTEL_EXPORTER_OTLP_*` configuration as ADR 0050
+(`gen_ai.evaluation.result` span events; fail-open) — no vendor-specific
+score adapters in core. `fullsend` owns the parser, scorers,
Relevance

●●● Strong

Recent ADR precedent explicitly accepted reverting substantive edits to accepted ADR content.

PR-#5244

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The compliance rule prohibits substantive rewrites to accepted ADR content. The ADR is `status:
Accepted`, yet the Decision section text is altered to incorporate new implementation-specific
details about OTLP score export events.

docs/ADRs/0087-eval-measurements-online-trace-scoring.md[1-20]
docs/ADRs/0087-eval-measurements-online-trace-scoring.md[71-92]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`docs/ADRs/0087-eval-measurements-online-trace-scoring.md` is an `Accepted` ADR, but this PR modifies the Decision text to add new implementation specifics (e.g., `gen_ai.evaluation.result` span events). Accepted ADRs must not have substantive content rewritten.

## Issue Context
Preserve the original accepted decision text and add new implementation details as an explicitly labeled note/cross-reference (or add a superseding ADR if the decision itself changed).

## Fix Focus Areas
- docs/ADRs/0087-eval-measurements-online-trace-scoring.md[86-93]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Skip scores break export ✓ Resolved 🐞 Bug ≡ Correctness
Description
ExportOTLPScores fails the whole export on any result with an empty/invalid SpanID, which is a
normal outcome for some label: skip measurement rows. This will cause persistent
RemoteExportWarning noise and misleading “OTLP score export failed” warnings even when other
scores could export successfully.
Code

internal/evalmeasure/export_otlp.go[R121-124]

+	sid, err := parseSpanID(r.SpanID)
+	if err != nil {
+		return fmt.Errorf("span_id %q: %w", r.SpanID, err)
+	}
Relevance

●●● Strong

Recent eval-measurement precedents accepted handling skipped runs and edge-case result semantics.

PR-#6036

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new OTLP export path hard-requires a valid SpanID and returns an error otherwise; however the
existing fitness scorer can legitimately produce label: skip rows with an empty SpanID (e.g.,
missing root run span). Since MeasureAndExport exports all newly written rows (including skips),
this mismatch will commonly trigger export warnings and mark the export as failed even when it
should just skip those rows.

internal/evalmeasure/export_otlp.go[116-124]
internal/evalmeasure/fitness.go[53-88]
internal/evalmeasure/run.go[82-86]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ExportOTLPScores` currently returns an error when `EvaluationResult.SpanID` is empty or malformed, but some scorers legitimately emit `label: skip` rows with `SpanID == ""` (e.g., when the root run span is missing). Because the OTLP export is meant to be fail-open, these rows should be skipped (or handled specially) without failing the entire export and triggering `RemoteExportWarning`.

### Issue Context
- `exportOneScore` hard-fails on `parseSpanID(r.SpanID)`.
- `ScoreFitnessNamed` returns skip results with an empty `SpanID` when the run span is missing.
- `MeasureAndExport` exports all newly written rows, including skips.

### Fix Focus Areas
- internal/evalmeasure/export_otlp.go[116-161]
- internal/evalmeasure/run.go[82-86]
- internal/evalmeasure/fitness.go[53-88]

### Suggested approach
- In `ExportOTLPScores` (or `exportOneScore`), treat empty `SpanID` (and potentially invalid IDs) as a **soft skip**: do not return an error; just continue.
- Only return an error for exporter/flush failures (network, protocol, etc.).
- Add a unit test where `results` includes:
 - one valid score with TraceID+SpanID
 - one skip score with the same TraceID but `SpanID == ""`
 and assert export succeeds and still emits the event for the valid score, with no `RemoteExportWarning`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (1)
4. Guide not in admin/user 📜 Skill insight ⌂ Architecture
Description
A modified guide exists under docs/guides/infrastructure/, but guides are required to live under
either docs/guides/admin/ or docs/guides/user/. This breaks the required documentation directory
structure.
Code

docs/guides/infrastructure/eval-measurements.md[R28-31]

Fullsend does not pick an observability product for scores. The portable
contract is a local JSONL artifact next to telemetry; remote export reuses
the same OpenTelemetry (`OTEL_EXPORTER_OTLP_*`) configuration as agent
-traces when implemented.
+traces.
Evidence
The rule requires every guide under docs/guides/ to be located in either the admin/ or user/
subdirectory. This PR modifies a guide located at docs/guides/infrastructure/eval-measurements.md,
which is outside the allowed directories.

docs/guides/infrastructure/eval-measurements.md[1-20]
Skill: writing-user-docs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The file `docs/guides/infrastructure/eval-measurements.md` is a documentation guide, but it is not placed under `docs/guides/admin/` or `docs/guides/user/` as required.

## Issue Context
Choose the correct audience (likely `admin/` for infrastructure/ops content) and move/rename the file accordingly, then update inbound links (e.g., from `docs/architecture.md` and glossary entries) to the new location.

## Fix Focus Areas
- docs/guides/infrastructure/eval-measurements.md[1-60]
- docs/architecture.md[321-325]
- docs/glossary.md[89-93]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. TraceID undefined in guide ✓ Resolved 📜 Skill insight ✧ Quality
Description
The guide introduces jargon (TraceID, GenAI semconv) without an inline definition or a glossary
link on first use. This reduces clarity for readers unfamiliar with OpenTelemetry terminology.
Code

docs/guides/infrastructure/eval-measurements.md[R45-47]

+  └─ if OTEL_EXPORTER_OTLP_* set → OTLP export of scores as
+       gen_ai.evaluation.result span events on the same TraceID
+       (fail-open; local JSONL always wins)
Relevance

●●● Strong

Team accepted defining domain jargon on first use in guides.

PR-#5778

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The compliance rule requires domain-specific jargon in guides to be defined on first use via a
glossary link or inline definition. The modified guide text uses TraceID and references `GenAI
semconv` without a definition or glossary link at the point of introduction.

docs/guides/infrastructure/eval-measurements.md[36-56]
Skill: writing-user-docs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`docs/guides/infrastructure/eval-measurements.md` uses terms like `TraceID` and `GenAI semconv` without defining them inline or linking to `docs/glossary.md` on first use.

## Issue Context
Compliance requires jargon to be defined on first use in documentation guides. This can be satisfied by adding a short parenthetical definition (e.g., what a TraceID is) and/or linking to an existing glossary entry or authoritative reference.

## Fix Focus Areas
- docs/guides/infrastructure/eval-measurements.md[42-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Per-score synchronous export ✓ Resolved 🐞 Bug ➹ Performance
Description
ExportOTLPScores uses NewSimpleSpanProcessor and creates/ends one span per score, which drives
synchronous exporting on each score and can significantly increase runtime for many measurements.
This can make eval-measure unexpectedly slow when OTLP is enabled, even though the local JSONL
work is already done.
Code

internal/evalmeasure/export_otlp.go[R68-71]

+	tp := sdktrace.NewTracerProvider(
+		sdktrace.WithSampler(sdktrace.AlwaysSample()),
+		sdktrace.WithSpanProcessor(sdktrace.NewSimpleSpanProcessor(capExp)),
+	)
Relevance

●● Moderate

Performance concern is plausible, but history lacks a close precedent for rejecting this OTLP
batching design.

PR-#6036

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new exporter is wired with NewSimpleSpanProcessor and a per-result emission loop, which is the
code-level pattern that causes synchronous per-score export behavior rather than batching.

internal/evalmeasure/export_otlp.go[68-84]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The score export path uses `sdktrace.NewSimpleSpanProcessor`, and emits one span per score in a loop. This makes exports effectively synchronous per score and can lead to high latency when many measurement rows are written.

### Issue Context
- `ExportOTLPScores` builds a new `TracerProvider` with a `SimpleSpanProcessor`.
- It iterates through `results` and emits one child span + event per result.

### Fix Focus Areas
- internal/evalmeasure/export_otlp.go[68-87]

### Suggested approach
- Replace `NewSimpleSpanProcessor` with a `NewBatchSpanProcessor` configured for short-lived CLI usage (small batch timeout + `ForceFlush`/`Shutdown` with bounded context).
- If you move to batch processing, make `capturingExporter.err` concurrency-safe (mutex/atomic) because export can happen from a worker goroutine.
- Keep the existing fail-open behavior: exporter errors should surface only as warnings, never as a hard failure of measurement persistence.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 58 rules

Grey Divider

Tip of the day
💡 Did you know, you can hide the parts of a finding you never read, like the evidence or the agent prompt

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread docs/ADRs/0087-eval-measurements-online-trace-scoring.md Outdated
Comment on lines 28 to +31
Fullsend does not pick an observability product for scores. The portable
contract is a local JSONL artifact next to telemetry; remote export reuses
the same OpenTelemetry (`OTEL_EXPORTER_OTLP_*`) configuration as agent
traces when implemented.
traces.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Guide not in admin/user 📜 Skill insight ⌂ Architecture

A modified guide exists under docs/guides/infrastructure/, but guides are required to live under
either docs/guides/admin/ or docs/guides/user/. This breaks the required documentation directory
structure.
Agent Prompt
## Issue description
The file `docs/guides/infrastructure/eval-measurements.md` is a documentation guide, but it is not placed under `docs/guides/admin/` or `docs/guides/user/` as required.

## Issue Context
Choose the correct audience (likely `admin/` for infrastructure/ops content) and move/rename the file accordingly, then update inbound links (e.g., from `docs/architecture.md` and glossary entries) to the new location.

## Fix Focus Areas
- docs/guides/infrastructure/eval-measurements.md[1-60]
- docs/architecture.md[321-325]
- docs/glossary.md[89-93]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread docs/guides/infrastructure/eval-measurements.md Outdated
Comment thread internal/evalmeasure/export_otlp.go
Comment thread internal/evalmeasure/export_otlp.go Outdated
Comment thread internal/evalmeasure/export_otlp.go
Bound post-hoc export retries/budget, share fullsend resource identity,
batch scores, skip empty span IDs, keep Ok status for all labels, omit
score.value on skip, and sync docs that still said OTLP was planned.

Signed-off-by: Adam Scerra <ascerra@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ascerra

ascerra commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Review squad follow-up

Addressed verified findings from the review pass:

  • Bounded retries / wall budget for post-hoc score export (NewOTLPExporterBounded + 15s export timeout + batch processor) so a flaky collector cannot hang the job.
  • Shared BuildResource so score spans keep service.name=fullsend (+ OTEL_RESOURCE_ATTRIBUTES).
  • Empty span_id skip rows no longer warn as OTLP failures.
  • No Error span status on measurement fail (label lives on the GenAI event only).
  • Omit score.value on skip.
  • Docs: removed leftover “not wired yet” callouts; ADR 0050 annotation marked done; ledger best-effort remote noted.

Deferred (documented / intentional): separate remote-export ledger for OTLP retry after local success — remote remains best-effort once; Assessments stay a MLflow-side consumer, not core.

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 21, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 7:52 PM UTC · Completed 8:07 PM UTC

Commit: 17e3154 · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review

Findings

Medium

  • [incomplete-doc] docs/glossary.md:157 — The "OTEL Derived Products" glossary entry says "Derived products sit beside telemetry as sibling files" without mentioning the new OTLP export path. The sibling "OTEL Primary Facts" entry already describes OTLP export, creating an asymmetry.
    Remediation: Add a clause noting that when OTEL_EXPORTER_OTLP_* is set, scores also export as OTLP span events on the same TraceID (fail-open).

Low

  • [error-handling] internal/evalmeasure/export_otlp.go:212capturingExporter.ExportSpans overwrites c.err on every call. If the BatchSpanProcessor calls ExportSpans multiple times, only the last call's error is retained. The overwrite is intentional (tested by TestCapturingExporter_ClearsErrorOnLaterSuccess) and practical batch sizes (1–5 scores) make multi-batch splits implausible.
  • [edge-case] internal/evalmeasure/export_otlp.go:105ExportOTLPScores shares the otlpExportBudget (15s) context between ForceFlush and the deferred Shutdown. If ForceFlush consumes most of the deadline, Shutdown may receive an expired context. Harmless since ForceFlush already flushed spans, and the error is additive (errors.Join, not replacing).
  • [naming-conventions] internal/cli/evalmeasure_test.go:22clearOTLPEnv in this file clears 6 env vars while the identically-named function in internal/evalmeasure/export_otlp_test.go clears 8 (also blanking OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT and OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT). No cli test exercises attribute truncation, so the risk is theoretical.
  • [naming-alignment] internal/evalmeasure/export_otlp.go — GenAI evaluation event and attribute names are pinned to the OpenTelemetry GenAI semantic conventions reference document (low-stability / reference status). The code includes a clear comment noting measurement versions should be bumped when attribute names change.
  • [incomplete-doc] docs/cli/README.md:30 — The CLI command table describes fullsend eval-measure as "Score wild-run traces into eval-measurements.jsonl" without mentioning the new OTLP export capability. The command's --help text already documents OTLP export.
Previous run

Review

Findings

Low

  • [error-handling] internal/evalmeasure/export_otlp.go:131capturingExporter.ExportSpans overwrites c.err on every call (c.err = err). If the BatchSpanProcessor calls ExportSpans multiple times, only the last call's error is retained. A successful final batch after an earlier failed batch would silently lose the export error. The overwrite is intentional (tested by TestCapturingExporter_ClearsErrorOnLaterSuccess) and practical batch sizes (1–5 scores) make multi-batch splits implausible.
  • [edge-case] internal/evalmeasure/export_otlp.go:90ExportOTLPScores shares the otlpExportBudget (15s) context between ForceFlush and the deferred Shutdown. If ForceFlush consumes most of the deadline, Shutdown may receive an expired context and produce a misleading otlp shutdown: context deadline exceeded error joined to the return. Harmless since ForceFlush already flushed spans, and the error is additive (errors.Join, not replacing).
  • [naming-conventions] internal/cli/evalmeasure_test.go:22clearOTLPEnv in this file clears 6 env vars while the identically-named function in internal/evalmeasure/export_otlp_test.go clears 8 (also blanking OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT and OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT). No cli test exercises attribute truncation, so the risk is theoretical.
  • [naming-alignment] internal/evalmeasure/export_otlp.go — GenAI evaluation event and attribute names are pinned to the OpenTelemetry GenAI semantic conventions reference document (low-stability / reference status). The code includes a clear comment noting measurement versions should be bumped when attribute names change.
Previous run (2)

Review

Findings

Low

  • [error-handling] internal/evalmeasure/export_otlp.go:131capturingExporter.ExportSpans overwrites c.err on every call (c.err = err). If the BatchSpanProcessor calls ExportSpans multiple times, only the last call's error is retained. A successful final batch after an earlier failed batch would silently lose the export error. The overwrite is intentional (tested by TestCapturingExporter_ClearsErrorOnLaterSuccess) and practical batch sizes (1–5 scores) make multi-batch splits implausible.
  • [code-organization] internal/evalmeasure/export_otlp_test.go:383hexOf reimplements encoding/hex.EncodeToString. The hack/prove-otlp-scores/main.go in this same PR already uses the stdlib function.

Info

  • [naming-conventions] internal/cli/evalmeasure_test.go:22clearOTLPEnv in this file clears 6 env vars while the identically-named function in internal/evalmeasure/export_otlp_test.go clears 8 (also blanking OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT and OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT). No cli test exercises attribute truncation, so the risk is theoretical.
  • [api-shape] internal/evalmeasure/parse.go:55RemoteExportWarning is added to ParseStats, which is otherwise a parse-centric struct. Consistent with how Incomplete was already used as a cross-concern signal surface for MeasureAndExport.
Previous run (3)

Review

Findings

Low

  • [edge-case] internal/evalmeasure/export_otlp.go:189exportOneScore silently returns nil when TraceID or SpanID is empty. Currently these are LabelSkip results (missing root run span), but a future scorer producing pass/fail with an empty SpanID would be silently dropped with no warning in RemoteExportWarning. Consider logging or counting silent skips.
  • [code-organization] internal/evalmeasure/export_otlp_test.go:383hexOf reimplements encoding/hex.EncodeToString. The hack/prove-otlp-scores/main.go in this same PR already uses the stdlib function.
  • [code-organization] internal/evalmeasure/export_otlp_test.go:25scoreOTLPSink duplicates otlpSink from internal/telemetry/otlpsink_test.go. Different packages prevent direct reuse without a shared test utility.
Previous run (4)

Review

Findings

Low

  • [edge-case] internal/evalmeasure/export_otlp.go:108exportOneScore silently returns nil when TraceID or SpanID is empty. Currently these are LabelSkip results (missing root run span), but a future scorer producing pass/fail with an empty SpanID would be silently dropped with no warning in RemoteExportWarning. Consider logging or counting silent skips.
  • [code-organization] internal/evalmeasure/export_otlp_test.go:988hexOf reimplements encoding/hex.EncodeToString. The hack/prove-otlp-scores/main.go in this same PR already uses the stdlib function.
  • [code-organization] internal/evalmeasure/export_otlp_test.go:678scoreOTLPSink duplicates otlpSink from internal/telemetry. Different packages prevent direct reuse without a shared test utility.
  • [naming-convention] internal/evalmeasure/export_otlp_test.go:730clearOTLPEnv vs pinOTELEnv (in internal/telemetry) naming mismatch for the same env-clearing pattern.
  • [naming-convention] internal/evalmeasure/export_otlp.go — Score-specific Attr* constants in export_otlp.go are separate from run-level Attr* constants in types.go. Feature-scoped grouping is valid Go convention.
  • [api-shape] internal/telemetry/telemetry.go:42newOTLPExporter (test seam var) and NewOTLPExporter (exported func) differ only by case. Primary call site uses NewOTLPExporterBounded, making confusion risk minimal.
  • [incomplete-doc] docs/glossary.md:157 — "OTEL Derived Products" definition says derived products "sit beside telemetry as sibling files" — with OTLP score export, they also travel over OTLP as span events. Incomplete but not incorrect.
Previous run (5)

Review

Findings

Medium

  • [stale-doc] docs/problems/operational-observability.md:195 — Line says "remote scores reuse OTEL_EXPORTER_OTLP_* when implemented" — this PR implements the feature, so "when implemented" is now stale.
    Remediation: Update to present tense, e.g., "remote scores reuse OTEL_EXPORTER_OTLP_*" (drop "when implemented").

Low

  • [error-handling] internal/evalmeasure/export_otlp.go:72 — TracerProvider shutdown error is silently discarded (_ = tp.Shutdown(shutCtx)). Unlikely to matter in practice since ForceFlush already exports spans.
  • [naming-convention] internal/evalmeasure/export_otlp.go — Score-specific Attr* constants are declared separately from run-level Attr* constants in types.go. Feature-scoped grouping is valid Go convention but splits attribute definitions across files.
  • [naming-convention] internal/telemetry/telemetry.goBuildResource exports buildResource with an empty-string fallback. Naming asymmetry is idiomatic Go (unexported→exported) but the wrapper is minimal.
  • [error-handling-idiom] internal/evalmeasure/export_otlp.goparseTraceID/parseSpanID use sentence-like "trace_id must be non-zero" for leaf errors rather than the terse fragment style used elsewhere in the package.
  • [code-organization] internal/evalmeasure/export_otlp_test.gohexOf helper reimplements encoding/hex.EncodeToString.
  • [code-organization] internal/evalmeasure/export_otlp_test.goscoreOTLPSink duplicates otlpSink from internal/telemetry. Different packages prevent direct reuse without a shared test utility.
  • [api-shape] internal/telemetry/telemetry.gonewOTLPExporter (test seam var) and NewOTLPExporter (exported func) differ only by case, though the primary call site uses NewOTLPExporterBounded which is more distinct.
  • [adr-immutability] docs/ADRs/0087-eval-measurements-online-trace-scoring.md — Edits Accepted ADR's Decision/Consequences sections from future to present tense. Borderline but defensible as minor annotation rather than semantic rewrite — the architectural choice is unchanged.
  • [incomplete-doc] docs/glossary.md:157 — "OTEL Derived Products" definition says derived products "sit beside telemetry as sibling files" — with OTLP score export now implemented, they also travel over OTLP as span events. Definition is incomplete but not incorrect.

@fullsend-ai-review fullsend-ai-review Bot added the requires-manual-review Review requires human judgment label Aug 21, 2026

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review (MEDIUM+)

Four inline comments on unique issues. Fail-open OTLP path looks solid (empty IDs skipped, export bounded, batch processor). The correctness gap to fix is resource identity.

Already posted (skipped): ADR 0087 Decision rewrite on docs/ADRs/0087-eval-measurements-online-trace-scoring.md — existing thread already covers it. Preferred fix remains a 0050-style annotation rather than editing the Decision.

Comment thread internal/evalmeasure/export_otlp.go
Comment thread internal/evalmeasure/export_otlp.go
Comment thread internal/evalmeasure/export_otlp.go Outdated
Comment thread internal/evalmeasure/export_otlp.go
Align score resource service.version with CLI Version(), no-op OTLP when
inbound TRACEPARENT is unsampled, apply shared span limits and truncate
evaluation explanations, cite GenAI semconv and clarify vendor UI mapping,
and restore ADR 0087 Decision with an Implemented annotation.

Signed-off-by: Adam Scerra <ascerra@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 24, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 11:33 AM UTC · Completed 11:53 AM UTC

Commit: 911b9bf · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Cost: $6.68

fullsend-ai-review[bot]

This comment was marked as outdated.

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot added ready-for-merge All reviewers approved — ready to merge and removed requires-manual-review Review requires human judgment labels Aug 24, 2026

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Additional review pass (8 findings, all verified against PR head 911b9bf and cross-checked against existing threads for duplicates).

Comment thread internal/evalmeasure/run_test.go Outdated
require.NoError(t, os.Mkdir(meas, 0o755))
})
results, _, err := MeasureAndExport(ctx, telem, filepath.Join("testdata", "sample-registry.yaml"), out)
results, _, err := MeasureAndExport(ctx, telem, filepath.Join("testdata", "sample-registry.yaml"), out, "")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] — Tests here (and in internal/cli/evalmeasure_test.go) can export fixture scores to a real OTLP backend

MeasureAndExport/MeasureFile trigger ExportOTLPScores whenever OTEL_EXPORTER_OTLP_* is set in the process env. Neither run_test.go nor evalmeasure_test.go reference the clearOTLPEnv/pinOTELEnv hermeticity helpers already used in export_otlp_test.go and telemetry_test.go (the latter explicitly documents "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT may be set by org vars" in CI). Both files call MeasureAndExport/MeasureFile repeatedly — e.g. TestMeasureAndExport_KeepsFirstWhenSecondPersistFails right here produces a real scored result. If ambient OTEL env vars are present, fixture trace IDs (aaaaaaaa...) can leak to a real collector and retries can add flakiness.

Suggestion: Apply the existing clearOTLPEnv/pinOTELEnv pattern to every Measure*-calling test in these two files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

apply this suggestion

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 807faa1.

clearOTLPEnv is now applied in every MeasureFile/MeasureAndExport test in run_test.go, and the same helper was added for CLI evalmeasure_test.go paths that invoke measure. Ambient OTEL_EXPORTER_OTLP_* from CI org vars can no longer leak fixture TraceIDs to a real collector.

Comment thread internal/evalmeasure/run.go Outdated
}
}
if len(all) > 0 {
if err := ExportOTLPScores(ctx, all, serviceVersion); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] — Mid-loop persist error after a successful row permanently skips OTLP export for that row

The loop above (appending to all, then AppendMeasurements/RecordScored) persists+ledgers each result as it goes, but ExportOTLPScores is only called once here, after the whole loop completes. If AppendMeasurements or RecordScored fails on a later trace, the function returns early from inside the loop and never reaches this call, so an earlier trace that was already durably persisted+ledgered never gets exported. TestMeasureAndExport_KeepsFirstWhenSecondPersistFails exercises exactly this scenario (asserts results has the first trace and an error) but doesn't check/require OTLP export of that first row — confirming the gap is untested.

Suggestion: Export all (fail-open, consistent with the rest of the design) before returning on the mid-loop error path, or explicitly document that a partial-batch failure silently drops remote export for already-ledgered rows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

apply this suggestion

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 807faa1.

MeasureAndExport now calls fail-open ExportOTLPScores for the already-persisted prefix before returning on mid-loop persist/ledger errors (same helper as the success path). TestMeasureAndExport_KeepsFirstWhenSecondPersistFails asserts the first row actually hits a local OTLP sink before the early return.

Comment thread internal/evalmeasure/export_otlp.go Outdated

// newScoreOTLPExporter is a test seam. Production uses a retry-bounded
// exporter so Simple/Batch export cannot retry forever (unlike live agent
// Setup, which may leave MaxElapsedTime at the SDK default).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] — Comment misdescribes agent Setup's OTLP exporter as capped at the SDK's default MaxElapsedTime

This comment says the bounded score exporter exists "unlike live agent Setup, which may leave MaxElapsedTime at the SDK default." In telemetry.go, NewOTLPExporter's RetryConfig sets InitialInterval/MaxInterval but never MaxElapsedTime, leaving it at the Go zero value (0). In the vendored otlptracehttp source, WithRetry replaces the whole RetryConfig with no merge against retry.DefaultConfig (MaxElapsedTime=1m), and the retry loop's give-up check is if maxElapsedTime != 0 && ... — so a zero value means retries never give up on elapsed time. Agent Setup's exporter is actually less bounded than the SDK's own 1-minute default, not equal to it as this comment implies.

Suggestion: Fix the comment to say agent Setup's exporter has no MaxElapsedTime cap at all (bounded only by ctx cancellation at shutdown), or explicitly set MaxElapsedTime to the SDK default in NewOTLPExporter if that was the real intent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

explicitly set MaxElapsedTime to the SDK default in NewOTLPExporter if that was the real intent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 807faa1.

Comment corrected: agent NewOTLPExporter leaves MaxElapsedTime at 0, which otlptracehttp treats as “never give up on elapsed time” (bounded only by shutdown ctx). Score export still uses the tighter NewOTLPExporterBounded path on purpose.

Comment thread internal/evalmeasure/export_otlp.go Outdated
//
// Attribute names follow OpenTelemetry GenAI semantic conventions
// (semantic-conventions-genai, evaluation events — pin consulted for this
// ship: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-events/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] — Cited semconv gen-ai-events URL is a dead/moved page, not the current spec

This const-block comment cites https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-events/ as the "pin consulted for this ship" (the same URL is repeated in docs/guides/infrastructure/eval-measurements.md). A live fetch of that URL now serves: "GenAI semantic conventions have moved to the OpenTelemetry GenAI semantic conventions repository. This page has moved and is no longer maintained in this repository." This is the URL added in response to a prior review comment about premature-decision/instability, so the citation itself is already stale even after that fix.

Suggestion: Update the citation in both export_otlp.go and eval-measurements.md to the current source (github.com/open-telemetry/semantic-conventions-genai), and note the event's low-stability/reference-implementation status there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

accept suggestion

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 807faa1.

Citation updated in export_otlp.go and docs/guides/infrastructure/eval-measurements.md to the current GenAI repo pin: https://github.com/open-telemetry/semantic-conventions-genai/blob/main/reference/reports/gen-ai-evaluation-result-event.md (noted as low-stability / reference).

Comment thread internal/evalmeasure/export_otlp.go Outdated
if !telemetry.OTLPEnabled() {
return nil
}
if inboundTRACEPARENTUnsampled() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] — Job-level TRACEPARENT-unsampled gate doesn't account for multiple TraceIDs in one telemetry file

ParseTelemetryFile groups spans by TraceID into a map, so a single run-telemetry.jsonl can hold multiple distinct traces — realistic since telemetry.go opens the file with os.O_APPEND, meaning repeated fullsend run invocations sharing an output dir append rather than truncate. MeasureAndExport accumulates every scored row across all traces into one all slice and passes it to a single ExportOTLPScores call, which applies one process-global inboundTRACEPARENTUnsampled() check here based only on the current process's own TRACEPARENT env var, not per-row/per-TraceID sampled status. A file holding traces from more than one prior invocation with different sampled decisions gets one ambient gate applied to all of them, which can either wrongly suppress export of an actually-sampled trace or export a score span onto a TraceID whose agent spans were never sent remotely (an orphan) — the exact failure mode this check exists to prevent.

Suggestion: Gate suppression per-TraceID rather than one job-level env check, or explicitly document/enforce a 1:1 telemetry-file-to-sampled-decision assumption if that is guaranteed elsewhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 807faa1 (narrowed, with managed-path context).

Managed fullsend run does not append two runs into one run-telemetry.jsonl — each run gets a unique output/fs-<slug>-<hash>/. The gate is still TraceID-scoped for defense in depth: when inbound TRACEPARENT is valid+unsampled, we suppress only scores whose trace_id equals that parent TraceID; other TraceIDs in the batch still export. Guide documents the 1:1 runDir assumption. Parsing now uses propagation.TraceContext{}.Extract (same as agent run).

Comment thread internal/evalmeasure/export_otlp.go Outdated
err := c.base.ExportSpans(ctx, spans)
if err != nil {
c.mu.Lock()
if c.err == nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] — capturingExporter latches the first ExportSpans error even after a later flush succeeds

ExportSpans stores the first non-nil error in c.err (if c.err == nil { c.err = err }) and never clears it on a subsequent successful call. Combined with the errors.Join after ForceFlush, a transient failure on an early batch followed by a successful later batch still results in stats.RemoteExportWarning being set. Since the ledger already marks these rows as scored (no retry path), the operator gets a false "remote export failed" signal for data that actually landed.

Suggestion: Only report export failure if ForceFlush/Shutdown ultimately still report failure, or clear/overwrite the latched error on a later successful ExportSpans call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 807faa1.

capturingExporter now stores the latest ExportSpans error (nil on success), so a later successful batch clears a prior transient failure. Added TestCapturingExporter_ClearsErrorOnLaterSuccess.

if stats.SkippedSpans > 0 {
printer.StepWarn(fmt.Sprintf("%s: skipped %d unreadable span(s) inside otherwise-valid telemetry line(s)", p, stats.SkippedSpans))
}
if stats.RemoteExportWarning != "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] — CLI RemoteExportWarning branch has no dedicated test

No test under internal/cli references RemoteExportWarning or the "OTLP score export failed" message, even though this is new production control flow introduced by this PR. Coverage exists only one layer down at TestMeasureAndExport_OTLPFailOpen in the evalmeasure package.

Suggestion: Add a runEvalMeasure-level test pointed at a closed/unreachable OTLP endpoint (with OTEL env pinned) asserting the CLI-level StepWarn fires.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 807faa1.

Added TestRunEvalMeasure_OTLPFailWarns: pins OTEL to a closed port and asserts the CLI StepWarn path emits OTLP score export failed while local JSONL is still written.

Comment thread internal/evalmeasure/export_otlp.go Outdated
// inboundTRACEPARENTUnsampled reports whether TRACEPARENT is present and
// carries the W3C sampled flag cleared (…-00). Empty/malformed TRACEPARENT
// is treated as "no inbound parent" (export proceeds).
func inboundTRACEPARENTUnsampled() bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] — Hand-rolled TRACEPARENT parsing can diverge from the agent's W3C propagator

inboundTRACEPARENTUnsampled manually splits the TRACEPARENT env var on - and checks the flags byte, while the agent run path (internal/cli/run.go) uses propagation.TraceContext{}.Extract for the same env var. These are two independent implementations of W3C traceparent parsing; they can silently diverge on edge cases (all-zero IDs, malformed flag fields, future header versions), causing score-export sampling suppression to drift from the agent's own sampling decision over time.

Suggestion: Reuse propagation.TraceContext{}.Extract (the same propagator the agent path uses) instead of a bespoke string-split parser.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 807faa1.

Replaced the hand-rolled TRACEPARENT string split with propagation.TraceContext{}.Extract + SpanContextFromContext (same propagator path as resolveTraceIdentity in run.go), then apply TraceID-scoped unsampled suppression from that context.

Scope TRACEPARENT suppression per TraceID via W3C propagator, export
already-persisted scores on mid-loop persist failure, clear transient
export latch on success, hermetic OTEL in Measure tests, and refresh
the GenAI semconv citation.

Signed-off-by: Adam Scerra <ascerra@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 24, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 7:52 PM UTC · Ended 7:54 PM UTC

Commit: 807faa1 · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Aug 24, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 7:56 PM UTC · Completed 8:11 PM UTC

Commit: 766e64d · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $8.36

fullsend-ai-review[bot]

This comment was marked as outdated.

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review sweep: 4 findings (all MEDIUM), inline below.

Comment thread internal/evalmeasure/export_otlp.go Outdated
Comment thread internal/evalmeasure/export_otlp.go Outdated
Comment thread internal/evalmeasure/export_otlp.go Outdated
Comment thread hack/prove-otlp-scores/main.go Outdated
Fold Shutdown into the shared export budget and surface its errors,
honor OTEL attribute value limits for evaluation explanations, report
partial export as N/M in warnings, and stop prove-otlp-scores from
wiping ledger state beside live telemetry by default.

Signed-off-by: Adam Scerra <ascerra@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 25, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 5:17 PM UTC · Completed 5:35 PM UTC

Commit: 8278aee · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $5.00

fullsend-ai-review[bot]

This comment was marked as outdated.

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review sweep: 3 findings (all MEDIUM), inline below. Verified against head 8278aeef.

if len(errs) == 0 {
return nil
}
exported := attempted - failed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] The new "N/M scores exported" warning reports total success for the dominant (transport) failure mode

failed (line 136) is incremented only inside the per-row loop and only when exportOneScore returns an error, which it can do exclusively on trace_id/span_id parse failures (lines 212-219). The two transport-level failure signals — tp.ForceFlush(ctx) (line 140) and the captured capExp.err (lines 143-148) — are appended to errs but never adjust attempted or failed. So exported := attempted - failed (line 152) still counts every row whose span was merely constructed, not delivered.

With the collector unreachable and zero spans landing, the operator-facing warning reads 1/1 scores exported; 0 failed: <flush err>; otlp export: <err> — it declares total success and total failure in the same sentence. That makes both the round-3 fix (commit 8278aee, reply on thread 3855447436: "Partial/total failures now report as N/M scores exported; K failed") and the doc comment at lines 77-78 ("Partial ID failures report N/M scores exported so operators can tell partial from total failure") untrue for the most common failure.

The test gap is visible: TestExportOTLPScores_PartialIDFailureReportsCounts (export_otlp_test.go:391) asserts the count text only against a healthy httptest sink, while the two closed-port tests that do exercise transport failure — TestMeasureAndExport_OTLPFailOpen (export_otlp_test.go:265) and TestRunEvalMeasure_OTLPFailWarns (internal/cli/evalmeasure_test.go:543) — assert nothing about the count text.

Suggestion: Treat a transport error as invalidating the per-row tally: when flushErr != nil or expErr != nil, either report 0/%d scores exported (nothing is known to have landed) or drop the N/M prefix entirely for that branch and emit a distinct message such as otlp export failed for all %d scores. Reserve N/M for the pure ID-failure path. Add an assertion on the warning text in the closed-port test so the two failure classes cannot silently converge again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c530e9d.

Transport failures (ForceFlush / capturingExporter err) now emit a distinct otlp export failed for all N scores: … warning instead of claiming N/M scores exported from locally constructed spans. The N/M form is reserved for pure ID-parse failures. Closed-port tests (TestMeasureAndExport_OTLPFailOpen, TestRunEvalMeasure_OTLPFailWarns) assert the new transport wording.

require.NotEmpty(t, stats.RemoteExportWarning, "expected OTLP failure warning with local JSONL kept")
}

func TestExportOTLPScores_UnsampledTRACEPARENTNoop(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] No positive test for a sampled (-01) TRACEPARENT, so silently dropping the IsSampled clause would go undetected on the common path

clearOTLPEnv (line 77) sets TRACEPARENT to "" for every test, so all tests except the two TRACEPARENT ones exit inboundUnsampledTRACEPARENT at the empty-string guard (export_otlp.go:162) and never reach the sc.IsSampled() check at export_otlp.go:170. The only two tests that set TRACEPARENTTestExportOTLPScores_UnsampledTRACEPARENTNoop (line 284) and TestExportOTLPScores_UnsampledTRACEPARENTOtherTraceIDExports (line 298) — both use the -00 (unsampled) flag.

To be precise: a full inversion of the clause would be caught, because the -00 Noop test would then see spans in the sink and fail its assert.Empty. The genuinely uncovered mutation is dropping or weakening the sc.IsSampled() clause: suppression would then fire for a sampled -01 parent too, silently killing score export for the normal dispatched-pipeline case (where fullsend run adopts a live sampled TRACEPARENT), while both existing -00 tests stay green. That is the highest-traffic production path for this feature and it currently has no positive assertion.

Suggestion: Add TestExportOTLPScores_SampledTRACEPARENTExports: set TRACEPARENT to 00-84d470ba2451ffeccfe09022d9b2aebd-77f8c0902eaeedcb-01 with a score row on that same TraceID, and assert sink.allSpans() is non-empty. This pins the common path against the clause being weakened, mirroring the existing agent-tracing test pattern in internal/cli/telemetry_run_test.go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c530e9d.

Added TestExportOTLPScores_SampledTRACEPARENTExports with TRACEPARENT …-01 on the same TraceID as the score row, asserting the sink is non-empty so dropping/weakening sc.IsSampled() would fail the common path.

}))
defer srv.Close()

_ = os.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", srv.URL)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[MEDIUM] hack/prove-otlp-scores leaves TRACEPARENT ambient, so it can report FAIL while the export path works

The tool deliberately normalizes the OTLP environment before measuring — os.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", srv.URL) (line 90), os.Unsetenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") (line 91), os.Unsetenv("OTEL_SDK_DISABLED") (line 92) — but never clears TRACEPARENT/TRACESTATE. ExportOTLPScores reads those directly from the process env via inboundUnsampledTRACEPARENT (export_otlp.go:161-167).

The suppression gate is now TraceID-scoped, so the false negative needs the ambient TRACEPARENT to be both unsampled and to carry the same TraceID as the run being scored — which is exactly this tool's natural usage: do a fullsend run under an unsampled inbound parent, then prove against that run dir from the same shell. In that case every score row is skipped at export_otlp.go:125, reqs stays empty, and the tool prints FAIL: no OTLP requests received and exits 1 (lines 124-126) even though the export path is behaving exactly as designed. That is a false negative in the one tool whose entire purpose is to prove the path works — and which the PR body cites as dogfood evidence.

Suggestion: Add os.Unsetenv("TRACEPARENT") and os.Unsetenv("TRACESTATE") alongside the existing unsets at lines 91-92, so the tool controls the full set of inputs that gate export. Failing that, print the resolved suppression decision before the FAIL line so an operator can tell suppression apart from a broken export path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c530e9d.

hack/prove-otlp-scores now Unsetenvs TRACEPARENT and TRACESTATE alongside the existing OTEL unsets, so an ambient unsampled parent from a prior fullsend run in the same shell cannot suppress every score and produce a false FAIL.

Distinguish transport failures from partial ID failures in warnings,
add a sampled TRACEPARENT positive export test, clear ambient
TRACEPARENT in prove-otlp-scores, and replace hand-rolled hexOf with
encoding/hex.

Signed-off-by: Adam Scerra <ascerra@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 25, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 6:44 PM UTC · Completed 7:02 PM UTC

Commit: c530e9d · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $8.35

@fullsend-ai-review fullsend-ai-review Bot added the risk/moderate PR risk: moderate label Aug 25, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 25, 2026

Copy link
Copy Markdown

Risk Assessment: moderate (2/5)

Details

Moderate risk: large change size (16 files, 1242 lines) offset by no protected paths, no security-sensitive files, strong test ratio, and well-aligned issue scope.

Previous run

Risk Assessment: moderate (2/5)

Details

Moderate risk. Large additive change (1230 lines, 16 files) drives elevated change-size signal, but no protected paths or security-sensitive files are touched. Feature is gated behind an environment variable (OTEL_EXPORTER_OTLP_ENDPOINT), making it opt-in and safely rollbackable. PR aligns well with issue #6458 acceptance criteria.

fullsend-ai-review[bot]

This comment was marked as outdated.

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review sweep: 1 finding (MEDIUM), inline below. Verified against head c530e9da.

Comment thread docs/guides/infrastructure/eval-measurements.md Outdated
ascerra and others added 2 commits August 25, 2026 16:36
Repoint citations from the support-matrix report to gen-ai-events.md and
document that fullsend emits a span event while the convention specifies
a log record.

Signed-off-by: Adam Scerra <ascerra@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@fullsend-ai-review

fullsend-ai-review Bot commented Aug 25, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 8:38 PM UTC · Completed 8:57 PM UTC

Commit: 2e029bf · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $7.88

@fullsend-ai-review fullsend-ai-review 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.

See the review comment for full details.

}

func (c *capturingExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error {
err := c.base.ExportSpans(ctx, 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.

[low] error-handling

capturingExporter.ExportSpans overwrites c.err on every call. If the BatchSpanProcessor calls ExportSpans multiple times, only the last call's error is retained. The overwrite is intentional (tested by TestCapturingExporter_ClearsErrorOnLaterSuccess) and practical batch sizes (1-5 scores) make multi-batch splits implausible.

}

ctx, cancel := context.WithTimeout(ctx, otlpExportBudget)
defer cancel()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] edge-case

ExportOTLPScores shares the otlpExportBudget (15s) context between ForceFlush and the deferred Shutdown. If ForceFlush consumes most of the deadline, Shutdown may receive an expired context. Harmless since ForceFlush already flushed spans, and the error is additive (errors.Join, not replacing).


// clearOTLPEnv keeps Measure*/eval-measure tests hermetic when CI injects
// OTEL_EXPORTER_OTLP_* org vars (same pattern as evalmeasure/export_otlp_test).
func clearOTLPEnv(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[low] naming-conventions

clearOTLPEnv in this file clears 6 env vars while the identically-named function in internal/evalmeasure/export_otlp_test.go clears 8 (also blanking OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT and OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT). No cli test exercises attribute truncation, so the risk is theoretical.

@fullsend-ai-review fullsend-ai-review Bot added requires-manual-review Review requires human judgment and removed ready-for-merge All reviewers approved — ready to merge labels Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

requires-manual-review Review requires human judgment risk/moderate PR risk: moderate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Portable OTLP export for eval measurement scores

2 participants