feat: export gateway and agent conversation waterfalls to Langfuse (#581)
This commit is contained in:
1 parent
24bd8aa1ae
commit
38b4a9d984
30 files changed
+3987
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "包含完整内容",
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:"-"`
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user