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
//! The `logs` subcommand: tail a site's captured guest stdout/stderr.
//! One-shot by default; `--follow` polls for new lines.
use std::time::Duration;
use crate::client;
use crate::config::ProjectConfig;
/// A failure in the `logs` / `stats` subcommands.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Resolving the target or talking to the control plane failed.
#[error(transparent)]
Client(#[from] crate::client::ClientError),
/// Serializing the handler stats to JSON failed.
#[error(transparent)]
Json(#[from] serde_json::Error),
}
/// `logs` module result; `Err` is [`Error`].
type Result<T> = std::result::Result<T, Error>;
/// Arguments for `boatramp logs`.
#[derive(Debug, clap::Args)]
pub struct LogsArgs {
/// boatramp server base URL (overrides [deploy].server).
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
/// Site whose guest logs to tail (overrides [deploy].site). Mutually exclusive
/// with `--function`.
#[arg(long, env = "BOATRAMP_SITE", global = true)]
site: Option<String>,
/// Function whose captured guest stdout/stderr to tail — a standalone component
/// (GraphQL subgraph, auth function, worker) invoked via `emit::invoke`. Its
/// `println!`/`eprintln!` output is otherwise unreadable. Scoped to `--project`;
/// takes precedence over `--site`.
#[arg(long)]
function: Option<String>,
/// Only show one stream: `stdout` or `stderr`.
#[arg(long)]
stream: Option<String>,
/// Number of recent lines to show.
#[arg(long, default_value_t = 200)]
limit: usize,
/// Keep polling for new lines (like `tail -f`).
#[arg(long, short)]
follow: bool,
}
/// What `logs` is tailing: a site's or a function's captured guest output. Both read
/// the same control-plane log store; only the endpoint differs.
enum Target {
Site(String),
Function(String),
}
/// One fetch against the control plane for either target — so the initial pull and the
/// `--follow` poll share exactly one code path.
async fn fetch(
cp: &client::ControlPlane,
target: &Target,
limit: usize,
after: u64,
stream: Option<&str>,
) -> Result<client::LogsResponse> {
Ok(match target {
Target::Site(site) => cp.fetch_logs(site, limit, after, stream).await?,
Target::Function(function) => {
cp.fetch_function_logs(function, limit, after, stream)
.await?
}
})
}
/// Entry point for `boatramp logs`.
pub async fn run(args: LogsArgs, config: &ProjectConfig) -> Result<()> {
let stream = args.stream.as_deref();
let http = client::http_client(client::token(config).as_deref());
let project = client::resolve_project(config);
// A `--function` selector takes precedence over the (env/config-backed) `--site`,
// so `BOATRAMP_SITE` in the environment never blocks tailing a function.
let (cp, target) = if let Some(function) = args.function {
let server = client::resolve_server(args.server, config)?;
(
client::ControlPlane::new(server, http, project),
Target::Function(function),
)
} else {
let (server, site) = client::resolve_target(args.server, args.site, config)?;
(
client::ControlPlane::new(server, http, project),
Target::Site(site),
)
};
// Initial fetch: the most recent `limit` lines.
let first = fetch(&cp, &target, args.limit, 0, stream).await?;
let mut cursor = first.entries.last().map(|e| e.seq).unwrap_or(0);
for entry in &first.entries {
print_line(entry);
}
if first.dropped > 0 {
eprintln!(
"(\u{2026} {} line(s) dropped by the rate cap)",
first.dropped
);
}
if !args.follow {
return Ok(());
}
// Follow: poll for lines newer than the cursor (a stable monotonic seq, so
// no duplicates and no gaps across polls).
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let next = fetch(&cp, &target, args.limit.max(1000), cursor, stream).await?;
for entry in &next.entries {
print_line(entry);
cursor = cursor.max(entry.seq);
}
}
}
fn print_line(entry: &client::LogEntry) {
println!("[{}] {}", entry.stream, entry.line);
}
/// Arguments for `boatramp stats`.
#[derive(Debug, clap::Args)]
pub struct StatsArgs {
/// boatramp server base URL (overrides [deploy].server).
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
/// Site whose handler stats to show (overrides [deploy].site).
#[arg(long, env = "BOATRAMP_SITE", global = true)]
site: Option<String>,
}
/// Entry point for `boatramp stats`: the site's operator handler stats —
/// per-`(trigger, route)` invocation counters, consumer backlog/dead-letters,
/// and live stream connections.
pub async fn stats(args: StatsArgs, config: &ProjectConfig) -> Result<()> {
let (server, site) = client::resolve_target(args.server, args.site, config)?;
let cp = client::ControlPlane::new(
server,
client::http_client(client::token(config).as_deref()),
client::resolve_project(config),
);
let stats = cp.fetch_handler_stats(&site).await?;
println!("{}", serde_json::to_string_pretty(&stats)?);
Ok(())
}