1use std::fmt::Debug;
5use std::sync::{Arc, OnceLock};
6
7use console::style;
8use serde_json::{Map, Value};
9use tracing::field::{Field, Visit};
10use tracing::{span, Level, Subscriber};
11use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
12use tracing_subscriber::registry::LookupSpan;
13
14fn skip() -> &'static std::sync::RwLock<Vec<String>> {
16 static SKIP: std::sync::OnceLock<std::sync::RwLock<Vec<String>>> = std::sync::OnceLock::new();
17 SKIP.get_or_init(|| {
18 std::sync::RwLock::new(parse_skip(
19 &std::env::var("COMINDLY_LOG_SKIP").unwrap_or_default(),
20 ))
21 })
22}
23
24pub fn parse_skip(text: &str) -> Vec<String> {
26 text.split(',')
27 .map(|k| k.trim().to_lowercase())
28 .filter(|k| !k.is_empty())
29 .collect()
30}
31
32pub fn logs(kind: &str) -> bool {
34 !skip().read().is_ok_and(|s| s.iter().any(|k| k == kind))
35}
36
37pub fn set_logs(kind: &str, on: bool) {
39 if let Ok(mut s) = skip().write() {
40 s.retain(|k| k != kind);
41 if !on {
42 s.push(kind.to_string());
43 }
44 }
45}
46
47pub fn skipped() -> String {
49 skip().read().map(|s| s.join(",")).unwrap_or_default()
50}
51
52pub fn init(stdout: bool) -> Guard {
55 let _ = STDOUT.set(stdout);
56 let level = match std::env::var("COMINDLY_LOG_LEVEL").as_deref() {
57 Ok("error") => Level::ERROR,
58 Ok("warn") => Level::WARN,
59 Ok("debug") => Level::DEBUG,
60 Ok("trace") => Level::TRACE,
61 _ => Level::INFO,
62 };
63 let lines = Lines::to(crate::ui::json_logs(), write).with_filter(
64 tracing_subscriber::filter::filter_fn(move |m| {
65 ours(m.target()) && (m.is_span() || *m.level() <= level)
66 }),
67 );
68 let registry = tracing_subscriber::registry().with(lines);
69 #[cfg(feature = "otel")]
70 let registry = registry.with(otel::layer());
71 #[cfg(not(feature = "otel"))]
72 if [
73 "OTEL_EXPORTER_OTLP_ENDPOINT",
74 "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
75 ]
76 .iter()
77 .any(|k| std::env::var(k).is_ok_and(|v| !v.trim().is_empty()))
78 {
79 crate::ui::warn("traces will not be exported: this build has no OTLP support; use the container image or build with `--features otel`");
80 }
81 let _ = tracing::subscriber::set_global_default(registry);
82 Guard(())
83}
84
85static STDOUT: OnceLock<bool> = OnceLock::new();
86
87pub fn write(line: &str) {
89 if STDOUT.get() == Some(&true) {
90 println!("{line}");
91 } else {
92 eprintln!("{line}");
93 }
94}
95
96pub fn flush() {
98 #[cfg(feature = "otel")]
99 otel::flush();
100}
101
102pub struct Guard(());
103
104impl Drop for Guard {
105 fn drop(&mut self) {
106 #[cfg(feature = "otel")]
107 otel::shutdown();
108 }
109}
110
111fn ours(target: &str) -> bool {
112 target.starts_with("comindly")
113}
114
115#[derive(Default)]
117struct Scope {
118 request_id: Option<String>,
119 job_id: Option<String>,
120}
121
122impl Visit for Scope {
123 fn record_str(&mut self, f: &Field, v: &str) {
124 match f.name() {
125 "request_id" => self.request_id = Some(v.to_string()),
126 "job_id" => self.job_id = Some(v.to_string()),
127 _ => {}
128 }
129 }
130
131 fn record_debug(&mut self, f: &Field, v: &dyn Debug) {
132 self.record_str(f, &format!("{v:?}"))
133 }
134}
135
136struct Fields(Map<String, Value>);
137
138impl Visit for Fields {
139 fn record_str(&mut self, f: &Field, v: &str) {
140 self.0.insert(f.name().into(), v.into());
141 }
142 fn record_u64(&mut self, f: &Field, v: u64) {
143 self.0.insert(f.name().into(), v.into());
144 }
145 fn record_i64(&mut self, f: &Field, v: i64) {
146 self.0.insert(f.name().into(), v.into());
147 }
148 fn record_f64(&mut self, f: &Field, v: f64) {
149 self.0.insert(f.name().into(), v.into());
150 }
151 fn record_bool(&mut self, f: &Field, v: bool) {
152 self.0.insert(f.name().into(), v.into());
153 }
154 fn record_debug(&mut self, f: &Field, v: &dyn Debug) {
155 self.0.insert(f.name().into(), format!("{v:?}").into());
156 }
157}
158
159pub struct Lines {
161 json: bool,
162 out: Arc<dyn Fn(&str) + Send + Sync>,
163}
164
165impl Lines {
166 pub fn to(json: bool, out: impl Fn(&str) + Send + Sync + 'static) -> Self {
168 Lines {
169 json,
170 out: Arc::new(out),
171 }
172 }
173}
174
175impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for Lines {
176 fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) {
177 let mut scope = Scope::default();
178 attrs.record(&mut scope);
179 if let Some(span) = ctx.span(id) {
180 span.extensions_mut().insert(scope);
181 }
182 }
183
184 fn on_record(&self, id: &span::Id, values: &span::Record<'_>, ctx: Context<'_, S>) {
185 if let Some(span) = ctx.span(id) {
186 if let Some(scope) = span.extensions_mut().get_mut::<Scope>() {
187 values.record(scope);
188 }
189 }
190 }
191
192 fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) {
193 let mut fields = Fields(Map::new());
194 event.record(&mut fields);
195 let mut fields = fields.0;
196 for span in ctx.event_scope(event).into_iter().flatten() {
197 if let Some(s) = span.extensions().get::<Scope>() {
198 for (k, v) in [("request_id", &s.request_id), ("job_id", &s.job_id)] {
199 if let Some(v) = v {
200 fields.entry(k).or_insert_with(|| v.clone().into());
201 }
202 }
203 }
204 }
205 (self.out)(&render(*event.metadata().level(), fields, self.json));
206 }
207}
208
209pub(crate) fn render(level: Level, mut f: Map<String, Value>, json: bool) -> String {
212 let readable = f.shift_remove("line");
213 let text = |v: Option<Value>| match v {
214 Some(Value::String(s)) => s,
215 Some(v) => v.to_string(),
216 None => String::new(),
217 };
218 let message = text(f.shift_remove("message"));
219 let kind = match f.shift_remove("kind") {
220 Some(k) => text(Some(k)),
221 None if level == Level::ERROR => "error".into(),
222 None if level == Level::WARN => "warning".into(),
223 None => "note".into(),
224 };
225 if json {
226 let mut out = Map::new();
227 out.insert(
228 "timestamp".into(),
229 chrono::Utc::now()
230 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
231 .into(),
232 );
233 out.insert("level".into(), level_name(level).into());
234 out.insert("kind".into(), kind.into());
235 out.insert("message".into(), message.into());
236 for k in ["step", "job_id", "request_id"] {
237 if let Some(v) = f.shift_remove(k) {
238 out.insert(k.into(), v);
239 }
240 }
241 out.extend(f);
242 return Value::Object(out).to_string();
243 }
244 let mut line = match readable {
245 Some(r) => text(Some(r)),
246 None => match kind.as_str() {
247 "step" => format!("\n{}", style(&message).bold().cyan()),
248 "done" => format!("{} {message}", style("✓").green().bold()),
249 "warning" => format!("{} {message}", style("!").yellow().bold()),
250 "error" => format!("{} {message}", style("✗").red().bold()),
251 "access" => format!("{} {message}", style("·").dim()),
252 _ => format!(" {}", style(&message).dim()),
253 },
254 };
255 let tagged = level <= Level::WARN || kind == "access";
256 if let Some(id) = f
257 .get("request_id")
258 .and_then(Value::as_str)
259 .filter(|_| tagged)
260 {
261 let who = f.get("email").and_then(Value::as_str);
262 let suffix = who.map_or(format!("request {id}"), |w| format!("{w} · request {id}"));
263 line.push_str(&format!(" {}", style(format!("({suffix})")).dim()));
264 }
265 line
266}
267
268fn level_name(l: Level) -> &'static str {
269 match l {
270 Level::ERROR => "error",
271 Level::WARN => "warn",
272 Level::INFO => "info",
273 Level::DEBUG => "debug",
274 Level::TRACE => "trace",
275 }
276}
277
278#[cfg(feature = "otel")]
279mod otel {
280 use std::sync::OnceLock;
281
282 use opentelemetry::trace::TracerProvider as _;
283 use opentelemetry_sdk::trace::SdkTracerProvider;
284 use tracing::Subscriber;
285 use tracing_subscriber::registry::LookupSpan;
286 use tracing_subscriber::Layer;
287
288 static PROVIDER: OnceLock<SdkTracerProvider> = OnceLock::new();
289
290 pub(super) fn layer<S>() -> Option<impl Layer<S>>
292 where
293 S: Subscriber + for<'a> LookupSpan<'a>,
294 {
295 let set = |k: &str| std::env::var(k).is_ok_and(|v| !v.trim().is_empty());
296 if !set("OTEL_EXPORTER_OTLP_ENDPOINT") && !set("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") {
297 return None;
298 }
299 let exporter = match opentelemetry_otlp::SpanExporter::builder()
300 .with_http()
301 .build()
302 {
303 Ok(e) => e,
304 Err(e) => {
305 crate::ui::warn(&format!("traces will not be exported: {e}"));
306 return None;
307 }
308 };
309 let mut resource = opentelemetry_sdk::Resource::builder().with_attribute(
310 opentelemetry::KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
311 );
312 if !set("OTEL_SERVICE_NAME") {
313 resource = resource.with_service_name("comindly");
314 }
315 let provider = SdkTracerProvider::builder()
316 .with_batch_exporter(exporter)
317 .with_resource(resource.build())
318 .build();
319 let tracer = provider.tracer("comindly");
320 let _ = PROVIDER.set(provider);
321 let only_ours = tracing_subscriber::filter::filter_fn(|m| {
322 super::ours(m.target()) && *m.level() <= tracing::Level::INFO
323 });
324 Some(
325 tracing_opentelemetry::layer()
326 .with_tracer(tracer)
327 .with_filter(only_ours),
328 )
329 }
330
331 const AT_EXIT: std::time::Duration = std::time::Duration::from_secs(2);
333
334 pub(super) fn flush() {
335 if let Some(p) = PROVIDER.get() {
336 bounded(move || drop(p.force_flush()));
337 }
338 }
339
340 pub(super) fn shutdown() {
341 if let Some(p) = PROVIDER.get() {
342 bounded(move || drop(p.shutdown()));
343 }
344 }
345
346 fn bounded(f: impl FnOnce() + Send + 'static) {
348 let (done, wait) = std::sync::mpsc::channel();
349 std::thread::spawn(move || {
350 f();
351 let _ = done.send(());
352 });
353 let _ = wait.recv_timeout(AT_EXIT);
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn json_lines_keep_their_field_names() {
363 let mut f = Map::new();
364 f.insert("message".into(), "hello".into());
365 f.insert("kind".into(), "done".into());
366 f.insert("job_id".into(), "j1".into());
367 f.insert("request_id".into(), "r1".into());
368 let v: Value = serde_json::from_str(&render(Level::INFO, f, true)).unwrap();
369 assert_eq!(v["level"], "info");
370 assert_eq!(v["kind"], "done");
371 assert_eq!(v["message"], "hello");
372 assert_eq!(v["job_id"], "j1");
373 assert_eq!(v["request_id"], "r1");
374 assert!(v["timestamp"].is_string());
375 }
376}