Skip to content

Commit e8fdd31

Browse files
committed
feat(cache-analytics): timestamp every analytics event
Adds Timestamp time.Time as the leading field on every analytics event struct (CacheKeyEvent, CacheWriteEvent, FetchTimingEvent, SubgraphErrorEvent, EntityFieldHash, ShadowComparisonEvent, MutationEvent, CacheOperationError, HeaderImpactEvent). Record* methods auto-stamp time.Now() when the field is zero, so callers don't have to remember to set it but may pre-stamp at the actual capture site (e.g. fetch-start time vs record time).
1 parent 13b75f2 commit e8fdd31

2 files changed

Lines changed: 143 additions & 23 deletions

File tree

v2/pkg/engine/resolve/cache_analytics.go

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const (
5050

5151
// CacheKeyEvent records a single cache key lookup result.
5252
type CacheKeyEvent struct {
53+
Timestamp time.Time // when the lookup occurred; stamped by Record* if zero
5354
CacheKey string
5455
EntityType string
5556
Kind CacheKeyEventKind
@@ -61,6 +62,7 @@ type CacheKeyEvent struct {
6162

6263
// CacheWriteEvent records a single cache write operation.
6364
type CacheWriteEvent struct {
65+
Timestamp time.Time // when the write occurred; stamped by RecordWrite if zero
6466
CacheKey string
6567
EntityType string
6668
ByteSize int
@@ -74,6 +76,7 @@ type CacheWriteEvent struct {
7476

7577
// FetchTimingEvent records the duration of a subgraph fetch or cache lookup.
7678
type FetchTimingEvent struct {
79+
Timestamp time.Time // when the fetch started; stamped by RecordFetchTiming if zero
7780
DataSource string // subgraph name
7881
EntityType string // entity type (empty for root fetches)
7982
DurationMs int64 // time spent on this operation in milliseconds
@@ -87,15 +90,17 @@ type FetchTimingEvent struct {
8790

8891
// SubgraphErrorEvent records a subgraph error for analytics.
8992
type SubgraphErrorEvent struct {
90-
DataSource string // subgraph name
91-
EntityType string // entity type (empty for root fetches)
92-
Message string // error message (truncated for safety)
93-
Code string // error code from errors[0].extensions.code (empty if not present)
93+
Timestamp time.Time // when the error was observed; stamped by RecordError if zero
94+
DataSource string // subgraph name
95+
EntityType string // entity type (empty for root fetches)
96+
Message string // error message (truncated for safety)
97+
Code string // error code from errors[0].extensions.code (empty if not present)
9498
}
9599

96100
// EntityFieldHash stores an xxhash of a scalar field value on an entity type,
97101
// along with the entity's key data and the source of the data.
98102
type EntityFieldHash struct {
103+
Timestamp time.Time // when the hash was computed; stamped by HashFieldValue if zero
99104
EntityType string
100105
FieldName string
101106
FieldHash uint64 // xxhash of the non-key field value
@@ -127,6 +132,7 @@ type entitySourceRecord struct {
127132

128133
// ShadowComparisonEvent records a comparison between cached and fresh data in shadow mode.
129134
type ShadowComparisonEvent struct {
135+
Timestamp time.Time // when the comparison was performed; stamped by RecordShadowComparison if zero
130136
CacheKey string // cache key for correlation
131137
EntityType string // entity type name
132138
IsFresh bool // true if ProvidesData fields match between cached and fresh
@@ -143,14 +149,15 @@ type ShadowComparisonEvent struct {
143149
// Recorded during mutation execution by proactively comparing the mutation response
144150
// with the L2 cached value for the same entity.
145151
type MutationEvent struct {
146-
MutationRootField string // e.g., "updateUsername"
147-
EntityType string // e.g., "User"
148-
EntityCacheKey string // display key e.g. {"__typename":"User","key":{"id":"1234"}}
149-
HadCachedValue bool // true if L2 had a cached value for this entity
150-
IsStale bool // true if cached value differs from mutation response (always false when HadCachedValue=false)
151-
CachedHash uint64 // xxhash of cached ProvidesData fields (0 when HadCachedValue=false)
152-
FreshHash uint64 // xxhash of mutation response ProvidesData fields
153-
CachedBytes int // 0 when HadCachedValue=false
152+
Timestamp time.Time // when the mutation was observed; stamped by RecordMutationEvent if zero
153+
MutationRootField string // e.g., "updateUsername"
154+
EntityType string // e.g., "User"
155+
EntityCacheKey string // display key e.g. {"__typename":"User","key":{"id":"1234"}}
156+
HadCachedValue bool // true if L2 had a cached value for this entity
157+
IsStale bool // true if cached value differs from mutation response (always false when HadCachedValue=false)
158+
CachedHash uint64 // xxhash of cached ProvidesData fields (0 when HadCachedValue=false)
159+
FreshHash uint64 // xxhash of mutation response ProvidesData fields
160+
CachedBytes int // 0 when HadCachedValue=false
154161
FreshBytes int
155162
Source CacheOperationSource // what triggered this event (query/mutation/subscription)
156163
}
@@ -159,24 +166,26 @@ type MutationEvent struct {
159166
// Cache errors are non-fatal (the engine falls back to subgraph fetch), but tracking them
160167
// in analytics allows operators to detect cache infrastructure issues.
161168
type CacheOperationError struct {
162-
Operation string // "get", "set", "set_negative", or "delete"
163-
CacheName string // named cache instance
164-
EntityType string // entity type (empty for root fetches)
165-
DataSource string // subgraph name
166-
Message string // error message (truncated for safety)
167-
ItemCount int // number of keys involved in the failed operation
169+
Timestamp time.Time // when the error occurred; stamped by RecordCacheOperationError if zero
170+
Operation string // "get", "set", "set_negative", or "delete"
171+
CacheName string // named cache instance
172+
EntityType string // entity type (empty for root fetches)
173+
DataSource string // subgraph name
174+
Message string // error message (truncated for safety)
175+
ItemCount int // number of keys involved in the failed operation
168176
}
169177

170178
// HeaderImpactEvent records a fresh fetch that wrote to L2 cache with header-prefixed keys.
171179
// A cross-request consumer can aggregate these events: when the same BaseKey appears with
172180
// different HeaderHash values but identical ResponseHash values, the forwarded headers
173181
// do not affect the subgraph response, and IncludeSubgraphHeaderPrefix can be disabled.
174182
type HeaderImpactEvent struct {
175-
BaseKey string // cache key WITHOUT header prefix (stable identity for grouping)
176-
HeaderHash uint64 // hash of forwarded headers for this subgraph
177-
ResponseHash uint64 // xxhash of the response value bytes written to L2
178-
EntityType string // entity type (e.g., "User") or "Query" for root fields
179-
DataSource string // subgraph name
183+
Timestamp time.Time // when the header-prefixed write occurred; stamped by RecordHeaderImpactEvent if zero
184+
BaseKey string // cache key WITHOUT header prefix (stable identity for grouping)
185+
HeaderHash uint64 // hash of forwarded headers for this subgraph
186+
ResponseHash uint64 // xxhash of the response value bytes written to L2
187+
EntityType string // entity type (e.g., "User") or "Query" for root fields
188+
DataSource string // subgraph name
180189
}
181190

182191
// CacheAnalyticsCollector accumulates cache analytics events during request execution.
@@ -265,6 +274,7 @@ func (c *CacheAnalyticsCollector) ResetForReuse() {
265274
// RecordL1KeyEvent records an L1 cache key lookup event. Main thread only.
266275
func (c *CacheAnalyticsCollector) RecordL1KeyEvent(kind CacheKeyEventKind, entityType, cacheKey, dataSource string, byteSize int) {
267276
c.l1KeyEvents = append(c.l1KeyEvents, CacheKeyEvent{
277+
Timestamp: time.Now(),
268278
CacheKey: cacheKey,
269279
EntityType: entityType,
270280
Kind: kind,
@@ -279,6 +289,7 @@ func (c *CacheAnalyticsCollector) RecordL1KeyEvent(kind CacheKeyEventKind, entit
279289
// Use MergeL2Events to merge events collected on per-result slices from goroutines.
280290
func (c *CacheAnalyticsCollector) RecordL2KeyEvent(kind CacheKeyEventKind, entityType, cacheKey, dataSource string, byteSize int) {
281291
c.l2KeyEvents = append(c.l2KeyEvents, CacheKeyEvent{
292+
Timestamp: time.Now(),
282293
CacheKey: cacheKey,
283294
EntityType: entityType,
284295
Kind: kind,
@@ -295,6 +306,9 @@ func (c *CacheAnalyticsCollector) MergeL2Events(events []CacheKeyEvent) {
295306

296307
// RecordWrite records a cache write event. Main thread only.
297308
func (c *CacheAnalyticsCollector) RecordWrite(event CacheWriteEvent) {
309+
if event.Timestamp.IsZero() {
310+
event.Timestamp = time.Now()
311+
}
298312
c.writeEvents = append(c.writeEvents, event)
299313
}
300314

@@ -306,6 +320,7 @@ func (c *CacheAnalyticsCollector) HashFieldValue(entityType, fieldName string, v
306320
hash := c.xxh.Sum64()
307321

308322
c.fieldHashes = append(c.fieldHashes, EntityFieldHash{
323+
Timestamp: time.Now(),
309324
EntityType: entityType,
310325
FieldName: fieldName,
311326
FieldHash: hash,
@@ -357,6 +372,9 @@ func (c *CacheAnalyticsCollector) MergeEntitySources(sources []entitySourceRecor
357372
// It is exported for external consumers such as cosmo router; this repository
358373
// has no production caller. If cosmo no longer uses it, internalize it in the next breaking window.
359374
func (c *CacheAnalyticsCollector) RecordFetchTiming(event FetchTimingEvent) {
375+
if event.Timestamp.IsZero() {
376+
event.Timestamp = time.Now()
377+
}
360378
c.fetchTimings = append(c.fetchTimings, event)
361379
}
362380

@@ -368,6 +386,9 @@ func (c *CacheAnalyticsCollector) MergeL2FetchTimings(timings []FetchTimingEvent
368386

369387
// RecordError records a subgraph error event. Main thread only.
370388
func (c *CacheAnalyticsCollector) RecordError(event SubgraphErrorEvent) {
389+
if event.Timestamp.IsZero() {
390+
event.Timestamp = time.Now()
391+
}
371392
c.errorEvents = append(c.errorEvents, event)
372393
}
373394

@@ -380,21 +401,33 @@ func (c *CacheAnalyticsCollector) MergeL2Errors(events []SubgraphErrorEvent) {
380401
// RecordShadowComparison records a shadow mode comparison between cached and fresh data.
381402
// Main thread only.
382403
func (c *CacheAnalyticsCollector) RecordShadowComparison(event ShadowComparisonEvent) {
404+
if event.Timestamp.IsZero() {
405+
event.Timestamp = time.Now()
406+
}
383407
c.shadowComparisons = append(c.shadowComparisons, event)
384408
}
385409

386410
// RecordMutationEvent records a mutation entity impact event. Main thread only.
387411
func (c *CacheAnalyticsCollector) RecordMutationEvent(event MutationEvent) {
412+
if event.Timestamp.IsZero() {
413+
event.Timestamp = time.Now()
414+
}
388415
c.mutationEvents = append(c.mutationEvents, event)
389416
}
390417

391418
// RecordHeaderImpactEvent records a header impact event. Main thread only.
392419
func (c *CacheAnalyticsCollector) RecordHeaderImpactEvent(event HeaderImpactEvent) {
420+
if event.Timestamp.IsZero() {
421+
event.Timestamp = time.Now()
422+
}
393423
c.headerImpactEvents = append(c.headerImpactEvents, event)
394424
}
395425

396426
// RecordCacheOperationError records a cache operation error. Main thread only.
397427
func (c *CacheAnalyticsCollector) RecordCacheOperationError(event CacheOperationError) {
428+
if event.Timestamp.IsZero() {
429+
event.Timestamp = time.Now()
430+
}
398431
c.cacheOpErrors = append(c.cacheOpErrors, event)
399432
}
400433

v2/pkg/engine/resolve/cache_analytics_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2088,3 +2088,90 @@ func TestSnapshotSlicesAreIndependent(t *testing.T) {
20882088
assert.Equal(t, origCacheOpErrors, snap.CacheOpErrors)
20892089
assert.Equal(t, origFieldHashes, snap.FieldHashes)
20902090
}
2091+
2092+
// TestCacheAnalyticsCollector_TimestampsAutoStamp verifies every Record* method
2093+
// auto-stamps Timestamp = time.Now() when the caller passes a zero time.
2094+
// Without this, downstream consumers that rely on Timestamp for ordering or
2095+
// rate calculations would see uninitialized zero times and silently misorder.
2096+
func TestCacheAnalyticsCollector_TimestampsAutoStamp(t *testing.T) {
2097+
c := NewCacheAnalyticsCollector()
2098+
2099+
before := time.Now()
2100+
c.RecordL1KeyEvent(CacheKeyHit, "User", `{"id":"1"}`, "accounts", 100)
2101+
c.RecordL2KeyEvent(CacheKeyMiss, "User", `{"id":"1"}`, "accounts", 0)
2102+
c.RecordWrite(CacheWriteEvent{CacheKey: "k", EntityType: "User", CacheLevel: CacheLevelL1})
2103+
c.RecordFetchTiming(FetchTimingEvent{DataSource: "accounts", DurationMs: 10})
2104+
c.RecordError(SubgraphErrorEvent{DataSource: "accounts", Message: "boom"})
2105+
c.RecordShadowComparison(ShadowComparisonEvent{CacheKey: "k", EntityType: "User"})
2106+
c.RecordMutationEvent(MutationEvent{MutationRootField: "updateUser", EntityType: "User"})
2107+
c.RecordHeaderImpactEvent(HeaderImpactEvent{BaseKey: "k", EntityType: "User"})
2108+
c.RecordCacheOperationError(CacheOperationError{Operation: "get", EntityType: "User"})
2109+
c.HashFieldValue("User", "name", nil, []byte(`"Alice"`), `{"id":"1"}`, 0, FieldSourceSubgraph, "accounts")
2110+
after := time.Now()
2111+
2112+
snap := c.Snapshot()
2113+
require.Equal(t, 1, len(snap.L1Reads))
2114+
require.Equal(t, 1, len(snap.L2Reads))
2115+
require.Equal(t, 1, len(snap.L1Writes)+len(snap.L2Writes))
2116+
require.Equal(t, 1, len(snap.FetchTimings))
2117+
require.Equal(t, 1, len(snap.ErrorEvents))
2118+
require.Equal(t, 1, len(snap.ShadowComparisons))
2119+
require.Equal(t, 1, len(snap.MutationEvents))
2120+
require.Equal(t, 1, len(snap.HeaderImpactEvents))
2121+
require.Equal(t, 1, len(snap.CacheOpErrors))
2122+
require.Equal(t, 1, len(snap.FieldHashes))
2123+
2124+
// Every event's Timestamp must fall in [before, after]. The exact value
2125+
// doesn't matter, only that it's stamped and within the recording window.
2126+
timestamps := []time.Time{
2127+
snap.L1Reads[0].Timestamp,
2128+
snap.L2Reads[0].Timestamp,
2129+
append(snap.L1Writes, snap.L2Writes...)[0].Timestamp,
2130+
snap.FetchTimings[0].Timestamp,
2131+
snap.ErrorEvents[0].Timestamp,
2132+
snap.ShadowComparisons[0].Timestamp,
2133+
snap.MutationEvents[0].Timestamp,
2134+
snap.HeaderImpactEvents[0].Timestamp,
2135+
snap.CacheOpErrors[0].Timestamp,
2136+
snap.FieldHashes[0].Timestamp,
2137+
}
2138+
for i, ts := range timestamps {
2139+
assert.False(t, ts.IsZero(), "event %d Timestamp must be auto-stamped", i)
2140+
assert.False(t, ts.Before(before), "event %d Timestamp must not predate Record* call", i)
2141+
assert.False(t, ts.After(after), "event %d Timestamp must not be in the future", i)
2142+
}
2143+
}
2144+
2145+
// TestCacheAnalyticsCollector_TimestampsPreservePreSet verifies that Record*
2146+
// methods preserve a caller-supplied Timestamp instead of overwriting it.
2147+
// This is load-bearing for events captured at one point in time and recorded
2148+
// later (e.g. fetch start time vs Record call time).
2149+
func TestCacheAnalyticsCollector_TimestampsPreservePreSet(t *testing.T) {
2150+
c := NewCacheAnalyticsCollector()
2151+
preSet := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
2152+
2153+
c.RecordWrite(CacheWriteEvent{Timestamp: preSet, CacheKey: "k", EntityType: "User"})
2154+
c.RecordFetchTiming(FetchTimingEvent{Timestamp: preSet, DataSource: "ds"})
2155+
c.RecordError(SubgraphErrorEvent{Timestamp: preSet, DataSource: "ds"})
2156+
c.RecordShadowComparison(ShadowComparisonEvent{Timestamp: preSet, CacheKey: "k"})
2157+
c.RecordMutationEvent(MutationEvent{Timestamp: preSet, EntityType: "User"})
2158+
c.RecordHeaderImpactEvent(HeaderImpactEvent{Timestamp: preSet, BaseKey: "k"})
2159+
c.RecordCacheOperationError(CacheOperationError{Timestamp: preSet, Operation: "get"})
2160+
2161+
snap := c.Snapshot()
2162+
// Note: writes route to L1Writes/L2Writes by CacheLevel. CacheLevel zero
2163+
// (unset) lands in neither, so RecordWrite snapshots through the L1+L2
2164+
// view. We only need to prove preservation, so look at the raw collector.
2165+
for _, ts := range []time.Time{
2166+
c.writeEvents[0].Timestamp,
2167+
snap.FetchTimings[0].Timestamp,
2168+
snap.ErrorEvents[0].Timestamp,
2169+
snap.ShadowComparisons[0].Timestamp,
2170+
snap.MutationEvents[0].Timestamp,
2171+
snap.HeaderImpactEvents[0].Timestamp,
2172+
snap.CacheOpErrors[0].Timestamp,
2173+
} {
2174+
assert.True(t, ts.Equal(preSet), "pre-set Timestamp must be preserved exactly, got %v", ts)
2175+
}
2176+
}
2177+

0 commit comments

Comments
 (0)