返回 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/billing"
10 "reasonix/internal/event"
11 "reasonix/internal/evidence"
12 "reasonix/internal/provider"
13 )
14
15 // Recorder is a passthrough event.Sink that snapshots token usage (event.Usage)
16 // and completed turns (event.TurnDone) into the daily stats files. It observes
17 // only; it never alters the event stream.
18 //
19 // Wire it around the frontend sink at the boot layer so every entry point
20 // (desktop, CLI, serve) records consistently; Source distinguishes them.
21 type Recorder struct {
22 inner event.Sink
23 writer *Writer
24 dispatcher *recordDispatcher
25 source string
26 }
27
28 var _ event.OptionalSinkCapabilities = (*Recorder)(nil)
29
30 const recorderQueueSize = 2048
31
32 type dispatchItem struct {
33 record record
34 flush chan struct{}
35 }
36
37 // recordDispatcher keeps filesystem latency off provider/UI event goroutines.
38 // Dispatchers are shared per state directory, so controller rebuilds do not
39 // create one goroutine per recorder instance.
40 type recordDispatcher struct {
41 writer *Writer
42 queue chan dispatchItem
43 }
44
45 var recorderDispatchers = struct {
46 sync.Mutex
47 byDir map[string]*recordDispatcher
48 }{byDir: map[string]*recordDispatcher{}}
49
50 func dispatcherFor(writer *Writer) *recordDispatcher {
51 if writer == nil || writer.dir == "" {
52 return nil
53 }
54 recorderDispatchers.Lock()
55 defer recorderDispatchers.Unlock()
56 if dispatcher := recorderDispatchers.byDir[writer.dir]; dispatcher != nil {
57 return dispatcher
58 }
59 dispatcher := &recordDispatcher{writer: writer, queue: make(chan dispatchItem, recorderQueueSize)}
60 recorderDispatchers.byDir[writer.dir] = dispatcher
61 go dispatcher.run()
62 return dispatcher
63 }
64
65 func existingDispatcher(dir string) *recordDispatcher {
66 if strings.TrimSpace(dir) == "" {
67 return nil
68 }
69 recorderDispatchers.Lock()
70 defer recorderDispatchers.Unlock()
71 return recorderDispatchers.byDir[dir]
72 }
73
74 func (d *recordDispatcher) run() {
75 for item := range d.queue {
76 if item.flush != nil {
77 close(item.flush)
78 continue
79 }
80 _ = d.writer.Append(item.record)
81 }
82 }
83
84 func (d *recordDispatcher) enqueue(rec record) {
85 if d == nil {
86 return
87 }
88 // Statistics are observational. A full queue may lose a record, but it must
89 // never apply backpressure to model streaming or turn completion.
90 select {
91 case d.queue <- dispatchItem{record: rec}:
92 default:
93 }
94 }
95
96 func (d *recordDispatcher) flush(ctx context.Context) error {
97 if d == nil {
98 return nil
99 }
100 if ctx == nil {
101 ctx = context.Background()
102 }
103 done := make(chan struct{})
104 select {
105 case d.queue <- dispatchItem{flush: done}:
106 case <-ctx.Done():
107 return ctx.Err()
108 }
109 select {
110 case <-done:
111 return nil
112 case <-ctx.Done():
113 return ctx.Err()
114 }
115 }
116
117 // NewRecorder wraps inner with usage recording. source labels every record
118 // (desktop/cli/serve/...); an empty source keeps records unlabelled.
119 func NewRecorder(inner event.Sink, dir, source string) *Recorder {
120 writer := NewWriter(dir)
121 writer.usage = managerForUsage(writer.dir)
122 return &Recorder{
123 inner: inner, writer: writer, dispatcher: dispatcherFor(writer), source: strings.TrimSpace(source),
124 }
125 }
126
127 // Emit forwards user-visible events unchanged, then queues any usage/turn
128 // record without waiting for filesystem I/O. Request-only usage is internal
129 // accounting for failed provider calls, so it is persisted without surfacing a
130 // zero-token receipt in the wrapped frontend.
131 func (r *Recorder) Emit(e event.Event) {
132 requestOnly := e.Kind == event.Usage && e.Usage != nil && e.Usage.TotalTokens <= 0 && e.Usage.RequestCount > 0
133 if r != nil && r.inner != nil && !requestOnly {
134 r.inner.Emit(e)
135 }
136 if r != nil && r.writer != nil && e.Kind == event.Usage {
137 r.recordUsage(e)
138 } else if r != nil && r.writer != nil && e.Kind == event.GuardianAssessment && e.Guardian.Usage != nil {
139 r.recordProviderUsage(e.ModelRef, e.Guardian.Usage, nil, "")
140 } else if r != nil && r.writer != nil && e.Kind == event.TurnDone {
141 r.recordTurnCompletion()
142 }
143 }
144
145 // RecordTurnCompletion records synchronous controller runs that deliberately do
146 // not emit TurnDone into the UI event stream.
147 func (r *Recorder) RecordTurnCompletion() {
148 r.recordTurnCompletion()
149 if r != nil {
150 event.RecordTurnCompletion(r.inner)
151 }
152 }
153
154 func (r *Recorder) recordTurnCompletion() {
155 if r == nil || r.dispatcher == nil {
156 return
157 }
158 r.dispatcher.enqueue(record{Timestamp: time.Now(), Source: r.source, Turn: true})
159 }
160
161 // Flush waits until records already accepted by this recorder's shared queue
162 // have been written. Production event paths never call Flush; it exists for
163 // shutdown/verification boundaries that can explicitly tolerate waiting.
164 func (r *Recorder) Flush(ctx context.Context) error {
165 if r == nil {
166 return nil
167 }
168 if err := r.dispatcher.flush(ctx); err != nil {
169 return err
170 }
171 if r.writer != nil && r.writer.usage != nil {
172 if catalog := r.writer.usage.catalog.Load(); catalog != nil {
173 return catalog.Flush(ctx)
174 }
175 }
176 return nil
177 }
178
179 // Flush waits for records already queued for dir. It is primarily useful when
180 // a caller must read its own just-recorded statistics deterministically.
181 func Flush(ctx context.Context, dir string) error {
182 dir = strings.TrimSpace(dir)
183 if err := existingDispatcher(dir).flush(ctx); err != nil {
184 return err
185 }
186 if manager := existingUsageManager(dir); manager != nil {
187 if catalog := manager.catalog.Load(); catalog != nil {
188 return catalog.Flush(ctx)
189 }
190 }
191 return nil
192 }
193
194 // RecordReadinessAudit forwards audit receipts to the wrapped sink.
195 func (r *Recorder) RecordReadinessAudit(a evidence.ReadinessAudit) {
196 event.RecordReadinessAudit(r.inner, a)
197 }
198
199 func (r *Recorder) RecordAnchorSafetyAudit(a event.AnchorSafetyAudit) {
200 event.RecordAnchorSafetyAudit(r.inner, a)
201 }
202
203 // RecordProtocolRecovery preserves the wrapped sink's audit capability.
204 func (r *Recorder) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
205 event.RecordProtocolRecovery(r.inner, a)
206 }
207
208 // RecordContractShadow preserves the wrapped sink's audit capability.
209 func (r *Recorder) RecordContractShadow(a event.ContractShadowAudit) {
210 event.RecordContractShadow(r.inner, a)
211 }
212
213 // RecordCompletionReport preserves the wrapped sink's audit capability.
214 func (r *Recorder) RecordDelegationAudit(a evidence.DelegationAudit) {
215 event.RecordDelegationAudit(r.inner, a)
216 }
217
218 func (r *Recorder) RecordCompletionReport(a event.CompletionReportAudit) {
219 event.RecordCompletionReport(r.inner, a)
220 }
221
222 // RecordOutcomeProgress preserves the wrapped sink's audit capability.
223 func (r *Recorder) RecordOutcomeProgress(sample evidence.OutcomeSample) {
224 event.RecordOutcomeProgress(r.inner, sample)
225 }
226
227 // RecordMemoryRecall preserves the wrapped sink's audit capability.
228 func (r *Recorder) RecordMemoryRecall(a event.MemoryRecallAudit) {
229 event.RecordMemoryRecall(r.inner, a)
230 }
231
232 // RecordDelegationAdmission preserves the wrapped sink's audit capability.
233 func (r *Recorder) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
234 event.RecordDelegationAdmission(r.inner, a)
235 }
236
237 func (r *Recorder) RecordWorkspaceMutation(m event.WorkspaceMutation) {
238 event.RecordWorkspaceMutation(r.inner, m)
239 }
240
241 func (r *Recorder) RecordRunBudget(sample event.RunBudgetSample) {
242 event.RecordRunBudget(r.inner, sample)
243 }
244
245 func (r *Recorder) RecordSubagentLifecycle(info event.SubagentLifecycleInfo) {
246 event.RecordSubagentLifecycle(r.inner, info)
247 }
248
249 func (r *Recorder) recordUsage(e event.Event) {
250 r.recordProviderUsage(e.ModelRef, e.Usage, e.CostQuote, e.UsageSource)
251 }
252
253 func (r *Recorder) recordProviderUsage(modelRef string, usage *provider.Usage, quote *billing.CostQuote, usageSource string) {
254 if usage == nil || (usage.TotalTokens <= 0 && usage.RequestCount <= 0) {
255 return
256 }
257 // Recording is best-effort: a stats file failure (disk full, permissions)
258 // must never interrupt the event stream, matching telemetry's append idiom.
259 rec := record{
260 Timestamp: time.Now(),
261 ModelRef: modelRef,
262 Source: r.source,
263 Prompt: usage.PromptTokens,
264 Completion: usage.CompletionTokens,
265 Reasoning: usage.ReasoningTokens,
266 CacheHit: usage.CacheHitTokens,
267 CacheMiss: usage.CacheMissTokens,
268 Total: usage.TotalTokens,
269 Requests: usageRequestCount(usage),
270 UsageSource: strings.TrimSpace(usageSource),
271 }
272 if quote != nil {
273 rec.CostAmount = quote.Original.Amount
274 rec.CostCurrency = quote.Original.Currency
275 rec.PricingFingerprint = quote.PricingFingerprint
276 rec.RateDate = quote.RateDate
277 rec.RateBand = quote.RateBand
278 rec.RatedAt = quote.RatedAt
279 rec.IncompleteReason = quote.IncompleteReason
280 rec.BillingMode = quote.BillingMode
281 rec.CostEstimated = quote.Estimated
282 rec.LegacyEstimate = quote.LegacyEstimate
283 costComplete := quote.CostComplete
284 displayComplete := quote.DisplayComplete
285 rec.CostComplete = &costComplete
286 rec.DisplayComplete = &displayComplete
287 rec.DisplayStatus = quote.DisplayStatus
288 rec.AggregateMode = quote.AggregateMode
289 for _, total := range quote.OriginalTotals {
290 rec.OriginalTotals = append(rec.OriginalTotals, total.Currency+":"+total.Amount)
291 }
292 if quote.Selected != nil {
293 rec.SelectedAmount = quote.Selected.Amount
294 rec.SelectedCurrency = quote.Selected.Currency
295 rec.SelectedCost = quote.Selected.Float64()
296 }
297 if v, ok := quote.Valuations["CNY"]; ok {
298 rec.ValuationCNY = v.Money.Amount
299 }
300 if v, ok := quote.Valuations["USD"]; ok {
301 rec.ValuationUSD = v.Money.Amount
302 }
303 }
304 r.dispatcher.enqueue(rec)
305 }
306
307 func usageRequestCount(usage *provider.Usage) int {
308 if usage != nil && usage.RequestCount > 0 {
309 return usage.RequestCount
310 }
311 return 1
312 }
313
313 lines GO