返回 DeepSeek-Reasonix
record.go
根目录 / internal / stats / record.go
1 // Package stats records per-call token usage as append-only daily JSONL files
2 // under the user state root (config.StatsDir), and aggregates them for the
3 // desktop "usage statistics" panel.
4 //
5 // Design notes:
6 // - Only provider usage (including request-only failures) and turn
7 // completions (event.TurnDone) are recorded here. Turn markers power the
8 // panel's "completed turns" metric;
9 // they are deliberately not presented as distinct conversation sessions.
10 // Token usage was never persisted before this feature, so token numbers
11 // accumulate from the day the feature ships.
12 // - Files are append-only: each record is one JSON line appended with
13 // O_APPEND. A crash mid-line leaves at most one torn trailing line, which
14 // decodeRecords tolerates and skips.
15 package stats
16
17 import (
18 "bufio"
19 "context"
20 "encoding/json"
21 "errors"
22 "io"
23 "os"
24 "path/filepath"
25 "strings"
26 "time"
27
28 "reasonix/internal/filelock"
29 )
30
31 // dayLayout names one stats file per UTC-free local day, e.g. 2026-08-02.jsonl.
32 const dayLayout = "2006-01-02"
33
34 const appendLockTimeout = 2 * time.Second
35
36 // record is one line in a daily stats file. TurnDone marks a completed turn so
37 // per-day turn counts are available without touching session files.
38 type record struct {
39 Timestamp time.Time `json:"ts"`
40 ModelRef string `json:"model,omitempty"` // canonical "provider/model"
41 Source string `json:"source,omitempty"` // desktop | cli | serve | bot | remote
42 Prompt int `json:"prompt,omitempty"`
43 Completion int `json:"completion,omitempty"`
44 Reasoning int `json:"reasoning,omitempty"`
45 CacheHit int `json:"cache_hit,omitempty"`
46 CacheMiss int `json:"cache_miss,omitempty"`
47 Total int `json:"total,omitempty"`
48 Requests int `json:"requests,omitempty"` // provider requests represented by this row
49 Turn bool `json:"turn,omitempty"` // true for TurnDone marker rows
50 }
51
52 // Writer appends records to the daily stats file for a given stats dir.
53 type Writer struct {
54 dir string
55 }
56
57 // NewWriter returns a Writer rooted at dir. An empty dir disables recording
58 // (query-only usage).
59 func NewWriter(dir string) *Writer {
60 return &Writer{dir: strings.TrimSpace(dir)}
61 }
62
63 // Append writes one record, appending to the daily file (O_APPEND) so records
64 // from concurrent turns never overwrite each other. Each record is a single
65 // JSON line; a crash mid-line leaves at most one torn trailing line, which
66 // decodeRecords tolerates.
67 func (w *Writer) Append(r record) error {
68 if w == nil || w.dir == "" {
69 return nil
70 }
71 day := r.Timestamp.Format(dayLayout)
72 path := filepath.Join(w.dir, day+".jsonl")
73 b, err := json.Marshal(r)
74 if err != nil {
75 return err
76 }
77 if err := os.MkdirAll(w.dir, 0o700); err != nil {
78 return err
79 }
80 ctx, cancel := context.WithTimeout(context.Background(), appendLockTimeout)
81 defer cancel()
82 release, err := filelock.Acquire(ctx, filepath.Join(w.dir, ".append.lock"))
83 if err != nil {
84 return err
85 }
86 defer release()
87 f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600)
88 if err != nil {
89 return err
90 }
91 defer f.Close()
92 if err := ensureRecordBoundary(f); err != nil {
93 return err
94 }
95 _, err = f.Write(append(b, '\n'))
96 return err
97 }
98
99 // ensureRecordBoundary separates a torn trailing JSON object from the next
100 // append. The caller holds the cross-process append lock, so checking the last
101 // byte and repairing it cannot race another Reasonix writer.
102 func ensureRecordBoundary(f *os.File) error {
103 st, err := f.Stat()
104 if err != nil || st.Size() == 0 {
105 return err
106 }
107 var tail [1]byte
108 if _, err := f.ReadAt(tail[:], st.Size()-1); err != nil {
109 return err
110 }
111 if tail[0] == '\n' {
112 return nil
113 }
114 _, err = f.Write([]byte{'\n'})
115 return err
116 }
117
118 // readDaily loads one daily file into records. Missing files yield nil, nil.
119 func readDaily(dir, day string) ([]record, error) {
120 if strings.TrimSpace(dir) == "" {
121 return nil, nil
122 }
123 f, err := os.Open(filepath.Join(dir, day+".jsonl"))
124 if errors.Is(err, os.ErrNotExist) {
125 return nil, nil
126 }
127 if err != nil {
128 return nil, err
129 }
130 defer f.Close()
131 return decodeRecords(f)
132 }
133
134 // readDailyRange snapshots the available daily files with one directory scan,
135 // then reads only dates requested by the query. Long custom ranges are often
136 // mostly empty; avoiding one failed os.Open per absent day keeps their cost
137 // proportional to the data that actually exists.
138 func readDailyRange(dir string, days []string) (map[string][]record, error) {
139 out := make(map[string][]record)
140 if strings.TrimSpace(dir) == "" || len(days) == 0 {
141 return out, nil
142 }
143 wanted := make(map[string]struct{}, len(days))
144 for _, day := range days {
145 wanted[day] = struct{}{}
146 }
147 entries, err := os.ReadDir(dir)
148 if errors.Is(err, os.ErrNotExist) {
149 return out, nil
150 }
151 if err != nil {
152 return nil, err
153 }
154 for _, entry := range entries {
155 if entry.IsDir() {
156 continue
157 }
158 name := entry.Name()
159 if !strings.HasSuffix(name, ".jsonl") {
160 continue
161 }
162 day := strings.TrimSuffix(name, ".jsonl")
163 if _, ok := wanted[day]; !ok {
164 continue
165 }
166 records, err := readDaily(dir, day)
167 if err != nil {
168 return nil, err
169 }
170 out[day] = records
171 }
172 return out, nil
173 }
174
175 func decodeRecords(r io.Reader) ([]record, error) {
176 var out []record
177 sc := bufio.NewScanner(r)
178 sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
179 for sc.Scan() {
180 line := strings.TrimSpace(sc.Text())
181 if line == "" {
182 continue
183 }
184 var rec record
185 if err := json.Unmarshal([]byte(line), &rec); err != nil {
186 // Malformed lines (a crash mid-write or a manual edit) are skipped
187 // rather than failing the whole day's aggregation. This tolerates
188 // any number of bad lines; a fully corrupt file reads as an empty
189 // day, which is preferable to the panel erroring out.
190 continue
191 }
192 out = append(out, rec)
193 }
194 return out, sc.Err()
195 }
196
197 // daysInRange lists the daily file names (without extension) whose timestamps
198 // intersect [from, to], inclusive.
199 func daysInRange(from, to time.Time) []string {
200 from = dayStart(from)
201 to = dayStart(to)
202 if to.Before(from) {
203 return nil
204 }
205 var days []string
206 for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
207 days = append(days, d.Format(dayLayout))
208 }
209 return days
210 }
211
212 func dayStart(t time.Time) time.Time {
213 y, m, d := t.Date()
214 return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
215 }
216
216 lines GO