Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bin/opik-logger-darwin-amd64
Binary file not shown.
Binary file modified bin/opik-logger-darwin-arm64
Binary file not shown.
Binary file modified bin/opik-logger-linux-amd64
Binary file not shown.
Binary file modified bin/opik-logger-windows-amd64.exe
Binary file not shown.
25 changes: 20 additions & 5 deletions src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 32 additions & 6 deletions src/uuid.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
69 changes: 69 additions & 0 deletions src/uuid_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading