diff --git a/bin/opik-logger-darwin-amd64 b/bin/opik-logger-darwin-amd64 index 2d340dd..2ced0ea 100755 Binary files a/bin/opik-logger-darwin-amd64 and b/bin/opik-logger-darwin-amd64 differ diff --git a/bin/opik-logger-darwin-arm64 b/bin/opik-logger-darwin-arm64 index 63b387a..c9b70a2 100755 Binary files a/bin/opik-logger-darwin-arm64 and b/bin/opik-logger-darwin-arm64 differ diff --git a/bin/opik-logger-linux-amd64 b/bin/opik-logger-linux-amd64 index cfdea14..97c60c9 100755 Binary files a/bin/opik-logger-linux-amd64 and b/bin/opik-logger-linux-amd64 differ diff --git a/bin/opik-logger-windows-amd64.exe b/bin/opik-logger-windows-amd64.exe index ddbe033..29f3366 100755 Binary files a/bin/opik-logger-windows-amd64.exe and b/bin/opik-logger-windows-amd64.exe differ diff --git a/src/main.go b/src/main.go index 883c09b..7c5bee8 100644 --- a/src/main.go +++ b/src/main.go @@ -136,7 +136,10 @@ func onPrompt() { traceID := config.ParentTraceID if traceID == "" { bucket := now / 5 - traceID = toV7(fmt.Sprintf("trace:%s:%s:%d", input.SessionID, promptHash, bucket)) + // Embed the bucket-aligned time (ms) so the v7 timestamp matches the + // trace's start_time. Bucket alignment keeps both concurrent onPrompt + // fires on the same timestamp, preserving the deterministic-ID dedup. + traceID = toV7(fmt.Sprintf("trace:%s:%s:%d", input.SessionID, promptHash, bucket), bucket*5*1000) } ts := isoNow() @@ -394,7 +397,20 @@ func onSubagentStop() { } } - parentSpanID := toV7(parentUUID) + // Read the parent transcript up front so we can recover the parent Task + // entry's timestamp — toV7 embeds it, so the reference here must use the + // same value the parent span ID was built with (see span creation in + // processTranscript). Reused below to patch the parent span's output. + parentEntries, parentErr := ReadTranscript(input.TranscriptPath, 0) + parentMs := int64(0) + for _, entry := range parentEntries { + if entry.UUID == parentUUID { + parentMs = millisFromISO(entry.Timestamp) + break + } + } + + parentSpanID := toV7(parentUUID, parentMs) debugLog("processing subagent with parent=%s", parentSpanID) spans := processTranscript(state.TraceID, input.AgentTranscriptPath, 0, parentSpanID) @@ -408,8 +424,7 @@ func onSubagentStop() { } // Patch the parent Task span with output (it was sent before the subagent completed). - parentEntries, err := ReadTranscript(input.TranscriptPath, 0) - if err == nil { + if parentErr == nil { taskResults := BuildTaskResults(parentEntries) for _, entry := range parentEntries { if entry.UUID != parentUUID || entry.Type != "assistant" || entry.Message == nil { @@ -678,7 +693,7 @@ func processTranscriptEntries(traceID string, entries []TranscriptEntry, parentS } span := Span{ - ID: toV7(p.UUID), + ID: toV7(p.UUID, millisFromISO(p.Timestamp)), TraceID: traceID, StartTime: p.Timestamp, EndTime: endTime, diff --git a/src/uuid.go b/src/uuid.go index 53bdf1a..476af73 100644 --- a/src/uuid.go +++ b/src/uuid.go @@ -45,12 +45,25 @@ func uuid7() string { randHex[7:19]) } -// toV7 converts any UUID to a deterministic UUIDv7 using MD5 hash -// This enables idempotent upserts by generating consistent IDs from transcript UUIDs -func toV7(uuid string) string { - hash := md5.Sum([]byte(uuid)) +// toV7 builds a deterministic UUIDv7 from an arbitrary key and a millisecond +// timestamp. The first 48 bits carry tsMillis (so the embedded time matches +// the entity's start_time and the ID sorts chronologically, like a real v7), +// while the remaining "random" bits are taken from MD5(key). Deriving the tail +// from the key keeps IDs deterministic — same key + same tsMillis always yields +// the same ID — which is what enables idempotent upserts and the duplicate-fire +// dedup guard in onPrompt. +// +// Callers must pass the SAME tsMillis whenever they recompute an ID for a given +// key (e.g. a parent span referenced from two call sites), or the IDs won't +// match. In practice the timestamp is always derived from the same immutable +// transcript entry, so this holds. +func toV7(key string, tsMillis int64) string { + hash := md5.Sum([]byte(key)) h := hex.EncodeToString(hash[:]) + // First 48 bits: timestamp (Unix milliseconds). + tsHex := fmt.Sprintf("%012x", tsMillis&0xFFFFFFFFFFFF) + // Set version to 7 (0111) in byte 6 b6 := (hash[6] & 0x0F) | 0x70 b6Hex := fmt.Sprintf("%02x", b6) @@ -60,11 +73,24 @@ func toV7(uuid string) string { b8Hex := fmt.Sprintf("%02x", b8) return fmt.Sprintf("%s-%s-%s%s-%s%s-%s", - h[0:8], - h[8:12], + tsHex[0:8], + tsHex[8:12], b6Hex, h[14:16], b8Hex, h[18:20], h[20:32]) } + +// millisFromISO parses an ISO-8601 timestamp (as found in Claude Code +// transcripts and produced by isoNow) into Unix milliseconds. On parse failure +// it returns 0 — callers embed that into the v7 timestamp field, which is still +// deterministic for a given input string and so preserves ID stability. +func millisFromISO(s string) int64 { + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t.UnixMilli() + } + } + return 0 +} diff --git a/src/uuid_test.go b/src/uuid_test.go new file mode 100644 index 0000000..ac12fb3 --- /dev/null +++ b/src/uuid_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "strconv" + "strings" + "testing" +) + +// embeddedMillis decodes the first 48 bits of a UUID (the v7 timestamp field). +func embeddedMillis(t *testing.T, id string) int64 { + t.Helper() + parts := strings.Split(id, "-") + if len(parts) != 5 { + t.Fatalf("malformed uuid: %q", id) + } + ms, err := strconv.ParseInt(parts[0]+parts[1], 16, 64) + if err != nil { + t.Fatalf("parse ts hex: %v", err) + } + return ms +} + +func TestToV7EmbedsTimestamp(t *testing.T) { + // 2026-06-09T08:25:29.754Z + const ms int64 = 1780993529754 + id := toV7("trace:abc", ms) + + if got := embeddedMillis(t, id); got != ms { + t.Errorf("embedded timestamp = %d, want %d", got, ms) + } + // version nibble must be 7 (first char of 3rd group) + if v := strings.Split(id, "-")[2][0]; v != '7' { + t.Errorf("version nibble = %c, want 7", v) + } + // variant nibble must be 8/9/a/b (first char of 4th group) + if c := strings.Split(id, "-")[3][0]; !strings.ContainsRune("89ab", rune(c)) { + t.Errorf("variant nibble = %c, want one of 89ab", c) + } +} + +func TestToV7Deterministic(t *testing.T) { + const ms int64 = 1780993529754 + a := toV7("trace:session:hash:42", ms) + b := toV7("trace:session:hash:42", ms) + if a != b { + t.Errorf("same key+ts must be deterministic: %s != %s", a, b) + } + // Different key → different tail (entropy bytes), same timestamp prefix. + c := toV7("trace:session:hash:43", ms) + if c == a { + t.Errorf("different keys collided: %s", c) + } + if embeddedMillis(t, c) != ms { + t.Errorf("timestamp prefix changed with key") + } +} + +func TestMillisFromISO(t *testing.T) { + cases := map[string]int64{ + "2026-06-09T08:25:29.754Z": 1780993529754, + "2026-06-09T08:25:29Z": 1780993529000, + "not-a-time": 0, + } + for in, want := range cases { + if got := millisFromISO(in); got != want { + t.Errorf("millisFromISO(%q) = %d, want %d", in, got, want) + } + } +}