···175175 let level = std::str::from_utf8(&mem_data[level_start..level_end]).unwrap_or("info");
176176 let msg = std::str::from_utf8(&mem_data[msg_start..msg_end]).unwrap_or("");
177177178178- let plugin_id = &caller.data().plugin_id;
178178+ let plugin_id = caller.data().plugin_id.clone();
179179+ let db = caller.data().db.clone();
180180+ let db_backend = caller.data().db_backend;
179181 let log_level: super::LogLevel = level.parse().unwrap_or_default();
180180- super::log(plugin_id, log_level, msg);
182182+ super::log(&plugin_id, log_level, msg, db, db_backend);
181183}
182184183185/// Host function: get a secret value by name
+48-7
src/plugin/host/logging.rs
···2525 }
2626}
27272828-/// Log a message from a plugin
2929-pub fn log(plugin_id: &str, level: LogLevel, message: &str) {
2828+/// Log a message from a plugin.
2929+///
3030+/// Always emits to `tracing`. If `db` is `Some`, also spawns a detached task
3131+/// that writes the event to the `event_logs` table so it appears in the
3232+/// Event Logs UI. The spawned task is fire-and-forget; errors are logged by
3333+/// `event_log::log_event` but not returned to the caller.
3434+pub fn log(
3535+ plugin_id: &str,
3636+ level: LogLevel,
3737+ message: &str,
3838+ db: Option<sqlx::AnyPool>,
3939+ db_backend: crate::db::DatabaseBackend,
4040+) {
3041 match level {
3142 LogLevel::Debug => debug!(plugin = %plugin_id, "{}", message),
3243 LogLevel::Info => info!(plugin = %plugin_id, "{}", message),
3344 LogLevel::Warn => warn!(plugin = %plugin_id, "{}", message),
3445 LogLevel::Error => error!(plugin = %plugin_id, "{}", message),
3546 }
4747+4848+ let Some(db) = db else { return };
4949+5050+ let severity = match level {
5151+ LogLevel::Debug | LogLevel::Info => crate::event_log::Severity::Info,
5252+ LogLevel::Warn => crate::event_log::Severity::Warn,
5353+ LogLevel::Error => crate::event_log::Severity::Error,
5454+ };
5555+ let level_str = match level {
5656+ LogLevel::Debug => "debug",
5757+ LogLevel::Info => "info",
5858+ LogLevel::Warn => "warn",
5959+ LogLevel::Error => "error",
6060+ };
6161+6262+ let event = crate::event_log::EventLog {
6363+ event_type: "plugin.log".to_string(),
6464+ severity,
6565+ actor_did: None,
6666+ subject: Some(plugin_id.to_string()),
6767+ detail: serde_json::json!({
6868+ "level": level_str,
6969+ "message": message,
7070+ }),
7171+ };
7272+7373+ tokio::spawn(async move {
7474+ crate::event_log::log_event(&db, event, db_backend).await;
7575+ });
3676}
37773878#[cfg(test)]
···6110162102 #[test]
63103 fn test_log_does_not_panic() {
6464- // Verify log() runs without panicking for each level
6565- log("test-plugin", LogLevel::Debug, "debug message");
6666- log("test-plugin", LogLevel::Info, "info message");
6767- log("test-plugin", LogLevel::Warn, "warn message");
6868- log("test-plugin", LogLevel::Error, "error message");
104104+ // With db=None, only the tracing path runs. Verifies each level does not panic.
105105+ let backend = crate::db::DatabaseBackend::Sqlite;
106106+ log("test-plugin", LogLevel::Debug, "debug message", None, backend);
107107+ log("test-plugin", LogLevel::Info, "info message", None, backend);
108108+ log("test-plugin", LogLevel::Warn, "warn message", None, backend);
109109+ log("test-plugin", LogLevel::Error, "error message", None, backend);
69110 }
70111}
+97
tests/plugin_logging.rs
···11+//! Integration tests for plugin logging -> event_logs persistence.
22+//!
33+//! Requires TEST_DATABASE_URL to be set (see CLAUDE.md "Testing" section).
44+55+mod common;
66+77+use common::db::{test_backend, test_pool, truncate_all};
88+use happyview::db::adapt_sql;
99+use happyview::plugin::host::{LogLevel, log};
1010+use serde_json::Value;
1111+use serial_test::serial;
1212+1313+/// Wait briefly for detached `tokio::spawn` tasks to flush writes.
1414+/// The log() function spawns fire-and-forget tasks; we need to yield
1515+/// until they complete before querying.
1616+async fn flush_spawned_tasks() {
1717+ for _ in 0..20 {
1818+ tokio::task::yield_now().await;
1919+ tokio::time::sleep(tokio::time::Duration::from_millis(25)).await;
2020+ }
2121+}
2222+2323+#[tokio::test]
2424+#[serial]
2525+async fn plugin_log_writes_all_four_levels_to_event_logs() {
2626+ let pool = test_pool().await;
2727+ let backend = test_backend();
2828+ truncate_all(&pool).await;
2929+3030+ log("my-plugin", LogLevel::Debug, "dbg msg", Some(pool.clone()), backend);
3131+ log("my-plugin", LogLevel::Info, "info msg", Some(pool.clone()), backend);
3232+ log("my-plugin", LogLevel::Warn, "warn msg", Some(pool.clone()), backend);
3333+ log("my-plugin", LogLevel::Error, "err msg", Some(pool.clone()), backend);
3434+3535+ flush_spawned_tasks().await;
3636+3737+ let sql = adapt_sql(
3838+ "SELECT severity, subject, detail FROM event_logs WHERE event_type = ? ORDER BY created_at ASC",
3939+ backend,
4040+ );
4141+ let rows: Vec<(String, Option<String>, String)> = sqlx::query_as(&sql)
4242+ .bind("plugin.log")
4343+ .fetch_all(&pool)
4444+ .await
4545+ .expect("failed to query event_logs");
4646+4747+ assert_eq!(rows.len(), 4, "expected 4 plugin.log rows, got {}", rows.len());
4848+4949+ // Severity mapping: Debug->info, Info->info, Warn->warn, Error->error
5050+ let severities: Vec<&str> = rows.iter().map(|(s, _, _)| s.as_str()).collect();
5151+ assert_eq!(severities, vec!["info", "info", "warn", "error"]);
5252+5353+ // All rows should have subject = plugin id
5454+ for (_, subject, _) in &rows {
5555+ assert_eq!(subject.as_deref(), Some("my-plugin"));
5656+ }
5757+5858+ // detail.level preserves the original level; detail.message carries the message
5959+ let details: Vec<Value> = rows
6060+ .iter()
6161+ .map(|(_, _, d)| serde_json::from_str(d).expect("detail not valid JSON"))
6262+ .collect();
6363+6464+ assert_eq!(details[0]["level"], "debug");
6565+ assert_eq!(details[0]["message"], "dbg msg");
6666+ assert_eq!(details[1]["level"], "info");
6767+ assert_eq!(details[1]["message"], "info msg");
6868+ assert_eq!(details[2]["level"], "warn");
6969+ assert_eq!(details[2]["message"], "warn msg");
7070+ assert_eq!(details[3]["level"], "error");
7171+ assert_eq!(details[3]["message"], "err msg");
7272+}
7373+7474+#[tokio::test]
7575+#[serial]
7676+async fn plugin_log_with_none_db_does_not_write_event_log() {
7777+ let pool = test_pool().await;
7878+ let backend = test_backend();
7979+ truncate_all(&pool).await;
8080+8181+ // db=None: should only emit to tracing, not persist.
8282+ log("silent-plugin", LogLevel::Info, "should not persist", None, backend);
8383+8484+ flush_spawned_tasks().await;
8585+8686+ let sql = adapt_sql(
8787+ "SELECT COUNT(*) FROM event_logs WHERE event_type = ?",
8888+ backend,
8989+ );
9090+ let count: i64 = sqlx::query_scalar(&sql)
9191+ .bind("plugin.log")
9292+ .fetch_one(&pool)
9393+ .await
9494+ .expect("failed to count event_logs");
9595+9696+ assert_eq!(count, 0);
9797+}