Skip to content
6 changes: 4 additions & 2 deletions docs/ADRs/0050-distributed-tracing-instrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,10 @@ online scoring of wild-run traces writes `eval-measurements.jsonl`
beside telemetry when at least one new score is produced (tool-agnostic). Distinct from functional eval fixtures
([ADR 0051](0051-agent-eval-harness-for-test-infrastructure.md)).

> **Planned:** portable remote score export follows the same OTLP
> configuration as this ADR — no vendor score adapters in core.
> **Done ([#6459](https://github.com/fullsend-ai/fullsend/pull/6459) /
> [ADR 0087](0087-eval-measurements-online-trace-scoring.md)):** portable
> remote score export uses the same OTLP configuration as this ADR — no
> vendor score adapters in core.

**2026-08-18 — Remove duplicate token/cost from root span (3278b059):**
`gen_ai.request.model` and `gen_ai.usage.*` token attributes moved to agent
Expand Down
9 changes: 5 additions & 4 deletions docs/ADRs/0087-eval-measurements-online-trace-scoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ rewrite primary facts, and they are [fail-open](../glossary.md#fail-open).

Scores land in a tool-agnostic `eval-measurements.jsonl` (plus a
small idempotency ledger) next to `run-telemetry.jsonl` whenever at least
one new measurement row is produced (including `label: skip`). Remote score export
will use the same `OTEL_EXPORTER_OTLP_*` configuration as ADR 0050 — no
vendor-specific score adapters in core. `fullsend` owns the parser, scorers,
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,
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
CLI, and GHA step; `fullsend-ai/agents` owns per-agent measurement manifests
(`eval/measurements/<agent>.yaml`) that declare which scorers to enable.
Stock-agent defaults resolve from `agents@v0` at runtime; local files are for
Expand Down Expand Up @@ -136,7 +137,7 @@ Entirely new signal → new `em-NNN` (and usually a new `scorer` string).
fetch from public `agents@v0` even without `GH_TOKEN` (rate-limited); a
token is recommended on shared runners.
- Core stays tool-agnostic: no product-specific score env vars in managed
workflows; remote scores follow OTEL when that path lands.
workflows; remote scores follow the shared OTEL path.
- Functional scenarios (gate) and eval measurements (trend) stay separate;
retro can recommend either a manifest scorer or a scenario fixture.
- Level 1/2 metadata scorers (EM-001) are the foundation; Level 3 content
Expand Down
6 changes: 2 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,15 +321,13 @@ Observability is a cross-cutting concern that touches every other component. Eac
- JSONL reasoning trace exposure: raw JSONL conversation transcripts are extracted from sandboxes and stored with owner-scoped access. Credential scanning acts as an invariant check on [ADR 0017](ADRs/0017-credential-isolation-for-sandboxed-agents.md)'s isolation model. Agents handling data from protected sources beyond the target repo can opt in to JSONL suppression via configuration ([ADR 0021](ADRs/0021-jsonl-reasoning-trace-exposure.md)).
- Event-driven stage dispatch remains traceable end-to-end in the GitHub Actions UI by using synchronous `workflow_call` dispatch (see [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)).
- Distributed tracing: framework-native OpenTelemetry instrumentation with zero-configuration baseline. Every run produces `run-telemetry.jsonl` locally; optional live OTLP export to any compatible backend. W3C trace context propagation links multi-agent pipelines into unified traces. OTEL GenAI semantic conventions enable LLM-aware backends ([ADR 0050](ADRs/0050-distributed-tracing-instrumentation.md)).
- Eval measurements: the concept of scoring traces ([fail-open](glossary.md#fail-open)). [OTEL primary facts](glossary.md#otel-primary-facts) stay on the run trace (`run-telemetry.jsonl`); [OTEL derived products](glossary.md#otel-derived-products) are the scores (`eval-measurements.jsonl`) ([ADR 0087](ADRs/0087-eval-measurements-online-trace-scoring.md)). See [Eval Measurements](guides/infrastructure/eval-measurements.md).

> **Planned:** portable remote score export via the same OTLP configuration as agent traces ([ADR 0087](ADRs/0087-eval-measurements-online-trace-scoring.md)). Not yet implemented.
- Eval measurements: the concept of scoring traces ([fail-open](glossary.md#fail-open)). [OTEL primary facts](glossary.md#otel-primary-facts) stay on the run trace (`run-telemetry.jsonl`); [OTEL derived products](glossary.md#otel-derived-products) are the scores (`eval-measurements.jsonl`) ([ADR 0087](ADRs/0087-eval-measurements-online-trace-scoring.md)). See [Eval Measurements](guides/infrastructure/eval-measurements.md). When `OTEL_EXPORTER_OTLP_*` is set, scores also export as `gen_ai.evaluation.result` span events on the same TraceID (same OTLP path as agent traces; fail-open).

**Open questions:**

- What signals matter most — cost, latency, token usage, action logs, decision traces, or something else?
- ~~How do we balance detailed tracing (useful for debugging) with the volume of data agents will produce?~~ Decided in [ADR 0050](ADRs/0050-distributed-tracing-instrumentation.md): instrument all lifecycle steps comprehensively; volume is managed by backends not by suppressing data at the source.
- ~~How do we score wild agent traces for trends without a second export stack?~~ Decided in [ADR 0087](ADRs/0087-eval-measurements-online-trace-scoring.md): eval measurements write local JSONL beside telemetry when at least one new score row is produced (including `label: skip`); portable remote export uses the same OTLP config as traces (planned). The JSONL is absent (not empty) when telemetry/manifest is missing, no traces match, or every candidate is already in the ledger.
- ~~How do we score wild agent traces for trends without a second export stack?~~ Decided in [ADR 0087](ADRs/0087-eval-measurements-online-trace-scoring.md): eval measurements write local JSONL beside telemetry when at least one new score row is produced (including `label: skip`); portable remote export uses the same OTLP config as traces (`gen_ai.evaluation.result` events). The JSONL is absent (not empty) when telemetry/manifest is missing, no traces match, or every candidate is already in the ledger.
- What is the retention and access model for agent logs? Who can see what? (JSONL trace access model decided in [ADR 0021](ADRs/0021-jsonl-reasoning-trace-exposure.md); retention policy and broader log access remain open.)
- How does observability interact with the security requirement that "every action is logged, attributable, and reviewable"? (See [security-threat-model.md](problems/security-threat-model.md).)
- Is there a real-time monitoring requirement (agent is stuck, agent is behaving anomalously), or is observability primarily forensic?
Expand Down
6 changes: 3 additions & 3 deletions docs/guides/infrastructure/distributed-tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,9 @@ authentication mechanism.
After each managed agent run, `fullsend eval-measure` scores
`run-telemetry.jsonl` in the same job (fail-open). Scores land in
`eval-measurements.jsonl` beside telemetry when at least one new score is
produced (tool-agnostic artifact). Portable
remote export will reuse the same `OTEL_EXPORTER_OTLP_*` configuration as
agent traces when implemented.
produced (tool-agnostic artifact). When `OTEL_EXPORTER_OTLP_*` is set, those
scores also export as `gen_ai.evaluation.result` span events on the same
TraceID (fail-open; does not rewrite `run-telemetry.jsonl`).

Today's scorers (starting with EM-001) read the Level 1/2 **metadata**
contract of `run-telemetry.jsonl` — span tree and attributes, not prompt or
Expand Down
24 changes: 15 additions & 9 deletions docs/guides/infrastructure/eval-measurements.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ computed from that trace (`eval-measurements.jsonl`). The step is
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.
Comment on lines 28 to +31

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


OTLP (OpenTelemetry Protocol) is the wire format that carries spans and
scores to any compatible backend — Phoenix, MLflow, Jaeger, etc.
Expand All @@ -42,21 +42,22 @@ fullsend run
fullsend eval-measure (same GHA job, fail-open, after run)
└─ writes output/<runDir>/eval-measurements.jsonl when at least one
new score is produced (+ eval-measure-ledger.txt for idempotency)
└─ 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)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
```

> **Planned:** portable remote score export via the same `OTEL_EXPORTER_OTLP_*`
> path as agent traces. Not yet implemented.

| Artifact | When | Purpose |
|---|---|---|
| `run-telemetry.jsonl` | Every run | OTLP JSON TracesData lines (local source of truth for spans) |
| `eval-measurements.jsonl` | Every measured run | One JSON object per score (`name`, `label`, `value`, `explanation`, `trace_id`, …). On `label: skip`, `value` is unused (serialized as `0`; ignore it). |
| Remote agent spans | OTEL configured | Same spans the local file holds |
| Remote scores *(planned)* | OTEL configured | Scores on the OTLP path — any OTLP backend |
| Remote scores | OTEL configured | Child span `fullsend.eval_measure` + event `gen_ai.evaluation.result` (GenAI semconv) correlated by `trace_id` / parent `span_id` |

Orgs choose Phoenix, MLflow, Jaeger, or another collector independently.
Fullsend does not forward vendor-specific score credentials in managed
workflows.
workflows. Scores are not rewritten into `run-telemetry.jsonl` (derived
products must not mutate primary facts).

## Measurements vs functional evals

Expand Down Expand Up @@ -255,6 +256,11 @@ least one new measurement row is appended (including `label: skip`). No
file is written when telemetry/manifest is missing, no traces match, or
every candidate row is already in the ledger.

> **Planned:** portable OTLP score export (same `OTEL_*` as traces) is the
> ADR 0087 remote contract and is not wired yet. Until it lands, consume the
> JSONL artifact (or your own pipeline) for remote dashboards.
When `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`
is set, newly written scores also export as OTLP span events
(`fullsend.eval_measure` + `gen_ai.evaluation.result`) on the same
`trace_id`. Export is fail-open and does not rewrite `run-telemetry.jsonl`.
The idempotency ledger keys local rows; a remote OTLP failure after a
successful local write will not retry that row on the next run (remote is
best-effort once). Re-export offline by clearing the ledger or pointing at
a fresh out dir.
166 changes: 166 additions & 0 deletions hack/prove-otlp-scores/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Command prove-otlp-scores scores a real run-telemetry.jsonl and asserts
// portable OTLP gen_ai.evaluation.result events arrive at a local sink.
package main

import (
"bytes"
"compress/gzip"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"

coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1"
commonpb "go.opentelemetry.io/proto/otlp/common/v1"
"google.golang.org/protobuf/proto"

"github.com/fullsend-ai/fullsend/internal/evalmeasure"
)

func main() {
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "usage: %s <run-telemetry.jsonl> <registry.yaml> [out-dir]\n", os.Args[0])
os.Exit(2)
}
telem := os.Args[1]
reg := os.Args[2]
out := filepath.Dir(telem)
Comment thread
ascerra marked this conversation as resolved.
Outdated
if len(os.Args) > 3 {
out = os.Args[3]
}
_ = os.Remove(filepath.Join(out, evalmeasure.LedgerFile))
_ = os.Remove(filepath.Join(out, evalmeasure.MeasurementsFile))

var mu sync.Mutex
var reqs []*coltracepb.ExportTraceServiceRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if r.Header.Get("Content-Encoding") == "gzip" {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
raw, err = io.ReadAll(zr)
_ = zr.Close()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
var req coltracepb.ExportTraceServiceRequest
if err := proto.Unmarshal(raw, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
mu.Lock()
reqs = append(reqs, &req)
mu.Unlock()
resp, _ := proto.Marshal(&coltracepb.ExportTraceServiceResponse{})
w.Header().Set("Content-Type", "application/x-protobuf")
_, _ = w.Write(resp)
}))
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.

_ = os.Unsetenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
_ = os.Unsetenv("OTEL_SDK_DISABLED")

results, stats, err := evalmeasure.MeasureAndExport(context.Background(), telem, reg, out)
if err != nil {
fmt.Fprintf(os.Stderr, "measure failed: %v\n", err)
os.Exit(1)
}

events := extractEvents(reqs)
report := map[string]any{
"endpoint": srv.URL,
"scores_written": len(results),
"remote_export_warning": stats.RemoteExportWarning,
"results": results,
"otlp_requests": len(reqs),
"events": events,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(report)

if len(results) == 0 {
fmt.Fprintf(os.Stderr, "FAIL: no scores written\n")
os.Exit(1)
}
if len(reqs) == 0 {
fmt.Fprintf(os.Stderr, "FAIL: no OTLP requests received\n")
os.Exit(1)
}
if len(events) == 0 {
fmt.Fprintf(os.Stderr, "FAIL: no gen_ai.evaluation.result events\n")
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "PASS: %d score(s), %d OTLP event(s)\n", len(results), len(events))
}

type eventView struct {
SpanName string `json:"span_name"`
TraceID string `json:"trace_id"`
ParentID string `json:"parent_span_id"`
EventName string `json:"event_name"`
Attributes map[string]any `json:"attributes"`
}

func extractEvents(reqs []*coltracepb.ExportTraceServiceRequest) []eventView {
var out []eventView
for _, req := range reqs {
for _, rs := range req.GetResourceSpans() {
for _, ss := range rs.GetScopeSpans() {
for _, sp := range ss.GetSpans() {
for _, ev := range sp.GetEvents() {
if ev.GetName() != evalmeasure.EventGenAIEvaluationResult {
continue
}
attrs := map[string]any{}
for _, kv := range ev.GetAttributes() {
attrs[kv.GetKey()] = anyValue(kv.GetValue())
}
out = append(out, eventView{
SpanName: sp.GetName(),
TraceID: hex.EncodeToString(sp.GetTraceId()),
ParentID: hex.EncodeToString(sp.GetParentSpanId()),
EventName: ev.GetName(),
Attributes: attrs,
})
}
}
}
}
}
return out
}

func anyValue(v *commonpb.AnyValue) any {
if v == nil {
return nil
}
switch x := v.GetValue().(type) {
case *commonpb.AnyValue_StringValue:
return x.StringValue
case *commonpb.AnyValue_DoubleValue:
return x.DoubleValue
case *commonpb.AnyValue_IntValue:
return x.IntValue
case *commonpb.AnyValue_BoolValue:
return x.BoolValue
default:
return v.String()
}
}
12 changes: 10 additions & 2 deletions internal/cli/evalmeasure.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,19 @@ func newEvalMeasureCmd() *cobra.Command {
Long: `Parse run-telemetry.jsonl, score with an agents measurement manifest,
and write eval-measurements.jsonl beside the telemetry artifact.

When OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is
set (same env as agent traces, ADR 0050), newly written scores are also
exported as OTLP span events (gen_ai.evaluation.result) on the scored
trace. Export is fail-open: local JSONL always wins.

The binary resolves the manifest (local FULLSEND_DIR override, else a
SHA-pinned fetch from fullsend-ai/agents — same pin, allowlist, hash, and
audit as harness fallback). Platform telemetry is the file at the top of
each run directory; nested iteration-N/output/ copies are ignored.

Remote backends are not selected by fullsend: scores are a portable local
JSONL artifact. When portable OTLP score export lands, it will reuse the
same OTEL_EXPORTER_OTLP_* configuration as agent traces (ADR 0050 / 0087).
JSONL artifact plus optional OTLP on the shared OTEL_* path (ADR 0087).
No vendor score adapters (MLflow Assessments, Phoenix SDK, …) in core.

Exit 0 when scores fail — measurements are data, not gates. Non-zero only
on hard IO/parse errors. Missing telemetry or manifest is a skip (exit 0).`,
Expand Down Expand Up @@ -122,6 +127,9 @@ func runEvalMeasure(ctx context.Context, printer *ui.Printer, opts evalMeasureOp
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.

printer.StepWarn(fmt.Sprintf("%s: OTLP score export failed (local JSONL kept): %s", p, stats.RemoteExportWarning))
}
if err != nil {
return append(all, results...), false, err
}
Expand Down
Loading
Loading