Skip to content

Commit fb6b59a

Browse files
grokifyclaude
andcommitted
feat(report): add DeveloperPeriodReport aggregation from canonical events
Build/BuildDaily/Rollup reduce stored events into a DeveloperPeriodReport (omnidevx.developer-period/v1): a daily-summary builder plus a rollup so reprocessing a changed formula never requires recollection, session IDs deduplicated across day boundaries rather than summed per day, metrics split into a cross-source Combined view and a per-source BySource view per the safe-to-combine rules (e.g. autonomous task completion stays bySource-only pending cross-source normalization), and a DataQuality score/warnings including a flag for period-total snapshot events that aren't yet merged into daily buckets. Verified end-to-end against the real local event store: a 7-day report over 15,450 stored events (claude-code + git) reproduces identically across repeated runs and reports full 7/7 day coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 91188e5 commit fb6b59a

5 files changed

Lines changed: 740 additions & 0 deletions

File tree

report/daily.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package report
2+
3+
import (
4+
"sort"
5+
"time"
6+
7+
omnidevx "github.com/plexusone/omnidevx-core"
8+
)
9+
10+
// combinedMetricKeys lists the metric keys computed here that combine
11+
// safely across sources (see package doc's safe-to-combine notes) and
12+
// therefore appear in MetricSet.Combined as well as BySource. Metrics
13+
// omitted here (e.g. "tasks" — autonomous task completion needs
14+
// cross-source normalization first) remain visible in BySource only.
15+
var combinedMetricKeys = map[string]bool{
16+
"prompts": true,
17+
"messages": true,
18+
"tool_calls": true,
19+
"tool_calls_failed": true,
20+
"patches_applied": true,
21+
"input_tokens": true,
22+
"output_tokens": true,
23+
"cache_read_tokens": true,
24+
"cache_creation_tokens": true,
25+
"reasoning_tokens": true,
26+
"total_tokens": true,
27+
"cost_usd": true,
28+
"commits": true,
29+
"ai_assisted_commits": true,
30+
"insertions": true,
31+
"deletions": true,
32+
"files_changed": true,
33+
"contributions": true,
34+
"sessions": true, // dedup handled at Rollup, not summed per day
35+
}
36+
37+
// sourceAccum tracks one source's contribution within a day or a rolled-up
38+
// period.
39+
type sourceAccum struct {
40+
source omnidevx.Source
41+
eventCount int
42+
modes map[omnidevx.CollectionMode]bool
43+
minConfidence float64
44+
hasConfidence bool
45+
}
46+
47+
func newSourceAccum(s omnidevx.Source) *sourceAccum {
48+
return &sourceAccum{source: s, modes: map[omnidevx.CollectionMode]bool{}}
49+
}
50+
51+
func (a *sourceAccum) observe(p omnidevx.Provenance) {
52+
a.eventCount++
53+
a.modes[p.CollectionMode] = true
54+
if !a.hasConfidence || p.Confidence < a.minConfidence {
55+
a.minConfidence = p.Confidence
56+
a.hasConfidence = true
57+
}
58+
}
59+
60+
func (a *sourceAccum) merge(other *sourceAccum) {
61+
a.eventCount += other.eventCount
62+
for m := range other.modes {
63+
a.modes[m] = true
64+
}
65+
if other.hasConfidence && (!a.hasConfidence || other.minConfidence < a.minConfidence) {
66+
a.minConfidence = other.minConfidence
67+
a.hasConfidence = true
68+
}
69+
}
70+
71+
func (a *sourceAccum) coverage() SourceCoverage {
72+
modes := make([]omnidevx.CollectionMode, 0, len(a.modes))
73+
for m := range a.modes {
74+
modes = append(modes, m)
75+
}
76+
sort.Slice(modes, func(i, j int) bool { return modes[i] < modes[j] })
77+
conf := a.minConfidence
78+
if !a.hasConfidence {
79+
conf = 1
80+
}
81+
return SourceCoverage{
82+
Source: a.source,
83+
EventCount: a.eventCount,
84+
CollectionModes: modes,
85+
MinConfidence: conf,
86+
}
87+
}
88+
89+
// DailySummary is one day's reduction of canonical events: additive metric
90+
// counts plus enough per-source bookkeeping for Rollup to merge many days
91+
// into a period report without re-reading raw events.
92+
type DailySummary struct {
93+
Date time.Time
94+
Combined map[string]float64
95+
BySource map[string]map[string]float64
96+
Snapshots int // period-total events seen but not summarized (see package doc)
97+
sessionIDs map[string]map[string]bool
98+
sources map[string]*sourceAccum
99+
}
100+
101+
func sourceKey(s omnidevx.Source) string { return s.Provider + "/" + s.Product }
102+
103+
// BuildDaily reduces one day's events into a DailySummary. day identifies
104+
// the UTC calendar day being summarized; events outside [day, day+24h) are
105+
// ignored so callers can pass a pre-bucketed slice or the full event set
106+
// interchangeably.
107+
func BuildDaily(events []omnidevx.Event, day time.Time) *DailySummary {
108+
day = day.UTC().Truncate(24 * time.Hour)
109+
next := day.Add(24 * time.Hour)
110+
111+
d := &DailySummary{
112+
Date: day,
113+
Combined: map[string]float64{},
114+
BySource: map[string]map[string]float64{},
115+
sessionIDs: map[string]map[string]bool{},
116+
sources: map[string]*sourceAccum{},
117+
}
118+
119+
for _, e := range events {
120+
ts := e.Timestamp.UTC()
121+
if ts.Before(day) || !ts.Before(next) {
122+
continue
123+
}
124+
key := sourceKey(e.Source)
125+
if d.sources[key] == nil {
126+
d.sources[key] = newSourceAccum(e.Source)
127+
}
128+
d.sources[key].observe(e.Provenance)
129+
130+
if isSnapshotEvent(e.Type) {
131+
d.Snapshots++
132+
continue
133+
}
134+
135+
for metric, delta := range metricDeltas(e) {
136+
d.add(key, metric, delta)
137+
}
138+
if e.Context.SessionID != "" && isSessionScoped(e.Type) {
139+
if d.sessionIDs[key] == nil {
140+
d.sessionIDs[key] = map[string]bool{}
141+
}
142+
d.sessionIDs[key][e.Context.SessionID] = true
143+
}
144+
}
145+
return d
146+
}
147+
148+
func (d *DailySummary) add(srcKey, metric string, delta float64) {
149+
d.Combined[metric] += delta
150+
if d.BySource[srcKey] == nil {
151+
d.BySource[srcKey] = map[string]float64{}
152+
}
153+
d.BySource[srcKey][metric] += delta
154+
}
155+
156+
func isSnapshotEvent(t omnidevx.EventType) bool {
157+
return t == omnidevx.EventProfileSnapshot || t == omnidevx.EventContributionSnapshot
158+
}
159+
160+
func isSessionScoped(t omnidevx.EventType) bool {
161+
switch t {
162+
case omnidevx.EventSessionStarted, omnidevx.EventSessionEnded, omnidevx.EventPromptSubmitted,
163+
omnidevx.EventMessageCompleted, omnidevx.EventToolCompleted, omnidevx.EventPatchApplied,
164+
omnidevx.EventTaskStarted, omnidevx.EventTaskCompleted, omnidevx.EventUsageRecorded:
165+
return true
166+
default:
167+
return false
168+
}
169+
}
170+
171+
// metricDeltas returns the additive metric increments one event
172+
// contributes.
173+
func metricDeltas(e omnidevx.Event) map[string]float64 {
174+
deltas := map[string]float64{}
175+
switch e.Type {
176+
case omnidevx.EventPromptSubmitted:
177+
deltas["prompts"] = 1
178+
case omnidevx.EventMessageCompleted:
179+
deltas["messages"] = 1
180+
addTokenDeltas(deltas, e.Attributes)
181+
case omnidevx.EventUsageRecorded:
182+
addTokenDeltas(deltas, e.Attributes)
183+
case omnidevx.EventToolCompleted:
184+
deltas["tool_calls"] = 1
185+
if success, ok := e.Attributes[omnidevx.AttrSuccess].(bool); ok && !success {
186+
deltas["tool_calls_failed"] = 1
187+
}
188+
case omnidevx.EventPatchApplied:
189+
deltas["patches_applied"] = 1
190+
case omnidevx.EventTaskCompleted:
191+
deltas["tasks"] = 1
192+
case omnidevx.EventChangeCommitted:
193+
deltas["commits"] = 1
194+
if assisted, ok := e.Attributes[omnidevx.AttrAIAssisted].(bool); ok && assisted {
195+
deltas["ai_assisted_commits"] = 1
196+
}
197+
addFloatDelta(deltas, "insertions", e.Attributes[omnidevx.AttrInsertions])
198+
addFloatDelta(deltas, "deletions", e.Attributes[omnidevx.AttrDeletions])
199+
addFloatDelta(deltas, "files_changed", e.Attributes[omnidevx.AttrFilesChanged])
200+
case omnidevx.EventContributionRecorded:
201+
addFloatDelta(deltas, "contributions", e.Attributes[omnidevx.AttrContributions])
202+
}
203+
if cost, ok := numeric(e.Attributes[omnidevx.AttrCostUSD]); ok {
204+
deltas["cost_usd"] += cost
205+
}
206+
return deltas
207+
}
208+
209+
func addTokenDeltas(deltas map[string]float64, attrs map[string]any) {
210+
pairs := []struct{ attr, metric string }{
211+
{omnidevx.AttrInputTokens, "input_tokens"},
212+
{omnidevx.AttrOutputTokens, "output_tokens"},
213+
{omnidevx.AttrCacheReadTokens, "cache_read_tokens"},
214+
{omnidevx.AttrCacheCreationTokens, "cache_creation_tokens"},
215+
{omnidevx.AttrReasoningTokens, "reasoning_tokens"},
216+
{omnidevx.AttrTotalTokens, "total_tokens"},
217+
}
218+
for _, p := range pairs {
219+
addFloatDelta(deltas, p.metric, attrs[p.attr])
220+
}
221+
}
222+
223+
func addFloatDelta(deltas map[string]float64, metric string, v any) {
224+
if n, ok := numeric(v); ok {
225+
deltas[metric] += n
226+
}
227+
}
228+
229+
// numeric converts an attribute value — set directly by a collector (any Go
230+
// numeric kind) or decoded from stored JSON (always float64) — to float64.
231+
func numeric(v any) (float64, bool) {
232+
switch n := v.(type) {
233+
case float64:
234+
return n, true
235+
case float32:
236+
return float64(n), true
237+
case int:
238+
return float64(n), true
239+
case int32:
240+
return float64(n), true
241+
case int64:
242+
return float64(n), true
243+
default:
244+
return 0, false
245+
}
246+
}

