Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 5 additions & 0 deletions docs/ADRs/0087-eval-measurements-online-trace-scoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ Until that release lands, GHA/GitLab `eval-measure` wiring is provisional
(clean skip when the remote manifest is missing). Local `FULLSEND_DIR`
manifests are exercised in unit tests today.

> **Implemented ([#6459](https://github.com/fullsend-ai/fullsend/pull/6459)):**
> portable remote score export now ships via the shared OTEL path as
> `gen_ai.evaluation.result` span events (fail-open). Decision text above
> is unchanged; this note records delivery only.

The first scorer is `trace_fitness` (catalog id `em-001`) — span-tree and
attribute fitness so later scorers can trust the trace. EM-001 reads
OpenTelemetry GenAI attribute names (`gen_ai.*` constants in
Expand Down
6 changes: 2 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,15 +352,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
37 changes: 27 additions & 10 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,25 @@ 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
(the W3C Trace ID shared with the agent run — fail-open;
local JSONL always wins)
```

> **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 evaluation event — semantic-conventions-genai](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/reference/reports/gen-ai-evaluation-result-event.md); low-stability / reference) correlated by TraceID / parent span ID |
Comment thread
ascerra marked this conversation as resolved.
Outdated

Orgs choose Phoenix, MLflow, Jaeger, or another collector independently.
Fullsend does not forward vendor-specific score credentials in managed
workflows.
Any OTLP backend can **correlate** scores to the agent run by TraceID.
Vendor score UIs (for example MLflow Assessments panels) may still need a
collector or side consumer that maps the evaluation event — fullsend does
not call those product APIs. 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 +259,19 @@ 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.

Managed measure assumes one platform `run-telemetry.jsonl` per runDir (each
`fullsend run` creates a unique `output/fs-<slug>-<hash>/`). If inbound
`TRACEPARENT` is present and unsampled, score export skips only rows whose
`trace_id` matches that parent TraceID (same orphan-avoidance rule as agent
`parentSampledProcessor`); other TraceIDs in the batch still export.
Cross-run cost/correlation rollup is out of scope here (see hierarchical
work-graph IDs).
2 changes: 1 addition & 1 deletion docs/problems/operational-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ This works for early experimentation when the volume is low and the operators ar
- How should trace access be controlled? (JSONL trace exposure decided in [ADR 0021](../ADRs/0021-jsonl-reasoning-trace-exposure.md): owner-scoped storage with credential scanning as defense-in-depth. Broader question of balancing security and transparency for non-JSONL observability data remains open.)
- What retention policy applies to traces? Indefinite retention supports audit requirements but increases storage cost and data sensitivity exposure. Time-bounded retention (e.g., 90 days) limits exposure but may lose traces needed for incident investigation.
- How do we measure "is the system getting better"? What metrics constitute a meaningful quality signal for an autonomous software factory? Merge revert rate? Human override rate? Time-to-review? Cost per decision? Some composite score? The choice of metric shapes what gets optimized. First-ship trend scores (trace fitness on wild runs, local `eval-measurements.jsonl`) are [ADR 0087](../ADRs/0087-eval-measurements-online-trace-scoring.md); richer quality signals remain open.
- At what scale does a dedicated LLM observability platform justify its operational overhead (Postgres, ClickHouse, Redis, S3 for something like Langfuse)? Is there a threshold of agent activity below which structured logging suffices? Score files are local JSONL ([ADR 0087](../ADRs/0087-eval-measurements-online-trace-scoring.md)); remote scores reuse `OTEL_EXPORTER_OTLP_*` when implemented. Platform choice remains open.
- At what scale does a dedicated LLM observability platform justify its operational overhead (Postgres, ClickHouse, Redis, S3 for something like Langfuse)? Is there a threshold of agent activity below which structured logging suffices? Score files are local JSONL ([ADR 0087](../ADRs/0087-eval-measurements-online-trace-scoring.md)); remote scores reuse `OTEL_EXPORTER_OTLP_*`. Platform choice remains open.
- ~~How do we handle the bootstrapping problem — the factory needs observability to improve, but building the observability infrastructure is itself work that competes with building the factory?~~ Decided in [ADR 0050](../ADRs/0050-distributed-tracing-instrumentation.md): zero-configuration baseline (local JSONL + summary files) eliminates infrastructure requirements for initial observability; OTLP export adds backends when the org is ready.
- Should observability data feed back into agent instructions automatically (e.g., auto-adjusting prompts when false positive rates exceed a threshold), or should it only inform human-driven instruction changes? Automatic feedback creates the risk of instruction oscillation; human-only feedback is slower but more controlled.
- How do we build community dashboards that are useful to contributors with different levels of technical depth — from "is the agent doing a good job on my repo" to "show me the trace of this specific review"?
Expand Down
192 changes: 192 additions & 0 deletions hack/prove-otlp-scores/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// 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])
fmt.Fprintf(os.Stderr, " out-dir defaults to a fresh temp dir (never the telemetry file's directory).\n")
os.Exit(2)
}
telem := os.Args[1]
reg := os.Args[2]
out := ""
if len(os.Args) > 3 {
out = os.Args[3]
} else {
tmp, err := os.MkdirTemp("", "prove-otlp-scores-*")
if err != nil {
fmt.Fprintf(os.Stderr, "temp out-dir: %v\n", err)
os.Exit(1)
}
out = tmp
fmt.Fprintf(os.Stderr, "using temp out-dir %s\n", out)
}
if err := os.MkdirAll(out, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "out-dir: %v\n", err)
os.Exit(1)
}
// Only wipe ledger/measurements inside the chosen out-dir (temp by
// default), never beside a live run-telemetry.jsonl by accident.
_ = 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")
// Clear ambient W3C parents so an unsampled TRACEPARENT from a prior
// fullsend run in this shell cannot suppress every score and make the
// prove tool report a false FAIL.
_ = os.Unsetenv("TRACEPARENT")
_ = os.Unsetenv("TRACESTATE")

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

mu.Lock()
reqsCopy := append([]*coltracepb.ExportTraceServiceRequest(nil), reqs...)
nReqs := len(reqs)
mu.Unlock()

events := extractEvents(reqsCopy)
report := map[string]any{
"endpoint": srv.URL,
"out_dir": out,
"scores_written": len(results),
"remote_export_warning": stats.RemoteExportWarning,
"results": results,
"otlp_requests": nReqs,
"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 nReqs == 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()
}
}
Loading
Loading