spacetimedb_cli/subcommands/
logs.rs

1use 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>>, // TODO: remove Option once 0.9 has been out for a while
69    level: LogLevel,
70    #[serde(borrow)]
71    #[allow(unused)] // TODO: format this somehow
72    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        // We typically don't want logs from the very beginning if we're also following.
127        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}