1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only
use clap::Subcommand;
use comfy_table::Table;
use ordinary_monitor::LogFileMetadata;
use std::collections::BTreeMap;
#[derive(Clone, Debug)]
pub enum LogFormat {
/// all logs that match query
All,
/// top logs that match query
Top,
/// count of logs that match query
Count,
}
impl LogFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::All => "all",
Self::Top => "top",
Self::Count => "count",
}
}
}
impl clap::ValueEnum for LogFormat {
fn value_variants<'a>() -> &'a [Self] {
&[Self::All, Self::Top, Self::Count]
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
match self {
Self::All => Some(clap::builder::PossibleValue::new("all")),
Self::Top => Some(clap::builder::PossibleValue::new("top")),
Self::Count => Some(clap::builder::PossibleValue::new("count")),
}
}
}
#[derive(Subcommand, Debug)]
pub enum Logs {
/// sync files, indexes and tables
Sync {
#[command(subcommand)]
sync: Sync,
},
/// list all log files
List,
/// read one or all files to stdout
Read {
#[arg(short, long)]
/// name of the log file to read
name: Option<String>,
},
/// query logs with SQL
///
/// stored in a `SQLite` file located at `~/.ordinary/logs/<your-domain>/events.sqlite`.
///
/// results are logged to stdout as JSON lines.
Query {
/// [reference](https://sqlite.org/lang.html)
///
/// schema:
///
/// ```sql
/// CREATE TABLE IF NOT EXISTS events (
/// id TEXT PRIMARY KEY, -- string formatted UUID
/// ts TEXT NOT NULL, -- timestamp (2026-06-28T14:19:54.937575Z)
/// lvl TEXT NOT NULL, -- level string (TRACE | DEBUG | INFO | WARN | ERROR)
/// spn BLOB, -- JSONB formatted spans
/// fld BLOB, -- JSONB formatted fields
/// file TEXT NOT NULL -- file name the event was pulled from
/// );
/// ```
///
/// query:
///
/// ```sql
/// SELECT file, id, ts, lvl, json(spn), json(fld) FROM events;
/// ```
///
query: String,
#[arg(short, long, value_delimiter = ',')]
/// which columns should be nested as JSON
///
/// i.e.
///
/// ```sh
/// ordinary app logs query \
/// 'SELECT file, id, ts, lvl, json(spn) as spn, json(fld) as fld FROM events' \
/// --json-columns spn,fld
/// ```
json_columns: Option<Vec<String>>,
#[arg(short, long)]
/// whether to sync from remote
sync: Option<bool>,
},
/// search the tantivy index
Search {
/// format
format: LogFormat,
/// [reference](https://quickwit.io/docs/reference/query-language)
query: String,
#[arg(long)]
/// limit (when using 'top' format)
limit: Option<usize>,
#[arg(short, long)]
/// whether to sync from remote
sync: Option<bool>,
},
}
#[derive(Subcommand, Debug)]
pub enum Sync {
/// get information about remote and local log files.
///
/// - "✅" remote and local are synced
/// - "❌" not found on remote *or* local
/// - "⚠️" present on both but local is out of sync
Info,
/// sync a single file.
File {
/// file name
name: String,
},
/// repair local state for all out of sync (⚠️) and remote
/// files not yet downloaded (❌).
All {
#[arg(short, long)]
/// download all files from the server,
/// overwriting everything you have locally.
///
/// Note: will preserve the state of any files the server
/// no longer has a record of.
force: Option<bool>,
},
}
pub fn print_logs_metadata_table(
remote_metadata_vec: Vec<LogFileMetadata>,
local_metadata_vec: Vec<LogFileMetadata>,
) {
let mut table = Table::new();
table.set_header(vec!["file", "size", "remote", "local"]);
let mut rows_map = BTreeMap::new();
for metadata in remote_metadata_vec {
rows_map.insert(
metadata.name.clone(),
vec![
metadata.name.clone(),
format!("{}", bytesize::ByteSize(metadata.size).display().si_short()),
"✅".to_string(),
"❌".to_string(),
],
);
}
for metadata in local_metadata_vec {
if let Some(row) = rows_map.get_mut(&metadata.name) {
let local_size = format!("{}", bytesize::ByteSize(metadata.size).display().si_short());
if row[1] == local_size {
row[3] = "✅".to_string();
} else {
row[3] = "⚠️".to_string();
}
row[1] = format!("{local_size}/{}", row[1]);
} else {
rows_map.insert(
metadata.name.clone(),
vec![
metadata.name.clone(),
format!("{}", bytesize::ByteSize(metadata.size).display().si_short()),
"❌".to_string(),
"✅".to_string(),
],
);
}
}
for (_, row) in rows_map {
table.add_row(row);
}
println!("\n{table}\n");
}