11package query
22
33import (
4+ "fmt"
45 "maps"
6+ "strings"
57 "sync"
68 "time"
79)
@@ -21,18 +23,20 @@ type AnalyzeStats struct {
2123
2224// AnalyzeObserver is a thread-safe Observer that collects execution statistics keyed by CanonicalKey.
2325type AnalyzeObserver struct {
24- mu sync.Mutex
25- stats map [CanonicalKey ]AnalyzeStats // GUARDED_BY(mu)
26+ mu sync.Mutex
27+ stats map [CanonicalKey ]AnalyzeStats // GUARDED_BY(mu)
28+ startTimes map [ObserverOperation ]map [CanonicalKey ]time.Time // GUARDED_BY(mu)
2629}
2730
2831// NewAnalyzeObserver creates a new thread-safe analyze observer.
2932func NewAnalyzeObserver () * AnalyzeObserver {
3033 return & AnalyzeObserver {
31- stats : make (map [CanonicalKey ]AnalyzeStats ),
34+ stats : make (map [CanonicalKey ]AnalyzeStats ),
35+ startTimes : make (map [ObserverOperation ]map [CanonicalKey ]time.Time ),
3236 }
3337}
3438
35- // ObserveEnterIterator increments the call counter for the given iterator key and operation .
39+ // ObserveEnterIterator increments the call counter and records the start time .
3640func (a * AnalyzeObserver ) ObserveEnterIterator (op ObserverOperation , key CanonicalKey ) {
3741 a .mu .Lock ()
3842 defer a .mu .Unlock ()
@@ -47,6 +51,11 @@ func (a *AnalyzeObserver) ObserveEnterIterator(op ObserverOperation, key Canonic
4751 stats .IterResourcesCalls ++
4852 }
4953 a .stats [key ] = stats
54+
55+ if a .startTimes [op ] == nil {
56+ a .startTimes [op ] = make (map [CanonicalKey ]time.Time )
57+ }
58+ a.startTimes [op ][key ] = time .Now ()
5059}
5160
5261// ObservePath increments the result counter for the given iterator key and operation.
@@ -66,26 +75,29 @@ func (a *AnalyzeObserver) ObservePath(op ObserverOperation, key CanonicalKey, _
6675 a .stats [key ] = stats
6776}
6877
69- // ObserveTiming records the elapsed time for the given iterator key and operation .
70- func (a * AnalyzeObserver ) ObserveTiming (op ObserverOperation , key CanonicalKey , dur time. Duration ) {
78+ // ObserveReturnIterator records elapsed time since ObserveEnterIterator was called .
79+ func (a * AnalyzeObserver ) ObserveReturnIterator (op ObserverOperation , key CanonicalKey ) {
7180 a .mu .Lock ()
7281 defer a .mu .Unlock ()
7382
74- stats := a .stats [key ]
75- switch op {
76- case CheckOperation :
77- stats .CheckTime += dur
78- case IterSubjectsOperation :
79- stats .IterSubjectsTime += dur
80- case IterResourcesOperation :
81- stats .IterResourcesTime += dur
83+ if opTimes := a .startTimes [op ]; opTimes != nil {
84+ if start , ok := opTimes [key ]; ok {
85+ elapsed := time .Since (start )
86+ stats := a .stats [key ]
87+ switch op {
88+ case CheckOperation :
89+ stats .CheckTime += elapsed
90+ case IterSubjectsOperation :
91+ stats .IterSubjectsTime += elapsed
92+ case IterResourcesOperation :
93+ stats .IterResourcesTime += elapsed
94+ }
95+ a .stats [key ] = stats
96+ delete (opTimes , key )
97+ }
8298 }
83- a .stats [key ] = stats
8499}
85100
86- // ObserveReturnIterator is a no-op for the analyze observer.
87- func (a * AnalyzeObserver ) ObserveReturnIterator (_ ObserverOperation , _ CanonicalKey ) {}
88-
89101// GetStats returns a copy of all collected stats.
90102func (a * AnalyzeObserver ) GetStats () map [CanonicalKey ]AnalyzeStats {
91103 a .mu .Lock ()
@@ -95,3 +107,107 @@ func (a *AnalyzeObserver) GetStats() map[CanonicalKey]AnalyzeStats {
95107 maps .Copy (result , a .stats )
96108 return result
97109}
110+
111+ // FormatAnalysis returns a formatted string showing the iterator tree with execution statistics
112+ // for each iterator. Stats are looked up by iterator canonical key from the analyze map.
113+ func FormatAnalysis (tree Iterator , analyze map [CanonicalKey ]AnalyzeStats ) string {
114+ if tree == nil {
115+ return "No iterator tree provided"
116+ }
117+ if len (analyze ) == 0 {
118+ return "No analysis data available"
119+ }
120+
121+ var sb strings.Builder
122+ formatNode (tree , analyze , & sb , "" , true )
123+ return sb .String ()
124+ }
125+
126+ // AggregateAnalyzeStats combines all the analyze stats from a map into a single
127+ // aggregated AnalyzeStats. This is useful for getting total counts across all
128+ // iterators in a query execution.
129+ func AggregateAnalyzeStats (analyze map [CanonicalKey ]AnalyzeStats ) AnalyzeStats {
130+ var total AnalyzeStats
131+ for _ , stats := range analyze {
132+ total .CheckCalls += stats .CheckCalls
133+ total .IterSubjectsCalls += stats .IterSubjectsCalls
134+ total .IterResourcesCalls += stats .IterResourcesCalls
135+ total .CheckResults += stats .CheckResults
136+ total .IterSubjectsResults += stats .IterSubjectsResults
137+ total .IterResourcesResults += stats .IterResourcesResults
138+ total .CheckTime += stats .CheckTime
139+ total .IterSubjectsTime += stats .IterSubjectsTime
140+ total .IterResourcesTime += stats .IterResourcesTime
141+ }
142+ return total
143+ }
144+
145+ // formatNode recursively formats a single iterator node and its children
146+ // depth parameter tracks how deep we are in the tree (0 = root)
147+ func formatNode (it Iterator , analyze map [CanonicalKey ]AnalyzeStats , sb * strings.Builder , indent string , isLast bool ) {
148+ if it == nil {
149+ return
150+ }
151+
152+ // Get iterator info from Explain()
153+ explain := it .Explain ()
154+ iterKey := it .CanonicalKey ()
155+ stats := analyze [iterKey ]
156+
157+ // Draw tree branch for non-root nodes
158+ // Root nodes have both empty indent and are drawn without any prefix
159+ // Check if we're a child by seeing if we have any indentation OR if this is being called recursively
160+ isRoot := indent == "" && ! strings .Contains (sb .String (), "Calls:" )
161+
162+ if ! isRoot {
163+ if isLast {
164+ sb .WriteString (indent + "└─ " )
165+ } else {
166+ sb .WriteString (indent + "├─ " )
167+ }
168+ }
169+
170+ // Write iterator name and key (truncated for readability)
171+ fmt .Fprintf (sb , "%s (Hash: %016x)\n " , explain .Info , iterKey .Hash ())
172+
173+ // Write stats with indentation
174+ statsIndent := indent
175+ if indent != "" {
176+ if isLast {
177+ statsIndent += " "
178+ } else {
179+ statsIndent += "│ "
180+ }
181+ }
182+
183+ // Format call counts
184+ sb .WriteString (statsIndent + fmt .Sprintf (" Calls: Check=%d, IterSubjects=%d, IterResources=%d\n " ,
185+ stats .CheckCalls , stats .IterSubjectsCalls , stats .IterResourcesCalls ))
186+
187+ // Format result counts
188+ sb .WriteString (statsIndent + fmt .Sprintf (" Results: Check=%d, IterSubjects=%d, IterResources=%d\n " ,
189+ stats .CheckResults , stats .IterSubjectsResults , stats .IterResourcesResults ))
190+
191+ // Format timing
192+ sb .WriteString (statsIndent + fmt .Sprintf (" Time: Check=%v, IterSubjects=%v, IterResources=%v\n " ,
193+ stats .CheckTime , stats .IterSubjectsTime , stats .IterResourcesTime ))
194+
195+ // Recursively format children
196+ subIts := it .Subiterators ()
197+ if len (subIts ) > 0 {
198+ var childIndent string
199+ switch {
200+ case indent == "" :
201+ // Root node - children get no indent prefix, but will show tree chars
202+ childIndent = ""
203+ case isLast :
204+ childIndent = indent + " "
205+ default :
206+ childIndent = indent + "│ "
207+ }
208+
209+ for i , subIt := range subIts {
210+ formatNode (subIt , analyze , sb , childIndent , i == len (subIts )- 1 )
211+ }
212+ }
213+ }
0 commit comments