My agentic slop goes here. Not intended for anyone else!
1open Dancer_logs
2open Cmdliner
3
4let style_ok = Fmt.styled `Green
5let style_error = Fmt.styled `Red
6let style_warning = Fmt.styled `Yellow
7let style_info = Fmt.styled `Blue
8let style_debug = Fmt.styled `Cyan
9let style_dim = Fmt.styled `Faint
10let style_bold = Fmt.styled `Bold
11
12let pp_level ppf = function
13 | Logs.App -> Fmt.pf ppf "%a" style_bold "APP"
14 | Logs.Error -> Fmt.pf ppf "%a" style_error "ERR"
15 | Logs.Warning -> Fmt.pf ppf "%a" style_warning "WRN"
16 | Logs.Info -> Fmt.pf ppf "%a" style_info "INF"
17 | Logs.Debug -> Fmt.pf ppf "%a" style_debug "DBG"
18
19let pp_timestamp ppf timestamp =
20 let ptime = Ptime.of_float_s timestamp |> Option.get in
21 Fmt.pf ppf "%a" (Ptime.pp_human ~frac_s:3 ()) ptime
22
23let pp_source ppf source =
24 Fmt.pf ppf "%a" style_bold source
25
26let pp_duration ppf ms =
27 if ms > 1000.0 then
28 Fmt.pf ppf "%a" style_warning (Printf.sprintf "%.1fs" (ms /. 1000.0))
29 else if ms > 100.0 then
30 Fmt.pf ppf "%a" style_warning (Printf.sprintf "%.0fms" ms)
31 else
32 Fmt.pf ppf "%a" style_ok (Printf.sprintf "%.1fms" ms)
33
34let pp_log_row ppf row =
35 match row with
36 | timestamp :: level :: source :: message :: rest ->
37 let timestamp = match timestamp with
38 | Sqlite3.Data.FLOAT f -> f
39 | _ -> 0.0
40 in
41 let level = match level with
42 | Sqlite3.Data.INT i ->
43 (match Int64.to_int i with
44 | 0 -> Logs.App
45 | 1 -> Logs.Error
46 | 2 -> Logs.Warning
47 | 3 -> Logs.Info
48 | _ -> Logs.Debug)
49 | _ -> Logs.Info
50 in
51 let source = match source with
52 | Sqlite3.Data.TEXT s -> s
53 | _ -> "unknown"
54 in
55 let message = match message with
56 | Sqlite3.Data.TEXT s -> s
57 | _ -> ""
58 in
59
60 Fmt.pf ppf "@[<h>%a %a %a@]@. %s@."
61 pp_timestamp timestamp
62 pp_level level
63 pp_source source
64 message;
65
66 List.iteri (fun i data ->
67 match data with
68 | Sqlite3.Data.TEXT s when String.length s > 0 ->
69 Fmt.pf ppf " %a: %s@." style_dim
70 (match i with
71 | 0 -> "error"
72 | 1 -> "code"
73 | 2 -> "trace"
74 | _ -> string_of_int i) s
75 | Sqlite3.Data.INT i ->
76 Fmt.pf ppf " %a: %Ld@." style_dim (string_of_int i) i
77 | Sqlite3.Data.FLOAT f ->
78 Fmt.pf ppf " %a: %f@." style_dim (string_of_int i) f
79 | _ -> ()
80 ) rest
81 | _ -> ()
82
83let pp_pattern ppf pattern =
84 match pattern with
85 | [id; hash; pattern_text; first_seen; last_seen; count; severity] ->
86 let count = match count with
87 | Sqlite3.Data.INT i -> Int64.to_int i
88 | _ -> 0
89 in
90 let severity_color =
91 match severity with
92 | Sqlite3.Data.FLOAT f when f > 7.0 -> style_error
93 | Sqlite3.Data.FLOAT f when f > 4.0 -> style_warning
94 | _ -> style_info
95 in
96
97 Fmt.pf ppf "@[<v>%a Pattern (×%d) %a@. %s@. First: %a | Last: %a@]@."
98 severity_color "●"
99 count
100 style_dim
101 (match hash with Sqlite3.Data.TEXT s -> String.sub s 0 8 | _ -> "")
102 (match pattern_text with Sqlite3.Data.TEXT s -> s | _ -> "")
103 pp_timestamp (match first_seen with Sqlite3.Data.FLOAT f -> f | _ -> 0.0)
104 pp_timestamp (match last_seen with Sqlite3.Data.FLOAT f -> f | _ -> 0.0)
105 | _ -> ()
106
107let pp_error_summary ppf row =
108 match row with
109 | [hash; error_type; error_code; count; sessions; users; last; first; sources; avg_dur; sample] ->
110 let count = match count with Sqlite3.Data.INT i -> Int64.to_int i | _ -> 0 in
111 let sessions = match sessions with Sqlite3.Data.INT i -> Int64.to_int i | _ -> 0 in
112 let error_type = match error_type with Sqlite3.Data.TEXT s -> s | _ -> "unknown" in
113
114 Fmt.pf ppf "@[<v>%a %s (×%d in %d sessions)@. %s@. Sources: %s@]@."
115 style_error "ERROR"
116 error_type count sessions
117 (match sample with Sqlite3.Data.TEXT s ->
118 if String.length s > 80 then String.sub s 0 77 ^ "..." else s
119 | _ -> "")
120 (match sources with Sqlite3.Data.TEXT s -> s | _ -> "")
121 | _ -> ()
122
123let list_cmd =
124 let list db_path level source since limit follow =
125 Eio_main.run @@ fun _env ->
126 let db = Dancer_logs.init ~path:db_path () in
127
128 let filter = {
129 Query.default_filter with
130 level;
131 source;
132 since = Option.map (fun h -> Unix.gettimeofday () -. (h *. 3600.0)) since;
133 limit;
134 } in
135
136 let rows = Query.list db filter in
137
138 Fmt.pr "@[<v>%a@]@."
139 (Fmt.list ~sep:Fmt.cut pp_log_row) rows;
140
141 Dancer_logs.close db
142 in
143
144 let db_path = Arg.(value & opt string "dancer_logs.db" &
145 info ["d"; "db"] ~doc:"Database path") in
146
147 let level = Arg.(value & opt (some (enum [
148 "error", Logs.Error;
149 "warning", Logs.Warning;
150 "info", Logs.Info;
151 "debug", Logs.Debug;
152 ])) None & info ["l"; "level"] ~doc:"Filter by log level") in
153
154 let source = Arg.(value & opt (some string) None &
155 info ["s"; "source"] ~doc:"Filter by source module") in
156
157 let since = Arg.(value & opt (some float) None &
158 info ["since"] ~doc:"Show logs from last N hours") in
159
160 let limit = Arg.(value & opt int 100 &
161 info ["n"; "limit"] ~doc:"Maximum number of results") in
162
163 let follow = Arg.(value & flag &
164 info ["f"; "follow"] ~doc:"Follow mode (like tail -f)") in
165
166 let doc = "List log entries" in
167 let info = Cmd.info "list" ~doc in
168 Cmd.v info Term.(const list $ db_path $ level $ source $ since $ limit $ follow)
169
170let search_cmd =
171 let search db_path query limit =
172 Eio_main.run @@ fun _env ->
173 let db = Dancer_logs.init ~path:db_path () in
174
175 let rows = Query.search db query ~limit () in
176
177 Fmt.pr "@[<v>%a Found %d matches@.@.%a@]@."
178 style_bold "🔍" (List.length rows)
179 (Fmt.list ~sep:(Fmt.any "@.---@.") pp_log_row) rows;
180
181 Dancer_logs.close db
182 in
183
184 let db_path = Arg.(value & opt string "dancer_logs.db" &
185 info ["d"; "db"] ~doc:"Database path") in
186
187 let query = Arg.(required & pos 0 (some string) None &
188 info [] ~docv:"QUERY" ~doc:"Search query") in
189
190 let limit = Arg.(value & opt int 100 &
191 info ["n"; "limit"] ~doc:"Maximum number of results") in
192
193 let doc = "Full-text search in logs" in
194 let info = Cmd.info "search" ~doc in
195 Cmd.v info Term.(const search $ db_path $ query $ limit)
196
197let patterns_cmd =
198 let patterns db_path min_count since =
199 Eio_main.run @@ fun _env ->
200 let db = Dancer_logs.init ~path:db_path () in
201
202 let patterns = Pattern.list db ~min_count ?since () in
203
204 Fmt.pr "@[<v>%a Detected Patterns@.@.%a@]@."
205 style_bold "📊"
206 (Fmt.list ~sep:Fmt.cut (fun ppf p ->
207 pp_pattern ppf [
208 Sqlite3.Data.INT (Int64.of_int p.Pattern.id);
209 Sqlite3.Data.TEXT p.pattern_hash;
210 Sqlite3.Data.TEXT p.pattern;
211 Sqlite3.Data.FLOAT p.first_seen;
212 Sqlite3.Data.FLOAT p.last_seen;
213 Sqlite3.Data.INT (Int64.of_int p.occurrence_count);
214 match p.severity_score with
215 | Some s -> Sqlite3.Data.FLOAT s
216 | None -> Sqlite3.Data.NULL
217 ]
218 )) patterns;
219
220 Dancer_logs.close db
221 in
222
223 let db_path = Arg.(value & opt string "dancer_logs.db" &
224 info ["d"; "db"] ~doc:"Database path") in
225
226 let min_count = Arg.(value & opt int 5 &
227 info ["c"; "min-count"] ~doc:"Minimum occurrence count") in
228
229 let since = Arg.(value & opt (some float) None &
230 info ["since"] ~doc:"Patterns from last N hours") in
231
232 let doc = "Show detected error patterns" in
233 let info = Cmd.info "patterns" ~doc in
234 Cmd.v info Term.(const patterns $ db_path $ min_count $ since)
235
236let errors_cmd =
237 let errors db_path limit =
238 Eio_main.run @@ fun _env ->
239 let db = Dancer_logs.init ~path:db_path () in
240
241 let errors = Query.recent_errors db ~limit () in
242
243 Fmt.pr "@[<v>%a Recent Errors (last 24h)@.@.%a@]@."
244 style_error "⚠️"
245 (Fmt.list ~sep:(Fmt.any "@.") pp_error_summary) errors;
246
247 Dancer_logs.close db
248 in
249
250 let db_path = Arg.(value & opt string "dancer_logs.db" &
251 info ["d"; "db"] ~doc:"Database path") in
252
253 let limit = Arg.(value & opt int 50 &
254 info ["n"; "limit"] ~doc:"Maximum number of results") in
255
256 let doc = "Show recent errors with aggregation" in
257 let info = Cmd.info "errors" ~doc in
258 Cmd.v info Term.(const errors $ db_path $ limit)
259
260let stats_cmd =
261 let stats db_path =
262 Eio_main.run @@ fun _env ->
263 let db = Dancer_logs.init ~path:db_path () in
264
265 let count_by_level = Query.count_by_level db ~since:(Unix.gettimeofday () -. 86400.0) () in
266
267 Fmt.pr "@[<v>%a Database Statistics@.@." style_bold "📈";
268
269 List.iter (fun (level, count) ->
270 Fmt.pr " %a: %d@." pp_level level count
271 ) count_by_level;
272
273 let db_stats = Dancer_logs.stats db in
274 List.iter (fun (k, v) ->
275 Fmt.pr " %a: %s@." style_dim k v
276 ) db_stats;
277
278 Fmt.pr "@]@.";
279
280 Dancer_logs.close db
281 in
282
283 let db_path = Arg.(value & opt string "dancer_logs.db" &
284 info ["d"; "db"] ~doc:"Database path") in
285
286 let doc = "Show database statistics" in
287 let info = Cmd.info "stats" ~doc in
288 Cmd.v info Term.(const stats $ db_path)
289
290let export_cmd =
291 let export db_path format output since =
292 Eio_main.run @@ fun _env ->
293 let db = Dancer_logs.init ~path:db_path () in
294
295 let filter = {
296 Query.default_filter with
297 since = Option.map (fun h -> Unix.gettimeofday () -. (h *. 3600.0)) since;
298 limit = 10000;
299 } in
300
301 let rows = Query.list db filter in
302
303 let content = match format with
304 | "json" -> Yojson.Safe.to_string (Export.to_json rows)
305 | "csv" -> Export.to_csv rows
306 | "claude" -> Export.for_claude db ()
307 | _ -> failwith "Unknown format"
308 in
309
310 let oc = open_out output in
311 output_string oc content;
312 close_out oc;
313
314 Fmt.pr "%a Exported %d logs to %s@."
315 style_ok "✓" (List.length rows) output;
316
317 Dancer_logs.close db
318 in
319
320 let db_path = Arg.(value & opt string "dancer_logs.db" &
321 info ["d"; "db"] ~doc:"Database path") in
322
323 let format = Arg.(value & opt (enum [
324 "json", "json";
325 "csv", "csv";
326 "claude", "claude";
327 ]) "json" & info ["f"; "format"] ~doc:"Export format") in
328
329 let output = Arg.(required & opt (some string) None &
330 info ["o"; "output"] ~doc:"Output file") in
331
332 let since = Arg.(value & opt (some float) None &
333 info ["since"] ~doc:"Export logs from last N hours") in
334
335 let doc = "Export logs to file" in
336 let info = Cmd.info "export" ~doc in
337 Cmd.v info Term.(const export $ db_path $ format $ output $ since)
338
339let main_cmd =
340 let doc = "Dancer Logs CLI - Query and analyze structured logs" in
341 let info = Cmd.info "dancer-logs" ~version:"0.1.0" ~doc in
342 let default = Term.(ret (const (`Help (`Pager, None)))) in
343 Cmd.group info ~default [
344 list_cmd;
345 search_cmd;
346 patterns_cmd;
347 errors_cmd;
348 stats_cmd;
349 export_cmd;
350 ]
351
352let () = exit (Cmd.eval main_cmd)