返回 DeepSeek-Reasonix
recorder.go
根目录 / internal / stats / recorder.go
1 package stats
2
3 import (
4 "context"
5 "strings"
6 "sync"
7 "time"
8
9 "reasonix/internal/event"
10 "reasonix/internal/evidence"
11 "reasonix/internal/provider"
12 )
13
14 // Recorder is a passthrough event.Sink that snapshots token usage (event.Usage)
15 // and completed turns (event.TurnDone) into the daily stats files. It observes
16 // only; it never alters the event stream.
17 //
18 // Wire it around the frontend sink at the boot layer so every entry point
19 // (desktop, CLI, serve) records consistently; Source distinguishes them.
20 type Recorder struct {
21 inner event.Sink
22 writer *Writer
23 dispatcher *recordDispatcher
24 source string
25 }
26
27 const recorderQueueSize = 2048
28
29 type dispatchItem struct {
30 record record
31 flush chan struct{}
32 }
33
34 // recordDispatcher keeps filesystem latency off provider/UI event goroutines.
35 // Dispatchers are shared per state directory, so controller rebuilds do not
36 // create one goroutine per recorder instance.
37 type recordDispatcher struct {
38 writer *Writer
39 queue chan dispatchItem
40 }
41
42 var recorderDispatchers = struct {
43 sync.Mutex
44 byDir map[string]*recordDispatcher
45 }{byDir: map[string]*recordDispatcher{}}
46
47 func dispatcherFor(writer *Writer) *recordDispatcher {
48 if writer == nil || writer.dir == "" {
49 return nil
50 }
51 recorderDispatchers.Lock()
52 defer recorderDispatchers.Unlock()
53 if dispatcher := recorderDispatchers.byDir[writer.dir]; dispatcher != nil {
54 return dispatcher
55 }
56 dispatcher := &recordDispatcher{writer: writer, queue: make(chan dispatchItem, recorderQueueSize)}
57 recorderDispatchers.byDir[writer.dir] = dispatcher
58 go dispatcher.run()
59 return dispatcher
60 }
61
62 func existingDispatcher(dir string) *recordDispatcher {
63 if strings.TrimSpace(dir) == "" {
64 return nil
65 }
66 recorderDispatchers.Lock()
67 defer recorderDispatchers.Unlock()
68 return recorderDispatchers.byDir[dir]
69 }
70
71 func (d *recordDispatcher) run() {
72 for item := range d.queue {
73 if item.flush != nil {
74 close(item.flush)
75 continue
76 }
77 _ = d.writer.Append(item.record)
78 }
79 }
80
81 func (d *recordDispatcher) enqueue(rec record) {
82 if d == nil {
83 return
84 }
85 // Statistics are observational. A full queue may lose a record, but it must
86 // never apply backpressure to model streaming or turn completion.
87 select {
88 case d.queue <- dispatchItem{record: rec}:
89 default:
90 }
91 }
92
93 func (d *recordDispatcher) flush(ctx context.Context) error {
94 if d == nil {
95 return nil
96 }
97 if ctx == nil {
98 ctx = context.Background()
99 }
100 done := make(chan struct{})
101 select {
102 case d.queue <- dispatchItem{flush: done}:
103 case <-ctx.Done():
104 return ctx.Err()
105 }
106 select {
107 case <-done:
108 return nil
109 case <-ctx.Done():
110 return ctx.Err()
111 }
112 }
113
114 // NewRecorder wraps inner with usage recording. source labels every record
115 // (desktop/cli/serve/...); an empty source keeps records unlabelled.
116 func NewRecorder(inner event.Sink, dir, source string) *Recorder {
117 writer := NewWriter(dir)
118 return &Recorder{
119 inner: inner, writer: writer, dispatcher: dispatcherFor(writer), source: strings.TrimSpace(source),
120 }
121 }
122
123 // Emit forwards user-visible events unchanged, then queues any usage/turn
124 // record without waiting for filesystem I/O. Request-only usage is internal
125 // accounting for failed provider calls, so it is persisted without surfacing a
126 // zero-token receipt in the wrapped frontend.
127 func (r *Recorder) Emit(e event.Event) {
128 requestOnly := e.Kind == event.Usage && e.Usage != nil && e.Usage.TotalTokens <= 0 && e.Usage.RequestCount > 0
129 if r != nil && r.inner != nil && !requestOnly {
130 r.inner.Emit(e)
131 }
132 if r != nil && r.writer != nil && e.Kind == event.Usage {
133 r.recordUsage(e)
134 } else if r != nil && r.writer != nil && e.Kind == event.GuardianAssessment && e.Guardian.Usage != nil {
135 r.recordProviderUsage(e.ModelRef, e.Guardian.Usage)
136 } else if r != nil && r.writer != nil && e.Kind == event.TurnDone {
137 r.RecordTurnCompletion()
138 }
139 }
140
141 // RecordTurnCompletion records synchronous controller runs that deliberately do
142 // not emit TurnDone into the UI event stream.
143 func (r *Recorder) RecordTurnCompletion() {
144 if r == nil || r.dispatcher == nil {
145 return
146 }
147 r.dispatcher.enqueue(record{Timestamp: time.Now(), Source: r.source, Turn: true})
148 }
149
150 // Flush waits until records already accepted by this recorder's shared queue
151 // have been written. Production event paths never call Flush; it exists for
152 // shutdown/verification boundaries that can explicitly tolerate waiting.
153 func (r *Recorder) Flush(ctx context.Context) error {
154 if r == nil {
155 return nil
156 }
157 return r.dispatcher.flush(ctx)
158 }
159
160 // Flush waits for records already queued for dir. It is primarily useful when
161 // a caller must read its own just-recorded statistics deterministically.
162 func Flush(ctx context.Context, dir string) error {
163 writer := NewWriter(dir)
164 return existingDispatcher(writer.dir).flush(ctx)
165 }
166
167 // RecordReadinessAudit forwards audit receipts to the wrapped sink.
168 func (r *Recorder) RecordReadinessAudit(a evidence.ReadinessAudit) {
169 event.RecordReadinessAudit(r.inner, a)
170 }
171
172 // RecordProtocolRecovery preserves the wrapped sink's audit capability.
173 func (r *Recorder) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
174 event.RecordProtocolRecovery(r.inner, a)
175 }
176
177 func (r *Recorder) recordUsage(e event.Event) {
178 r.recordProviderUsage(e.ModelRef, e.Usage)
179 }
180
181 func (r *Recorder) recordProviderUsage(modelRef string, usage *provider.Usage) {
182 if usage == nil || (usage.TotalTokens <= 0 && usage.RequestCount <= 0) {
183 return
184 }
185 // Recording is best-effort: a stats file failure (disk full, permissions)
186 // must never interrupt the event stream, matching telemetry's append idiom.
187 r.dispatcher.enqueue(record{
188 Timestamp: time.Now(),
189 ModelRef: modelRef,
190 Source: r.source,
191 Prompt: usage.PromptTokens,
192 Completion: usage.CompletionTokens,
193 Reasoning: usage.ReasoningTokens,
194 CacheHit: usage.CacheHitTokens,
195 CacheMiss: usage.CacheMissTokens,
196 Total: usage.TotalTokens,
197 Requests: usageRequestCount(usage),
198 })
199 }
200
201 func usageRequestCount(usage *provider.Usage) int {
202 if usage != nil && usage.RequestCount > 0 {
203 return usage.RequestCount
204 }
205 return 1
206 }
207
207 lines GO