Skip to content

Commit 2c67e4a

Browse files
authored
Merge pull request #31 from comet-ml/aswynz/fix-span-id-uuidv7-timestamp
fix: embed the transcript timestamp in span ids so Opik accepts them
2 parents c351661 + c186104 commit 2c67e4a

3 files changed

Lines changed: 130 additions & 7 deletions

File tree

‎src/main.go‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,13 @@ func onSubagentStop() {
323323
}
324324
}
325325

326-
parentSpanID := toV7(parentUUID)
326+
// Must produce the same id processTranscriptEntries gave the Task span,
327+
// so use the Task entry's own transcript timestamp.
328+
parentTimestamp := entryTimestamp(input.TranscriptPath, parentUUID)
329+
if parentTimestamp == "" {
330+
debugLog("subagent_stop: no transcript timestamp for parent %s, span id will not match", parentUUID)
331+
}
332+
parentSpanID := toV7(parentUUID, parentTimestamp)
327333
debugLog("processing subagent with parent=%s", parentSpanID)
328334

329335
spans := processTranscript(state.TraceID, input.AgentTranscriptPath, 0, parentSpanID)
@@ -562,7 +568,7 @@ func processTranscriptEntries(traceID string, entries []TranscriptEntry, parentS
562568
}
563569

564570
span := Span{
565-
ID: toV7(p.UUID),
571+
ID: toV7(p.UUID, p.Timestamp),
566572
TraceID: traceID,
567573
StartTime: p.Timestamp,
568574
EndTime: endTime,
@@ -772,6 +778,21 @@ func countLines(path string) int {
772778
return count
773779
}
774780

781+
// entryTimestamp returns the transcript timestamp of the entry with the given
782+
// UUID, or "" when the transcript cannot be read or has no such entry.
783+
func entryTimestamp(path, uuid string) string {
784+
entries, err := ReadTranscript(path, 0)
785+
if err != nil {
786+
return ""
787+
}
788+
for _, e := range entries {
789+
if e.UUID == uuid {
790+
return e.Timestamp
791+
}
792+
}
793+
return ""
794+
}
795+
775796
func isoNow() string {
776797
return time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
777798
}

‎src/uuid.go‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,31 @@ func uuid7() string {
4545
randHex[7:19])
4646
}
4747

