Skip to main content

rlg_wasm/
lib.rs

1// lib.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! WebAssembly bindings for `rlg`.
6//!
7//! Targets browser, Deno, Cloudflare Workers, Bun, and any host
8//! that loads `wasm-bindgen`-produced modules. The 14 rlg
9//! `LogFormat` variants are all available; records are rendered to
10//! a UTF-8 `String` and (under `wasm32`) dispatched to `console.log`
11//! / `console.warn` / `console.error` via the JavaScript bridge.
12//!
13//! On non-wasm targets the bindings are still compilable — the JS
14//! dispatch is replaced by `eprintln!`, so the same code can be
15//! exercised in host-side unit tests.
16//!
17//! # JavaScript usage
18//!
19//! ```js,ignore
20//! import init, { RlgWasm } from "rlg-wasm";
21//! await init();
22//! const rlg = new RlgWasm("worker", "JSON");
23//! rlg.info("worker booted", '{"region":"eu-west-1"}');
24//! rlg.error("db timeout", null);
25//! ```
26
27#![forbid(unsafe_code)]
28#![deny(missing_docs)]
29
30use rlg::log::Log;
31use rlg::log_format::LogFormat;
32use rlg::log_level::LogLevel;
33use std::str::FromStr;
34
35#[cfg(target_arch = "wasm32")]
36use wasm_bindgen::prelude::*;
37
38// ---------------------------------------------------------------------------
39// Host bridge — `console.*` on wasm32, `eprintln!` everywhere else.
40// ---------------------------------------------------------------------------
41
42#[cfg(target_arch = "wasm32")]
43#[wasm_bindgen]
44unsafe extern "C" {
45    #[wasm_bindgen(js_namespace = console, js_name = log)]
46    fn console_log(s: &str);
47    #[wasm_bindgen(js_namespace = console, js_name = warn)]
48    fn console_warn(s: &str);
49    #[wasm_bindgen(js_namespace = console, js_name = error)]
50    fn console_error(s: &str);
51}
52
53fn dispatch(level: LogLevel, rendered: &str) {
54    #[cfg(target_arch = "wasm32")]
55    {
56        match level {
57            LogLevel::WARN => console_warn(rendered),
58            LogLevel::ERROR | LogLevel::FATAL | LogLevel::CRITICAL => {
59                console_error(rendered)
60            }
61            _ => console_log(rendered),
62        }
63    }
64    #[cfg(not(target_arch = "wasm32"))]
65    {
66        let _ = level;
67        eprintln!("{rendered}");
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Public surface — exposed to JS via `wasm-bindgen` on wasm32.
73// ---------------------------------------------------------------------------
74
75/// Logger handle. Construct once per "channel" (logical service or
76/// component) and emit records through the level shortcuts.
77#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
78#[derive(Debug, Clone)]
79pub struct RlgWasm {
80    component: String,
81    format: LogFormat,
82}
83
84#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
85impl RlgWasm {
86    /// Construct a new logger.
87    ///
88    /// `format` is the string name of a [`LogFormat`] variant
89    /// (`"JSON"`, `"MCP"`, `"OTLP"`, `"Logfmt"`, etc.). Defaults to
90    /// `"JSON"` if the input is unknown.
91    #[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
92    #[must_use]
93    pub fn new(component: &str, format: &str) -> Self {
94        Self {
95            component: component.to_string(),
96            format: LogFormat::from_str(format)
97                .unwrap_or(LogFormat::JSON),
98        }
99    }
100
101    /// Emit an INFO record. `attributes_json` is an optional JSON
102    /// object string; keys become structured attributes on the log
103    /// entry. Pass `null` / `undefined` (or `None` from Rust) to skip.
104    pub fn info(&self, message: &str, attributes_json: Option<String>) {
105        self.emit(LogLevel::INFO, message, attributes_json.as_deref());
106    }
107
108    /// Emit a WARN record.
109    pub fn warn(&self, message: &str, attributes_json: Option<String>) {
110        self.emit(LogLevel::WARN, message, attributes_json.as_deref());
111    }
112
113    /// Emit an ERROR record.
114    pub fn error(
115        &self,
116        message: &str,
117        attributes_json: Option<String>,
118    ) {
119        self.emit(LogLevel::ERROR, message, attributes_json.as_deref());
120    }
121
122    /// Emit a DEBUG record.
123    pub fn debug(
124        &self,
125        message: &str,
126        attributes_json: Option<String>,
127    ) {
128        self.emit(LogLevel::DEBUG, message, attributes_json.as_deref());
129    }
130
131    fn emit(
132        &self,
133        level: LogLevel,
134        message: &str,
135        attributes_json: Option<&str>,
136    ) {
137        let mut log = Log::build(level, message)
138            .component(&self.component)
139            .format(self.format);
140        if let Some(s) = attributes_json
141            && let Ok(serde_json::Value::Object(map)) =
142                serde_json::from_str::<serde_json::Value>(s)
143        {
144            for (k, v) in map {
145                log = log.with(&k, v);
146            }
147        }
148        let rendered = format!("{log}");
149        dispatch(level, &rendered);
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn new_with_unknown_format_falls_back_to_json() {
159        let r = RlgWasm::new("worker", "NotAFormat");
160        assert_eq!(r.format, LogFormat::JSON);
161        assert_eq!(r.component, "worker");
162    }
163
164    #[test]
165    fn new_accepts_each_format_variant() {
166        for name in [
167            "CLF", "CEF", "ELF", "W3C", "JSON", "GELF", "Logstash",
168            "NDJSON", "MCP", "OTLP", "Logfmt", "ECS",
169        ] {
170            let r = RlgWasm::new("svc", name);
171            assert_eq!(format!("{:?}", r.format), name);
172        }
173    }
174
175    #[test]
176    fn info_emits_without_panic() {
177        let r = RlgWasm::new("worker", "Logfmt");
178        r.info("hello", None);
179        r.info(
180            "with attrs",
181            Some(r#"{"user_id":42,"region":"eu-west-1"}"#.to_string()),
182        );
183    }
184
185    #[test]
186    fn invalid_attributes_json_is_silently_dropped() {
187        let r = RlgWasm::new("worker", "JSON");
188        // Invalid JSON: the closure short-circuits to None and the
189        // record is still emitted (no panic, no garbage attributes).
190        r.warn("malformed input", Some("not json".to_string()));
191    }
192
193    #[test]
194    fn level_shortcuts_compose() {
195        let r = RlgWasm::new("svc", "JSON");
196        r.debug("d", None);
197        r.info("i", None);
198        r.warn("w", None);
199        r.error("e", None);
200    }
201
202    #[test]
203    fn rlg_wasm_is_clone() {
204        let a = RlgWasm::new("svc", "JSON");
205        let b = a.clone();
206        assert_eq!(a.component, b.component);
207        assert_eq!(a.format, b.format);
208    }
209}