ocre/log.rs
1//! Structured logging to Workers Logs: levels, request-scoped fields, JSON lines (Rails' `Rails.logger` and tagged logging).
2//!
3//! Every [`Ctx`](crate::Ctx) carries a [`Logger`]: in a request it is tagged
4//! with the request's `request_id`, `method` and `path`, so each line of a
5//! request can be found together in the Workers Logs dashboard. Add fields
6//! with [`Logger::with`] (Rails' `logger.tagged`):
7//!
8//! ```no_run
9//! use axum::extract::{Path, State};
10//! use ocre::{Ctx, Result};
11//!
12//! async fn pay(State(ctx): State<Ctx>, Path(order_id): Path<i64>) -> Result<&'static str> {
13//! let log = ctx.log().with("order_id", order_id);
14//! log.info("payment started");
15//! log.debug(format_args!("cart: {:?}", vec![1, 2])); // formatted only when debug is on
16//! Ok("OK")
17//! }
18//! # let _ = pay;
19//! ```
20//!
21//! # Levels and formats
22//!
23//! Two Worker variables configure logging; they are read on every request,
24//! job batch and cron run (no binding call):
25//!
26//! | Variable | Values | Default in `ocre dev` (debug build) | Default after `ocre deploy` (release build) |
27//! |---|---|---|---|
28//! | [`LOG_LEVEL`] | `debug`, `info`, `warn`, `error`, `off` (`warning`, `fatal` accepted) | `debug` | `info` |
29//! | [`LOG_FORMAT`] | `json`, `text` | `text` | `json` |
30//!
31//! `json` writes each line as a JavaScript object (`{"level": "info",
32//! "message": "...", "request_id": "...", ...}`): Workers Logs indexes every
33//! field, so the dashboard can filter on `$metadata.request_id` or your own
34//! fields. `text` writes `INFO payment started order_id=42 request_id=...`,
35//! easier to read in the `ocre dev` terminal. Lines go to `console.debug`,
36//! `console.info`, `console.warn` or `console.error`, so the level also shows
37//! in the dashboard and in `ocre logs`.
38//!
39//! Field names matching [`FILTERED_PARAMETERS`](crate::security::FILTERED_PARAMETERS)
40//! (`password`, `email`, `token`...) are written as `[FILTERED]`, at any depth.
41//!
42//! # Free plan
43//!
44//! Workers Logs keeps 200,000 events a day for 3 days on the free plan
45//! (September 2026); each request is one event plus one per line logged.
46//! Above that, events are sampled, never billed. `info` in production logs
47//! only what the app asks for; Ocre's own per-request and SQL lines are
48//! `debug`, so they cost nothing after `ocre deploy`. Messages passed as
49//! `format_args!(...)` are only formatted when their level is on.
50
51use std::{
52 fmt::{Display, Write as _},
53 sync::atomic::{AtomicU8, Ordering},
54};
55
56use serde::Serialize;
57use serde_json::{Map, Value};
58
59/// Worker variable setting the lowest level logged: `debug`, `info`, `warn`, `error` or `off`.
60///
61/// Set it in cloudflare.config.ts (`LOG_LEVEL: bindings.text("debug"),`)
62/// or in `.dev.vars`. Unset: `debug` in debug builds (`ocre dev`), `info` in
63/// release builds (`ocre deploy`).
64///
65/// # Examples
66///
67/// ```
68/// assert_eq!(ocre::log::LOG_LEVEL, "LOG_LEVEL");
69/// ```
70pub const LOG_LEVEL: &str = "LOG_LEVEL";
71
72/// Worker variable choosing the line format: `json` or `text`.
73///
74/// Unset: `text` in debug builds (`ocre dev`), `json` in release builds.
75///
76/// # Examples
77///
78/// ```
79/// assert_eq!(ocre::log::LOG_FORMAT, "LOG_FORMAT");
80/// ```
81pub const LOG_FORMAT: &str = "LOG_FORMAT";
82
83/// Severity of a log line (Rails' `:debug`, `:info`, `:warn`, `:error`).
84///
85/// Rails' `:fatal` and `:unknown` are [`Error`](Self::Error) here.
86///
87/// # Examples
88///
89/// ```
90/// use ocre::log::Level;
91///
92/// assert!(Level::Debug < Level::Error);
93/// assert_eq!(Level::parse("WARNING"), Some(Level::Warn));
94/// assert_eq!(Level::Info.as_str(), "info");
95/// ```
96#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
97pub enum Level {
98 /// Details for development: SQL statements, request summaries.
99 Debug,
100 /// Normal events worth keeping in production.
101 Info,
102 /// Something unexpected that the app handled.
103 Warn,
104 /// A failure: internal errors, failed jobs.
105 Error,
106}
107
108impl Level {
109 /// Reads a level name, case-insensitively: `debug`, `info`, `warn` (`warning`), `error` (`fatal`, `unknown`).
110 ///
111 /// # Examples
112 ///
113 /// ```
114 /// use ocre::log::Level;
115 ///
116 /// assert_eq!(Level::parse("fatal"), Some(Level::Error));
117 /// assert_eq!(Level::parse("loud"), None);
118 /// ```
119 pub fn parse(name: &str) -> Option<Self> {
120 match name.trim().to_ascii_lowercase().as_str() {
121 "debug" => Some(Self::Debug),
122 "info" => Some(Self::Info),
123 "warn" | "warning" => Some(Self::Warn),
124 "error" | "fatal" | "unknown" => Some(Self::Error),
125 _ => None,
126 }
127 }
128
129 /// The lowercase name written in log lines: `debug`, `info`, `warn`, `error`.
130 ///
131 /// # Examples
132 ///
133 /// ```
134 /// assert_eq!(ocre::log::Level::Warn.as_str(), "warn");
135 /// ```
136 pub fn as_str(self) -> &'static str {
137 match self {
138 Self::Debug => "debug",
139 Self::Info => "info",
140 Self::Warn => "warn",
141 Self::Error => "error",
142 }
143 }
144}
145
146/// How log lines are written: see the [module documentation](self).
147///
148/// # Examples
149///
150/// ```
151/// use ocre::log::Format;
152///
153/// assert_eq!(Format::parse("JSON"), Some(Format::Json));
154/// assert_eq!(Format::parse("pretty"), Some(Format::Text));
155/// ```
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum Format {
158 /// One JavaScript object per line, indexed by Workers Logs.
159 Json,
160 /// `LEVEL message key=value ...`, for the terminal.
161 Text,
162}
163
164impl Format {
165 /// Reads a format name: `json`, or `text` (`pretty` and `compact` accepted, like Loco).
166 ///
167 /// # Examples
168 ///
169 /// ```
170 /// assert_eq!(ocre::log::Format::parse("yaml"), None);
171 /// ```
172 pub fn parse(name: &str) -> Option<Self> {
173 match name.trim().to_ascii_lowercase().as_str() {
174 "json" => Some(Self::Json),
175 "text" | "pretty" | "compact" => Some(Self::Text),
176 _ => None,
177 }
178 }
179}
180
181/// Current settings: 0 = not configured yet; else `1 + threshold * 2 + json`,
182/// where threshold 4 means `off`.
183static SETTINGS: AtomicU8 = AtomicU8::new(0);
184const OFF: u8 = 4;
185
186/// Applies the [`LOG_LEVEL`] and [`LOG_FORMAT`] values of the Worker
187/// (`None` or an unknown value: the build's default). Called by `Ctx::new`.
188pub(crate) fn configure(level: Option<&str>, format: Option<&str>) {
189 let threshold = match level.map(|name| name.trim().to_ascii_lowercase()) {
190 Some(name) if name == "off" || name == "none" => OFF,
191 Some(name) => Level::parse(&name).unwrap_or_else(default_level) as u8,
192 None => default_level() as u8,
193 };
194 let json = format.and_then(Format::parse).unwrap_or_else(default_format) == Format::Json;
195 SETTINGS.store(1 + threshold * 2 + u8::from(json), Ordering::Relaxed);
196}
197
198fn default_level() -> Level {
199 if cfg!(debug_assertions) { Level::Debug } else { Level::Info }
200}
201
202fn default_format() -> Format {
203 if cfg!(debug_assertions) { Format::Text } else { Format::Json }
204}
205
206fn settings() -> (u8, Format) {
207 match SETTINGS.load(Ordering::Relaxed) {
208 0 => (default_level() as u8, default_format()),
209 packed => ((packed - 1) / 2, if (packed - 1) % 2 == 1 { Format::Json } else { Format::Text }),
210 }
211}
212
213/// Writes log lines with a set of fields; cheap to clone (see the [module documentation](self)).
214///
215/// [`Ctx::log`](crate::Ctx::log) gives the request's logger; code without a
216/// `Ctx` uses [`Logger::new`].
217///
218/// # Examples
219///
220/// ```
221/// use ocre::log::{Format, Level, Logger};
222///
223/// let log = Logger::new().with("order_id", 42).with("password", "hunter2");
224/// log.info("paid"); // written to the Worker logs
225/// assert_eq!(
226/// log.line(Level::Info, "paid", Format::Json),
227/// r#"{"level":"info","message":"paid","order_id":42,"password":"[FILTERED]"}"#
228/// );
229/// assert_eq!(log.line(Level::Warn, "slow", Format::Text), "WARN slow order_id=42 password=[FILTERED]");
230/// ```
231#[derive(Debug, Clone, Default)]
232pub struct Logger {
233 fields: Map<String, Value>,
234}
235
236impl Logger {
237 /// A logger without fields.
238 ///
239 /// # Examples
240 ///
241 /// ```
242 /// ocre::log::Logger::new().warn("cache miss");
243 /// ```
244 pub fn new() -> Self {
245 Self::default()
246 }
247
248 /// A copy of this logger with one more field on every line (Rails' `logger.tagged`).
249 ///
250 /// Any serializable value; a value that does not serialize is written
251 /// as `null`. A field with the same name is replaced.
252 ///
253 /// # Examples
254 ///
255 /// ```
256 /// let log = ocre::log::Logger::new().with("user_id", 7);
257 /// assert_eq!(log.field("user_id"), Some(&serde_json::json!(7)));
258 /// ```
259 pub fn with(&self, key: &str, value: impl Serialize) -> Self {
260 let mut logger = self.clone();
261 logger.fields.insert(key.to_owned(), serde_json::to_value(value).unwrap_or(Value::Null));
262 logger
263 }
264
265 /// The value of a field, e.g. `request_id`.
266 ///
267 /// # Examples
268 ///
269 /// ```
270 /// assert_eq!(ocre::log::Logger::new().field("request_id"), None);
271 /// ```
272 pub fn field(&self, key: &str) -> Option<&Value> {
273 self.fields.get(key)
274 }
275
276 /// Whether lines of `level` are written, under the current [`LOG_LEVEL`].
277 ///
278 /// Check it before building an expensive message that is not
279 /// `format_args!(...)` (Rails' block form of `logger.debug { ... }`).
280 ///
281 /// # Examples
282 ///
283 /// ```
284 /// let log = ocre::log::Logger::new();
285 /// if log.enabled(ocre::log::Level::Debug) {
286 /// log.debug(serde_json::json!({"big": [1, 2, 3]}));
287 /// }
288 /// ```
289 pub fn enabled(&self, level: Level) -> bool {
290 level as u8 >= settings().0
291 }
292
293 /// Writes a `debug` line.
294 ///
295 /// # Examples
296 ///
297 /// ```
298 /// let rows = 3;
299 /// ocre::log::Logger::new().debug(format_args!("{rows} rows"));
300 /// ```
301 pub fn debug(&self, message: impl Display) {
302 self.log(Level::Debug, &message);
303 }
304
305 /// Writes an `info` line.
306 ///
307 /// # Examples
308 ///
309 /// ```
310 /// ocre::log::Logger::new().info("signed up");
311 /// ```
312 pub fn info(&self, message: impl Display) {
313 self.log(Level::Info, &message);
314 }
315
316 /// Writes a `warn` line.
317 ///
318 /// # Examples
319 ///
320 /// ```
321 /// ocre::log::Logger::new().warn("retrying");
322 /// ```
323 pub fn warn(&self, message: impl Display) {
324 self.log(Level::Warn, &message);
325 }
326
327 /// Writes an `error` line.
328 ///
329 /// # Examples
330 ///
331 /// ```
332 /// ocre::log::Logger::new().error("payment provider down");
333 /// ```
334 pub fn error(&self, message: impl Display) {
335 self.log(Level::Error, &message);
336 }
337
338 /// Writes a line of `level` when it is enabled; the message is formatted only then.
339 ///
340 /// # Examples
341 ///
342 /// ```
343 /// ocre::log::Logger::new().log(ocre::log::Level::Info, &"hello");
344 /// ```
345 pub fn log(&self, level: Level, message: &dyn Display) {
346 if !self.enabled(level) {
347 return;
348 }
349 let format = settings().1;
350 emit(level, format, &self.line(level, &message.to_string(), format));
351 }
352
353 /// The line [`log`](Self::log) writes, whatever the current level: JSON or text.
354 ///
355 /// # Examples
356 ///
357 /// ```
358 /// use ocre::log::{Format, Level, Logger};
359 ///
360 /// let line = Logger::new().with("path", "/a b").line(Level::Error, "boom", Format::Text);
361 /// assert_eq!(line, r#"ERROR boom path="/a b""#);
362 /// ```
363 pub fn line(&self, level: Level, message: &str, format: Format) -> String {
364 let fields = crate::security::filter_json(&Value::Object(self.fields.clone()));
365 let Value::Object(fields) = fields else { unreachable!("filtering keeps an object") };
366 match format {
367 Format::Json => {
368 let mut line = Map::with_capacity(fields.len() + 2);
369 line.insert("level".to_owned(), level.as_str().into());
370 line.insert("message".to_owned(), message.into());
371 line.extend(fields);
372 Value::Object(line).to_string()
373 }
374 Format::Text => {
375 let mut line = format!("{} {message}", level.as_str().to_ascii_uppercase());
376 for (key, value) in fields {
377 let _ = write!(line, " {key}={}", text_value(&value));
378 }
379 line
380 }
381 }
382 }
383}
384
385/// Strings bare unless they contain spaces, quotes or `=`; other values as JSON.
386fn text_value(value: &Value) -> String {
387 match value {
388 Value::String(text) if !text.is_empty() && !text.contains([' ', '"', '=']) => text.clone(),
389 other => other.to_string(),
390 }
391}
392
393/// `panicked at src/lib.rs:12:5: <message>`: the line the panic hook logs.
394pub(crate) fn panic_line(message: &str, location: Option<(&str, u32, u32)>) -> String {
395 match location {
396 Some((file, line, column)) => format!("panicked at {file}:{line}:{column}: {message}"),
397 None => format!("panicked: {message}"),
398 }
399}
400
401/// Worker logs: `console.<level>` with a JavaScript object for JSON lines;
402/// stderr in native builds (unit tests).
403fn emit(level: Level, format: Format, line: &str) {
404 #[cfg(target_arch = "wasm32")]
405 {
406 use worker::wasm_bindgen::JsValue;
407 let value = match format {
408 Format::Json => js_sys::JSON::parse(line).unwrap_or_else(|_| JsValue::from_str(line)),
409 Format::Text => JsValue::from_str(line),
410 };
411 let console = match level {
412 Level::Debug => worker::web_sys::console::debug_1,
413 Level::Info => worker::web_sys::console::info_1,
414 Level::Warn => worker::web_sys::console::warn_1,
415 Level::Error => worker::web_sys::console::error_1,
416 };
417 console(&value);
418 }
419 #[cfg(not(target_arch = "wasm32"))]
420 {
421 let _ = (level, format);
422 eprintln!("{line}");
423 }
424}
425
426#[cfg(test)]
427#[path = "../tests/log.rs"]
428mod tests;