48-
// toV7 converts any UUID to a deterministic UUIDv7 using MD5 hash
49-
// This enables idempotent upserts by generating consistent IDs from transcript UUIDs
50-
func toV7(uuid string) string {
48+
// toV7 derives a deterministic UUIDv7 from a transcript UUID and the entry's
49+
// timestamp.
50+
//
51+
// Determinism matters: the same spans are re-sent on every flush (PostToolUse,
52+
// Stop, SessionEnd) and Opik upserts them by id, so a given transcript entry
53+
// must always map to the same span id.
54+
//
55+
// The first 48 bits must be a real millisecond timestamp. Opik validates that
56+
// a v7 id's embedded timestamp falls inside its ingestion window (24h around
57+
// now) and rejects the whole batch otherwise. The previous implementation put
58+
// MD5 hash bytes there, which decoded to timestamps centuries away and made
59+
// every span batch fail with "Invalid UUID for id". Only the remaining 74 bits
60+
// are derived from the hash now.
61+
//
62+
// timestamp is the transcript entry's RFC 3339 timestamp. If it is empty or
63+
// unparseable the current time is used, which keeps the id valid but not
64+
// deterministic across flushes; callers should pass the entry timestamp.
65+
func toV7(uuid, timestamp string) string {
66+
ms := timestampMillis(timestamp)
5167
hash := md5.Sum([]byte(uuid))
5268
h := hex.EncodeToString(hash[:])
5369

70+
// First 48 bits: unix milliseconds
71+
tsHex := fmt.Sprintf("%012x", ms&0xFFFFFFFFFFFF)
72+
5473
// Set version to 7 (0111) in byte 6
5574
b6 := (hash[6] & 0x0F) | 0x70
5675
b6Hex := fmt.Sprintf("%02x", b6)
@@ -60,11 +79,22 @@ func toV7(uuid string) string {
6079
b8Hex := fmt.Sprintf("%02x", b8)
6180

6281
return fmt.Sprintf("%s-%s-%s%s-%s%s-%s",
63-
h[0:8],
64-
h[8:12],
82+
tsHex[0:8],
83+
tsHex[8:12],
6584
b6Hex,
6685
h[14:16],
6786
b8Hex,
6887
h[18:20],
6988
h[20:32])
7089
}
90+
91+
// timestampMillis parses a transcript RFC 3339 timestamp into unix
92+
// milliseconds, falling back to now when it is missing or malformed.
93+
func timestampMillis(timestamp string) int64 {
94+
if timestamp != "" {
95+
if t, err := time.Parse(time.RFC3339Nano, timestamp); err == nil {
96+
return t.UnixMilli()
97+
}
98+
}
99+
return time.Now().UnixMilli()
100+
}

‎src/uuid_test.go‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package main
2+
3+
import (
4+
"regexp"
5+
"strconv"
6+
"testing"
7+
"time"
8+
)
9+
10+
var uuidV7Pattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
11+
12+
// embeddedMillis decodes the 48-bit UUIDv7 timestamp prefix.
13+
func embeddedMillis(t *testing.T, id string) int64 {
14+
t.Helper()
15+
ms, err := strconv.ParseInt(id[0:8]+id[9:13], 16, 64)
16+
if err != nil {
17+
t.Fatalf("cannot parse timestamp bits of %s: %v", id, err)
18+
}
19+
return ms
20+
}
21+
22+
func TestToV7IsDeterministic(t *testing.T) {
23+
a := toV7("8b1d7f2e-1111-4c3a-9c1e-000000000001", "2026-09-11T14:54:43.966Z")
24+
b := toV7("8b1d7f2e-1111-4c3a-9c1e-000000000001", "2026-09-11T14:54:43.966Z")
25+
if a != b {
26+
t.Fatalf("same inputs produced different ids: %s vs %s", a, b)
27+
}
28+
c := toV7("8b1d7f2e-1111-4c3a-9c1e-000000000002", "2026-09-11T14:54:43.966Z")
29+
if a == c {
30+
t.Fatalf("different transcript uuids produced the same id: %s", a)
31+
}
32+
}
33+
34+
func TestToV7EmbedsEntryTimestamp(t *testing.T) {
35+
ts := "2026-09-11T14:54:43.966Z"
36+
want, _ := time.Parse(time.RFC3339Nano, ts)
37+
id := toV7("8b1d7f2e-1111-4c3a-9c1e-000000000001", ts)
38+
if !uuidV7Pattern.MatchString(id) {
39+
t.Fatalf("not a well-formed UUIDv7: %s", id)
40+
}
41+
if got := embeddedMillis(t, id); got != want.UnixMilli() {
42+
t.Fatalf("embedded timestamp %d, want %d (%s)", got, want.UnixMilli(), id)
43+
}
44+
}
45+
46+
func TestToV7FallsBackToNowWithoutTimestamp(t *testing.T) {
47+
for _, ts := range []string{"", "not-a-timestamp"} {
48+
before := time.Now().UnixMilli()
49+
id := toV7("8b1d7f2e-1111-4c3a-9c1e-000000000001", ts)
50+
after := time.Now().UnixMilli()
51+
if !uuidV7Pattern.MatchString(id) {
52+
t.Fatalf("not a well-formed UUIDv7: %s", id)
53+
}
54+
got := embeddedMillis(t, id)
55+
if got < before || got > after {
56+
t.Fatalf("timestamp %q: embedded %d not within [%d, %d]", ts, got, before, after)
57+
}
58+
}
59+
}
60+
61+
func TestUuid7EmbedsNow(t *testing.T) {
62+
before := time.Now().UnixMilli()
63+
id := uuid7()
64+
after := time.Now().UnixMilli()
65+
if !uuidV7Pattern.MatchString(id) {
66+
t.Fatalf("not a well-formed UUIDv7: %s", id)
67+
}
68+
got := embeddedMillis(t, id)
69+
if got < before || got > after {
70+
t.Fatalf("embedded %d not within [%d, %d]", got, before, after)
71+
}
72+
}

0 commit comments

Comments
 (0)