report/doc.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Package report builds DeveloperPeriodReport (schema
2+
// omnidevx.developer-period/v1) from canonical events.
3+
//
4+
// Aggregation is two-stage: BuildDaily reduces one day's events into a
5+
// DailySummary; Rollup combines summaries covering a period into a report.
6+
// Build is the one-call convenience that buckets events by day and runs
7+
// both stages. The two-stage split matters because not every metric sums
8+
// correctly across a day boundary — a session spanning midnight must be
9+
// deduplicated by ID at rollup time, not double-counted per day — and
10+
// because reprocessing with a changed formula should replay from stored
11+
// daily summaries, never require recollection from raw session files.
12+
//
13+
// Only additive, per-day event types are summarized here: ai.* session
14+
// activity, devx.change.committed, and devx.contribution.recorded.
15+
// Period-total events (devx.profile.snapshot, devx.contribution.snapshot)
16+
// already describe an entire period rather than one day, so they are not
17+
// decomposed into daily buckets; Rollup surfaces their presence as a
18+
// DataQuality warning until a provider-specific merge rule is defined.
19+
//
20+
// Metrics are split into combined and bySource views per source ("some
21+
// metrics combine safely — sessions, cost, tokens with model retained;
22+
// others do not — acceptance rate only with matching definitions"); every
23+
// metric carries a Measurement recording whether it was observed or
24+
// estimated, and from what confidence.
25+
package report

0 commit comments

Comments
 (0)