|
| 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 | +} |
0 commit comments