feat: export gateway and agent conversation waterfalls to Langfuse (#581)

This commit is contained in:
mofelee authored and GitHub committed 2026-10-03 01:43:32 +08:00
1 parent 24bd8aa1ae
commit 38b4a9d984
30 files changed
+3987 -30

No files matched your search

+3 -1
View File
@@ -51,7 +51,9 @@ jobs:
# first-use races, so it must include the race detector.
- name: Race
if: runner.os != 'Windows'
run: go test -race -tags "${{ steps.tags.outputs.tags }}" ./internal/gateway/ ./internal/redact/
# The full gateway suite can exceed Go's default 10-minute package
# timeout under the race detector on hosted macOS runners.
run: go test -race -timeout 20m -tags "${{ steps.tags.outputs.tags }}" ./internal/gateway/ ./internal/redact/
# Account usage mixes cached results with concurrent fetches.
- name: Race account usage
+85 -7
View File
@@ -935,6 +935,8 @@ MAGPIE_OTEL_ENABLED=true MAGPIE_OTEL_ENDPOINT=http://localhost:4318 magpie serve
- `MAGPIE_OTEL_HEADERS`: comma-separated `name=value` pairs, for example
`Authorization=Bearer%20token`. Percent-encode spaces and commas in values.
- `MAGPIE_OTEL_METRICS`: `true` or `false`, off by default.
- `MAGPIE_OTEL_SESSIONS`: `true` or `false`, off by default; trace supported local
agent interactions from newly recorded session events.
- `MAGPIE_OTEL_BODIES`: `true` or `false`, off by default; sends each call's
request and reply as the trace's Langfuse input and output.
- `MAGPIE_OTEL_BODIES_WHOLE`: `true` or `false`, off by default; with bodies on,
@@ -943,16 +945,92 @@ MAGPIE_OTEL_ENABLED=true MAGPIE_OTEL_ENDPOINT=http://localhost:4318 magpie serve
For Langfuse, use `https://<your-langfuse-host>/api/public/otel` as the base
URL and `Authorization=Basic%20<base64(public-key:secret-key)>` as the header.
Leave metrics off. This uses Langfuse's OTLP ingestion endpoint.
Leave metrics off. This uses Langfuse's OTLP ingestion endpoint. For Langfuse
v4, add the header `x-langfuse-ingestion-version=4` for real-time ingestion.
Traces include agent, provider, model, token counts (including cache and
reasoning), HTTP status, timing, and route ID. Attempts with the same route ID
share a trace ID. Metrics group duration and input/output token histograms by
agent, provider, model, operation and error status. Prompt/reply text, tool
arguments, sessions and provider account names/keys are never exported —
unless **Include request and response bodies** is on, which sends each call's
request and reply (secrets masked) as the trace's input and output, whole when
**Include the whole bodies** is on too.
share a trace ID. Chat, Responses, Anthropic and Gemini gateway requests
also export a parent request span and child spans for every routing attempt,
including unbilled failures, retries and fallbacks, so Langfuse can render a
waterfall. A valid incoming W3C version-00 `traceparent` connects these spans
to the caller's trace. Attempt spans carry token usage; the parent does not
duplicate it. Attempt timings include any wait for a concurrency slot, while
routing and retry delays remain visible as gaps inside the parent span.
Tool execution inside the caller is outside the gateway's trace.
Enable **Trace agent conversations** to instead export one trace per
user interaction, grouping model calls and tool executions under an agent
root. Conversation IDs group those traces into Langfuse sessions. Gateway traces
remain available until a visible local store produces recent observations for
that exact agent/session, and resume if those observations become stale (five minutes).
Only loopback requests without a gateway key can be deduplicated. The native
client session ID takes precedence over a Magpie routing override; unknown
sessions retain gateway traces, including WSL mirrored and Docker Desktop
clients whose sessions are not visible locally. Requests without a session ID
fall back to recent observations for that agent, so such clients cannot be
reliably distinguished on loopback. Keyed and non-loopback clients always
retain gateway traces. Magpie reads
new events from local session stores every two seconds; it does not upload
completed history when enabled. Restarting or changing the destination starts
an observation window. Span IDs remain stable across repeated records.
Pi and Codex have been tested end to end with real clients. The other adapters
are covered by format fixtures but have **not been tested end to end**.
Supported clients and formats:
| Client | Local store | Timing |
| --- | --- | --- |
| Codex | JSONL rollouts (`token_usage_record`, `item_completed`, `response_item`) | Recorded operations and paired native tools; inferred model/native-tool intervals |
| Pi | Version-3 JSONL; optional `timing-final` | Recorded model times; inferred tool intervals |
| Oh My Pi | Pi-compatible JSONL, including `model_usage` | Pi timing; auxiliary calls have inferred zero duration |
| Claude Code / Cowork | `projects/*/*.jsonl`, repeated assistant blocks and paired tool results | Inferred model/tool starts; recorded transcript boundaries |
| OpenCode | SQLite V1/V2 (`message`/`part` or `session_message`); legacy JSON storage | Recorded model and tool timestamps |
| Gemini CLI | `~/.gemini/tmp/*/chats/session-*.json[l]`, including patches and rewinds | Inferred intervals from message/tool event boundaries |
The adapters follow upstream schemas:
[OpenCode V1](https://github.com/anomalyco/opencode/blob/dev/packages/schema/src/v1/session.ts),
[OpenCode V2](https://github.com/anomalyco/opencode/blob/dev/packages/schema/src/session-message.ts),
[Gemini CLI](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/services/chatRecordingTypes.ts),
[Oh My Pi](https://github.com/can1357/oh-my-pi/blob/main/packages/coding-agent/src/session/session-manager.ts).
Claude transcript records are checked against local Claude Code sessions;
its [hook documentation](https://code.claude.com/docs/en/hooks) describes
transcript locations. Separate subagent transcripts are not joined: the parent
agent's task/delegation tool is included, but its children's internal work is
not yet linked. No hooks or client changes are required.
Polling uses a soft 8 MiB reading budget and up to 200 recent files per store.
JSONL files are read incrementally; incomplete lines wait for the next poll.
OpenCode SQLite is opened read-only, including WAL changes, and unchanged
stores are skipped. SQLite message/part updates are read incrementally in bounded
batches on separate read-only connections; oversized conversations resume on the
next poll. OpenCode retains up to 256 messages and an 8 MiB body window per
session; individual rows above 8 MiB export metadata only. Legacy JSON and
Gemini snapshot reads cap at 32 MiB; Gemini retains the current prompt plus
255 recent messages.
Completed observations are deduplicated with bounded metadata. Claude's final
response and Gemini's final token records may take one extra poll to settle.
Inferred timings carry `magpie.timing.source=inferred`. Tool arguments/results
and user input/output follow **Include request and response bodies**, masking
secrets and respecting the whole-body preference. Session directory/title
metadata, credentials and account names are not exported.
Conversation tracing replaces standalone gateway traces for these local
clients to avoid duplicate token usage. Unsupported clients and remote
forwarded requests retain gateway tracing; the local usage ledger is unchanged.
This mode shows agent activity rather than gateway retries or provider-routing
details. It reads this computer's local sessions, including calls made without
Magpie as the proxy.
Metrics group duration and input/output token histograms by agent, provider,
model, operation and error status. Prompt/reply text is exported only when
**Include request and response bodies** is enabled (`MAGPIE_OTEL_BODIES=true`);
secrets are masked and each body is limited to 256 KiB unless **Include the
whole bodies** is enabled. Conversation IDs are exported only with
conversation tracing; provider account names/keys are never exported.
Whole bodies increase transient memory and allocation costs during read-back,
secret scrubbing and JSON encoding; a 32 MiB request and reply can roughly
double total allocations compared with truncated export. The export limits
+2
View File
@@ -30,6 +30,8 @@ package agentenv
var Vars = []string{
// Claude Code, Codex and Copilot CLI
"CLAUDE_CONFIG_DIR", "CODEX_HOME", "COPILOT_HOME",
// Gemini CLI's session/config home
"GEMINI_CLI_HOME",
// Cline: its folder, its data, its sessions and its MCP settings file
"CLINE_DIR", "CLINE_DATA_DIR", "CLINE_SESSION_DATA_DIR", "CLINE_MCP_SETTINGS_PATH",
// Pi and its forks (OmO, Senpi), whose profile and config are apart
+7
View File
@@ -957,6 +957,8 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
call := Call{Time: start, From: from, Model: unprefixed(modelOf(body)), Agent: who.agent, Via: who.via, Kind: requestCallKind(r.Header, metadata),
RequestBody: requestBody, RequestTruncated: requestTruncated, otelIn: otelIn, wire: archiving(r, capture, start, body)}
defer discardArchive(capture)
r, telemetry := beginOTelRequest(r)
defer func() { telemetry.end(call, time.Since(start).Milliseconds()) }()
if call.Kind == "web_search" {
call.For = searchFor(r.Context())
}
@@ -1240,6 +1242,9 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
shown = nil // nobody else to stay away from
}
tr := s.trace.begin(Route{Pinned: pin, Time: start, Agent: call.Agent, Session: sessionOf(r.Header), ParentSession: titleParentSession(r.Header, metadata, call.Kind), Kind: call.Kind, For: call.For, Model: call.Model, Effort: requestEffort(from, body), Provider: p.ID, Group: group, Rule: hit, Nested: nested, Affinity: shown, Order: pl.order, Left: pl.left})
if telemetry != nil {
telemetry.routeID = tr.ID
}
var skipped []string
sent := "" // the reasoning the last try's model was asked for
where := "" // the last try's provider.Where, for the usage
@@ -1388,9 +1393,11 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// the agent went away: nobody failed, and nobody else is asked
call.Status, call.Error = 499, "the agent canceled the request"
try.Status, try.Error, try.Fail = call.Status, call.Error, failCanceled
telemetry.attempt(call, try, c.p.ID, sent, hw, capture)
s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try })
break
}
telemetry.attempt(call, try, c.p.ID, sent, hw, capture)
if resealed < 2 && from == provider.Responses && !hw.passing && hw.code() >= 400 && foreignReasoning.Match(hw.errBody()) {
// the conversation moved here from another account or vendor,
// whose sealed reasoning this one can't read: asked again
+4
View File
@@ -258,6 +258,10 @@ func accountOf(p provider.Provider) string {
func appendUsage(r *http.Request, rec usage.Record) {
who := access.Caller(r.Context())
rec.CallerKeyID, rec.CallerKeyName = who.KeyID, who.KeyName
rec.Local = local(r)
if r.Context().Value(otelRequestKey{}) != nil {
rec.SkipOTel = true
}
budget.Append(rec)
}
+115
View File
@@ -0,0 +1,115 @@
package gateway
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/usage"
)
type otelRequestKey struct{}
type otelRequest struct {
root usage.OTelSpan
routeID int64
}
func otelID(size int) string {
b := make([]byte, size)
rand.Read(b)
return hex.EncodeToString(b)
}
// Accept W3C version 00 only; malformed or zero IDs start a fresh trace.
// Context is used locally and never forwarded with provider credentials.
func otelParent(value string) (traceID, parentID string) {
parts := strings.Split(value, "-")
if len(parts) != 4 || parts[0] != "00" || len(parts[1]) != 32 || len(parts[2]) != 16 || len(parts[3]) != 2 {
return "", ""
}
for _, part := range parts[1:] {
if strings.ToLower(part) != part {
return "", ""
}
if _, err := hex.DecodeString(part); err != nil {
return "", ""
}
}
if parts[1] == strings.Repeat("0", 32) || parts[2] == strings.Repeat("0", 16) {
return "", ""
}
return parts[1], parts[2]
}
// Prefer the native client session: routing overrides need not name its store.
func otelSessionOf(h http.Header) string {
if id := nativeSessionOf(h); id != "" {
return id
}
return sessionOf(h)
}
func beginOTelRequest(r *http.Request) (*http.Request, *otelRequest) {
if !usage.OTelEnabled() || local(r) && access.Caller(r.Context()).KeyID == "" && callerOf(r).via == "" && usage.OTelSession(agentOf(r), otelSessionOf(r.Header)) {
return r, nil
}
traceID, parentID := otelParent(r.Header.Get("traceparent"))
if traceID == "" {
traceID = otelID(16)
}
t := &otelRequest{root: usage.OTelSpan{TraceID: traceID, SpanID: otelID(8), ParentID: parentID, Root: true}}
return r.WithContext(context.WithValue(r.Context(), otelRequestKey{}, t)), t
}
func (t *otelRequest) end(call Call, millis int64) {
if t == nil {
return
}
t.root.End = time.Now()
usage.ExportOTel(usage.Record{OTel: &t.root, RouteID: t.routeID, Time: call.Time, Agent: call.Agent, Model: call.Model, Status: call.Status, Millis: millis})
}
// Called for every routing attempt, including unbilled failures and retries.
// Its timing is the attempt's, not the whole request's ledger duration.
func (t *otelRequest) attempt(call Call, attempt Try, providerID, effort string, held *holdWriter, capture *captureResponseWriter) {
if t == nil {
return
}
span := &usage.OTelSpan{TraceID: t.root.TraceID, SpanID: otelID(8), ParentID: t.root.SpanID, End: time.Now()}
u := call.Usage
rec := usage.Record{OTel: span, RouteID: t.routeID, Time: attempt.Start, Agent: call.Agent,
Provider: providerID, Model: attempt.Model, Requested: call.Model, Served: u.Served, Effort: effort,
Input: u.Input, Output: u.Output, CacheRead: u.CacheRead, CacheWrite: u.CacheWrite, Reasoning: u.Reasoning,
Millis: time.Since(attempt.Start).Milliseconds(), Status: attempt.Status, TTFT: attempt.TTFT, FirstText: attempt.FirstText}
if usage.OTelBodies() {
reply, cut := capture.body.text(), capture.body.truncated
if !held.passing {
data := held.held.Bytes()
cut = len(data) > callBodyLimit
reply = string(data[:min(len(data), callBodyLimit)])
}
// Apply the same capture limit and masking to held responses.
var body capturedBody
body.add([]byte(reply))
bodies := call
bodies.ResponseBody, bodies.ResponseTruncated = body.text(), cut || body.truncated
if usage.OTelWhole() {
if !held.passing {
bodies.otelOut = held.held.Bytes()
bodies.otelOutCut = false
} else if capture.otel != nil {
bodies.otelOutCut = capture.otel.cut()
if data, err := capture.otel.read(); err == nil {
bodies.otelOut = data
}
}
}
withBodies(&rec, &bodies)
}
usage.ExportOTel(rec)
}
+360 -5
View File
@@ -1,19 +1,141 @@
package gateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/access"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
func TestOTelSessionDedupOnlyUnkeyedLoopback(t *testing.T) {
f := &fake{ctype: "application/json", reply: `{"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}`}
setup(t, provider.Chat, f)
root := t.TempDir()
t.Setenv("PI_CODING_AGENT_DIR", root)
t.Setenv("PI_CODING_AGENT_SESSION_DIR", "")
dir := filepath.Join(root, "sessions", "work")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "2026-10-03T00-00-00-000Z_session.jsonl")
if err := os.WriteFile(path, []byte("{\"type\":\"session\",\"id\":\"session\"}\n"), 0600); err != nil {
t.Fatal(err)
}
received := make(chan []byte, 32)
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
received <- b
io.WriteString(w, `{}`)
}))
defer collector.Close()
t.Setenv("MAGPIE_OTEL_ENABLED", "true")
t.Setenv("MAGPIE_OTEL_ENDPOINT", collector.URL)
t.Setenv("MAGPIE_OTEL_HEADERS", "")
t.Setenv("MAGPIE_OTEL_METRICS", "false")
t.Setenv("MAGPIE_OTEL_SESSIONS", "true")
stop := usage.StartOTel()
t.Cleanup(stop)
b, _ := json.Marshal(map[string]any{"type": "message", "id": "user", "timestamp": time.Now(), "message": map[string]any{"role": "user", "content": "private"}})
h, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
if _, err = h.Write(append(b, '\n')); err != nil {
t.Fatal(err)
}
h.Close()
deadline := time.Now().Add(6 * time.Second)
for !usage.OTelSession("pi", "session") && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !usage.OTelSession("pi", "session") {
t.Fatal("local Pi reader unavailable")
}
cases := []struct {
name, addr, key string
session, override string
dedup bool
}{
{"loopback", "127.0.0.1:1234", "", "", "", true},
{"IPv6 loopback", "[::1]:1234", "", "", "", true},
{"LAN", "192.168.1.5:1234", "key", "", "", false},
{"Docker", "172.17.0.2:1234", "key", "", "", false},
{"WSL", "172.22.0.2:1234", "key", "", "", false},
{"keyed loopback", "127.0.0.1:1234", "key", "", "", false},
{"unkeyed remote", "192.168.1.5:1234", "", "", "", false},
{"visible native session", "127.0.0.1:1234", "", "session", "", true},
{"WSL mirrored unseen session", "127.0.0.1:1234", "", "wsl-session", "", false},
{"Docker Desktop unseen session", "127.0.0.1:1234", "", "docker-session", "", false},
{"visible native overrides routing", "127.0.0.1:1234", "", "session", "routing", true},
{"unseen native overrides visible routing", "127.0.0.1:1234", "", "wsl-session", "session", false},
{"visible Magpie session only", "127.0.0.1:1234", "", "", "session", true},
{"unseen Magpie session only", "127.0.0.1:1234", "", "", "wsl-session", false},
{"keyed visible session", "127.0.0.1:1234", "key", "session", "", false},
}
for i, tc := range cases {
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"m1","messages":[{"role":"user","content":"hello"}]}`))
req.RemoteAddr = tc.addr
req.Header.Set("User-Agent", "pi/1.0")
req.Header.Set("X-Session-Id", tc.session)
req.Header.Set(SessionHeader, tc.override)
req.Header.Set("traceparent", fmt.Sprintf("00-%032x-%016x-01", i+1, i+1))
if tc.key != "" {
req = req.WithContext(access.WithIdentity(req.Context(), access.Identity{KeyID: tc.key}))
}
w := httptest.NewRecorder()
New().Handler().ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("%s: %d %s", tc.name, w.Code, w.Body.String())
}
}
stop()
counts := map[string]int{}
for len(received) > 0 {
var wire struct {
ResourceSpans []struct {
ScopeSpans []struct {
Spans []struct {
TraceID string `json:"traceId"`
}
}
}
}
if err := json.Unmarshal(<-received, &wire); err != nil {
t.Fatal(err)
}
for _, rs := range wire.ResourceSpans {
for _, ss := range rs.ScopeSpans {
for _, s := range ss.Spans {
counts[s.TraceID]++
}
}
}
}
for i, tc := range cases {
want := 2
if tc.dedup {
want = 0
}
if got := counts[fmt.Sprintf("%032x", i+1)]; got != want {
t.Fatalf("%s: spans=%d want=%d", tc.name, got, want)
}
}
if records := usage.Load(time.Time{}); len(records) != len(cases) {
t.Fatalf("ledger lost requests: %d", len(records))
}
}
func TestOTelExportsGatewayUsageWithoutContent(t *testing.T) {
f := &fake{ctype: "application/json", reply: `{"id":"c1","model":"m1","choices":[{"message":{"role":"assistant","content":"PRIVATE-REPLY"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`}
setup(t, provider.Chat, f)
@@ -131,6 +253,150 @@ func TestOTelExportsBodiesWhenOn(t *testing.T) {
}
}
func TestOTelWaterfallIncludesUnbilledFallback(t *testing.T) {
plan := &fake{ctype: "application/json", code: 429, reply: `{"error":{"message":"PRIVATE-ERROR"}}`}
spare := &fake{ctype: "application/json", reply: `{"id":"ok","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}`}
twoProviders(t, plan, spare)
received := make(chan []byte, 4)
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
received <- b
io.WriteString(w, `{}`)
}))
defer collector.Close()
t.Setenv("MAGPIE_OTEL_ENABLED", "true")
t.Setenv("MAGPIE_OTEL_ENDPOINT", collector.URL)
t.Setenv("MAGPIE_OTEL_HEADERS", "")
t.Setenv("MAGPIE_OTEL_METRICS", "true")
t.Setenv("MAGPIE_OTEL_BODIES", "false")
stop := usage.StartOTel()
t.Cleanup(stop)
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq))
traceID, parentID := strings.Repeat("a", 32), strings.Repeat("b", 16)
req.Header.Set("traceparent", "00-"+traceID+"-"+parentID+"-01")
response := httptest.NewRecorder()
New().Handler().ServeHTTP(response, req)
if response.Code != 200 {
t.Fatalf("gateway: %d %s", response.Code, response.Body.String())
}
stop()
exported := <-received
if strings.Contains(string(exported), "PRIVATE-") {
t.Fatalf("private data exported: %s", exported)
}
var wire struct {
ResourceSpans []struct {
ScopeSpans []struct {
Spans []struct {
TraceID string `json:"traceId"`
SpanID string `json:"spanId"`
ParentID string `json:"parentSpanId"`
Start string `json:"startTimeUnixNano"`
End string `json:"endTimeUnixNano"`
Attributes []struct {
Key string
Value map[string]string
}
Status struct{ Code int }
}
}
}
}
if err := json.Unmarshal(exported, &wire); err != nil {
t.Fatal(err)
}
spans := wire.ResourceSpans[0].ScopeSpans[0].Spans
if len(spans) != 3 {
t.Fatalf("want two attempts and a root: %s", exported)
}
root := spans[2]
if root.TraceID != traceID || root.ParentID != parentID || root.Status.Code != 0 {
t.Fatalf("root: %+v", root)
}
for i, child := range spans[:2] {
if child.TraceID != traceID || child.ParentID != root.SpanID || child.SpanID == root.SpanID || child.Start < root.Start || child.End > root.End {
t.Fatalf("child %d: %+v, root %+v", i, child, root)
}
}
if spans[0].Status.Code != 2 || spans[1].Status.Code != 0 {
t.Fatalf("attempt statuses: %+v", spans)
}
for _, a := range root.Attributes {
if strings.HasPrefix(a.Key, "gen_ai.usage.") {
t.Fatalf("root duplicates usage: %+v", a)
}
}
// Accounting remains one successful call; failed attempts are telemetry only.
if records := usage.Load(time.Time{}); len(records) != 1 || records[0].Input != 10 {
t.Fatalf("ledger: %+v", records)
}
// Metric histograms count two attempts, not the request parent.
metrics := string(<-received)
if strings.Contains(metrics, `"stringValue":""`) {
t.Fatalf("root included in metrics: %s", metrics)
}
}
func TestOTelParentValidation(t *testing.T) {
valid := "00-" + strings.Repeat("a", 32) + "-" + strings.Repeat("b", 16) + "-01"
for _, value := range []string{valid, "", strings.Replace(valid, "00-", "ff-", 1), strings.ToUpper(valid), strings.Replace(valid, strings.Repeat("a", 32), strings.Repeat("0", 32), 1), valid + "-extra", valid[:len(valid)-1] + "z"} {
trace, parent := otelParent(value)
if value == valid {
if trace == "" || parent == "" {
t.Fatal("valid parent rejected")
}
} else if trace != "" || parent != "" {
t.Fatalf("accepted malformed parent: %q", value)
}
}
}
func TestOTelWaterfallFailureAndCancellation(t *testing.T) {
for _, canceled := range []bool{false, true} {
name := "failure"
if canceled {
name = "canceled"
}
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f := &fake{ctype: "application/json", code: 400, reply: `{"error":{"message":"invalid request"}}`}
if canceled {
f.refuse = func([]byte) (int, string) { cancel(); return 400, f.reply }
}
setup(t, provider.Chat, f)
received := make(chan []byte, 4)
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
received <- b
io.WriteString(w, `{}`)
}))
defer collector.Close()
t.Setenv("MAGPIE_OTEL_ENABLED", "true")
t.Setenv("MAGPIE_OTEL_ENDPOINT", collector.URL)
t.Setenv("MAGPIE_OTEL_HEADERS", "")
t.Setenv("MAGPIE_OTEL_METRICS", "false")
t.Setenv("MAGPIE_OTEL_BODIES", "false")
stop := usage.StartOTel()
t.Cleanup(stop)
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"m1","messages":[{"role":"user","content":"hi"}]}`)).WithContext(ctx)
New().Handler().ServeHTTP(httptest.NewRecorder(), req)
stop()
select {
case b := <-received:
if strings.Count(string(b), `"code":2`) != 2 || strings.Count(string(b), `"parentSpanId"`) != 1 {
t.Fatalf("expected failed child and root: %s", b)
}
if canceled && !strings.Contains(string(b), `"intValue":"499"`) {
t.Fatalf("cancellation status missing: %s", b)
}
case <-time.After(time.Second):
t.Fatal("no failure trace")
}
})
}
}
// with bodies whole (#538) the export carries the request and reply entire,
// not the 256 KiB the Recent-calls page keeps, and no cut mark; with it off
// the first 256 KiB of each goes, cut, as before
@@ -139,15 +405,22 @@ func TestOTelExportsWholeBodiesWhenOn(t *testing.T) {
reply := `{"id":"c1","model":"m1","choices":[{"message":{"role":"assistant","content":"` + big + `"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`
request := `{"model":"m1","messages":[{"role":"user","content":"` + big + `"}]}`
for _, c := range []struct {
name string
whole bool
cut bool
name string
whole bool
cut bool
stream bool
}{
{"cut", false, true},
{"whole", true, false},
{"cut", false, true, false},
{"whole", true, false, false},
{"cut streamed", false, true, true},
{"whole streamed", true, false, true},
} {
t.Run(c.name, func(t *testing.T) {
f := &fake{ctype: "application/json", reply: reply}
if c.stream {
f.ctype = "text/event-stream"
f.reply = "data: {\"choices\":[{\"delta\":{\"content\":\"" + big + "\"}}]}\n\ndata: [DONE]\n\n"
}
setup(t, provider.Chat, f)
received := make(chan string, 1)
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -229,3 +502,85 @@ func otelSpanAttrs(t *testing.T, exported string) map[string]string {
}
return attrs
}
// Each attempt keeps its own complete body across fallback.
func TestOTelWholeWaterfallFallback(t *testing.T) {
big := strings.Repeat("A", 300<<10)
secret := "sk-ant-api03-" + strings.Repeat("Q7x", 30)
plan := &fake{ctype: "application/json", code: 429, reply: `{"error":{"message":"` + big + `"}}`}
spare := &fake{ctype: "application/json", reply: `{"choices":[{"message":{"content":"` + big + `"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`}
twoProviders(t, plan, spare)
received := make(chan string, 4)
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
received <- string(b)
io.WriteString(w, `{}`)
}))
defer collector.Close()
t.Setenv("MAGPIE_OTEL_ENABLED", "true")
t.Setenv("MAGPIE_OTEL_ENDPOINT", collector.URL)
t.Setenv("MAGPIE_OTEL_HEADERS", "")
t.Setenv("MAGPIE_OTEL_METRICS", "false")
t.Setenv("MAGPIE_OTEL_BODIES", "true")
t.Setenv("MAGPIE_OTEL_BODIES_WHOLE", "true")
stop := usage.StartOTel()
t.Cleanup(stop)
request := `{"model":"plan/m1","messages":[{"role":"user","content":"` + big + `"}],"metadata":{"api_key":"` + secret + `"}}`
if code, _ := post(t, "/v1/chat/completions", request); code != 200 {
t.Fatalf("gateway: %d", code)
}
stop()
exported := ""
for len(received) > 0 {
exported += <-received + "\n"
}
if strings.Contains(exported, secret) {
t.Fatal("secret after capture limit leaked")
}
var wire struct {
ResourceSpans []struct {
ScopeSpans []struct {
Spans []struct {
ParentID string `json:"parentSpanId"`
SpanID string `json:"spanId"`
Attributes []struct {
Key string
Value map[string]string
}
}
}
}
}
var spans []struct {
ParentID string `json:"parentSpanId"`
SpanID string `json:"spanId"`
Attributes []struct {
Key string
Value map[string]string
}
}
for _, batch := range strings.Split(strings.TrimSpace(exported), "\n") {
if err := json.Unmarshal([]byte(batch), &wire); err != nil {
t.Fatal(err)
}
spans = append(spans, wire.ResourceSpans[0].ScopeSpans[0].Spans...)
}
if len(spans) != 3 {
t.Fatalf("spans: %d", len(spans))
}
for i, span := range spans[:2] {
attrs := map[string]string{}
for _, a := range span.Attributes {
attrs[a.Key] = a.Value["stringValue"]
}
for _, key := range []string{"langfuse.observation.input", "langfuse.observation.output"} {
value := attrs[key]
if !strings.Contains(value, big) || strings.Contains(value, usage.BodyCut) {
t.Fatalf("attempt %d %s incomplete (%d bytes)", i, key, len(value))
}
}
if span.ParentID != spans[2].SpanID {
t.Fatal("wrong waterfall parent")
}
}
}
+1 -1
View File
@@ -329,7 +329,7 @@ func settingsState() settingsJSON {
s.NotifyProblem = notifyProblem()
}
s.ProxyNow, s.ProxySource = netproxy.Describe()
for _, name := range []string{"MAGPIE_OTEL_ENABLED", "MAGPIE_OTEL_ENDPOINT", "MAGPIE_OTEL_HEADERS", "MAGPIE_OTEL_METRICS", "MAGPIE_OTEL_BODIES", "MAGPIE_OTEL_BODIES_WHOLE"} {
for _, name := range []string{"MAGPIE_OTEL_ENABLED", "MAGPIE_OTEL_ENDPOINT", "MAGPIE_OTEL_HEADERS", "MAGPIE_OTEL_METRICS", "MAGPIE_OTEL_BODIES", "MAGPIE_OTEL_BODIES_WHOLE", "MAGPIE_OTEL_SESSIONS"} {
if _, ok := os.LookupEnv(name); ok {
s.OTelEnv = true
}
+2
View File
@@ -13078,6 +13078,8 @@ function renderOTel(s, keep) {
row("otelHeadersRow", "OTLP headers", "Comma-separated name=value pairs; percent-encode spaces and commas in values", headers);
row("otelMetricsRow", "Export metrics", "Also send duration and token histograms. Leave off for a traces-only service such as Langfuse",
segs([["off", t("Off")], ["on", t("On")]], config.metrics ? "on" : "off", (v) => save({ metrics: v === "on" })));
row("otelSessionsRow", "Trace agent conversations", "Reads all local sessions from Codex, Pi, Oh My Pi, Claude Code, Cowork, OpenCode and Gemini CLI, including calls not routed through Magpie. With “Include request and response bodies” on, exports prompts, replies, tool arguments and tool output (including file contents and command output), with secrets masked",
segs([["off", t("Off")], ["on", t("On")]], config.sessions ? "on" : "off", (v) => save({ sessions: v === "on" })));
row("otelBodiesRow", "Include request and response bodies", "Attach each call's request and reply to its trace, as Langfuse's input and output. Secrets are masked and each body is cut at 256 KB",
segs([["off", t("Off")], ["on", t("On")]], config.bodies ? "on" : "off", (v) => save({ bodies: v === "on" })));
if (config.bodies) row("otelWholeRow", "Include the whole bodies", "Keep each request and reply entire, not cut at 256 KB. A long reply is written to a temporary file, and a very large body may still be refused by the collector",
+2
View File
@@ -215,6 +215,8 @@ const I18N = {
"Use comma-separated name=value headers": "请使用以逗号分隔的 name=value 请求头",
"Export metrics": "导出指标",
"Also send duration and token histograms. Leave off for a traces-only service such as Langfuse": "同时发送耗时和 Token 直方图;Langfuse 等仅接收链路的服务请保持关闭",
"Trace agent conversations": "追踪代理对话",
"Reads all local sessions from Codex, Pi, Oh My Pi, Claude Code, Cowork, OpenCode and Gemini CLI, including calls not routed through Magpie. With “Include request and response bodies” on, exports prompts, replies, tool arguments and tool output (including file contents and command output), with secrets masked": "读取 Codex、Pi、Oh My Pi、Claude Code、Cowork、OpenCode 和 Gemini CLI 的所有本地会话,包括未通过 Magpie 的调用。开启「包含请求和响应内容」后,会导出提示词、回复、工具参数和工具输出(包括文件内容和命令输出),并遮蔽敏感信息",
"Include request and response bodies": "包含请求和响应内容",
"Attach each call's request and reply to its trace, as Langfuse's input and output. Secrets are masked and each body is cut at 256 KB": "把每次调用的请求和回复附到链路上,作为 Langfuse 的 Input 和 Output;密钥会脱敏,每份内容最多 256 KB",
"Include the whole bodies": "包含完整内容",
+10
View File
@@ -68,6 +68,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert.equal(await page.locator("#otelExportRow .opt.on").textContent(), off);
assert.equal(await page.locator("#otelMetricsRow .opt.on").textContent(), off);
assert.equal(await page.locator("#otelBodiesRow .opt.on").textContent(), off);
assert.equal(await page.locator("#otelSessionsRow .opt.on").textContent(), off);
const consent = await page.locator("#otelSessionsRow").textContent();
for (const phrase of (lang === "zh" ? ["所有本地会话", "未通过 Magpie", "文件内容", "命令输出", "遮蔽敏感信息"] : ["all local sessions", "not routed through Magpie", "file contents", "command output", "secrets masked"])) assert(consent.includes(phrase), phrase);
assert.equal(await page.locator("#otelWholeRow").count(), 0, "whole bodies require bodies on");
assert((await page.locator("#otelBodiesRow").textContent()).includes(lang === "zh" ? "包含请求和响应内容" : "Include request and response bodies"));
assert.equal(await page.locator("#otelHeadersRow input").getAttribute("type"), "password");
@@ -148,6 +151,13 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
p = await saved(() => page.locator("#otelBodiesRow .opt").nth(1).click(), 10);
assert.equal(p.otel.bodies, true);
assert.equal(await page.locator("#otelWholeRow .opt.on").textContent(), lang === "zh" ? "开启" : "On");
p = await saved(() => page.locator("#otelSessionsRow .opt").nth(1).click(), 11);
assert.equal(p.otel.sessions, true);
await page.reload();
await page.locator("#otelSessionsRow .opt.on").waitFor();
assert.equal(await page.locator("#otelSessionsRow .opt.on").textContent(), lang === "zh" ? "开启" : "On");
p = await saved(() => page.locator("#otelBodiesRow .opt").first().click(), 12);
assert.equal(p.otel.sessions, true, "body preferences preserve session tracing");
assert.deepEqual(errors, []);
await context.close();
});
+19
View File
@@ -0,0 +1,19 @@
These fixtures were derived from real sessions supplied by the PR author:
- Pi: Niulai session `01a0fce1-4bdb-7357-909d-1fc983a98dad` (56 records).
- Codex: deer session `01a0fce4-eacb-7890-b42c-f140d51b593d` (43 records).
Record order, event types, timestamps, token counts and timing fields are
retained. Event, response, process and tool-call IDs are consistently replaced
with anonymous IDs, preserving their relationships. Only the two session IDs
above remain as fixture labels.
Prompts, replies, reasoning, tool arguments/results, command output, paths,
model/provider configuration and unknown fields are replaced with placeholders.
Account/user identifiers, instructions, signatures, encrypted reasoning,
environment/permission configuration, tool definitions, headers and URLs are
removed. Remaining string values were checked against an explicit allowlist.
These are sanitized real transcripts, not synthetic event sequences. Pi and
Codex have been tested end to end. Claude Code/Cowork, Oh My Pi, OpenCode and
Gemini CLI have format tests but are **not tested end to end** by the PR author.
@@ -0,0 +1,43 @@
{"timestamp":"2026-10-02T13:54:58.015Z","redacted_field_2":"[REDACTED]","type":"session_meta","payload":{"session_id":"id-0001","id":"01a0fce4-eacb-7890-b42c-f140d51b593d","timestamp":"2026-10-02T13:54:15.887Z","cwd":"/workspace/example","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]","redacted_field_8":"[REDACTED]","redacted_field_9":"[REDACTED]","model_provider":"fixture-provider","redacted_field_11":"[REDACTED]","redacted_field_12":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:54:58.016Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"task_started","turn_id":"id-0002","redacted_field_3":"[REDACTED]","started_at":1790949298,"model_context_window":258400,"redacted_field_6":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:54:59.534Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"message","id":"id-0003","role":"developer","content":[{"type":"input_text","text":"[REDACTED]"},{"type":"input_text","text":"[REDACTED]"},{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:54:59.534Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"message","id":"id-0004","role":"developer","content":[{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:54:59.534Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"message","id":"id-0005","role":"developer","content":[{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:54:59.534Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"message","id":"id-0006","role":"user","content":[{"type":"input_text","text":"[REDACTED]"},{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:54:59.538Z","redacted_field_2":"[REDACTED]","type":"world_state","payload":{"redacted_field_1":"[REDACTED]","redacted_field_2":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:54:59.540Z","redacted_field_2":"[REDACTED]","type":"turn_context","payload":{"turn_id":"id-0002","redacted_field_2":"[REDACTED]","redacted_field_3":"[REDACTED]","cwd":"/workspace/example","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]","redacted_field_8":"[REDACTED]","redacted_field_9":"[REDACTED]","redacted_field_10":"[REDACTED]","redacted_field_11":"[REDACTED]","model":"fixture-model","redacted_field_13":"[REDACTED]","redacted_field_14":"[REDACTED]","redacted_field_15":"[REDACTED]","redacted_field_16":"[REDACTED]","redacted_field_17":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:54:59.582Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"message","id":"id-0007","role":"user","content":[{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:54:59.583Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"UserMessage","id":"id-0008","redacted_field_3":"[REDACTED]","content":[{"type":"text","text":"[REDACTED]","redacted_field_3":"[REDACTED]"}]},"started_at_ms":1790949299583,"completed_at_ms":1790949299583}}
{"timestamp":"2026-10-02T13:55:09.333Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"AgentMessage","id":"id-0009","content":[{"type":"[REDACTED]","text":"[REDACTED]"}],"redacted_field_4":"[REDACTED]"},"started_at_ms":1790949306580,"completed_at_ms":1790949309333}}
{"timestamp":"2026-10-02T13:55:09.347Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"message","id":"id-0009","role":"assistant","content":[{"type":"output_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:55:10.549Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"custom_tool_call","id":"id-0010","status":"completed","call_id":"id-0011","name":"exec","input":"[REDACTED]","redacted_field_7":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:55:10.676Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"CommandExecution","id":"id-0012","process_id":"id-0013","command":{"redacted":true},"cwd":"/workspace/example","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]","status":"failed","stdout":"[REDACTED]","stderr":"[REDACTED]","aggregated_output":"[REDACTED]","exit_code":1,"duration":{"redacted_field_1":"[REDACTED]","nanos":15709},"formatted_output":"[REDACTED]"},"started_at_ms":1790949310676,"completed_at_ms":1790949310676}}
{"timestamp":"2026-10-02T13:55:10.846Z","redacted_field_2":"[REDACTED]","type":"token_usage_record","payload":{"redacted_field_1":"[REDACTED]","turn_id":"id-0002","session_id":"id-0001","redacted_field_4":"[REDACTED]","response_id":"id-0014","usage":{"input_tokens":20339,"cached_input_tokens":4096,"cache_write_input_tokens":0,"output_tokens":135,"reasoning_output_tokens":0,"total_tokens":20474},"redacted_field_7":"[REDACTED]","redacted_field_8":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:55:10.848Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"custom_tool_call_output","id":"id-0015","call_id":"id-0011","output":[{"type":"input_text","text":"[REDACTED]"},{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:55:10.849Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":20339,"cached_input_tokens":4096,"cache_write_input_tokens":0,"output_tokens":135,"reasoning_output_tokens":0,"total_tokens":20474},"last_token_usage":{"input_tokens":20339,"cached_input_tokens":4096,"cache_write_input_tokens":0,"output_tokens":135,"reasoning_output_tokens":0,"total_tokens":20474},"model_context_window":258400}}}
{"timestamp":"2026-10-02T13:55:36.070Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"Reasoning","id":"id-0016","redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]"},"started_at_ms":1790949336069,"completed_at_ms":1790949336070}}
{"timestamp":"2026-10-02T13:55:36.076Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"reasoning","id":"id-0016","redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:55:36.472Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"custom_tool_call","id":"id-0017","status":"completed","call_id":"id-0018","name":"exec","input":"[REDACTED]","redacted_field_7":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:55:36.576Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"CommandExecution","id":"id-0019","process_id":"id-0020","command":{"redacted":true},"cwd":"/workspace/example","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]","status":"completed","stdout":"[REDACTED]","stderr":"[REDACTED]","aggregated_output":"[REDACTED]","exit_code":0,"duration":{"redacted_field_1":"[REDACTED]","nanos":8833},"formatted_output":"[REDACTED]"},"started_at_ms":1790949336576,"completed_at_ms":1790949336576}}
{"timestamp":"2026-10-02T13:55:36.842Z","redacted_field_2":"[REDACTED]","type":"token_usage_record","payload":{"redacted_field_1":"[REDACTED]","turn_id":"id-0002","session_id":"id-0001","redacted_field_4":"[REDACTED]","response_id":"id-0021","usage":{"input_tokens":20548,"cached_input_tokens":20224,"cache_write_input_tokens":0,"output_tokens":397,"reasoning_output_tokens":366,"total_tokens":20945},"redacted_field_7":"[REDACTED]","redacted_field_8":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:55:36.846Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"custom_tool_call_output","id":"id-0022","call_id":"id-0018","output":[{"type":"input_text","text":"[REDACTED]"},{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:55:36.847Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":40887,"cached_input_tokens":24320,"cache_write_input_tokens":0,"output_tokens":532,"reasoning_output_tokens":366,"total_tokens":41419},"last_token_usage":{"input_tokens":20548,"cached_input_tokens":20224,"cache_write_input_tokens":0,"output_tokens":397,"reasoning_output_tokens":366,"total_tokens":20945},"model_context_window":258400}}}
{"timestamp":"2026-10-02T13:58:48.896Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"custom_tool_call","id":"id-0023","status":"completed","call_id":"id-0024","name":"exec","input":"[REDACTED]","redacted_field_7":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:58:49.058Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"CommandExecution","id":"id-0025","process_id":"id-0026","command":{"redacted":true},"cwd":"/workspace/example","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]","status":"completed","stdout":"[REDACTED]","stderr":"[REDACTED]","aggregated_output":"[REDACTED]","exit_code":0,"duration":{"redacted_field_1":"[REDACTED]","nanos":8000},"formatted_output":"[REDACTED]"},"started_at_ms":1790949529057,"completed_at_ms":1790949529058}}
{"timestamp":"2026-10-02T13:58:49.108Z","redacted_field_2":"[REDACTED]","type":"token_usage_record","payload":{"redacted_field_1":"[REDACTED]","turn_id":"id-0002","session_id":"id-0001","redacted_field_4":"[REDACTED]","response_id":"id-0027","usage":{"input_tokens":21058,"cached_input_tokens":20352,"cache_write_input_tokens":0,"output_tokens":6197,"reasoning_output_tokens":0,"total_tokens":27255},"redacted_field_7":"[REDACTED]","redacted_field_8":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:58:49.109Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"custom_tool_call_output","id":"id-0028","call_id":"id-0024","output":[{"type":"input_text","text":"[REDACTED]"},{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:58:49.110Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":61945,"cached_input_tokens":44672,"cache_write_input_tokens":0,"output_tokens":6729,"reasoning_output_tokens":366,"total_tokens":68674},"last_token_usage":{"input_tokens":21058,"cached_input_tokens":20352,"cache_write_input_tokens":0,"output_tokens":6197,"reasoning_output_tokens":0,"total_tokens":27255},"model_context_window":258400}}}
{"timestamp":"2026-10-02T13:59:02.941Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"Reasoning","id":"id-0029","redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]"},"started_at_ms":1790949542937,"completed_at_ms":1790949542941}}
{"timestamp":"2026-10-02T13:59:02.947Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"reasoning","id":"id-0029","redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:59:03.641Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"AgentMessage","id":"id-0030","content":[{"type":"[REDACTED]","text":"[REDACTED]"}],"redacted_field_4":"[REDACTED]"},"started_at_ms":1790949542949,"completed_at_ms":1790949543640}}
{"timestamp":"2026-10-02T13:59:03.644Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"message","id":"id-0030","role":"assistant","content":[{"type":"output_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:59:14.580Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"custom_tool_call","id":"id-0031","status":"completed","call_id":"id-0032","name":"exec","input":"[REDACTED]","redacted_field_7":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:59:14.763Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"CommandExecution","id":"id-0033","process_id":"id-0034","command":{"redacted":true},"cwd":"/workspace/example","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]","status":"completed","stdout":"[REDACTED]","stderr":"[REDACTED]","aggregated_output":"[REDACTED]","exit_code":0,"duration":{"redacted_field_1":"[REDACTED]","nanos":7709},"formatted_output":"[REDACTED]"},"started_at_ms":1790949554763,"completed_at_ms":1790949554763}}
{"timestamp":"2026-10-02T13:59:14.793Z","redacted_field_2":"[REDACTED]","type":"token_usage_record","payload":{"redacted_field_1":"[REDACTED]","turn_id":"id-0002","session_id":"id-0001","redacted_field_4":"[REDACTED]","response_id":"id-0035","usage":{"input_tokens":27323,"cached_input_tokens":20864,"cache_write_input_tokens":0,"output_tokens":655,"reasoning_output_tokens":125,"total_tokens":27978},"redacted_field_7":"[REDACTED]","redacted_field_8":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:59:14.794Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"custom_tool_call_output","id":"id-0036","call_id":"id-0032","output":[{"type":"input_text","text":"[REDACTED]"},{"type":"input_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:59:14.794Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":89268,"cached_input_tokens":65536,"cache_write_input_tokens":0,"output_tokens":7384,"reasoning_output_tokens":491,"total_tokens":96652},"last_token_usage":{"input_tokens":27323,"cached_input_tokens":20864,"cache_write_input_tokens":0,"output_tokens":655,"reasoning_output_tokens":125,"total_tokens":27978},"model_context_window":258400}}}
{"timestamp":"2026-10-02T13:59:20.672Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"item_completed","redacted_field_2":"[REDACTED]","turn_id":"id-0002","item":{"type":"AgentMessage","id":"id-0037","content":[{"type":"[REDACTED]","text":"[REDACTED]"}],"redacted_field_4":"[REDACTED]"},"started_at_ms":1790949558511,"completed_at_ms":1790949560672}}
{"timestamp":"2026-10-02T13:59:20.681Z","redacted_field_2":"[REDACTED]","type":"response_item","payload":{"type":"message","id":"id-0037","role":"assistant","content":[{"type":"output_text","text":"[REDACTED]"}],"redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]"},"redacted_field_5":"[REDACTED]"}
{"timestamp":"2026-10-02T13:59:20.848Z","redacted_field_2":"[REDACTED]","type":"token_usage_record","payload":{"redacted_field_1":"[REDACTED]","turn_id":"id-0002","session_id":"id-0001","redacted_field_4":"[REDACTED]","response_id":"id-0038","usage":{"input_tokens":28054,"cached_input_tokens":27136,"cache_write_input_tokens":0,"output_tokens":73,"reasoning_output_tokens":0,"total_tokens":28127},"redacted_field_7":"[REDACTED]","redacted_field_8":"[REDACTED]"}}
{"timestamp":"2026-10-02T13:59:20.855Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":117322,"cached_input_tokens":92672,"cache_write_input_tokens":0,"output_tokens":7457,"reasoning_output_tokens":491,"total_tokens":124779},"last_token_usage":{"input_tokens":28054,"cached_input_tokens":27136,"cache_write_input_tokens":0,"output_tokens":73,"reasoning_output_tokens":0,"total_tokens":28127},"model_context_window":258400}}}
{"timestamp":"2026-10-02T13:59:20.876Z","redacted_field_2":"[REDACTED]","type":"event_msg","payload":{"type":"task_complete","turn_id":"id-0002","redacted_field_3":"[REDACTED]","started_at":1790949298,"completed_at":1790949560,"duration_ms":262860,"redacted_field_7":"[REDACTED]"}}
@@ -0,0 +1,56 @@
{"type":"session","version":3,"id":"01a0fce1-4bdb-7357-909d-1fc983a98dad","timestamp":"2026-10-02T13:50:18.588Z","cwd":"/workspace/example"}
{"type":"model_change","id":"id-0002","parentId":null,"timestamp":"2026-10-02T13:50:18.666Z","provider":"fixture-provider","modelId":"fixture-model"}
{"type":"thinking_level_change","id":"id-0003","parentId":"id-0002","timestamp":"2026-10-02T13:50:18.666Z","thinkingLevel":"high"}
{"type":"message","id":"id-0004","parentId":"id-0003","timestamp":"2026-10-02T13:50:38.711Z","message":{"role":"system","content":"[REDACTED]","timestamp":1790949038709,"redacted_field_4":"[REDACTED]"}}
{"type":"message","id":"id-0005","parentId":"id-0004","timestamp":"2026-10-02T13:50:38.712Z","message":{"role":"user","content":[{"type":"text","text":"[REDACTED]"}],"timestamp":1790949038709}}
{"type":"message","id":"id-0006","parentId":"id-0005","timestamp":"2026-10-02T13:50:40.407Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0007","name":"read","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":124,"output":100,"cacheRead":2432,"cacheWrite":0,"reasoning":38,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949038724,"responseId":"id-0008","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0009","parentId":"id-0006","timestamp":"2026-10-02T13:50:40.411Z","message":{"role":"toolResult","toolCallId":"id-0007","toolName":"read","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949040411}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":124,"output":100,"cacheRead":2432,"cacheWrite":0,"total":2656},"timing":{"redacted_field_1":"[REDACTED]","totalMs":1700.9857090000005,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":148.1,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949040412},"id":"id-0010","parentId":"id-0009","timestamp":"2026-10-02T13:50:40.412Z"}
{"type":"message","id":"id-0011","parentId":"id-0010","timestamp":"2026-10-02T13:50:43.651Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0012","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":4780,"output":247,"cacheRead":2560,"cacheWrite":0,"reasoning":178,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949040414,"responseId":"id-0013","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0014","parentId":"id-0011","timestamp":"2026-10-02T13:50:43.726Z","message":{"role":"toolResult","toolCallId":"id-0012","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949043726}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":4780,"output":247,"cacheRead":2560,"cacheWrite":0,"total":7587},"timing":{"redacted_field_1":"[REDACTED]","totalMs":3312.914832999999,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":117.1,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949043726},"id":"id-0015","parentId":"id-0014","timestamp":"2026-10-02T13:50:43.726Z"}
{"type":"message","id":"id-0016","parentId":"id-0015","timestamp":"2026-10-02T13:50:47.125Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0017","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":283,"output":335,"cacheRead":7552,"cacheWrite":0,"reasoning":171,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949043727,"responseId":"id-0018","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0019","parentId":"id-0016","timestamp":"2026-10-02T13:50:47.157Z","message":{"role":"toolResult","toolCallId":"id-0017","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949047157}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":283,"output":335,"cacheRead":7552,"cacheWrite":0,"total":8170},"timing":{"redacted_field_1":"[REDACTED]","totalMs":3431.196333,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":132,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949047158},"id":"id-0020","parentId":"id-0019","timestamp":"2026-10-02T13:50:47.158Z"}
{"type":"message","id":"id-0021","parentId":"id-0020","timestamp":"2026-10-02T13:50:49.125Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0022","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":143,"output":187,"cacheRead":8064,"cacheWrite":0,"reasoning":23,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949047159,"responseId":"id-0023","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0024","parentId":"id-0021","timestamp":"2026-10-02T13:50:49.146Z","message":{"role":"toolResult","toolCallId":"id-0022","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949049146}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":143,"output":187,"cacheRead":8064,"cacheWrite":0,"total":8394},"timing":{"redacted_field_1":"[REDACTED]","totalMs":1988.1887079999979,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":184.6,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949049146},"id":"id-0025","parentId":"id-0024","timestamp":"2026-10-02T13:50:49.146Z"}
{"type":"message","id":"id-0026","parentId":"id-0025","timestamp":"2026-10-02T13:50:50.790Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0027","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":111,"output":131,"cacheRead":8320,"cacheWrite":0,"reasoning":38,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949049147,"responseId":"id-0028","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0029","parentId":"id-0026","timestamp":"2026-10-02T13:50:50.819Z","message":{"role":"toolResult","toolCallId":"id-0027","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949050819}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":111,"output":131,"cacheRead":8320,"cacheWrite":0,"total":8562},"timing":{"redacted_field_1":"[REDACTED]","totalMs":1672.713249999997,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":132.1,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949050820},"id":"id-0030","parentId":"id-0029","timestamp":"2026-10-02T13:50:50.820Z"}
{"type":"message","id":"id-0031","parentId":"id-0030","timestamp":"2026-10-02T13:50:52.703Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0032","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":142,"output":160,"cacheRead":8448,"cacheWrite":0,"reasoning":47,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949050820,"responseId":"id-0033","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0034","parentId":"id-0031","timestamp":"2026-10-02T13:50:52.737Z","message":{"role":"toolResult","toolCallId":"id-0032","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949052737}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":142,"output":160,"cacheRead":8448,"cacheWrite":0,"total":8750},"timing":{"redacted_field_1":"[REDACTED]","totalMs":1917.922542000004,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":143.2,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949052738},"id":"id-0035","parentId":"id-0034","timestamp":"2026-10-02T13:50:52.738Z"}
{"type":"message","id":"id-0036","parentId":"id-0035","timestamp":"2026-10-02T13:50:55.476Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0037","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":83,"output":256,"cacheRead":8704,"cacheWrite":0,"reasoning":187,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949052739,"responseId":"id-0038","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0039","parentId":"id-0036","timestamp":"2026-10-02T13:50:55.504Z","message":{"role":"toolResult","toolCallId":"id-0037","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949055504}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":83,"output":256,"cacheRead":8704,"cacheWrite":0,"total":9043},"timing":{"redacted_field_1":"[REDACTED]","totalMs":2766.0337500000023,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":112.2,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949055504},"id":"id-0040","parentId":"id-0039","timestamp":"2026-10-02T13:50:55.504Z"}
{"type":"message","id":"id-0041","parentId":"id-0040","timestamp":"2026-10-02T13:50:56.880Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"id-0042","name":"read","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":202,"output":63,"cacheRead":8960,"cacheWrite":0,"reasoning":0,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949055505,"responseId":"id-0043","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0044","parentId":"id-0041","timestamp":"2026-10-02T13:50:56.885Z","message":{"role":"toolResult","toolCallId":"id-0042","toolName":"read","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949056885}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":202,"output":63,"cacheRead":8960,"cacheWrite":0,"total":9225},"timing":{"redacted_field_1":"[REDACTED]","totalMs":1381.3172080000004,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":263.1,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949056887},"id":"id-0045","parentId":"id-0044","timestamp":"2026-10-02T13:50:56.888Z"}
{"type":"message","id":"id-0046","parentId":"id-0045","timestamp":"2026-10-02T13:50:59.035Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0047","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":1529,"output":154,"cacheRead":9216,"cacheWrite":0,"reasoning":90,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949056890,"responseId":"id-0048","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0049","parentId":"id-0046","timestamp":"2026-10-02T13:50:59.085Z","message":{"role":"toolResult","toolCallId":"id-0047","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949059085}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":1529,"output":154,"cacheRead":9216,"cacheWrite":0,"total":10899},"timing":{"redacted_field_1":"[REDACTED]","totalMs":2196.6615840000013,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":110.5,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949059086},"id":"id-0050","parentId":"id-0049","timestamp":"2026-10-02T13:50:59.086Z"}
{"type":"message","id":"id-0051","parentId":"id-0050","timestamp":"2026-10-02T13:51:05.195Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0052","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":3409,"output":642,"cacheRead":10880,"cacheWrite":0,"reasoning":550,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949059086,"responseId":"id-0053","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0054","parentId":"id-0051","timestamp":"2026-10-02T13:51:05.218Z","message":{"role":"toolResult","toolCallId":"id-0052","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949065218}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":3409,"output":642,"cacheRead":10880,"cacheWrite":0,"total":14931},"timing":{"redacted_field_1":"[REDACTED]","totalMs":6132.348208999996,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":121.5,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949065218},"id":"id-0055","parentId":"id-0054","timestamp":"2026-10-02T13:51:05.218Z"}
{"type":"message","id":"id-0056","parentId":"id-0055","timestamp":"2026-10-02T13:51:06.894Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"id-0057","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":110,"output":135,"cacheRead":14848,"cacheWrite":0,"reasoning":0,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949065219,"responseId":"id-0058","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0059","parentId":"id-0056","timestamp":"2026-10-02T13:51:07.569Z","message":{"role":"toolResult","toolCallId":"id-0057","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949067568}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":110,"output":135,"cacheRead":14848,"cacheWrite":0,"total":15093},"timing":{"redacted_field_1":"[REDACTED]","totalMs":2352.0801250000004,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":204.6,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949067571},"id":"id-0060","parentId":"id-0059","timestamp":"2026-10-02T13:51:07.571Z"}
{"type":"message","id":"id-0061","parentId":"id-0060","timestamp":"2026-10-02T13:51:09.472Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0062","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":178,"output":128,"cacheRead":14976,"cacheWrite":0,"reasoning":36,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949067573,"responseId":"id-0063","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0064","parentId":"id-0061","timestamp":"2026-10-02T13:51:09.536Z","message":{"role":"toolResult","toolCallId":"id-0062","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949069536}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":178,"output":128,"cacheRead":14976,"cacheWrite":0,"total":15282},"timing":{"redacted_field_1":"[REDACTED]","totalMs":1964.3476249999949,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":157.9,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949069536},"id":"id-0065","parentId":"id-0064","timestamp":"2026-10-02T13:51:09.536Z"}
{"type":"message","id":"id-0066","parentId":"id-0065","timestamp":"2026-10-02T13:51:13.322Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0067","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":2818,"output":391,"cacheRead":15232,"cacheWrite":0,"reasoning":267,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949069538,"responseId":"id-0068","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0069","parentId":"id-0066","timestamp":"2026-10-02T13:51:13.716Z","message":{"role":"toolResult","toolCallId":"id-0067","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949073716}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":2818,"output":391,"cacheRead":15232,"cacheWrite":0,"total":18441},"timing":{"redacted_field_1":"[REDACTED]","totalMs":4179.148125,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":142.2,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949073716},"id":"id-0070","parentId":"id-0069","timestamp":"2026-10-02T13:51:13.716Z"}
{"type":"message","id":"id-0071","parentId":"id-0070","timestamp":"2026-10-02T13:51:15.301Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"id-0072","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":89,"output":92,"cacheRead":18432,"cacheWrite":0,"reasoning":0,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949073718,"responseId":"id-0073","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0074","parentId":"id-0071","timestamp":"2026-10-02T13:51:15.348Z","message":{"role":"toolResult","toolCallId":"id-0072","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949075348}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":89,"output":92,"cacheRead":18432,"cacheWrite":0,"total":18613},"timing":{"redacted_field_1":"[REDACTED]","totalMs":1631.1956250000003,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":230.9,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949075348},"id":"id-0075","parentId":"id-0074","timestamp":"2026-10-02T13:51:15.348Z"}
{"type":"message","id":"id-0076","parentId":"id-0075","timestamp":"2026-10-02T13:51:19.292Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"toolCall","id":"id-0077","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":1464,"output":379,"cacheRead":18560,"cacheWrite":0,"reasoning":238,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949075349,"responseId":"id-0078","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0079","parentId":"id-0076","timestamp":"2026-10-02T13:51:21.297Z","message":{"role":"toolResult","toolCallId":"id-0077","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949081297}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":1464,"output":379,"cacheRead":18560,"cacheWrite":0,"total":20403},"timing":{"redacted_field_1":"[REDACTED]","totalMs":5949.427458999999,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":135.5,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949081298},"id":"id-0080","parentId":"id-0079","timestamp":"2026-10-02T13:51:21.298Z"}
{"type":"message","id":"id-0081","parentId":"id-0080","timestamp":"2026-10-02T13:51:23.008Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"id-0082","name":"bash","arguments":{"redacted":true}}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":148,"output":92,"cacheRead":20352,"cacheWrite":0,"reasoning":0,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790949081299,"responseId":"id-0083","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"message","id":"id-0084","parentId":"id-0081","timestamp":"2026-10-02T13:51:23.089Z","message":{"role":"toolResult","toolCallId":"id-0082","toolName":"bash","content":[{"type":"text","text":"[REDACTED]"}],"isError":false,"timestamp":1790949083089}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":148,"output":92,"cacheRead":20352,"cacheWrite":0,"total":20592},"timing":{"redacted_field_1":"[REDACTED]","totalMs":1791.5209590000013,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":234.3,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949083090},"id":"id-0085","parentId":"id-0084","timestamp":"2026-10-02T13:51:23.090Z"}
{"type":"message","id":"id-0086","parentId":"id-0085","timestamp":"2026-10-02T13:51:34.803Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[REDACTED]"},{"type":"text","text":"[REDACTED]"}],"api":"[REDACTED]","provider":"fixture-provider","model":"fixture-model","usage":{"input":6119,"output":1284,"cacheRead":20480,"cacheWrite":0,"reasoning":738,"redacted_field_6":"[REDACTED]","cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1790949083091,"responseId":"id-0087","responseModel":"fixture-model","redacted_field_11":"[REDACTED]","thinkingLevel":"high"}}
{"type":"custom","customType":"tps","data":{"model":{"provider":"fixture-provider","modelId":"fixture-model"},"tokens":{"input":6119,"output":1284,"cacheRead":20480,"cacheWrite":0,"total":27883},"timing":{"redacted_field_1":"[REDACTED]","totalMs":11713.452750000004,"redacted_field_3":"[REDACTED]","redacted_field_4":"[REDACTED]","redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]"},"tps":121.5,"isPrimaryBranch":true,"cost":null,"rateUsdPerMTokens":null,"timestamp":1790949094804},"id":"id-0088","parentId":"id-0086","timestamp":"2026-10-02T13:51:34.804Z"}
{"type":"custom","customType":"timing-final","data":{"totalMs":56096,"endAt":1790949094806,"runStats":{"input":21732,"output":4776,"cacheRead":198016,"cacheWrite":0,"redacted_field_5":"[REDACTED]","redacted_field_6":"[REDACTED]","redacted_field_7":"[REDACTED]","redacted_field_8":"[REDACTED]"},"llmMs":36152,"toolMs":3553,"ttftAvgMs":962.2352941176471,"ttftCount":17},"id":"id-0089","parentId":"id-0088","timestamp":"2026-10-02T13:51:34.806Z"}
+659
View File
@@ -0,0 +1,659 @@
package sessions
// Agent trace readers are separate from the usage/listing cache: they retain
// just an incremental cursor and the active interaction, never session titles
// or completed conversations. They don't send data themselves.
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// TraceSpan is a recorded agent operation, model response, or tool execution.
type TraceSpan struct {
Agent, Session, Turn, ID, Parent, Name, Kind, Model, Provider, Served string
Start, End time.Time
Input, Output string
Tokens Tokens
Reasoning int
Error, Inferred bool
Update bool
}
type traceTurn struct {
id string
start, last time.Time
input, output, model string
failed, completed bool
}
type traceCursor struct {
info os.FileInfo
offset int64
size int64
mod time.Time
agent, session, provider string
turns map[string]*traceTurn
current string
tools map[string]TraceSpan
parents map[string]string
claudeMessage *claudeTraceMessage
}
// TraceReader follows supported local agent stores modified since activation.
// Existing lines establish ancestry, but events older than Since aren't emitted.
// Deterministic IDs make resumed rollouts and copied session branches idempotent.
type TraceReader struct {
Since time.Time
files map[string]*traceCursor
snapshots map[string]*traceSnapshot
ocStamp [4]int64
ocPending bool
ocInfo os.FileInfo
ocNext int
}
func NewTraceReader(since time.Time) *TraceReader {
return &TraceReader{Since: since, files: map[string]*traceCursor{}, snapshots: map[string]*traceSnapshot{}}
}
// TraceAgent lists formats with an interaction and tool-aware trace adapter.
func TraceAgent(agent string) bool {
switch agent {
case "codex", "pi", "omp", "claude", "claude-desktop", "opencode", "gemini":
return true
}
return false
}
// Trace discovery omits nested transcripts rather than statting every child
// agent's artifacts every two seconds. They need an explicit parent link first.
func traceLineFiles() []file {
files := append(codexFiles(), piFiles()...)
claude := func(agent, dir string) {
projects := filepath.Join(dir, "projects")
for _, project := range readDirectory(projects) {
if !project.IsDir() && project.Type()&os.ModeSymlink == 0 {
continue
}
root := filepath.Join(projects, project.Name())
for _, entry := range readDirectory(root) {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
f := file{agent: agent, path: filepath.Join(root, entry.Name()), main: true}
if stat(&f) {
files = append(files, f)
}
}
}
}
claude("claude", ClaudeDir())
for _, dir := range callDesktopDirs() {
homes, _ := SessionGlob(filepath.Join(dir, "local-agent-mode-sessions", "*", "*", "local_*", ".claude"))
for _, home := range homes {
claude("claude-desktop", home)
}
}
seen := map[string]bool{}
for _, root := range ompSessionRoots() {
paths, _ := SessionGlob(filepath.Join(root, "*", "*.jsonl"))
for _, path := range paths {
if seen[path] {
continue
}
seen[path] = true
f := file{agent: "omp", path: path, main: true}
if stat(&f) {
files = append(files, f)
}
}
}
return append(files, geminiTraceFiles()...)
}
func TraceID(parts ...string) string {
sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
return hex.EncodeToString(sum[:16])
}
func traceSpanID(parts ...string) string { return TraceID(parts...)[:16] }
func traceTime(raw json.RawMessage, millis bool) time.Time {
var n int64
if json.Unmarshal(raw, &n) == nil && n > 0 {
if millis {
return time.UnixMilli(n).UTC()
}
return time.Unix(n, 0).UTC()
}
var s string
if json.Unmarshal(raw, &s) == nil {
t, _ := time.Parse(time.RFC3339Nano, s)
return t
}
return time.Time{}
}
func (c *traceCursor) span(turn, id, name, kind string, start, end time.Time) TraceSpan {
return TraceSpan{Agent: c.agent, Session: c.session, Turn: turn, ID: traceSpanID(c.agent, c.session, turn, id), Parent: traceSpanID(c.agent, c.session, turn, "root"), Name: name, Kind: kind, Start: start, End: end}
}
func (c *traceCursor) root(t *traceTurn, end time.Time, failed bool) TraceSpan {
s := c.span(t.id, "root", c.agent+" interaction", "span", t.start, end)
s.Parent = ""
s.Input = t.input
s.Output = t.output
s.Error = failed
return s
}
func (c *traceCursor) turn(id string, at time.Time) *traceTurn {
t := c.turns[id]
if t == nil {
if len(c.turns) >= 32 {
var oldest string
var at time.Time
for key, turn := range c.turns {
if oldest == "" || turn.last.Before(at) {
oldest, at = key, turn.last
}
}
delete(c.turns, oldest)
}
t = &traceTurn{id: id, start: at, last: at}
c.turns[id] = t
}
return t
}
// Poll reads appended complete lines only. It limits watched files to the most
// recent sessions; a partial last line remains for the following poll.
func (r *TraceReader) Poll(bodies bool) []TraceSpan {
files := traceLineFiles()
sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) })
if len(files) > Limit {
files = files[:Limit]
}
var result []TraceSpan
budget := int64(8 << 20)
seen := map[string]bool{}
for _, f := range files {
seen[f.path] = true
// Filesystem timestamps can be coarse or lag the write (Linux VFS).
// Use mtime only to prune clearly old stores; event timestamps below
// still enforce the exact activation boundary and prevent history replay.
if budget <= 0 || strings.HasSuffix(f.path, zstSuffix) || f.mod.Before(r.Since.Add(-2*time.Second)) {
continue
}
c := r.files[f.path]
if c == nil || f.size < c.offset {
c = &traceCursor{agent: f.agent, turns: map[string]*traceTurn{}, tools: map[string]TraceSpan{}, parents: map[string]string{}}
r.files[f.path] = c
}
if c.size == f.size && c.mod.Equal(f.mod) {
if f.mod.Before(time.Now().Add(-2 * time.Second)) {
if f.agent == "gemini" {
snap := r.snapshot(f.path)
if snap.gemini != nil && !snap.settled {
result = append(result, r.observations(snap, snap.gemini.spans(bodies, true))...)
snap.settled = true
}
}
for _, s := range c.finishClaude(false) {
if !s.End.Before(r.Since) {
result = append(result, s)
}
}
}
continue
}
if f.agent == "gemini" {
result = append(result, r.readGemini(f, bodies, &budget)...)
continue
}
h, err := os.Open(f.path)
if err != nil {
continue
}
info, err := h.Stat()
if err != nil {
h.Close()
continue
}
if c.info != nil && !os.SameFile(c.info, info) {
c = &traceCursor{agent: f.agent, turns: map[string]*traceTurn{}, tools: map[string]TraceSpan{}, parents: map[string]string{}}
r.files[f.path] = c
}
c.info = info
if _, err = h.Seek(c.offset, io.SeekStart); err != nil {
h.Close()
continue
}
b := bufio.NewReader(h)
// A large file is processed in bounded slices so one session cannot monopolize polling.
read := int64(0)
for read < budget {
line, err := b.ReadBytes('\n')
if err != nil {
break
}
c.offset += int64(len(line))
read += int64(len(line))
spans := c.line(line, bodies)
for _, s := range spans {
if !s.End.Before(r.Since) && !s.Start.IsZero() && !s.End.Before(s.Start) && s.Session != "" {
result = append(result, s)
}
}
}
h.Close()
budget -= read
if c.offset == f.size {
c.size, c.mod = f.size, f.mod
}
}
for path := range r.files {
if !seen[path] {
delete(r.files, path)
}
}
result = append(result, r.pollOpenCode(bodies, &budget)...)
for path := range r.snapshots {
if !seen[path] && !strings.HasPrefix(path, "opencode:") {
delete(r.snapshots, path)
}
}
return result
}
func (c *traceCursor) line(line []byte, bodies bool) []TraceSpan {
if c.agent == "pi" || c.agent == "omp" {
return c.pi(line, bodies)
}
if c.agent == "claude" || c.agent == "claude-desktop" {
return c.claude(line, bodies)
}
return c.codex(line, bodies)
}
func (c *traceCursor) codex(line []byte, bodies bool) []TraceSpan {
var o struct {
Type string
Timestamp time.Time
Payload json.RawMessage
}
if json.Unmarshal(line, &o) != nil {
return nil
}
var p struct {
Type, ID, Model string
Name string
CallID string `json:"call_id"`
Input, Arguments, Output json.RawMessage
TurnID string `json:"turn_id"`
ResponseID string `json:"response_id"`
ModelProvider string `json:"model_provider"`
StartedAt json.RawMessage `json:"started_at"`
CompletedAt json.RawMessage `json:"completed_at"`
StartedAtMS json.RawMessage `json:"started_at_ms"`
CompletedAtMS json.RawMessage `json:"completed_at_ms"`
DurationMS int64 `json:"duration_ms"`
LastAgentMessage string `json:"last_agent_message"`
Usage struct {
Input int `json:"input_tokens"`
Cached int `json:"cached_input_tokens"`
CacheWrite int `json:"cache_write_input_tokens"`
Output int `json:"output_tokens"`
Reasoning int `json:"reasoning_output_tokens"`
}
Item struct {
Type, ID, Tool, Server, Status string
Command json.RawMessage
Arguments json.RawMessage
Content json.RawMessage
Output json.RawMessage
Result json.RawMessage
AggregatedOutput string `json:"aggregated_output"`
ExitCode *int `json:"exit_code"`
}
}
if o.Type != "session_meta" && o.Type != "turn_context" && o.Type != "event_msg" && o.Type != "token_usage_record" && o.Type != "response_item" {
return nil
}
if json.Unmarshal(o.Payload, &p) != nil {
return nil
}
if o.Type == "session_meta" {
c.session = p.ID
c.provider = p.ModelProvider
return nil
}
if o.Type == "turn_context" {
if p.TurnID == "" {
return nil
}
c.current = p.TurnID
t := c.turn(p.TurnID, o.Timestamp)
t.model = p.Model
return nil
}
id := p.TurnID
if id == "" {
id = c.current
}
if id == "" {
return nil
}
t := c.turn(id, o.Timestamp)
switch {
case o.Type == "response_item":
switch p.Type {
case "custom_tool_call", "function_call":
if p.CallID == "" || len(c.tools) >= 256 {
return nil
}
s := c.span(id, p.CallID, p.Name, "tool", o.Timestamp, o.Timestamp)
s.Inferred = true
if bodies {
s.Input = string(p.Arguments)
if s.Input == "" {
s.Input = string(p.Input)
}
}
c.tools[p.CallID] = s
case "custom_tool_call_output", "function_call_output":
s, ok := c.tools[p.CallID]
if !ok {
return nil
}
delete(c.tools, p.CallID)
s.End = o.Timestamp
if bodies {
s.Output = string(p.Output)
}
if s.End.After(t.last) {
t.last = s.End
}
return []TraceSpan{s}
}
case o.Type == "event_msg" && p.Type == "task_started":
c.current = id
at := traceTime(p.StartedAt, false)
if !at.IsZero() {
t.start, t.last = at, at
}
return []TraceSpan{c.root(t, t.start, false)}
case o.Type == "event_msg" && p.Type == "item_completed":
start, end := traceTime(p.StartedAtMS, true), traceTime(p.CompletedAtMS, true)
if start.IsZero() {
start = o.Timestamp
}
if end.IsZero() {
end = o.Timestamp
}
switch p.Item.Type {
case "UserMessage":
if bodies {
t.input = string(p.Item.Content)
}
return nil
case "AgentMessage":
if bodies {
t.output = string(p.Item.Content)
}
return nil
case "CommandExecution", "McpToolCall", "FileChange", "ImageView", "ContextCompaction":
// A recorded MCP completion is authoritative over the matching
// inferred function_call/output pair, which uses the same call ID.
if p.Item.Type == "McpToolCall" {
delete(c.tools, p.Item.ID)
}
name := map[string]string{"CommandExecution": "command", "McpToolCall": "mcp tool", "FileChange": "file change", "ImageView": "view image", "ContextCompaction": "compaction"}[p.Item.Type]
if p.Item.Tool != "" {
name = p.Item.Tool
}
s := c.span(id, p.Item.ID, name, "tool", start, end)
s.Error = p.Item.Status == "failed" || p.Item.Status == "error" || p.Item.ExitCode != nil && *p.Item.ExitCode != 0
if bodies {
s.Input = string(p.Item.Arguments)
if s.Input == "" {
s.Input = string(p.Item.Command)
}
s.Output = p.Item.AggregatedOutput
if s.Output == "" {
s.Output = string(p.Item.Output)
}
if s.Output == "" {
s.Output = string(p.Item.Result)
}
}
// exec may run commands which also have recorded completion events.
// Keep those operations beneath the enclosing exec, rather than
// showing two independent executions of the same command.
if p.Item.Type == "CommandExecution" {
var parent string
for _, call := range c.tools {
if call.Turn == id && call.Name == "exec" {
if parent != "" {
parent = ""
break
}
parent = call.ID
}
}
if parent != "" {
s.Parent = parent
}
}
if end.After(t.last) {
t.last = end
}
return []TraceSpan{s}
}
case o.Type == "token_usage_record":
if p.ResponseID == "" {
return nil
}
s := c.span(id, p.ResponseID, "model "+t.model, "generation", t.last, o.Timestamp)
s.Model = t.model
s.Provider = c.provider
if s.Provider == "" {
s.Provider = "openai"
}
s.Inferred = true
s.Tokens = Tokens{Input: max(0, p.Usage.Input-p.Usage.Cached-p.Usage.CacheWrite), CacheRead: p.Usage.Cached, CacheWrite: p.Usage.CacheWrite, Output: p.Usage.Output}
s.Reasoning = p.Usage.Reasoning
if bodies {
s.Output = t.output
s.Input = t.input
}
t.last = o.Timestamp
return []TraceSpan{s}
case o.Type == "event_msg" && (p.Type == "task_complete" || p.Type == "turn_aborted"):
end := o.Timestamp
if end.IsZero() {
end = traceTime(p.CompletedAt, false)
}
if p.DurationMS > 0 {
t.start = end.Add(-time.Duration(p.DurationMS) * time.Millisecond)
}
if bodies && p.LastAgentMessage != "" {
t.output = p.LastAgentMessage
}
s := c.root(t, end, p.Type == "turn_aborted")
delete(c.turns, id)
var unfinished []TraceSpan
for key, call := range c.tools {
if call.Turn == id {
call.End, call.Error = end, true
unfinished = append(unfinished, call)
delete(c.tools, key)
}
}
return append(unfinished, s)
}
return nil
}
func (c *traceCursor) pi(line []byte, bodies bool) []TraceSpan {
var o struct {
Type, ID, ParentID, CustomType string
Timestamp time.Time
Message struct {
Role, ToolCallID, ToolName, Model, Provider, ResponseID, ResponseModel, StopReason string
Timestamp int64
IsError bool
Content json.RawMessage
Usage piUsage
}
Data struct{ EndAt, TotalMS int64 }
}
if json.Unmarshal(line, &o) != nil {
return nil
}
if o.Type == "session" {
c.session = o.ID
return nil
}
m := o.Message
if o.Type == "custom" && o.CustomType == "timing-final" {
id := c.parents[o.ParentID]
if id == "" {
id = c.current
}
if t := c.turns[id]; t != nil {
end := time.UnixMilli(o.Data.EndAt).UTC()
if o.Data.TotalMS > 0 {
t.start = end.Add(-time.Duration(o.Data.TotalMS) * time.Millisecond)
}
s := c.root(t, end, t.failed)
delete(c.turns, id)
return []TraceSpan{s}
}
return nil
}
if o.Type != "message" {
if id := c.parents[o.ParentID]; id != "" {
c.parents[o.ID] = id
if c.agent == "omp" && o.Type == "model_usage" {
var usage struct {
Model, Provider, Purpose string
Usage piUsage
}
if json.Unmarshal(line, &usage) == nil {
s := c.span(id, o.ID, "model "+usage.Model+" ("+usage.Purpose+")", "generation", o.Timestamp, o.Timestamp)
s.Model, s.Provider, s.Inferred = usage.Model, usage.Provider, true
s.Tokens = Tokens{usage.Usage.Input, usage.Usage.Output, usage.Usage.CacheRead, usage.Usage.CacheWrite}
return []TraceSpan{s}
}
}
}
return nil
}
if m.Role == "user" {
var interrupted []TraceSpan
if previous := c.turns[c.current]; previous != nil {
if !previous.completed && c.current != o.ID {
root := c.root(previous, o.Timestamp, true)
root.Inferred = true
interrupted = append(interrupted, root)
for key, tool := range c.tools {
if tool.Turn == previous.id {
tool.End = o.Timestamp
tool.Error = true
interrupted = append(interrupted, tool)
delete(c.tools, key)
}
}
}
delete(c.turns, c.current)
}
c.current = o.ID
c.parents[o.ID] = o.ID
t := c.turn(o.ID, o.Timestamp)
if bodies {
t.input = string(m.Content)
}
return append(interrupted, c.root(t, t.start, false))
}
id := c.parents[o.ParentID]
if id == "" {
id = c.current
}
if id == "" {
return nil
}
c.parents[o.ID] = id
if len(c.parents) > 4096 {
c.parents = map[string]string{o.ID: id}
}
t := c.turn(id, o.Timestamp)
if m.Role == "toolResult" {
s, ok := c.tools[m.ToolCallID]
if !ok {
return nil
}
delete(c.tools, m.ToolCallID)
s.End = o.Timestamp
s.Error = m.IsError
if bodies {
s.Output = string(m.Content)
}
t.last = o.Timestamp
return []TraceSpan{s}
}
if m.Role != "assistant" {
return nil
}
start := time.UnixMilli(m.Timestamp).UTC()
if m.Timestamp == 0 {
start = t.last
}
name := "model " + m.Model
key := m.ResponseID
if key == "" {
key = o.ID
}
s := c.span(id, key, name, "generation", start, o.Timestamp)
s.Model = m.Model
s.Provider = m.Provider
s.Served = m.ResponseModel
s.Tokens = Tokens{Input: m.Usage.Input, Output: m.Usage.Output, CacheRead: m.Usage.CacheRead, CacheWrite: m.Usage.CacheWrite}
s.Error = m.StopReason == "error" || m.StopReason == "aborted"
t.failed = s.Error
var content []struct {
Type, ID, Name string
Arguments json.RawMessage
}
json.Unmarshal(m.Content, &content)
toolCalls := 0
for _, part := range content {
if part.Type == "toolCall" {
toolCalls++
if len(c.tools) >= 256 {
continue
}
tool := c.span(id, part.ID, part.Name, "tool", o.Timestamp, o.Timestamp)
tool.Inferred = true
if bodies {
tool.Input = string(part.Arguments)
}
c.tools[part.ID] = tool
}
}
if bodies {
s.Output = string(m.Content)
s.Input = t.input
}
t.last = o.Timestamp
spans := []TraceSpan{s}
if toolCalls == 0 && m.StopReason != "toolUse" {
t.completed = true
if bodies {
t.output = string(m.Content)
}
spans = append(spans, c.root(t, o.Timestamp, s.Error))
}
return spans
}
+208
View File
@@ -0,0 +1,208 @@
package sessions
import (
"encoding/json"
"strings"
"time"
)
// Claude appends one record per content block, repeating message-level usage.
// Keep the current response until a result, next response or turn boundary;
// count it once, using the last usage record. Transcript timestamps don't
// record request or tool start, so those intervals are explicitly inferred.
type claudeTraceMessage struct {
span TraceSpan
content []json.RawMessage
stop string
}
func (c *traceCursor) flushClaude() []TraceSpan {
m := c.claudeMessage
if m == nil {
return nil
}
c.claudeMessage = nil
if len(m.content) > 0 {
b, _ := json.Marshal(m.content)
m.span.Output = string(b)
}
return []TraceSpan{m.span}
}
func (c *traceCursor) finishClaude(force bool) []TraceSpan {
m := c.claudeMessage
if m == nil || !force && (len(c.tools) != 0 || m.stop == "tool_use") {
return nil
}
out := c.flushClaude()
if t := c.turns[c.current]; t != nil {
t.completed = true
t.output = out[0].Output
root := c.root(t, t.last, t.failed)
root.Inferred = true
out = append(out, root)
}
return out
}
func (c *traceCursor) claude(line []byte, bodies bool) []TraceSpan {
var o struct {
Type, Subtype, UUID, SessionID, Entrypoint string
Timestamp time.Time
IsMeta, IsApiErrorMessage bool
Message struct {
ID, Model string
StopReason string `json:"stop_reason"`
Content json.RawMessage
Usage struct {
Input int `json:"input_tokens"`
Output int `json:"output_tokens"`
CacheRead int `json:"cache_read_input_tokens"`
CacheWrite int `json:"cache_creation_input_tokens"`
}
}
}
if json.Unmarshal(line, &o) != nil || o.Timestamp.IsZero() {
return nil
}
if o.SessionID != "" {
c.session = o.SessionID
}
if o.Entrypoint == "claude-desktop" {
c.agent = "claude-desktop"
}
type block struct {
Type, ID, Name string
ToolUseID string `json:"tool_use_id"`
Input, Content json.RawMessage
IsError bool `json:"is_error"`
}
var parts []block
// tool_use_id cannot be decoded into ToolUseID without its explicit tag.
var blocks []json.RawMessage
json.Unmarshal(o.Message.Content, &blocks)
for _, b := range blocks {
var p block
if json.Unmarshal(b, &p) == nil {
parts = append(parts, p)
}
}
var out []TraceSpan
if o.Type == "user" {
result := false
for _, p := range parts {
if p.Type != "tool_result" {
continue
}
result = true
out = append(out, c.flushClaude()...)
if tool, ok := c.tools[p.ToolUseID]; ok {
delete(c.tools, p.ToolUseID)
tool.End, tool.Error = o.Timestamp, p.IsError
if bodies {
tool.Output = string(p.Content)
}
out = append(out, tool)
if t := c.turns[tool.Turn]; t != nil {
t.last = o.Timestamp
t.failed = t.failed || tool.Error
}
}
}
if result {
return out
}
if o.IsMeta || strings.HasPrefix(string(o.Message.Content), `"<local-command`) {
return nil
}
if o.UUID == "" {
return nil
}
if previous := c.turns[c.current]; previous != nil && !previous.completed && len(c.tools) > 0 {
previous.last, previous.failed = o.Timestamp, true
for key, tool := range c.tools {
tool.End, tool.Error = o.Timestamp, true
out = append(out, tool)
delete(c.tools, key)
}
}
out = append(out, c.finishClaude(true)...)
if t := c.turns[c.current]; t != nil && !t.completed {
root := c.root(t, t.last, t.failed)
root.Inferred = true
out = append(out, root)
}
// The next user prompt begins a new main interaction.
c.turns = map[string]*traceTurn{}
c.current = o.UUID
t := c.turn(o.UUID, o.Timestamp)
if bodies {
t.input = string(o.Message.Content)
}
out = append(out, c.root(t, o.Timestamp, false))
return out
}
if c.current == "" {
return nil
}
t := c.turns[c.current]
if t == nil {
return nil
}
if o.Type == "system" && o.Subtype == "turn_duration" {
out = append(out, c.flushClaude()...)
if len(out) > 0 && bodies {
t.output = out[0].Output
}
t.completed = true
root := c.root(t, o.Timestamp, t.failed)
// durationMs excludes pauses/permission waits in some versions; retain
// the user's recorded start rather than subtracting active duration.
out = append(out, root)
return out
}
if o.IsApiErrorMessage {
out = append(out, c.flushClaude()...)
t.failed, t.last = true, o.Timestamp
if bodies {
t.output = string(o.Message.Content)
}
out = append(out, c.root(t, o.Timestamp, true))
return out
}
if o.Type != "assistant" || o.Message.ID == "" || o.Message.Model == "<synthetic>" {
return nil
}
if c.claudeMessage != nil && c.claudeMessage.span.ID != traceSpanID(c.agent, c.session, c.current, o.Message.ID) {
out = append(out, c.flushClaude()...)
}
if c.claudeMessage == nil {
s := c.span(c.current, o.Message.ID, "model "+o.Message.Model, "generation", t.last, o.Timestamp)
s.Model, s.Provider, s.Inferred = o.Message.Model, "anthropic", true
if bodies {
s.Input = t.input
}
c.claudeMessage = &claudeTraceMessage{span: s}
}
m := c.claudeMessage
m.span.End = o.Timestamp
m.span.Tokens = Tokens{o.Message.Usage.Input, o.Message.Usage.Output, o.Message.Usage.CacheRead, o.Message.Usage.CacheWrite}
m.span.Error = o.IsApiErrorMessage
m.stop = o.Message.StopReason
if bodies {
m.content = append(m.content, blocks...)
}
for _, p := range parts {
if p.Type != "tool_use" || p.ID == "" {
continue
}
tool := c.span(c.current, p.ID, p.Name, "tool", o.Timestamp, o.Timestamp)
tool.Inferred = true
if bodies {
tool.Input = string(p.Input)
}
c.tools[p.ID] = tool
}
t.last, t.failed = o.Timestamp, t.failed || m.span.Error
return out
}
+374
View File
@@ -0,0 +1,374 @@
package sessions
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func clientTraceFeed(t *testing.T, c *traceCursor, bodies bool, lines ...string) []TraceSpan {
t.Helper()
var out []TraceSpan
for _, line := range lines {
out = append(out, c.line([]byte(line), bodies)...)
}
return out
}
func spanKinds(spans []TraceSpan) (roots, models, tools []TraceSpan) {
for _, s := range spans {
switch s.Kind {
case "generation":
models = append(models, s)
case "tool":
tools = append(tools, s)
default:
roots = append(roots, s)
}
}
return
}
func TestClaudeTraceRepeatedBlocksAndToolResult(t *testing.T) {
for _, bodies := range []bool{false, true} {
c := traceTestCursor("claude")
spans := clientTraceFeed(t, c, bodies,
`{"type":"user","sessionId":"session","uuid":"u","timestamp":"2026-10-02T12:00:00Z","message":{"content":"question"}}`,
`{"type":"assistant","uuid":"a","timestamp":"2026-10-02T12:00:02Z","message":{"id":"m","model":"claude-model","stop_reason":"tool_use","content":[{"type":"thinking","thinking":"private"}],"usage":{"input_tokens":100,"output_tokens":1,"cache_read_input_tokens":50}}}`,
`{"type":"assistant","uuid":"b","timestamp":"2026-10-02T12:00:03Z","message":{"id":"m","model":"claude-model","stop_reason":"tool_use","content":[{"type":"tool_use","id":"tool","name":"Read","input":{"file":"private"}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_read_input_tokens":50}}}`,
`{"type":"user","uuid":"result","timestamp":"2026-10-02T12:00:05Z","message":{"content":[{"type":"tool_result","tool_use_id":"tool","content":"result","is_error":true}]}}`,
`{"type":"assistant","uuid":"final","timestamp":"2026-10-02T12:00:08Z","message":{"id":"m2","model":"claude-model","stop_reason":"end_turn","content":[{"type":"text","text":"answer"}],"usage":{"input_tokens":120,"output_tokens":30}}}`,
`{"type":"system","subtype":"turn_duration","timestamp":"2026-10-02T12:00:09Z","durationMs":8000}`)
roots, models, tools := spanKinds(spans)
if len(models) != 2 || models[0].Tokens.Output != 20 || models[0].Tokens.Input != 100 || len(tools) != 1 || len(roots) != 2 {
t.Fatalf("counts: %+v", spans)
}
if tools[0].Parent != roots[0].ID || !tools[0].Error || tools[0].End.Sub(tools[0].Start) != 2*time.Second || !tools[0].Inferred {
t.Fatalf("tool %+v", tools)
}
if roots[1].ID != roots[0].ID || !roots[1].Error || roots[1].End.Sub(roots[1].Start) != 9*time.Second {
t.Fatalf("root %+v", roots)
}
for _, s := range spans {
if !bodies && (s.Input != "" || s.Output != "") {
t.Fatal("body leaked")
}
}
if bodies && (!strings.Contains(models[0].Output, "tool_use") || !strings.Contains(roots[1].Output, "answer")) {
t.Fatal("missing content")
}
}
}
func TestClaudeTraceFinalWithoutTurnDuration(t *testing.T) {
c := traceTestCursor("claude-desktop")
clientTraceFeed(t, c, false,
`{"type":"user","sessionId":"s","uuid":"u","timestamp":"2026-10-02T12:00:00Z","message":{"content":"question"}}`,
`{"type":"assistant","timestamp":"2026-10-02T12:00:05Z","message":{"id":"m","model":"claude-model","stop_reason":"end_turn","content":[{"type":"text","text":"answer"}],"usage":{"output_tokens":3}}}`)
spans := c.finishClaude(false)
if len(spans) != 2 || spans[0].Agent != "claude-desktop" || !spans[1].Inferred || len(c.finishClaude(false)) != 0 {
t.Fatalf("final %+v", spans)
}
}
func TestOmpTraceMainAndAuxiliaryModels(t *testing.T) {
c := traceTestCursor("omp")
spans := clientTraceFeed(t, c, false,
`{"type":"title","title":"private"}`,
`{"type":"session","id":"s"}`,
`{"type":"message","id":"u","timestamp":"2026-10-02T12:00:00Z","message":{"role":"user","content":"question"}}`,
`{"type":"message","id":"a","parentId":"u","timestamp":"2026-10-02T12:00:04Z","message":{"role":"assistant","model":"model","usage":{"input":100,"output":5},"content":[{"type":"toolCall","id":"tool","name":"bash"}],"stopReason":"toolUse"}}`,
`{"type":"model_usage","id":"aux","parentId":"a","timestamp":"2026-10-02T12:00:05Z","model":"small","provider":"p","purpose":"title","usage":{"input":10,"output":2}}`,
`{"type":"message","id":"result","parentId":"a","timestamp":"2026-10-02T12:00:06Z","message":{"role":"toolResult","toolCallId":"tool","content":"result"}}`,
`{"type":"message","id":"final","parentId":"result","timestamp":"2026-10-02T12:00:08Z","message":{"role":"assistant","model":"model","usage":{"input":120,"output":3},"content":[],"stopReason":"stop"}}`)
roots, models, tools := spanKinds(spans)
if len(roots) != 2 || len(models) != 3 || len(tools) != 1 || models[1].Tokens.Input != 10 || models[1].Parent != roots[0].ID {
t.Fatalf("omp %+v", spans)
}
}
func TestOpenCodeTraceSQLiteIDsAndUpdates(t *testing.T) {
for _, v2 := range []bool{false, true} {
t.Run(map[bool]string{false: "v1", true: "v2"}[v2], func(t *testing.T) {
path := filepath.Join(t.TempDir(), "opencode.db")
t.Setenv("OPENCODE_DB", path)
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
queries := []string{
`CREATE TABLE session (id TEXT PRIMARY KEY,parent_id TEXT,directory TEXT,title TEXT,time_created INTEGER,time_updated INTEGER)`,
`CREATE TABLE message (id TEXT PRIMARY KEY,session_id TEXT,time_created INTEGER,time_updated INTEGER,data TEXT)`,
`CREATE TABLE part (id TEXT PRIMARY KEY,message_id TEXT,session_id TEXT,time_updated INTEGER,data TEXT)`,
`CREATE TABLE session_v2 (id TEXT PRIMARY KEY,parent_id TEXT,directory TEXT,title TEXT,time_created INTEGER,time_updated INTEGER)`,
`CREATE TABLE session_message (id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,time_created INTEGER,time_updated INTEGER,data TEXT)`,
`CREATE TABLE kv (key TEXT PRIMARY KEY,value TEXT)`,
}
for _, q := range queries {
if _, err = db.Exec(q); err != nil {
t.Fatal(err)
}
}
now := time.Now().Add(-5 * time.Second).UnixMilli()
table := "session"
if v2 {
table = "session_v2"
}
if _, err = db.Exec("INSERT INTO "+table+" VALUES ('s',NULL,'private','private',?,?)", now, now+5000); err != nil {
t.Fatal(err)
}
if v2 {
db.Exec(`INSERT INTO kv VALUES ('migration.v1-v2','{"phase":"completed"}')`)
}
tool := map[string]any{"id": "tool", "type": "tool", "name": "bash", "tool": "bash", "time": map[string]int64{"created": now + 2000, "ran": now + 2000, "completed": now + 3000}, "state": map[string]any{"status": "completed", "input": map[string]string{"command": "private"}, "output": "result", "time": map[string]int64{"start": now + 2000, "end": now + 3000}}}
insert := func(id, role string, seq int, doc map[string]any) {
b, _ := json.Marshal(doc)
var err error
if v2 {
_, err = db.Exec(`INSERT INTO session_message VALUES (?,'s',?,?,?, ?,?)`, id, role, seq, now+int64(seq)*1000, now+5000, string(b))
} else {
_, err = db.Exec(`INSERT INTO message VALUES (?,'s',?,?,?)`, id, now+int64(seq)*1000, now+5000, string(b))
}
if err != nil {
t.Fatal(err)
}
}
insert("u", "user", 0, map[string]any{"role": "user", "text": "question", "time": map[string]int64{"created": now}})
a := map[string]any{"role": "assistant", "parentID": "u", "modelID": "model", "model": map[string]string{"id": "model", "providerID": "p"}, "providerID": "p", "time": map[string]int64{"created": now + 1000, "completed": now + 4000}, "tokens": map[string]any{"input": 100, "output": 10, "reasoning": 5, "cache": map[string]int{"read": 20, "write": 0}}}
if v2 {
a["content"] = []any{tool}
}
insert("a", "assistant", 1, a)
if !v2 {
b, _ := json.Marshal(tool)
db.Exec(`INSERT INTO part VALUES ('tool','a','s',?,?)`, now+5000, string(b))
}
r := NewTraceReader(ms(now).Add(-time.Second))
budget := int64(8 << 20)
spans := r.pollOpenCode(false, &budget)
roots, models, tools := spanKinds(spans)
if len(roots) != 1 || len(models) != 1 || len(tools) != 1 {
t.Fatalf("sqlite %+v", spans)
}
if tools[0].Parent != roots[0].ID || tools[0].Inferred || models[0].Tokens.Output != 15 || models[0].Provider != "p" {
t.Fatalf("sqlite %+v", spans)
}
if len(r.pollOpenCode(false, &budget)) != 0 {
t.Fatal("unchanged snapshot replayed")
}
if !v2 {
db.Exec(`UPDATE part SET time_updated=?,data=json_set(data,'$.state.status','error','$.state.error','failed')`, now+6000)
updated := r.pollOpenCode(false, &budget)
// A terminal observation can be patched without recounting its metrics.
for _, s := range updated {
if s.Parent != "" && !s.Update {
t.Fatal("terminal observation recounted")
}
}
}
})
}
}
func TestGeminiTracePatchesRewindsAndPrivacy(t *testing.T) {
for _, bodies := range []bool{false, true} {
s := &geminiTraceState{messages: map[string]*geminiTraceMessage{}}
lines := []string{
`{"sessionId":"s","startTime":"2026-10-02T12:00:00Z"}`,
`{"type":"user","id":"u","timestamp":"2026-10-02T12:00:00Z","content":[{"text":"private"}]}`,
`{"type":"gemini","id":"a","timestamp":"2026-10-02T12:00:02Z","model":"gemini-model","content":"thinking","tokens":{"input":100,"output":10,"thoughts":5,"cached":40},"toolCalls":[{"id":"tool","name":"read_file","status":"success","timestamp":"2026-10-02T12:00:03Z","args":{"path":"private"}}]}`,
`{"$patch":{"id":"a","toolCalls":[{"id":"tool","result":[{"functionResponse":{"id":"tool","response":{"result":"private"}}}]}]}}`,
`{"type":"user","id":"fr","timestamp":"2026-10-02T12:00:03Z","content":[{"functionResponse":{"id":"tool","response":{"result":"private"}}}]}`,
`{"type":"gemini","id":"final","timestamp":"2026-10-02T12:00:05Z","model":"gemini-model","content":"answer","tokens":{"input":120,"output":20,"cached":50}}`,
}
for _, line := range lines {
s.line([]byte(line), bodies)
}
r := NewTraceReader(at("2026-10-02T11:59:59Z"))
snap := r.snapshot("test")
spans := r.observations(snap, s.spans(bodies, true))
roots, models, tools := spanKinds(spans)
if len(roots) != 1 || len(models) != 2 || len(tools) != 1 || models[0].Tokens.Input != 60 || models[0].Tokens.Output != 15 || tools[0].Parent != roots[0].ID {
t.Fatalf("gemini %+v", spans)
}
for _, span := range spans {
if !bodies && (span.Input != "" || span.Output != "") {
t.Fatal("privacy leak")
}
}
if bodies && !strings.Contains(tools[0].Output, "private") {
t.Fatal("tool patch lost")
}
if len(r.observations(snap, s.spans(bodies, true))) != 0 {
t.Fatal("snapshot duplicated")
}
s.line([]byte(`{"$rewindTo":"a"}`), bodies)
if len(s.order) != 2 || s.messages["final"] != nil {
t.Fatal("rewind ignored")
}
}
}
func TestGeminiReaderHistoryPartialAndSettledTokens(t *testing.T) {
path := filepath.Join(t.TempDir(), "session-test.jsonl")
old := time.Now().Add(-time.Minute)
now := time.Now().Add(-3 * time.Second)
msg := func(id, typ string, at time.Time) string {
b, _ := json.Marshal(map[string]any{"id": id, "type": typ, "timestamp": at, "content": "private", "model": "model", "tokens": map[string]int{"input": 10, "output": 2}})
return string(b) + "\n"
}
initial := `{"sessionId":"s"}` + "\n" + msg("old", "user", old) + msg("old-a", "gemini", old.Add(time.Second)) + msg("new", "user", now)
full := msg("new-a", "gemini", now.Add(time.Second))
os.WriteFile(path, []byte(initial+full[:len(full)/2]), 0600)
f := file{agent: "gemini", path: path}
stat(&f)
r := NewTraceReader(now.Add(-time.Second))
r.files[path] = traceTestCursor("gemini")
budget := int64(8 << 20)
first := r.readGemini(f, false, &budget)
for _, s := range first {
if s.Turn != "new" {
t.Fatal("history replayed")
}
}
h, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
h.WriteString(full[len(full)/2:])
h.Close()
stat(&f)
spans := r.readGemini(f, false, &budget)
_, models, _ := spanKinds(spans)
if len(models) != 0 {
t.Fatal("unsettled final counted")
}
snap := r.snapshot(path)
final := r.observations(snap, snap.gemini.spans(false, true))
_, models, _ = spanKinds(final)
if len(models) != 1 || models[0].Turn != "new" {
t.Fatalf("final %+v", final)
}
if len(r.observations(snap, snap.gemini.spans(false, true))) != 0 {
t.Fatal("settled replayed")
}
}
func TestGeminiLegacySnapshotAndLaterPatch(t *testing.T) {
s := &geminiTraceState{messages: map[string]*geminiTraceMessage{}}
s.line([]byte(`{"sessionId":"s","messages":[{"id":"u","type":"user","timestamp":"2026-10-02T12:00:00Z","content":"question"},{"id":"a","type":"gemini","timestamp":"2026-10-02T12:00:02Z","model":"model","tokens":{"input":5,"output":1},"toolCalls":[{"id":"tool","name":"read","status":"success","timestamp":"2026-10-02T12:00:03Z"}]}]}`), true)
r := NewTraceReader(at("2026-10-02T11:59:59Z"))
snap := r.snapshot("legacy")
first := r.observations(snap, s.spans(true, true))
_, models, tools := spanKinds(first)
if len(models) != 1 || len(tools) != 1 {
t.Fatalf("legacy %+v", first)
}
s.line([]byte(`{"$patch":{"updates":[{"id":"a","toolCalls":[{"id":"tool","result":"late result"}]}]}}`), true)
updated := r.observations(snap, s.spans(true, true))
_, models, tools = spanKinds(updated)
if len(models) != 0 || len(tools) != 1 || !tools[0].Update || tools[0].Output != `"late result"` {
t.Fatalf("late patch %+v", updated)
}
}
func TestOpenCodeLegacyJSONToolUpdates(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "opencode", "storage")
t.Setenv("XDG_DATA_HOME", dir)
t.Setenv("OPENCODE_DB", "")
write := func(path string, value any) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0700)
b, _ := json.Marshal(value)
if err := os.WriteFile(path, b, 0600); err != nil {
t.Fatal(err)
}
}
now := time.Now().Add(-time.Second).UnixMilli()
write(filepath.Join(root, "session", "project", "s.json"), map[string]any{"id": "s", "time": map[string]int64{"created": now, "updated": now}})
write(filepath.Join(root, "message", "s", "u.json"), map[string]any{"id": "u", "role": "user", "time": map[string]int64{"created": now}})
write(filepath.Join(root, "message", "s", "a.json"), map[string]any{"id": "a", "role": "assistant", "parentID": "u", "modelID": "model", "time": map[string]int64{"created": now + 100, "completed": now + 500}, "tokens": map[string]int{"input": 10, "output": 2}})
part := filepath.Join(root, "part", "a", "tool.json")
tool := map[string]any{"id": "tool", "type": "tool", "tool": "bash", "state": map[string]any{"status": "running", "time": map[string]int64{"start": now + 200}}}
write(part, tool)
r := NewTraceReader(ms(now).Add(-time.Second))
budget := int64(8 << 20)
initial := r.pollOpenCode(true, &budget)
_, models, tools := spanKinds(initial)
if len(models) != 1 || len(tools) != 0 {
t.Fatalf("legacy initial %+v", initial)
}
tool["state"] = map[string]any{"status": "completed", "output": "result", "time": map[string]int64{"start": now + 200, "end": now + 600}}
write(part, tool)
// Touch only the part. A message's metadata need not change with it.
os.Chtimes(part, time.Now().Add(time.Second), time.Now().Add(time.Second))
updated := r.pollOpenCode(true, &budget)
_, models, tools = spanKinds(updated)
if len(tools) != 1 || tools[0].Name != "bash" || tools[0].End.Sub(tools[0].Start) != 400*time.Millisecond {
t.Fatalf("legacy update %+v", updated)
}
for _, m := range models {
if !m.Update {
t.Fatal("model usage recounted")
}
}
}
func TestClaudeTraceInterruptedToolDoesNotBlockNextInteraction(t *testing.T) {
c := traceTestCursor("claude")
clientTraceFeed(t, c, false,
`{"type":"user","sessionId":"s","uuid":"u","timestamp":"2026-10-02T12:00:00Z","message":{"content":"question"}}`,
`{"type":"assistant","timestamp":"2026-10-02T12:00:02Z","message":{"id":"m","model":"model","stop_reason":"tool_use","content":[{"type":"tool_use","id":"tool","name":"bash"}]}}`)
interrupted := clientTraceFeed(t, c, false, `{"type":"user","uuid":"next","timestamp":"2026-10-02T12:00:05Z","message":{"content":"next"}}`)
_, _, tools := spanKinds(interrupted)
if len(tools) != 1 || !tools[0].Error || len(c.tools) != 0 {
t.Fatalf("interrupted %+v", interrupted)
}
clientTraceFeed(t, c, false, `{"type":"assistant","timestamp":"2026-10-02T12:00:08Z","message":{"id":"m2","model":"model","stop_reason":"end_turn","content":[{"type":"text","text":"answer"}]}}`)
final := c.finishClaude(false)
if len(final) != 2 || final[1].Turn != "next" || final[1].Error {
t.Fatalf("next %+v", final)
}
}
func TestTraceDiscoveryIncludesMainStoresOnly(t *testing.T) {
home := t.TempDir()
data := filepath.Join(home, "data")
t.Setenv("HOME", home)
t.Setenv("XDG_DATA_HOME", data)
t.Setenv("CODEX_HOME", filepath.Join(home, "codex"))
t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(home, "claude"))
t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(home, "pi"))
t.Setenv("PI_CODING_AGENT_SESSION_DIR", "")
t.Setenv("GEMINI_CLI_HOME", filepath.Join(home, "gemini-home"))
files := map[string]string{
"claude": filepath.Join(home, "claude", "projects", "p", "s.jsonl"),
"omp": filepath.Join(home, ".omp", "agent", "sessions", "p", "now_s.jsonl"),
"gemini": filepath.Join(home, "gemini-home", ".gemini", "tmp", "p", "chats", "session-s.jsonl"),
}
for _, path := range files {
os.MkdirAll(filepath.Dir(path), 0700)
os.WriteFile(path, []byte("{}\n"), 0600)
}
for _, path := range []string{filepath.Join(home, "claude", "projects", "p", "s", "subagents", "agent-a.jsonl"), filepath.Join(home, ".omp", "agent", "sessions", "p", "now_s", "agent-a.jsonl")} {
os.MkdirAll(filepath.Dir(path), 0700)
os.WriteFile(path, []byte("{}\n"), 0600)
}
got := traceLineFiles()
if len(got) != 3 {
t.Fatalf("discovery %+v", got)
}
for _, f := range got {
if f.path != files[f.agent] {
t.Fatalf("child discovered %+v", f)
}
}
}
func TestGeminiParallelToolResultsStayWithTheirCalls(t *testing.T) {
raw := json.RawMessage(`[{"functionResponse":{"id":"one","name":"read","response":{"result":"first"}}},{"functionResponse":{"id":"two","name":"read","response":{"result":"second"}}}]`)
got := geminiTraceResult(raw, "one", "read")
if !strings.Contains(got, "first") || strings.Contains(got, "second") {
t.Fatalf("parallel results mixed: %s", got)
}
}
+359
View File
@@ -0,0 +1,359 @@
package sessions
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
)
// Gemini moved from rewritten JSON snapshots to append-only JSONL metadata,
// messages, $set/$patch and $rewindTo records. Both stores use the same message
// schema. Message/tool timestamps are event boundaries, not measured latency.
func geminiTraceFiles() []file {
home, _ := os.UserHomeDir()
if custom := os.Getenv("GEMINI_CLI_HOME"); custom != "" {
home = custom
}
root := filepath.Join(home, ".gemini", "tmp")
var out []file
for _, pattern := range []string{"*/chats/session-*.json", "*/chats/session-*.jsonl"} {
paths, _ := SessionGlob(filepath.Join(root, pattern))
for _, path := range paths {
f := file{agent: "gemini", path: path, main: true}
if stat(&f) {
out = append(out, f)
}
}
}
return out
}
type geminiTraceTool struct {
ID, Name, Status string
Timestamp time.Time
Args, Result json.RawMessage
}
type geminiTraceMessage struct {
ID, Type, Model string
Timestamp time.Time
Content json.RawMessage
ToolCalls []geminiTraceTool
Tokens *struct{ Input, Output, Cached, Thoughts int }
prompt bool
}
type geminiTraceState struct {
session string
order []string
messages map[string]*geminiTraceMessage
}
func (s *geminiTraceState) put(m geminiTraceMessage, bodies bool) {
if m.ID == "" {
return
}
// A recorded functionResponse is a tool-result turn, not a user prompt.
m.prompt = m.Type == "user"
var parts []struct{ FunctionResponse json.RawMessage }
if json.Unmarshal(m.Content, &parts) == nil {
for _, p := range parts {
if len(p.FunctionResponse) > 0 {
m.prompt = false
break
}
}
}
if !bodies {
m.Content = nil
for i := range m.ToolCalls {
m.ToolCalls[i].Args = nil
m.ToolCalls[i].Result = nil
}
}
if s.messages[m.ID] == nil {
s.order = append(s.order, m.ID)
}
s.messages[m.ID] = &m
// Keep the last user prompt plus a bounded window of recent messages.
if len(s.order) > 256 {
lastUser := ""
for _, id := range s.order {
if s.messages[id].prompt {
lastUser = id
}
}
keep := append([]string(nil), s.order[len(s.order)-255:]...)
if lastUser != "" && !containsTraceID(keep, lastUser) {
keep = append([]string{lastUser}, keep...)
}
retained := map[string]bool{}
for _, id := range keep {
retained[id] = true
}
for id := range s.messages {
if !retained[id] {
delete(s.messages, id)
}
}
s.order = keep
}
}
func containsTraceID(ids []string, id string) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
func (s *geminiTraceState) line(b []byte, bodies bool) {
var o struct {
SessionID string
Messages []geminiTraceMessage
Set *struct {
SessionID string
Messages []geminiTraceMessage
} `json:"$set"`
Rewind string `json:"$rewindTo"`
Patch *struct {
ID string
Content json.RawMessage
ToolCalls []struct {
ID string
Result json.RawMessage
}
Updates []json.RawMessage
RemoveIDs, OrderIDs []string
} `json:"$patch"`
}
if json.Unmarshal(b, &o) != nil {
return
}
if o.SessionID != "" {
s.session = o.SessionID
}
for _, m := range o.Messages {
s.put(m, bodies)
}
if o.Set != nil {
if o.Set.SessionID != "" {
s.session = o.Set.SessionID
}
if o.Set.Messages != nil {
s.messages = map[string]*geminiTraceMessage{}
s.order = nil
for _, m := range o.Set.Messages {
s.put(m, bodies)
}
}
}
if o.Rewind != "" {
for i, id := range s.order {
if id == o.Rewind {
for _, remove := range s.order[i+1:] {
delete(s.messages, remove)
}
s.order = s.order[:i+1]
break
}
}
}
if p := o.Patch; p != nil {
if m := s.messages[p.ID]; m != nil {
if p.Content != nil && bodies {
m.Content = p.Content
}
for _, tool := range p.ToolCalls {
for i := range m.ToolCalls {
if m.ToolCalls[i].ID == tool.ID && bodies {
m.ToolCalls[i].Result = tool.Result
}
}
}
}
for _, u := range p.Updates {
wrapped, _ := json.Marshal(map[string]json.RawMessage{"$patch": u})
s.line(wrapped, bodies)
}
for _, id := range p.RemoveIDs {
delete(s.messages, id)
}
var keep []string
for _, id := range s.order {
if s.messages[id] != nil && !containsTraceID(p.OrderIDs, id) {
keep = append(keep, id)
}
}
for _, id := range p.OrderIDs {
if s.messages[id] != nil {
keep = append(keep, id)
}
}
s.order = keep
}
var m geminiTraceMessage
if json.Unmarshal(b, &m) == nil && m.ID != "" && m.Type != "" {
s.put(m, bodies)
}
}
func (r *TraceReader) readGemini(f file, bodies bool, budget *int64) []TraceSpan {
snap := r.snapshot(f.path)
cursor := r.files[f.path]
if snap.gemini == nil {
snap.gemini = &geminiTraceState{messages: map[string]*geminiTraceMessage{}}
}
h, err := os.Open(f.path)
if err != nil {
return nil
}
defer h.Close()
info, err := h.Stat()
if err != nil {
return nil
}
if cursor.info != nil && (!os.SameFile(cursor.info, info) || f.size < cursor.offset) {
cursor.offset = 0
snap.gemini = &geminiTraceState{messages: map[string]*geminiTraceMessage{}}
}
cursor.info = info
if strings.HasSuffix(f.path, ".json") {
// Legacy files are atomically replaced; only parse complete snapshots.
if f.size > 32<<20 || *budget <= 0 {
return nil
}
b, err := os.ReadFile(f.path)
if err != nil || !json.Valid(b) {
return nil
}
*budget -= int64(len(b))
snap.gemini = &geminiTraceState{messages: map[string]*geminiTraceMessage{}}
snap.gemini.line(b, bodies)
cursor.offset = f.size
} else {
if _, err = h.Seek(cursor.offset, 0); err != nil {
return nil
}
reader := bufio.NewReader(h)
for *budget > 0 {
b, err := reader.ReadBytes('\n')
if err != nil {
break
}
cursor.offset += int64(len(b))
*budget -= int64(len(b))
snap.gemini.line(b, bodies)
}
}
if cursor.offset == f.size {
cursor.size, cursor.mod = f.size, f.mod
}
// Wait for the current file batch to settle: text, tokens and toolCalls can
// be appended separately. Tool completions and earlier responses export now.
settled := f.mod.Before(time.Now().Add(-2 * time.Second))
snap.settled = settled
return r.observations(snap, snap.gemini.spans(bodies, settled))
}
func (s *geminiTraceState) spans(bodies, settled bool) []TraceSpan {
c := &traceCursor{agent: "gemini", session: s.session}
var t *traceTurn
var out []TraceSpan
finish := func() {
if t != nil {
root := c.root(t, t.last, t.failed)
root.Inferred = true
out = append(out, root)
}
}
for i, id := range s.order {
m := s.messages[id]
if m == nil {
continue
}
if m.prompt {
finish()
t = &traceTurn{id: m.ID, start: m.Timestamp, last: m.Timestamp}
if bodies {
t.input = string(m.Content)
}
continue
}
if t == nil {
continue
}
if m.Type == "error" {
t.failed = true
t.last = m.Timestamp
continue
}
if m.Type != "gemini" {
continue
}
next := i < len(s.order)-1
if next || settled {
span := c.span(t.id, m.ID, "model "+m.Model, "generation", t.last, m.Timestamp)
span.Model, span.Provider, span.Inferred = m.Model, "google", true
if u := m.Tokens; u != nil {
span.Tokens = Tokens{Input: max(0, u.Input-u.Cached), Output: u.Output + u.Thoughts, CacheRead: u.Cached}
span.Reasoning = u.Thoughts
}
if bodies {
span.Input = t.input
span.Output = string(m.Content)
t.output = span.Output
}
out = append(out, span)
}
if m.Timestamp.After(t.last) {
t.last = m.Timestamp
}
for _, tool := range m.ToolCalls {
if tool.Status != "success" && tool.Status != "error" && tool.Status != "cancelled" {
continue
}
// Gemini stamps tool state updates. Its result record supplies the end;
// the owning model message supplies a conservative start boundary.
span := c.span(t.id, m.ID+":"+tool.ID, tool.Name, "tool", m.Timestamp, tool.Timestamp)
span.Inferred = true
span.Error = tool.Status != "success"
if bodies {
span.Input = string(tool.Args)
span.Output = geminiTraceResult(tool.Result, tool.ID, tool.Name)
}
out = append(out, span)
if tool.Timestamp.After(t.last) {
t.last = tool.Timestamp
}
t.failed = t.failed || span.Error
}
}
finish()
return out
}
// Some Gemini versions attach a whole parallel functionResponse turn to each
// tool. Select that tool's own response, rather than showing its siblings too.
func geminiTraceResult(raw json.RawMessage, id, name string) string {
var parts []json.RawMessage
if json.Unmarshal(raw, &parts) != nil {
return string(raw)
}
var selected []json.RawMessage
for _, part := range parts {
var p struct{ FunctionResponse *struct{ ID, Name string } }
if json.Unmarshal(part, &p) != nil {
continue
}
if p.FunctionResponse == nil || p.FunctionResponse.ID == id || p.FunctionResponse.ID == "" && p.FunctionResponse.Name == name {
selected = append(selected, part)
}
}
if len(selected) == 0 {
return ""
}
b, _ := json.Marshal(selected)
return string(b)
}
+280
View File
@@ -0,0 +1,280 @@
package sessions
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func traceOCMessages(f file, budget *int64) [][]byte {
var out [][]byte
for _, b := range f.oc.messages(f.sid) {
if *budget <= 0 {
break
}
*budget -= int64(len(b))
out = append(out, b)
}
return out
}
func traceOCParts(f file, mid string, budget *int64) [][]byte {
var out [][]byte
for _, b := range f.oc.parts(mid) {
if *budget <= 0 {
break
}
*budget -= int64(len(b))
out = append(out, b)
}
return out
}
func (r *TraceReader) pollOpenCode(bodies bool, budget *int64) []TraceSpan {
// Unchanged SQLite/WAL files need no queries. Opening a new connection for
// data_version would reset its baseline, so use both files' stat identities.
dbPath := openCodeDB()
if fileExists(dbPath) {
if info, err := os.Stat(dbPath); err == nil {
if r.ocInfo != nil && !os.SameFile(r.ocInfo, info) {
for key := range r.snapshots {
if strings.HasPrefix(key, "opencode:") {
delete(r.snapshots, key)
}
}
r.ocStamp = [4]int64{}
}
r.ocInfo = info
}
var stamp [4]int64
for i, path := range []string{dbPath, dbPath + "-wal"} {
if info, err := os.Stat(path); err == nil {
stamp[i*2], stamp[i*2+1] = info.Size(), info.ModTime().UnixNano()
}
}
if r.ocStamp == stamp && !r.ocPending {
return nil
}
out, pending := r.pollOpenCodeDB(dbPath, bodies, budget)
r.ocStamp, r.ocPending = stamp, pending
return out
}
files := openCodeJSONFiles(filepath.Join(OpenCodeDir(), "storage"))
for i, f := range files {
if store, ok := f.oc.(ocFiles); ok {
for _, message := range readDirectory(filepath.Join(store.root, "message", f.sid)) {
mid := strings.TrimSuffix(message.Name(), ".json")
for _, part := range readDirectory(filepath.Join(store.root, "part", mid)) {
if info, err := part.Info(); err == nil && info.Mode().IsRegular() {
files[i].size += info.Size()
if info.ModTime().After(files[i].mod) {
files[i].mod = info.ModTime()
}
}
}
}
}
}
sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) })
if len(files) > Limit {
files = files[:Limit]
}
seen := map[string]bool{}
var out []TraceSpan
for _, f := range files {
path := "opencode:" + f.path
seen[path] = true
if !f.main || f.mod.Before(r.Since.Add(-2*time.Second)) || *budget <= 0 {
continue
}
s := r.snapshot(path)
if s.size == f.size && s.mod.Equal(f.mod) {
continue
}
localBudget := int64(32 << 20)
docs := traceOCMessages(f, &localBudget)
// Remember oversized legacy snapshots until they change. SQLite uses
// resumable row batches instead of this snapshot path.
if localBudget <= 0 {
*budget -= 32 << 20
s.size, s.mod = f.size, f.mod
continue
}
messages := make([]ocTraceMessage, 0, len(docs))
for _, b := range docs {
var m ocTraceMessage
if json.Unmarshal(b, &m) != nil {
continue
}
if m.Role == "" {
m.Role = m.Type
}
if m.Model.ID != "" {
m.ModelID = m.Model.ID
m.ProviderID = m.Model.ProviderID
}
if _, ok := f.oc.(ocV2DB); !ok {
for _, p := range traceOCParts(f, m.ID, &localBudget) {
var part ocTracePart
if json.Unmarshal(p, &part) == nil {
m.Content = append(m.Content, part)
}
}
}
messages = append(messages, m)
}
if localBudget <= 0 {
*budget -= 32 << 20
s.size, s.mod = f.size, f.mod
continue
}
*budget -= (32 << 20) - localBudget
out = append(out, r.observations(s, traceOpenCode(f.sid, messages, bodies))...)
s.size, s.mod = f.size, f.mod
}
for path := range r.snapshots {
if strings.HasPrefix(path, "opencode:") && !seen[path] {
delete(r.snapshots, path)
}
}
return out
}
type ocTracePart struct {
ID, Type, Text, Name, Tool, CallID string
Synthetic, Ignored bool
Time struct{ Created, Ran, Completed int64 }
State struct {
Status string
Input, Output, Content, Error json.RawMessage
Time struct{ Start, End int64 }
}
}
type ocTraceMessage struct {
ID, Type, Role, ParentID, ModelID, ProviderID, Finish, Text string
Model struct{ ID, ProviderID string }
Time struct{ Created, Completed int64 }
Tokens *struct {
Input, Output, Reasoning int
Cache struct{ Read, Write int }
}
Error json.RawMessage
Content []ocTracePart
}
func traceOpenCode(sid string, messages []ocTraceMessage, bodies bool) []TraceSpan {
sort.SliceStable(messages, func(i, j int) bool {
if messages[i].Time.Created == messages[j].Time.Created {
return messages[i].ID < messages[j].ID
}
return messages[i].Time.Created < messages[j].Time.Created
})
c := &traceCursor{agent: "opencode", session: sid, turns: map[string]*traceTurn{}}
var out []TraceSpan
var current *traceTurn
roots := map[string]*traceTurn{}
for _, m := range messages {
if m.ID == "" || m.Time.Created <= 0 {
continue
}
if m.Role == "user" {
current = &traceTurn{id: m.ID, start: ms(m.Time.Created), last: ms(m.Time.Created)}
if bodies {
current.input = m.Text
for _, p := range m.Content {
if p.Type == "text" && !p.Synthetic && !p.Ignored {
current.input += p.Text
}
}
}
roots[m.ID] = current
continue
}
if m.Role != "assistant" {
continue
}
t := roots[m.ParentID]
if t == nil {
if m.ParentID != "" {
continue
}
t = current
}
if t == nil {
continue
}
end := ms(m.Time.Completed)
failed := len(m.Error) > 0 && string(m.Error) != "null"
if !end.IsZero() {
s := c.span(t.id, m.ID, "model "+m.ModelID, "generation", ms(m.Time.Created), end)
s.Model, s.Provider, s.Error = m.ModelID, m.ProviderID, failed
if u := m.Tokens; u != nil {
s.Tokens = Tokens{u.Input, u.Output + u.Reasoning, u.Cache.Read, u.Cache.Write}
s.Reasoning = u.Reasoning
}
if bodies {
s.Input = t.input
b, _ := json.Marshal(m.Content)
s.Output = string(b)
t.output = s.Output
}
out = append(out, s)
if end.After(t.last) {
t.last = end
}
t.failed = t.failed || failed
}
for _, p := range m.Content {
if p.Type != "tool" {
continue
}
if p.State.Status != "completed" && p.State.Status != "error" {
continue
}
start, done := ms(p.Time.Ran), ms(p.Time.Completed)
if start.IsZero() {
start = ms(p.Time.Created)
}
if start.IsZero() {
start = ms(p.State.Time.Start)
}
if done.IsZero() {
done = ms(p.State.Time.End)
}
name := p.Name
if name == "" {
name = p.Tool
}
key := p.ID
if key == "" {
key = p.CallID
}
if key == "" {
continue
}
s := c.span(t.id, key, name, "tool", start, done)
s.Error = p.State.Status == "error"
if bodies {
s.Input = string(p.State.Input)
s.Output = string(p.State.Output)
if s.Output == "" {
s.Output = string(p.State.Content)
}
if s.Error {
s.Output = string(p.State.Error)
}
}
out = append(out, s)
if done.After(t.last) {
t.last = done
}
t.failed = t.failed || s.Error
}
}
for _, t := range roots {
out = append(out, c.root(t, t.last, t.failed))
}
return out
}
+385
View File
@@ -0,0 +1,385 @@
package sessions
import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/yetone/magpie/internal/provider"
)
// These connections belong to the trace reader, not the Sessions listing cache.
// Updates are read in timestamp/ID order in bounded batches; large histories
// neither hold dbReadMu nor force the entire conversation to be re-exported.
type ocTraceState struct {
messages map[string]ocTraceMessage
order []string
bytes int
sizes map[string]int
message, part ocTracePosition
turns map[string]TraceSpan
v2 bool
}
type ocTracePosition struct {
at int64
id string
}
func (r *TraceReader) pollOpenCodeDB(path string, bodies bool, budget *int64) ([]TraceSpan, bool) {
db, err := provider.OpenReadOnly(path)
if err != nil {
return nil, true
}
defer db.Close()
db.SetMaxOpenConns(1)
parent := map[string]string{}
const q = `SELECT id, COALESCE(parent_id, ''), time_updated, 0, 0 FROM %s %s`
var files []file
if ocHasTable(db, "session_v2") {
files = ocDBRows(db, fmt.Sprintf(q, "session_v2", ""), "opencode", path, ocV2DB{db}, parent)
}
if ocHasTable(db, "session") && !ocMigrated(db) {
where := ""
if len(files) > 0 {
where = `WHERE id NOT IN (SELECT id FROM session_v2)`
}
files = append(files, ocDBRows(db, fmt.Sprintf(q, "session", where), "opencode", path, ocDB{db}, parent)...)
}
files = ocRoots(files, parent)
// Retain only the latest stores, as for JSONL discovery.
sortTraceOCFiles(files)
if len(files) > Limit {
files = files[:Limit]
}
var out []TraceSpan
pending := false
seen := map[string]bool{}
resumeSaved := false
start := 0
if len(files) > 0 {
start = r.ocNext % len(files)
}
r.ocNext = 0
for n := 0; n < len(files); n++ {
i := (start + n) % len(files)
f := files[i]
key := "opencode:" + f.path
seen[key] = true
if !f.main {
continue
}
if *budget <= 0 {
pending = true
if !resumeSaved {
r.ocNext = i
resumeSaved = true
}
continue
}
snap := r.snapshot(key)
v2 := false
if _, ok := f.oc.(ocV2DB); ok {
v2 = true
}
if snap.oc == nil || snap.oc.v2 != v2 {
// Older prompts are fetched on demand as ancestry, never exported.
snap.oc = &ocTraceState{v2: v2, messages: map[string]ocTraceMessage{}, turns: map[string]TraceSpan{}, message: ocTracePosition{at: r.Since.UnixMilli() - 1}, part: ocTracePosition{at: r.Since.UnixMilli() - 1}}
}
state := snap.oc
changed := map[string]bool{}
table, kind := "message", "''"
if v2 {
table, kind = "session_message", "type"
}
// A row too large for the retained body window is read as metadata;
// body export is bounded independently from traversal progress.
more, ok := state.readRows(db, table, kind, f.sid, false, bodies, budget, changed)
pending = pending || more || !ok
if !v2 && *budget > 0 {
more, ok = state.readRows(db, "part", "''", f.sid, true, bodies, budget, changed)
pending = pending || more || !ok
}
// Part-only updates may refer to messages evicted from the body window.
// Hydrate their small headers after closing the streaming rows cursor.
for mid, m := range state.messages {
if m.Role != "" {
continue
}
var raw []byte
if db.QueryRow(`SELECT CASE WHEN length(data)>8388608 THEN json_remove(data,'$.text','$.content') ELSE data END FROM message WHERE id=? AND session_id=?`, mid, f.sid).Scan(&raw) == nil {
*budget -= int64(len(raw))
var doc ocTraceMessage
if json.Unmarshal(raw, &doc) == nil {
doc.ID = mid
doc.Content = m.Content
normalizeOCMessage(&doc, bodies)
state.put(doc)
changed[mid] = true
}
}
}
for mid := range changed {
if state.messages[mid].Role == "user" {
for id, m := range state.messages {
if m.Role == "assistant" && m.ParentID == mid {
changed[id] = true
}
}
}
}
for mid := range changed {
m, ok := state.messages[mid]
if !ok || m.Role != "assistant" {
continue
}
user := state.user(db, table, m, bodies, budget)
if user.ID == "" {
continue
}
m.ParentID = user.ID
spans := traceOpenCode(f.sid, []ocTraceMessage{user, m}, bodies)
for i, span := range spans {
if span.Parent != "" {
continue
}
if prior, exists := state.turns[user.ID]; exists {
if prior.End.After(span.End) {
span.End, span.Output = prior.End, prior.Output
}
span.Error = span.Error || prior.Error
}
state.turns[user.ID] = span
spans[i] = span
}
out = append(out, r.observations(snap, spans)...)
}
state.trim()
}
for key := range r.snapshots {
if strings.HasPrefix(key, "opencode:") && !seen[key] {
delete(r.snapshots, key)
}
}
return out, pending
}
// Soft budgets allow one row's metadata to make progress. LIMIT and the cursor
// ensure an oversized session resumes next tick instead of restarting forever.
func (s *ocTraceState) readRows(db *sql.DB, table, kind, sid string, parts, bodies bool, budget *int64, changed map[string]bool) (bool, bool) {
pos := &s.message
mid := "id"
if parts {
pos, mid = &s.part, "message_id"
}
query := fmt.Sprintf(`SELECT id, %s, %s, time_updated, length(data), CASE WHEN length(data) > %d THEN json_remove(data,'$.text','$.content','$.state.input','$.state.output','$.state.content','$.state.error') ELSE data END FROM %s WHERE session_id=? AND (time_updated>? OR (time_updated=? AND id>?)) ORDER BY time_updated,id LIMIT 256`, mid, kind, 8<<20, table)
rows, err := db.Query(query, sid, pos.at, pos.at, pos.id)
if err != nil {
return false, false
}
defer rows.Close()
n := 0
for rows.Next() {
var id, message, typ string
var at, size int64
var raw []byte
if rows.Scan(&id, &message, &typ, &at, &size, &raw) != nil {
return true, false
}
*budget -= int64(len(raw))
*pos = ocTracePosition{at: at, id: id}
n++
if parts {
m, exists := s.messages[message]
if !exists {
// Fetch outside the active rows cursor after it is closed.
changed[message] = true
// Missing messages are hydrated in a second pass below.
m.ID = message
}
var p ocTracePart
if json.Unmarshal(raw, &p) == nil {
p.ID = id
if !bodies {
stripOCPart(&p)
}
found := false
for i := range m.Content {
if m.Content[i].ID == id {
m.Content[i], found = p, true
break
}
}
if !found && len(m.Content) < 4096 {
m.Content = append(m.Content, p)
}
s.put(m)
changed[message] = true
}
} else {
var m ocTraceMessage
if json.Unmarshal(raw, &m) == nil {
m.ID = id
if m.Role == "" {
m.Role = typ
}
normalizeOCMessage(&m, bodies)
if table == "message" {
m.Content = s.messages[id].Content
}
s.put(m)
changed[id] = true
}
}
if *budget <= 0 {
return true, true
}
}
if rows.Err() != nil {
return true, false
}
rows.Close()
return n == 256, true
}
func normalizeOCMessage(m *ocTraceMessage, bodies bool) {
if m.Role == "" {
m.Role = m.Type
}
if m.Model.ID != "" {
m.ModelID, m.ProviderID = m.Model.ID, m.Model.ProviderID
}
if !bodies {
m.Text = ""
for i := range m.Content {
stripOCPart(&m.Content[i])
}
if len(m.Error) > 0 && string(m.Error) != "null" {
m.Error = json.RawMessage(`true`)
}
}
}
func stripOCPart(p *ocTracePart) {
p.Text = ""
p.State.Input = nil
p.State.Output = nil
p.State.Content = nil
p.State.Error = nil
}
func (s *ocTraceState) put(m ocTraceMessage) {
for i, id := range s.order {
if id == m.ID {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
s.order = append(s.order, m.ID)
if s.sizes == nil {
s.sizes = map[string]int{}
}
b, _ := json.Marshal(m)
s.bytes += len(b) - s.sizes[m.ID]
s.sizes[m.ID] = len(b)
s.messages[m.ID] = m
}
func (s *ocTraceState) trim() {
// Bodies are never retained when disabled; cap both count and bytes when on.
bytes := 0
keep := len(s.order)
for i := len(s.order) - 1; i >= 0; i-- {
bytes += s.sizes[s.order[i]]
if len(s.order)-i > 256 || (bytes > 8<<20 && i < len(s.order)-1) {
keep = len(s.order) - i - 1
break
}
}
for _, id := range s.order[:len(s.order)-keep] {
s.bytes -= s.sizes[id]
delete(s.messages, id)
delete(s.sizes, id)
}
s.order = append([]string(nil), s.order[len(s.order)-keep:]...)
if len(s.turns) > 256 {
keys := make([]string, 0, len(s.turns))
for id := range s.turns {
keys = append(keys, id)
}
sort.Slice(keys, func(i, j int) bool { return s.turns[keys[i]].End.After(s.turns[keys[j]].End) })
for _, id := range keys[128:] {
delete(s.turns, id)
}
}
// Root bodies also retain references; keep the most recent within the bound.
rootKeys := make([]string, 0, len(s.turns))
for id := range s.turns {
rootKeys = append(rootKeys, id)
}
sort.Slice(rootKeys, func(i, j int) bool { return s.turns[rootKeys[i]].End.After(s.turns[rootKeys[j]].End) })
rootBytes := 0
for _, id := range rootKeys {
span := s.turns[id]
rootBytes += len(span.Input) + len(span.Output)
if rootBytes > 8<<20 {
span.Input, span.Output = "", ""
s.turns[id] = span
}
}
}
func (s *ocTraceState) user(db *sql.DB, table string, m ocTraceMessage, bodies bool, budget *int64) ocTraceMessage {
if user, ok := s.messages[m.ParentID]; ok && user.Role == "user" {
return user
}
var id string
var raw []byte
doc := `CASE WHEN length(data)>8388608 THEN json_remove(data,'$.text','$.content') ELSE data END`
if !bodies || *budget <= 0 {
doc = `json_remove(data,'$.text','$.content')`
}
if m.ParentID != "" {
if db.QueryRow("SELECT id,"+doc+" FROM "+table+" WHERE id=?", m.ParentID).Scan(&id, &raw) != nil {
return ocTraceMessage{}
}
} else {
role := `json_extract(data,'$.role')`
if table == "session_message" {
role = "type"
}
// V2 has no parentID; pick the preceding user in this session only.
if db.QueryRow("SELECT id,"+doc+" FROM "+table+" WHERE session_id=(SELECT session_id FROM "+table+" WHERE id=?) AND "+role+"='user' AND time_created<=? ORDER BY time_created DESC,id DESC LIMIT 1", m.ID, m.Time.Created).Scan(&id, &raw) != nil {
return ocTraceMessage{}
}
}
*budget -= int64(len(raw))
var user ocTraceMessage
if json.Unmarshal(raw, &user) != nil {
return user
}
user.ID, user.Role = id, "user"
normalizeOCMessage(&user, bodies)
if table == "message" && bodies && *budget > 0 {
rows, err := db.Query(`SELECT id,CASE WHEN length(data)>8388608 THEN json_remove(data,'$.text','$.state.input','$.state.output','$.state.content','$.state.error') ELSE data END FROM part WHERE message_id=? LIMIT 4096`, id)
if err == nil {
defer rows.Close()
for *budget > 0 && rows.Next() {
var pid string
var b []byte
if rows.Scan(&pid, &b) == nil {
*budget -= int64(len(b))
var p ocTracePart
if json.Unmarshal(b, &p) == nil {
p.ID = pid
user.Content = append(user.Content, p)
}
}
}
}
}
s.put(user)
return user
}
func sortTraceOCFiles(files []file) {
sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) })
}
+204
View File
@@ -0,0 +1,204 @@
package sessions
import (
"bufio"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCodexTraceNativeCallsAndMCPResult(t *testing.T) {
for _, bodies := range []bool{false, true} {
c := traceTestCursor("codex")
spans := clientTraceFeed(t, c, bodies,
`{"type":"session_meta","payload":{"id":"session"}}`,
`{"type":"turn_context","timestamp":"2026-10-02T12:00:00Z","payload":{"turn_id":"turn","model":"model"}}`,
`{"type":"response_item","timestamp":"2026-10-02T12:00:01Z","payload":{"type":"custom_tool_call","name":"exec","call_id":"exec","input":"private"}}`,
`{"type":"response_item","timestamp":"2026-10-02T12:00:02Z","payload":{"type":"function_call","name":"wait","call_id":"wait","arguments":"private"}}`,
`{"type":"response_item","timestamp":"2026-10-02T12:00:03Z","payload":{"type":"function_call_output","call_id":"wait","output":"wait result"}}`,
`{"type":"response_item","timestamp":"2026-10-02T12:00:04Z","payload":{"type":"custom_tool_call_output","call_id":"exec","output":"exec result"}}`,
`{"type":"response_item","timestamp":"2026-10-02T12:00:04Z","payload":{"type":"custom_tool_call_output","call_id":"exec","output":"exec result"}}`,
`{"type":"event_msg","timestamp":"2026-10-02T12:00:05Z","payload":{"type":"item_completed","item":{"type":"McpToolCall","id":"mcp","tool":"read","result":{"content":[{"type":"text","text":"mcp result"}]}}}}`)
_, _, tools := spanKinds(spans)
if len(tools) != 3 || tools[0].Name != "wait" || tools[1].Name != "exec" || !tools[0].Inferred || !tools[1].Inferred || tools[1].End.Sub(tools[1].Start) != 3*time.Second {
t.Fatalf("tools %+v", tools)
}
for _, span := range tools {
if !bodies && (span.Input != "" || span.Output != "") {
t.Fatal("body leaked")
}
}
if bodies && (!strings.Contains(tools[2].Output, "mcp result") || !strings.Contains(tools[1].Output, "exec result")) {
t.Fatal("tool result missing")
}
}
}
func TestCodexTraceRecordedMCPWinsOverNativeOutput(t *testing.T) {
for _, bodies := range []bool{false, true} {
c := traceTestCursor("codex")
spans := clientTraceFeed(t, c, bodies,
`{"type":"session_meta","payload":{"id":"session"}}`,
`{"type":"turn_context","timestamp":"2026-10-02T12:00:00Z","payload":{"turn_id":"turn","model":"model"}}`,
`{"type":"response_item","timestamp":"2026-10-02T12:00:01Z","payload":{"type":"function_call","call_id":"same-id","name":"read","arguments":"private"}}`,
`{"type":"event_msg","timestamp":"2026-10-02T12:00:04Z","payload":{"type":"item_completed","started_at_ms":1790942402000,"completed_at_ms":1790942404000,"item":{"type":"McpToolCall","id":"same-id","tool":"read","arguments":{"path":"private"},"result":{"content":[{"type":"text","text":"recorded result"}]}}}}`,
`{"type":"response_item","timestamp":"2026-10-02T12:00:05Z","payload":{"type":"function_call_output","call_id":"same-id","output":"inferred result"}}`)
_, _, tools := spanKinds(spans)
if len(tools) != 1 || tools[0].Inferred || tools[0].End.Sub(tools[0].Start) != 2*time.Second {
t.Fatalf("duplicate or inferred MCP: %+v", tools)
}
if bodies && (!strings.Contains(tools[0].Output, "recorded result") || strings.Contains(tools[0].Output, "inferred result")) {
t.Fatal("recorded result overwritten")
}
if !bodies && (tools[0].Input != "" || tools[0].Output != "") {
t.Fatal("body leaked")
}
if len(c.tools) != 0 {
t.Fatal("completed MCP call retained")
}
}
}
func TestSanitizedRealTraceSessions(t *testing.T) {
for _, agent := range []string{"pi", "codex"} {
for _, bodies := range []bool{false, true} {
f, err := os.Open(filepath.Join("testdata", "traces", agent+"-real-sanitized.jsonl"))
if err != nil {
t.Fatal(err)
}
c := traceTestCursor(agent)
var spans []TraceSpan
s := bufio.NewScanner(f)
for s.Scan() {
spans = append(spans, c.line(s.Bytes(), bodies)...)
}
f.Close()
if s.Err() != nil {
t.Fatal(s.Err())
}
roots, models, tools := spanKinds(spans)
wantModels, wantTools := 17, 16
if agent == "codex" {
wantModels, wantTools = 5, 8
} // Four exec calls, each containing a recorded command.
if len(models) != wantModels || len(tools) != wantTools || len(roots) < 2 {
t.Fatalf("%s bodies=%v: roots=%d models=%d tools=%d", agent, bodies, len(roots), len(models), len(tools))
}
ids := map[string]bool{}
for _, span := range spans {
ids[span.ID] = true
if !bodies && (span.Input != "" || span.Output != "") {
t.Fatal("body leaked")
}
}
for _, span := range spans {
if span.Parent != "" && !ids[span.Parent] {
t.Fatalf("orphan %s", span.ID)
}
}
}
}
}
func TestOpenCodeTraceLargeSessionProgressAndIncrementalUpdate(t *testing.T) {
path := filepath.Join(t.TempDir(), "opencode.db")
t.Setenv("OPENCODE_DB", path)
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, q := range []string{
`CREATE TABLE session (id TEXT PRIMARY KEY,parent_id TEXT,directory TEXT,title TEXT,time_created INTEGER,time_updated INTEGER)`,
`CREATE TABLE message (id TEXT PRIMARY KEY,session_id TEXT,time_created INTEGER,time_updated INTEGER,data TEXT)`,
`CREATE TABLE part (id TEXT PRIMARY KEY,message_id TEXT,session_id TEXT,time_updated INTEGER,data TEXT)`,
`CREATE INDEX message_updates ON message(session_id,time_updated,id)`,
`CREATE INDEX part_updates ON part(session_id,time_updated,id)`,
} {
if _, err = db.Exec(q); err != nil {
t.Fatal(err)
}
}
now := time.Now().Add(-time.Second).UnixMilli()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(`INSERT INTO session VALUES ('s',NULL,'','',?,?)`, now, now); err != nil {
t.Fatal(err)
}
user := fmt.Sprintf(`{"role":"user","text":"prompt","time":{"created":%d}}`, now)
if _, err = tx.Exec(`INSERT INTO message VALUES ('u','s',?,?,?)`, now, now, user); err != nil {
t.Fatal(err)
}
large := strings.Repeat("x", 200<<10)
for i := 0; i < 200; i++ {
id := fmt.Sprintf("a%03d", i)
m := fmt.Sprintf(`{"role":"assistant","parentID":"u","modelID":"model","time":{"created":%d,"completed":%d}}`, now+1, now+2+int64(i))
if _, err = tx.Exec(`INSERT INTO message VALUES (?,'s',?,?,?)`, id, now+1, now, m); err != nil {
t.Fatal(err)
}
p, _ := json.Marshal(map[string]any{"type": "text", "text": large})
if _, err = tx.Exec(`INSERT INTO part VALUES (?,?,'s',?,?)`, id, id, now, string(p)); err != nil {
t.Fatal(err)
}
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
r := NewTraceReader(ms(now).Add(-time.Second))
models := 0
for i := 0; i < 12; i++ {
budget := int64(8 << 20)
// Trace reading must not acquire the Sessions listing mutex.
dbReadMu.Lock()
done := make(chan []TraceSpan, 1)
go func() { done <- r.pollOpenCode(false, &budget) }()
var spans []TraceSpan
select {
case spans = <-done:
dbReadMu.Unlock()
case <-time.After(10 * time.Second):
dbReadMu.Unlock()
<-done
t.Fatal("trace reading blocked on the listing mutex")
}
_, gen, _ := spanKinds(spans)
for _, s := range gen {
if !s.Update {
models++
}
}
if !r.ocPending {
break
}
}
if models != 200 || r.ocPending {
t.Fatalf("large session made no progress: models=%d pending=%v", models, r.ocPending)
}
budget := int64(8 << 20)
if spans := r.pollOpenCode(false, &budget); len(spans) != 0 || budget != 8<<20 {
t.Fatal("idle store re-read")
}
// One changed part must not re-read the other 199 200-KiB parts.
tool := fmt.Sprintf(`{"type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"private"},"output":"private","time":{"start":%d,"end":%d}}}`, now+1, now+3)
if _, err = db.Exec(`UPDATE part SET time_updated=?,data=? WHERE id='a000'`, now+1000, tool); err != nil {
t.Fatal(err)
}
budget = 8 << 20
spans := r.pollOpenCode(false, &budget)
_, _, tools := spanKinds(spans)
if len(tools) != 1 || budget < (8<<20)-4096 {
t.Fatalf("update re-read history: tools=%d bytes=%d", len(tools), (8<<20)-budget)
}
for _, span := range spans {
if span.Input != "" || span.Output != "" {
t.Fatal("body leaked")
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package sessions
import (
"crypto/sha256"
"encoding/json"
"time"
)
// Snapshot stores metadata only for emitted observations. Later patches update
// the same observation IDs without counting model metrics a second time.
// The latest 4096 observations per source bound memory for long-lived sessions.
type traceSnapshot struct {
size int64
mod time.Time
done map[string][32]byte
ends map[string]time.Time
floor time.Time
order []string
gemini *geminiTraceState
settled bool
oc *ocTraceState
}
func (r *TraceReader) snapshot(path string) *traceSnapshot {
s := r.snapshots[path]
if s == nil {
s = &traceSnapshot{done: map[string][32]byte{}, ends: map[string]time.Time{}}
r.snapshots[path] = s
}
return s
}
func (r *TraceReader) observations(s *traceSnapshot, spans []TraceSpan) []TraceSpan {
var out []TraceSpan
for _, span := range spans {
if span.Session == "" || span.Turn == "" || span.Start.IsZero() || span.End.Before(span.Start) || span.End.Before(r.Since) {
continue
}
b, _ := json.Marshal(span)
hash := sha256.Sum256(b)
if prior, ok := s.done[span.ID]; ok {
if prior == hash {
continue
}
span.Update = true
} else {
if !s.floor.IsZero() && !span.End.After(s.floor) {
continue
}
s.order = append(s.order, span.ID)
}
s.done[span.ID] = hash
s.ends[span.ID] = span.End
out = append(out, span)
}
if len(s.order) > 4096 {
for _, id := range s.order[:len(s.order)-4096] {
if s.ends[id].After(s.floor) {
s.floor = s.ends[id]
}
delete(s.done, id)
delete(s.ends, id)
}
s.order = append([]string(nil), s.order[len(s.order)-4096:]...)
}
return out
}
+174
View File
@@ -0,0 +1,174 @@
package sessions
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func traceLine(t *testing.T, c *traceCursor, at time.Time, typ string, payload any, bodies bool) []TraceSpan {
t.Helper()
line, err := json.Marshal(map[string]any{"timestamp": at, "type": typ, "payload": payload})
if err != nil {
t.Fatal(err)
}
return c.line(line, bodies)
}
func traceTestCursor(agent string) *traceCursor {
return &traceCursor{agent: agent, turns: map[string]*traceTurn{}, tools: map[string]TraceSpan{}, parents: map[string]string{}}
}
func TestCodexTraceRecordedToolsAndInterruptedTurns(t *testing.T) {
c := traceTestCursor("codex")
base := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
traceLine(t, c, base, "session_meta", map[string]any{"id": "session"}, false)
root := traceLine(t, c, base, "event_msg", map[string]any{"type": "task_started", "turn_id": "turn", "started_at": base.Unix()}, false)[0]
traceLine(t, c, base, "turn_context", map[string]any{"turn_id": "turn", "model": "model"}, false)
tools := traceLine(t, c, base.Add(3*time.Second), "event_msg", map[string]any{"type": "item_completed", "turn_id": "turn", "started_at_ms": base.Add(time.Second).UnixMilli(), "completed_at_ms": base.Add(3 * time.Second).UnixMilli(), "item": map[string]any{"type": "CommandExecution", "id": "tool", "command": "PRIVATE-COMMAND", "aggregated_output": "PRIVATE-OUTPUT", "exit_code": 1}}, false)
if len(tools) != 1 || tools[0].Inferred || !tools[0].Error || tools[0].Parent != root.ID || tools[0].End.Sub(tools[0].Start) != 2*time.Second || tools[0].Input != "" || tools[0].Output != "" {
t.Fatalf("tool: %+v", tools)
}
model := traceLine(t, c, base.Add(4*time.Second), "token_usage_record", map[string]any{"turn_id": "turn", "response_id": "response", "usage": map[string]int{"input_tokens": 100, "cached_input_tokens": 40, "output_tokens": 10}}, false)[0]
if model.Parent != root.ID || model.Tokens.Input != 60 || model.Tokens.CacheRead != 40 || model.Model != "model" || !model.Inferred {
t.Fatalf("model: %+v", model)
}
end := base.Add(5*time.Second + 100*time.Millisecond)
finished := traceLine(t, c, end, "event_msg", map[string]any{"type": "turn_aborted", "turn_id": "turn", "duration_ms": 5100, "completed_at": end.Unix()}, false)[0]
if !finished.Error || finished.ID != root.ID || finished.Start != base || finished.End != end {
t.Fatalf("finished: %+v", finished)
}
// The next interaction must have its own root, even in the same session.
next := traceLine(t, c, end, "event_msg", map[string]any{"type": "task_started", "turn_id": "next"}, false)[0]
if next.ID == root.ID || TraceID(next.Agent, next.Session, next.Turn) == TraceID(root.Agent, root.Session, root.Turn) {
t.Fatal("two turns share a trace")
}
}
func TestPiTracePairsToolsAndUsesFinalTiming(t *testing.T) {
c := traceTestCursor("pi")
base := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
feed := func(at time.Time, o map[string]any) []TraceSpan {
o["timestamp"] = at
b, _ := json.Marshal(o)
return c.line(b, true)
}
feed(base, map[string]any{"type": "session", "id": "session"})
root := feed(base, map[string]any{"type": "message", "id": "user", "message": map[string]any{"role": "user", "content": []any{map[string]string{"type": "text", "text": "question"}}}})[0]
model := feed(base.Add(4*time.Second), map[string]any{"type": "message", "id": "assistant", "parentId": "user", "message": map[string]any{"role": "assistant", "timestamp": base.Add(time.Second).UnixMilli(), "responseId": "response", "model": "model", "provider": "magpie", "stopReason": "toolUse", "usage": map[string]int{"input": 100, "output": 10, "cacheRead": 20}, "content": []any{map[string]any{"type": "toolCall", "id": "tool", "name": "bash", "arguments": map[string]string{"command": "hello"}}}}})[0]
tool := feed(base.Add(5*time.Second), map[string]any{"type": "message", "id": "result", "parentId": "assistant", "message": map[string]any{"role": "toolResult", "toolCallId": "tool", "toolName": "bash", "isError": false, "content": []any{map[string]string{"type": "text", "text": "result"}}}})[0]
if model.Parent != root.ID || model.Start != base.Add(time.Second) || model.Tokens.Input != 100 || tool.Parent != root.ID || !tool.Inferred || tool.End.Sub(tool.Start) != time.Second || tool.Input == "" || tool.Output == "" {
t.Fatalf("model %+v tool %+v", model, tool)
}
spans := feed(base.Add(8*time.Second), map[string]any{"type": "message", "id": "final", "parentId": "result", "message": map[string]any{"role": "assistant", "timestamp": base.Add(6 * time.Second).UnixMilli(), "model": "model", "stopReason": "stop", "content": []any{map[string]string{"type": "text", "text": "answer"}}}})
if len(spans) != 2 || spans[1].ID != root.ID || spans[1].Output == "" {
t.Fatalf("final: %+v", spans)
}
finished := feed(base.Add(8*time.Second), map[string]any{"type": "custom", "customType": "timing-final", "data": map[string]any{"totalMs": 8000, "endAt": base.Add(8 * time.Second).UnixMilli()}})[0]
if finished.ID != root.ID || finished.Start != base || finished.End != base.Add(8*time.Second) {
t.Fatalf("timing: %+v", finished)
}
}
func TestTraceReaderDoesNotReplayHistoryOrPartialLines(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
t.Setenv("OPENCODE_DB", "")
t.Setenv("GEMINI_CLI_HOME", t.TempDir())
root := t.TempDir()
t.Setenv("PI_CODING_AGENT_DIR", root)
t.Setenv("PI_CODING_AGENT_SESSION_DIR", "")
t.Setenv("CODEX_HOME", t.TempDir())
dir := filepath.Join(root, "sessions", "work")
os.MkdirAll(dir, 0700)
path := filepath.Join(dir, "2026-10-02T00-00-00-000Z_session.jsonl")
old := time.Now().Add(-time.Minute)
since := time.Now().Add(-time.Second)
now := time.Now()
header, _ := json.Marshal(map[string]any{"type": "session", "id": "session", "timestamp": old})
historical, _ := json.Marshal(map[string]any{"type": "message", "id": "old-user", "timestamp": old, "message": map[string]any{"role": "user", "content": "old"}})
live, _ := json.Marshal(map[string]any{"type": "message", "id": "live-user", "timestamp": now, "message": map[string]any{"role": "user", "content": "PRIVATE-PROMPT"}})
completed, _ := json.Marshal(map[string]any{"type": "message", "id": "old-final", "parentId": "old-user", "timestamp": old.Add(time.Second), "message": map[string]any{"role": "assistant", "stopReason": "stop", "content": "old answer"}})
data := append(append(append(header, '\n'), historical...), '\n')
data = append(data, append(completed, '\n')...)
data = append(data, live...)
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
r := NewTraceReader(since)
if spans := r.Poll(false); len(spans) != 0 {
t.Fatalf("replayed history or partial line: %+v", spans)
}
f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
f.WriteString("\n")
f.Close()
spans := r.Poll(false)
if len(spans) != 1 || spans[0].Turn != "live-user" || spans[0].Input != "" {
t.Fatalf("live: %+v", spans)
}
if len(r.Poll(false)) != 0 {
t.Fatal("unchanged file emitted twice")
}
}
func TestTraceReaderNewEventsWithCoarseFileMtime(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
t.Setenv("OPENCODE_DB", "")
t.Setenv("GEMINI_CLI_HOME", t.TempDir())
t.Setenv("CODEX_HOME", t.TempDir())
root := t.TempDir()
t.Setenv("PI_CODING_AGENT_DIR", root)
t.Setenv("PI_CODING_AGENT_SESSION_DIR", "")
dir := filepath.Join(root, "sessions", "work")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "2026-10-03T00-00-00-000Z_session.jsonl")
since := time.Now().UTC()
var data []byte
for _, o := range []map[string]any{
{"type": "session", "id": "session"},
{"type": "message", "id": "old", "timestamp": since.Add(-time.Minute), "message": map[string]any{"role": "user", "content": "old"}},
{"type": "message", "id": "old-final", "parentId": "old", "timestamp": since.Add(-30 * time.Second), "message": map[string]any{"role": "assistant", "stopReason": "stop", "content": "old reply"}},
{"type": "message", "id": "new", "timestamp": since.Add(time.Millisecond), "message": map[string]any{"role": "user", "content": "new"}},
} {
b, _ := json.Marshal(o)
data = append(data, append(b, '\n')...)
}
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
coarse := since.Add(-time.Second)
if err := os.Chtimes(path, coarse, coarse); err != nil {
t.Fatal(err)
}
r := NewTraceReader(since)
spans := r.Poll(false)
if len(spans) != 1 || spans[0].Turn != "new" {
t.Fatalf("new event lost or history replayed: %+v", spans)
}
if len(r.Poll(false)) != 0 {
t.Fatal("unchanged file replayed")
}
}
func TestPiTraceInterruptedInteractionClosesPendingTool(t *testing.T) {
c := traceTestCursor("pi")
base := time.Now().UTC()
feed := func(at time.Time, o map[string]any) []TraceSpan {
o["timestamp"] = at
b, _ := json.Marshal(o)
return c.line(b, false)
}
feed(base, map[string]any{"type": "session", "id": "session"})
root := feed(base, map[string]any{"type": "message", "id": "user", "message": map[string]any{"role": "user"}})[0]
feed(base.Add(time.Second), map[string]any{"type": "message", "id": "assistant", "parentId": "user", "message": map[string]any{"role": "assistant", "content": []any{map[string]any{"type": "toolCall", "id": "tool", "name": "bash"}}}})
spans := feed(base.Add(2*time.Second), map[string]any{"type": "message", "id": "next", "message": map[string]any{"role": "user"}})
if len(spans) != 3 || spans[0].ID != root.ID || !spans[0].Error || !spans[0].Inferred || spans[1].Parent != root.ID || !spans[1].Error || len(c.tools) != 0 {
t.Fatalf("interruption: %+v", spans)
}
}
+3 -1
View File
@@ -16,6 +16,8 @@ type OTel struct {
Endpoint string `json:"endpoint,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Metrics bool `json:"metrics,omitempty"`
// Sessions follows newly recorded local Codex and Pi interactions.
Sessions bool `json:"sessions,omitempty"`
// Bodies adds each call's request and reply, as the gateway captured
// them (secrets masked, cut at 256 KiB), to its span as Langfuse's
// observation input and output (#538); off, only metadata leaves
@@ -59,7 +61,7 @@ func OTelExport() (OTel, error) {
for _, x := range []struct {
name string
dst *bool
}{{"MAGPIE_OTEL_ENABLED", &o.Enabled}, {"MAGPIE_OTEL_METRICS", &o.Metrics}, {"MAGPIE_OTEL_BODIES", &o.Bodies}, {"MAGPIE_OTEL_BODIES_WHOLE", &o.BodiesWhole}} {
}{{"MAGPIE_OTEL_ENABLED", &o.Enabled}, {"MAGPIE_OTEL_METRICS", &o.Metrics}, {"MAGPIE_OTEL_BODIES", &o.Bodies}, {"MAGPIE_OTEL_BODIES_WHOLE", &o.BodiesWhole}, {"MAGPIE_OTEL_SESSIONS", &o.Sessions}} {
if v, ok := os.LookupEnv(x.name); ok {
b, err := strconv.ParseBool(v)
if err != nil {
+5 -3
View File
@@ -75,7 +75,7 @@ func TestOTelSettingsPreservesReadOnlyPermissions(t *testing.T) {
func TestOTelSettingsAndOverrides(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config"))
for _, k := range []string{"MAGPIE_OTEL_ENABLED", "MAGPIE_OTEL_ENDPOINT", "MAGPIE_OTEL_HEADERS", "MAGPIE_OTEL_METRICS", "MAGPIE_OTEL_BODIES", "MAGPIE_OTEL_BODIES_WHOLE"} {
for _, k := range []string{"MAGPIE_OTEL_ENABLED", "MAGPIE_OTEL_ENDPOINT", "MAGPIE_OTEL_HEADERS", "MAGPIE_OTEL_METRICS", "MAGPIE_OTEL_BODIES", "MAGPIE_OTEL_BODIES_WHOLE", "MAGPIE_OTEL_SESSIONS"} {
t.Setenv(k, "")
}
// Empty overrides are explicit; a malformed enabled value fails closed.
@@ -86,6 +86,7 @@ func TestOTelSettingsAndOverrides(t *testing.T) {
t.Setenv("MAGPIE_OTEL_METRICS", "false")
t.Setenv("MAGPIE_OTEL_BODIES", "false")
t.Setenv("MAGPIE_OTEL_BODIES_WHOLE", "false")
t.Setenv("MAGPIE_OTEL_SESSIONS", "false")
t.Setenv("MAGPIE_OTEL_ENDPOINT", "http://localhost:4318")
if o, err := OTelExport(); err != nil || o.Enabled {
t.Fatalf("endpoint enabled export: %+v %v", o, err)
@@ -94,9 +95,10 @@ func TestOTelSettingsAndOverrides(t *testing.T) {
t.Setenv("MAGPIE_OTEL_METRICS", "true")
t.Setenv("MAGPIE_OTEL_BODIES", "true")
t.Setenv("MAGPIE_OTEL_BODIES_WHOLE", "true")
t.Setenv("MAGPIE_OTEL_SESSIONS", "true")
t.Setenv("MAGPIE_OTEL_HEADERS", "Authorization=Basic%20YWJjZA==,X-Tag=a%2Cb%2Bc")
o, err := OTelExport()
if err != nil || !o.Enabled || !o.Metrics || !o.Bodies || !o.BodiesWhole || o.Headers["Authorization"] != "Basic YWJjZA==" || o.Headers["X-Tag"] != "a,b+c" {
if err != nil || !o.Enabled || !o.Metrics || !o.Bodies || !o.BodiesWhole || !o.Sessions || o.Headers["Authorization"] != "Basic YWJjZA==" || o.Headers["X-Tag"] != "a,b+c" {
t.Fatalf("%+v %v", o, err)
}
if err := Save(Settings{OTel: OTel{Endpoint: "https://collector.test/", Headers: map[string]string{"Authorization": "Bearer test"}}}); err != nil {
@@ -105,7 +107,7 @@ func TestOTelSettingsAndOverrides(t *testing.T) {
if got := Load().OTel; got.Enabled || got.Endpoint != "https://collector.test" || !reflect.DeepEqual(got.Headers, map[string]string{"Authorization": "Bearer test"}) {
t.Fatalf("saved: %+v", got)
}
if Load().OTel.Metrics || Load().OTel.Bodies || Load().OTel.BodiesWhole {
if Load().OTel.Metrics || Load().OTel.Bodies || Load().OTel.BodiesWhole || Load().OTel.Sessions {
t.Fatal("environment override persisted")
}
}
+35 -1
View File
@@ -49,6 +49,7 @@ type otelExporter struct {
last time.Time
dropped atomic.Uint64
bytes atomic.Int64 // the queued records' bodies, in bytes
sessions atomic.Pointer[sessionAvailability]
}
// StartOTel runs only in the process serving the gateway. Stopping drains
@@ -59,10 +60,13 @@ func StartOTel() func() {
if _, err := settings.OTelExport(); err != nil {
log.Printf("otel: %s", err)
}
watchCtx, stopWatch := context.WithCancel(e.ctx)
go e.watchSessions(watchCtx, time.Now())
go e.run()
var once sync.Once
return func() {
once.Do(func() {
stopWatch()
otel.CompareAndSwap(e, nil)
close(e.stop)
timer := time.NewTimer(3 * time.Second)
@@ -87,7 +91,22 @@ func newOTelExporter() *otelExporter {
return e
}
// ExportOTel queues telemetry without writing a usage ledger record.
func ExportOTel(r Record) { offerOTel(r) }
// OTelEnabled reports whether the running gateway exports traces.
func OTelEnabled() bool {
if otel.Load() == nil {
return false
}
config, err := settings.OTelExport()
return err == nil && config.Enabled
}
func offerOTel(r Record) {
if r.SkipOTel {
return
}
e := otel.Load()
if e == nil {
return
@@ -96,6 +115,13 @@ func offerOTel(r Record) {
if err != nil || !config.Enabled {
return
}
session := r.NativeSession
if session == "" {
session = r.Session
}
if r.Local && r.CallerKeyID == "" && r.Via == "" && r.OTel == nil && e.sessionObserved(r.Agent, session, config) {
return
}
if !config.Bodies {
r.BodyIn, r.BodyOut = "", ""
}
@@ -234,7 +260,15 @@ func (e *otelExporter) flush(batch []otelItem) {
i = j
}
if config.Metrics {
e.send(config, "metrics", otelMetrics(records, e.last, now))
ledger := make([]Record, 0, len(records))
for _, r := range records {
if r.OTel == nil || !r.OTel.Root && !r.OTel.Update && (!r.OTel.Session || r.OTel.Type == "generation") {
ledger = append(ledger, r)
}
}
if len(ledger) > 0 {
e.send(config, "metrics", otelMetrics(ledger, e.last, now))
}
}
e.last = now
}
+135
View File
@@ -0,0 +1,135 @@
package usage
import (
"context"
"reflect"
"time"
"github.com/yetone/magpie/internal/redact"
"github.com/yetone/magpie/internal/sessions"
"github.com/yetone/magpie/internal/settings"
)
type sessionAvailability struct {
config settings.OTel
agents map[string]time.Time
seen map[observedSession]time.Time
}
type observedSession struct {
agent, id string
}
// OTelSessionAgent reports recent observations for callers without a session ID.
func OTelSessionAgent(agent string) bool { return OTelSession(agent, "") }
// OTelSession reports whether this exact agent/session has recent local
// observations. Only requests without an ID fall back to agent-level readiness.
// Callers must also verify loopback and absence of a gateway key.
func OTelSession(agent, session string) bool {
e := otel.Load()
if e == nil || !sessions.TraceAgent(agent) {
return false
}
config, err := settings.OTelExport()
return err == nil && e.sessionObserved(agent, session, config)
}
func (e *otelExporter) sessionObserved(agent, session string, config settings.OTel) bool {
a := e.sessions.Load()
if !config.Enabled || !config.Sessions || a == nil || !reflect.DeepEqual(a.config, config) {
return false
}
at := a.agents[agent]
if session != "" {
at = a.seen[observedSession{agent, session}]
}
return time.Since(at) < 5*time.Minute
}
func sessionBody(body string, config settings.OTel) string {
if !config.Bodies {
return ""
}
body = string(redact.ScrubJSON([]byte(body)))
cut := false
if !config.BodiesWhole && len(body) > 256<<10 {
body = body[:256<<10]
cut = true
}
if cut {
body += BodyCut
}
return body
}
func sessionRecord(s sessions.TraceSpan, config settings.OTel) Record {
status := 200
if s.Error {
status = 500
}
typ := s.Kind
return Record{Agent: s.Agent, Model: s.Model, Served: s.Served, Provider: s.Provider, Time: s.Start, Millis: s.End.Sub(s.Start).Milliseconds(), Status: status,
Input: s.Tokens.Input, Output: s.Tokens.Output, CacheRead: s.Tokens.CacheRead, CacheWrite: s.Tokens.CacheWrite, Reasoning: s.Reasoning,
BodyIn: sessionBody(s.Input, config), BodyOut: sessionBody(s.Output, config),
OTel: &OTelSpan{TraceID: sessions.TraceID(s.Agent, s.Session, s.Turn), SpanID: s.ID, ParentID: s.Parent, Root: s.Parent == "", End: s.End,
Name: s.Name, Type: typ, SessionID: s.Session, TraceName: s.Agent + " interaction", Session: true, Inferred: s.Inferred, Update: s.Update}}
}
func (e *otelExporter) watchSessions(ctx context.Context, started time.Time) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var reader *sessions.TraceReader
var prior settings.OTel
if config, err := settings.OTelExport(); err == nil && config.Enabled && config.Sessions {
reader = sessions.NewTraceReader(started)
prior = config
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
config, err := settings.OTelExport()
if err != nil || !config.Enabled || !config.Sessions {
reader = nil
e.sessions.Store(nil)
continue
}
if reader == nil || !reflect.DeepEqual(config, prior) {
reader = sessions.NewTraceReader(time.Now())
prior = config
e.sessions.Store(nil)
}
spans := reader.Poll(config.Bodies)
available := &sessionAvailability{config: config, agents: map[string]time.Time{}, seen: map[observedSession]time.Time{}}
if previous := e.sessions.Load(); previous != nil {
for agent, at := range previous.agents {
if time.Since(at) < 5*time.Minute {
available.agents[agent] = at
}
}
for key, at := range previous.seen {
if time.Since(at) < 5*time.Minute {
available.seen[key] = at
}
}
}
for _, s := range spans {
if s.End.After(available.agents[s.Agent]) {
available.agents[s.Agent] = s.End
}
key := observedSession{s.Agent, s.Session}
if s.Session != "" && s.End.After(available.seen[key]) {
available.seen[key] = s.End
}
}
e.sessions.Store(available)
for _, s := range spans {
if ctx.Err() != nil {
return
}
e.offer(otelItem{record: sessionRecord(s, config), config: config})
}
}
}
+304
View File
@@ -0,0 +1,304 @@
package usage
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/sessions"
"github.com/yetone/magpie/internal/settings"
)
func TestSessionTraceWirePrivacyAndTokenOwnership(t *testing.T) {
at := time.Now()
secret := "sk-ant-api03-" + strings.Repeat("Q7x", 30)
for _, bodies := range []bool{false, true} {
cfg := settings.OTel{Bodies: bodies}
model := sessions.TraceSpan{Agent: "pi", Session: "session", Turn: "turn", ID: "1111111111111111", Parent: "2222222222222222", Name: "model m", Kind: "generation", Model: "m", Provider: "magpie", Start: at, End: at.Add(time.Second), Input: `{"api_key":"` + secret + `","prompt":"PRIVATE-PROMPT"}`, Tokens: sessions.Tokens{Input: 10, Output: 5}}
tool := model
tool.ID = "3333333333333333"
tool.Kind = "tool"
tool.Name = "bash"
tool.Inferred = true
root := model
root.ID = model.Parent
root.Parent = ""
root.Kind = "span"
root.Name = "pi interaction"
e := newOTelExporter()
payload := e.traces([]Record{sessionRecord(root, cfg), sessionRecord(model, cfg), sessionRecord(tool, cfg)})
e.cancel()
wire, _ := json.Marshal(payload)
s := string(wire)
if strings.Contains(s, secret) || !bodies && strings.Contains(s, "PRIVATE-PROMPT") {
t.Fatalf("private content exported: %s", s)
}
if strings.Count(s, `"key":"gen_ai.usage.input_tokens"`) != 1 || strings.Count(s, `"key":"langfuse.session.id"`) != 3 || !strings.Contains(s, `"stringValue":"tool"`) || !strings.Contains(s, `"stringValue":"inferred"`) {
t.Fatalf("wire: %s", s)
}
}
cfg := settings.OTel{Bodies: true}
long := strings.Repeat("A", 300<<10)
if got := sessionBody(long, cfg); !strings.HasSuffix(got, BodyCut) {
t.Fatal("body limit not applied")
}
// Scrub secret-named fields before cutting: truncation must not disable JSON masking.
if got := sessionBody(`{"api_key":"`+long+`"}`, cfg); strings.Contains(got, strings.Repeat("A", 100)) {
t.Fatal("truncation bypassed secret-field masking")
}
cfg.BodiesWhole = true
if got := sessionBody(long, cfg); got != long {
t.Fatal("whole body truncated")
}
}
func TestOTelSessionWatcherExportsNewInteractionWithoutGatewayDuplicates(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
t.Setenv("OPENCODE_DB", "")
t.Setenv("GEMINI_CLI_HOME", t.TempDir())
root := t.TempDir()
t.Setenv("PI_CODING_AGENT_DIR", root)
t.Setenv("PI_CODING_AGENT_SESSION_DIR", "")
t.Setenv("CODEX_HOME", t.TempDir())
dir := filepath.Join(root, "sessions", "work")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "2026-10-02T00-00-00-000Z_session.jsonl")
old := time.Now().Add(-time.Minute)
header, _ := json.Marshal(map[string]any{"type": "session", "id": "session", "timestamp": old})
historical, _ := json.Marshal(map[string]any{"type": "message", "id": "old-user", "timestamp": old, "message": map[string]any{"role": "user", "content": "PRIVATE-OLD"}})
if err := os.WriteFile(path, append(append(append(header, '\n'), historical...), '\n'), 0600); err != nil {
t.Fatal(err)
}
received := make(chan []byte, 4)
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
received <- b
io.WriteString(w, `{}`)
}))
defer collector.Close()
otelConfig(t, settings.OTel{Endpoint: collector.URL})
t.Setenv("MAGPIE_OTEL_SESSIONS", "true")
t.Setenv("MAGPIE_OTEL_BODIES", "false")
stop := StartOTel()
t.Cleanup(stop)
now := time.Now()
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
for _, o := range []map[string]any{
{"type": "message", "id": "new-user", "timestamp": now, "message": map[string]any{"role": "user", "content": "PRIVATE-NEW"}},
{"type": "message", "id": "assistant", "parentId": "new-user", "timestamp": now.Add(time.Millisecond), "message": map[string]any{"role": "assistant", "timestamp": now.UnixMilli(), "model": "model", "provider": "magpie", "stopReason": "stop", "content": "PRIVATE-REPLY", "usage": map[string]int{"input": 10, "output": 5}}},
} {
b, _ := json.Marshal(o)
f.Write(append(b, '\n'))
}
f.Close()
// Linux VFS mtime can lag a write. Event timestamps, not a precise stat
// timestamp, decide whether this immediately-after-start interaction is new.
coarse := now.Add(-time.Second)
if err := os.Chtimes(path, coarse, coarse); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(6 * time.Second)
for !OTelSessionAgent("pi") && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !OTelSessionAgent("pi") {
t.Fatal("reader did not become available")
}
Append(Record{Time: time.Now(), Agent: "pi", Local: true, Model: "gateway-duplicate", Input: 999, Status: 200})
select {
case b := <-received:
text := string(b)
if strings.Contains(text, "PRIVATE-") || strings.Contains(text, "gateway-duplicate") || strings.Contains(text, `"intValue":"999"`) {
t.Fatalf("history, bodies or duplicate exported: %s", text)
}
if !strings.Contains(text, `"stringValue":"pi interaction"`) || !strings.Contains(text, `"key":"parentSpanId"`) && !strings.Contains(text, `"parentSpanId"`) {
t.Fatalf("no hierarchy: %s", text)
}
if strings.Count(text, `"key":"gen_ai.usage.input_tokens"`) != 1 {
t.Fatalf("duplicate model usage: %s", text)
}
case <-time.After(10 * time.Second):
t.Fatal("session watcher did not export")
}
stop()
}
func TestSessionTracingRetainsRemoteGatewayTraces(t *testing.T) {
received := make(chan []byte, 1)
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
received <- b
io.WriteString(w, `{}`)
}))
defer collector.Close()
otelConfig(t, settings.OTel{Endpoint: collector.URL})
t.Setenv("MAGPIE_OTEL_SESSIONS", "true")
stop := StartOTel()
t.Cleanup(stop)
Append(Record{Time: time.Now(), Agent: "pi", Via: "office", Model: "remote-model", Input: 10, Status: 200})
stop()
select {
case b := <-received:
if !strings.Contains(string(b), "remote-model") {
t.Fatal("missing remote usage")
}
case <-time.After(time.Second):
t.Fatal("remote gateway trace suppressed")
}
}
func TestSessionTracingSuppressesSupportedLocalClientsOnly(t *testing.T) {
otelConfig(t, settings.OTel{Endpoint: "http://localhost:4318"})
t.Setenv("MAGPIE_OTEL_SESSIONS", "true")
e := newOTelExporter()
previous := otel.Swap(e)
defer func() { otel.Store(previous); e.cancel() }()
cfg, err := settings.OTelExport()
if err != nil {
t.Fatal(err)
}
ready := &sessionAvailability{config: cfg, agents: map[string]time.Time{}}
for _, agent := range []string{"codex", "pi", "omp", "claude", "claude-desktop", "opencode", "gemini"} {
if OTelSessionAgent(agent) {
t.Fatal("unreadable store suppressed gateway")
}
offerOTel(Record{Agent: agent, Local: true, Time: time.Now(), Status: 200})
if len(e.queue) != 1 {
t.Fatal("fallback gateway record lost")
}
<-e.queue
ready.agents[agent] = time.Now()
}
e.sessions.Store(ready)
for agent := range ready.agents {
if !OTelSessionAgent(agent) {
t.Fatalf("adapter not enabled: %s", agent)
}
offerOTel(Record{Agent: agent, Local: true, Time: time.Now(), Status: 200})
}
if len(e.queue) != 0 {
t.Fatal("supported clients duplicated gateway records")
}
offerOTel(Record{Agent: "cursor", Time: time.Now(), Status: 200})
offerOTel(Record{Agent: "opencode", Via: "remote", Time: time.Now(), Status: 200})
offerOTel(Record{Agent: "claude", OTel: &OTelSpan{Session: true}, Time: time.Now(), Status: 200})
if len(e.queue) != 3 {
t.Fatal("fallback, remote or session record suppressed")
}
// Empty Via does not prove locality: LAN/container/WSL clients may call
// this gateway directly while its own same-agent session reader is active.
offerOTel(Record{Agent: "codex", Time: time.Now(), Status: 200})
offerOTel(Record{Agent: "pi", Local: true, CallerKeyID: "gateway-key", Time: time.Now(), Status: 200})
if len(e.queue) != 5 {
t.Fatal("remote or keyed gateway trace suppressed")
}
// Gateway spans already started before availability changed must finish.
offerOTel(Record{Agent: "codex", OTel: &OTelSpan{TraceID: "existing"}, Time: time.Now(), Status: 200})
if len(e.queue) != 6 {
t.Fatal("in-flight gateway span suppressed")
}
stale := &sessionAvailability{config: cfg, agents: map[string]time.Time{"codex": time.Now().Add(-6 * time.Minute)}}
e.sessions.Store(stale)
if OTelSessionAgent("codex") {
t.Fatal("stale availability suppressed gateway")
}
cfg.Bodies = !cfg.Bodies
e.sessions.Store(&sessionAvailability{config: cfg, agents: map[string]time.Time{"codex": time.Now()}})
if OTelSessionAgent("codex") {
t.Fatal("availability survived changed config")
}
}
func TestSessionObservationUpdatesDoNotDuplicateMetrics(t *testing.T) {
var metric string
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if strings.HasSuffix(r.URL.Path, "/metrics") {
metric = string(b)
}
io.WriteString(w, `{}`)
}))
defer collector.Close()
otelConfig(t, settings.OTel{Endpoint: collector.URL, Metrics: true})
cfg, err := settings.OTelExport()
if err != nil {
t.Fatal(err)
}
e := newOTelExporter()
defer e.cancel()
now := time.Now()
span := sessions.TraceSpan{Agent: "gemini", Session: "s", Turn: "t", ID: "1111111111111111", Parent: "2222222222222222", Kind: "generation", Model: "m", Start: now, End: now.Add(time.Second), Tokens: sessions.Tokens{Input: 10, Output: 2}}
first := sessionRecord(span, cfg)
span.Update = true
second := sessionRecord(span, cfg)
e.flush([]otelItem{{record: first, config: cfg}, {record: second, config: cfg}})
if metric == "" || strings.Contains(metric, `"count":"2"`) {
t.Fatalf("update metrics: %s", metric)
}
}
func TestSessionTracingMatchesNativeConversation(t *testing.T) {
otelConfig(t, settings.OTel{Endpoint: "http://localhost:4318"})
t.Setenv("MAGPIE_OTEL_SESSIONS", "true")
e := newOTelExporter()
previous := otel.Swap(e)
defer func() { otel.Store(previous); e.cancel() }()
cfg, err := settings.OTelExport()
if err != nil {
t.Fatal(err)
}
now := time.Now()
e.sessions.Store(&sessionAvailability{config: cfg,
agents: map[string]time.Time{"codex": now},
seen: map[observedSession]time.Time{
{"codex", "local"}: now,
{"codex", "stale"}: now.Add(-6 * time.Minute),
{"pi", "other-agent"}: now,
},
})
for _, tc := range []struct {
name, session, native string
dedup bool
}{
{"visible", "local", "", true},
{"WSL mirrored", "wsl", "", false},
{"Docker Desktop", "docker", "", false},
{"native takes precedence", "routing", "local", true},
{"unseen native", "local", "wsl", false},
{"stale session despite recent agent", "stale", "", false},
{"same ID different agent", "other-agent", "", false},
{"no ID fallback", "", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
offerOTel(Record{Agent: "codex", Local: true, Session: tc.session, NativeSession: tc.native, Time: now, Status: 200})
want := 1
if tc.dedup {
want = 0
}
if len(e.queue) != want {
t.Fatalf("queued=%d want=%d", len(e.queue), want)
}
if want != 0 {
<-e.queue
}
})
}
cfg.Bodies = !cfg.Bodies
e.sessions.Store(&sessionAvailability{config: cfg, seen: map[observedSession]time.Time{{"codex", "local"}: now}})
if OTelSession("codex", "local") {
t.Fatal("session readiness survived changed config")
}
}
+76 -8
View File
@@ -53,18 +53,31 @@ func otelEnvelope(signal string, data any) map[string]any {
}}}
}
// OTelSpan carries explicit IDs for gateway requests and agent-session observations.
type OTelSpan struct {
TraceID, SpanID, ParentID string
Root bool
End time.Time
Name, Type, SessionID, TraceName string
Session, Inferred, Update bool
}
func (e *otelExporter) traces(records []Record) any {
spans := make([]any, 0, len(records))
for _, r := range records {
var traceID [16]byte
if r.RouteID != 0 {
copy(traceID[:8], e.salt[:])
binary.BigEndian.PutUint64(traceID[8:], uint64(r.RouteID))
} else {
rand.Read(traceID[:])
if r.OTel == nil {
if r.RouteID != 0 {
copy(traceID[:8], e.salt[:])
binary.BigEndian.PutUint64(traceID[8:], uint64(r.RouteID))
} else {
rand.Read(traceID[:])
}
}
var spanID [8]byte
rand.Read(spanID[:])
if r.OTel == nil {
rand.Read(spanID[:])
}
a := append(otelAttributes(r), otelInt("http.response.status_code", int64(r.Status)),
otelInt("gen_ai.usage.input_tokens", int64(r.Input+r.CacheRead+r.CacheWrite)), otelInt("gen_ai.usage.output_tokens", int64(r.Output)),
otelInt("gen_ai.usage.cache_read.input_tokens", int64(r.CacheRead)), otelInt("gen_ai.usage.cache_creation.input_tokens", int64(r.CacheWrite)),
@@ -92,10 +105,65 @@ func (e *otelExporter) traces(records []Record) any {
if op == "" {
op = "chat"
}
spans = append(spans, map[string]any{"traceId": hex.EncodeToString(traceID[:]), "spanId": hex.EncodeToString(spanID[:]),
span := map[string]any{"traceId": hex.EncodeToString(traceID[:]), "spanId": hex.EncodeToString(spanID[:]),
"name": op + " " + r.Model, "kind": 3, "flags": 1,
"startTimeUnixNano": strconv.FormatInt(r.Time.UnixNano(), 10), "endTimeUnixNano": strconv.FormatInt(r.Time.Add(time.Duration(r.Millis)*time.Millisecond).UnixNano(), 10),
"attributes": a, "status": map[string]any{"code": status}})
"attributes": a, "status": map[string]any{"code": status}}
if r.OTel != nil {
span["traceId"], span["spanId"] = r.OTel.TraceID, r.OTel.SpanID
if !r.OTel.End.IsZero() {
span["endTimeUnixNano"] = strconv.FormatInt(r.OTel.End.UnixNano(), 10)
}
if r.OTel.ParentID != "" {
span["parentSpanId"] = r.OTel.ParentID
}
if r.OTel.Root {
span["name"], span["kind"] = "gateway "+r.Model, 2
a = []otelAttribute{otelString("magpie.agent", r.Agent), otelString("magpie.requested_model", r.Model), otelInt("http.response.status_code", int64(r.Status))}
if r.RouteID != 0 {
a = append(a, otelInt("magpie.route.id", r.RouteID))
}
if r.Status >= 400 {
a = append(a, otelString("error.type", strconv.Itoa(r.Status)))
}
a = append(a, otelString("langfuse.observation.type", "span"))
} else {
a = append(a, otelString("langfuse.observation.type", "generation"))
}
if r.OTel.Session {
span["name"], span["kind"] = r.OTel.Name, 1
if r.OTel.Type != "generation" {
a = []otelAttribute{otelString("magpie.agent", r.Agent)}
if r.Status >= 400 {
a = append(a, otelString("error.type", "agent_operation_failed"))
}
}
a = append(a, otelString("langfuse.session.id", r.OTel.SessionID), otelString("langfuse.trace.name", r.OTel.TraceName))
// Replace the gateway-specific observation type, keeping the wire allowlist explicit.
filtered := a[:0]
for _, attr := range a {
if attr.Key != "langfuse.observation.type" {
filtered = append(filtered, attr)
}
}
a = append(filtered, otelString("langfuse.observation.type", r.OTel.Type))
if r.OTel.Inferred {
a = append(a, otelString("magpie.timing.source", "inferred"))
} else {
a = append(a, otelString("magpie.timing.source", "recorded"))
}
if r.OTel.Type != "generation" {
if r.BodyIn != "" {
a = append(a, otelString("langfuse.observation.input", r.BodyIn))
}
if r.BodyOut != "" {
a = append(a, otelString("langfuse.observation.output", r.BodyOut))
}
}
}
span["attributes"] = a
}
spans = append(spans, span)
}
return otelEnvelope("Spans", spans)
}
+10 -3
View File
@@ -90,8 +90,8 @@ type Record struct {
// the conversation: a Codex subagent's (review, compact, guardian…)
Kind string `json:"kind,omitempty"`
// Via is the computer whose magpie passed the call on to this one (a
// Remote magpie provider there), Agent being the agent's on it; "" for
// a call made on this computer
// Remote magpie provider there), Agent being the agent's on it; "" when
// no other magpie forwarded it (including direct LAN/container clients).
Via string `json:"via,omitempty"`
// Archive is where the request archive keeps the call, "<date>/<id>"
// (gateway/archive.go), when it was on: the Usage page reads it back
@@ -101,9 +101,16 @@ type Record struct {
// a call brought here by sync (#542); "" for one made here, as every
// call in usage.jsonl is
Computer string `json:"computer,omitempty"`
// OTel is transient trace context; SkipOTel avoids exporting ledger records
// when the gateway exports its attempts separately. Neither is persisted.
OTel *OTelSpan `json:"-"`
SkipOTel bool `json:"-"`
// Local is true only for verified loopback gateway requests. It is used
// for local-session deduplication, never persisted or exported.
Local bool `json:"-"`
// BodyIn and BodyOut are the request and reply as the gateway
// captured them, filled only for an OTLP export with bodies on (#538)
// and never written to usage.jsonl
// and never written to usage.jsonl.
BodyIn string `json:"-"`
BodyOut string `json:"-"`
}