spacetimedb_cli/subcommands/
logs.rs1use std::borrow::Cow;
2use std::io::{self, Write};
3
4use crate::common_args;
5use crate::config::Config;
6use crate::util::{add_auth_header_opt, database_identity, get_auth_header};
7use clap::{Arg, ArgAction, ArgMatches};
8use futures::{AsyncBufReadExt, TryStreamExt};
9use is_terminal::IsTerminal;
10use termcolor::{Color, ColorSpec, WriteColor};
11use tokio::io::AsyncWriteExt;
12
13pub fn cli() -> clap::Command {
14 clap::Command::new("logs")
15 .about("Prints logs from a SpacetimeDB database")
16 .arg(
17 Arg::new("database")
18 .required(true)
19 .help("The name or identity of the database to print logs from"),
20 )
21 .arg(
22 common_args::server()
23 .help("The nickname, host name or URL of the server hosting the database"),
24 )
25 .arg(
26 Arg::new("num_lines")
27 .long("num-lines")
28 .short('n')
29 .value_parser(clap::value_parser!(u32))
30 .help("The number of lines to print from the start of the log of this database")
31 .long_help("The number of lines to print from the start of the log of this database. If no num lines is provided, all lines will be returned."),
32 )
33 .arg(
34 Arg::new("follow")
35 .long("follow")
36 .short('f')
37 .required(false)
38 .action(ArgAction::SetTrue)
39 .help("A flag indicating whether or not to follow the logs")
40 .long_help("A flag that causes logs to not stop when end of the log file is reached, but rather to wait for additional data to be appended to the input."),
41 )
42 .arg(
43 Arg::new("format")
44 .long("format")
45 .default_value("text")
46 .required(false)
47 .value_parser(clap::value_parser!(Format))
48 .help("Output format for the logs")
49 )
50 .arg(common_args::yes())
51 .after_help("Run `spacetime help logs` for more detailed information.\n")
52}
53
54#[derive(serde::Deserialize)]
55pub enum LogLevel {
56 Error,
57 Warn,
58 Info,
59 Debug,
60 Trace,
61 Panic,
62}
63
64#[serde_with::serde_as]
65#[derive(serde::Deserialize)]
66struct Record<'a> {
67 #[serde_as(as = "Option<serde_with::TimestampMicroSeconds>")]
68 ts: Option<chrono::DateTime<chrono::Utc>>, level: LogLevel,
70 #[serde(borrow)]
71 #[allow(unused)] target: Option<Cow<'a, str>>,
73 #[serde(borrow)]
74 filename: Option<Cow<'a, str>>,
75 line_number: Option<u32>,
76 #[serde(borrow)]
77 message: Cow<'a, str>,
78 trace: Option<Vec<BacktraceFrame<'a>>>,
79}
80
81#[derive(serde::Deserialize)]
82pub struct BacktraceFrame<'a> {
83 #[serde(borrow)]
84 pub module_name: Option<Cow<'a, str>>,
85 #[serde(borrow)]
86 pub func_name: Option<Cow<'a, str>>,
87}
88
89#[derive(serde::Serialize)]
90struct LogsParams {
91 num_lines: Option<u32>,
92 follow: bool,
93}
94
95#[derive(Clone, Copy, PartialEq)]
96pub enum Format {
97 Text,
98 Json,
99}
100
101impl clap::ValueEnum for Format {
102 fn value_variants<'a>() -> &'a [Self] {
103 &[Self::Text, Self::Json]
104 }
105 fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
106 match self {
107 Self::Text => Some(clap::builder::PossibleValue::new("text").aliases(["default", "txt"])),
108 Self::Json => Some(clap::builder::PossibleValue::new("json")),
109 }
110 }
111}
112
113pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
114 let server = args.get_one::<String>("server").map(|s| s.as_ref());
115 let force = args.get_flag("force");
116 let mut num_lines = args.get_one::<u32>("num_lines").copied();
117 let database = args.get_one::<String>("database").unwrap();
118 let follow = args.get_flag("follow");
119 let format = *args.get_one::<Format>("format").unwrap();
120
121 let auth_header = get_auth_header(&mut config, false, server, !force).await?;
122
123 let database_identity = database_identity(&config, database, server).await?;
124
125 if follow && num_lines.is_none() {
126 num_lines = Some(10);
128 }
129 let query_params = LogsParams { num_lines, follow };
130
131 let host_url = config.get_host_url(server)?;
132
133 let builder = reqwest::Client::new().get(format!("{host_url}/v1/database/{database_identity}/logs"));
134 let builder = add_auth_header_opt(builder, &auth_header);
135 let mut res = builder.query(&query_params).send().await?;
136 let status = res.status();
137
138 if status.is_client_error() || status.is_server_error() {
139 let err = res.text().await?;
140 anyhow::bail!(err)
141 }
142
143 if format == Format::Json {
144 let mut stdout = tokio::io::stdout();
145 while let Some(chunk) = res.chunk().await? {
146 stdout.write_all(&chunk).await?;
147 }
148 return Ok(());
149 }
150
151 let term_color = if std::io::stdout().is_terminal() {
152 termcolor::ColorChoice::Auto
153 } else {
154 termcolor::ColorChoice::Never
155 };
156 let out = termcolor::StandardStream::stdout(term_color);
157 let mut out = out.lock();
158
159 let mut rdr = res.bytes_stream().map_err(io::Error::other).into_async_read();
160 let mut line = String::new();
161 while rdr.read_line(&mut line).await? != 0 {
162 let record = serde_json::from_str::<Record<'_>>(&line)?;
163
164 if let Some(ts) = record.ts {
165 out.set_color(ColorSpec::new().set_dimmed(true))?;
166 write!(out, "{ts:?} ")?;
167 }
168 let mut color = ColorSpec::new();
169 let level = match record.level {
170 LogLevel::Error => {
171 color.set_fg(Some(Color::Red));
172 "ERROR"
173 }
174 LogLevel::Warn => {
175 color.set_fg(Some(Color::Yellow));
176 "WARN"
177 }
178 LogLevel::Info => {
179 color.set_fg(Some(Color::Blue));
180 "INFO"
181 }
182 LogLevel::Debug => {
183 color.set_dimmed(true).set_bold(true);
184 "DEBUG"
185 }
186 LogLevel::Trace => {
187 color.set_dimmed(true);
188 "TRACE"
189 }
190 LogLevel::Panic => {
191 color.set_fg(Some(Color::Red)).set_bold(true).set_intense(true);
192 "PANIC"
193 }
194 };
195 out.set_color(&color)?;
196 write!(out, "{level:>5}: ")?;
197 out.reset()?;
198 let dimmed = ColorSpec::new().set_dimmed(true).clone();
199 if let Some(filename) = record.filename {
200 out.set_color(&dimmed)?;
201 write!(out, "{filename}")?;
202 if let Some(line) = record.line_number {
203 write!(out, ":{line}")?;
204 }
205 out.reset()?;
206 }
207 writeln!(out, ": {}", record.message)?;
208 if let Some(trace) = &record.trace {
209 for frame in trace {
210 write!(out, " in ")?;
211 if let Some(module) = &frame.module_name {
212 out.set_color(&dimmed)?;
213 write!(out, "{module}")?;
214 out.reset()?;
215 write!(out, " :: ")?;
216 }
217 if let Some(function) = &frame.func_name {
218 out.set_color(&dimmed)?;
219 writeln!(out, "{function}")?;
220 out.reset()?;
221 }
222 }
223 }
224
225 line.clear();
226 }
227
228 Ok(())
229}