A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: display plugin logs in event logs ui

Trezy 273a2ba4 a65cd4b2

+169 -9
+4 -2
src/plugin/host/bindings.rs
··· 175 175 let level = std::str::from_utf8(&mem_data[level_start..level_end]).unwrap_or("info"); 176 176 let msg = std::str::from_utf8(&mem_data[msg_start..msg_end]).unwrap_or(""); 177 177 178 - let plugin_id = &caller.data().plugin_id; 178 + let plugin_id = caller.data().plugin_id.clone(); 179 + let db = caller.data().db.clone(); 180 + let db_backend = caller.data().db_backend; 179 181 let log_level: super::LogLevel = level.parse().unwrap_or_default(); 180 - super::log(plugin_id, log_level, msg); 182 + super::log(&plugin_id, log_level, msg, db, db_backend); 181 183 } 182 184 183 185 /// Host function: get a secret value by name
+48 -7
src/plugin/host/logging.rs
··· 25 25 } 26 26 } 27 27 28 - /// Log a message from a plugin 29 - pub fn log(plugin_id: &str, level: LogLevel, message: &str) { 28 + /// Log a message from a plugin. 29 + /// 30 + /// Always emits to `tracing`. If `db` is `Some`, also spawns a detached task 31 + /// that writes the event to the `event_logs` table so it appears in the 32 + /// Event Logs UI. The spawned task is fire-and-forget; errors are logged by 33 + /// `event_log::log_event` but not returned to the caller. 34 + pub fn log( 35 + plugin_id: &str, 36 + level: LogLevel, 37 + message: &str, 38 + db: Option<sqlx::AnyPool>, 39 + db_backend: crate::db::DatabaseBackend, 40 + ) { 30 41 match level { 31 42 LogLevel::Debug => debug!(plugin = %plugin_id, "{}", message), 32 43 LogLevel::Info => info!(plugin = %plugin_id, "{}", message), 33 44 LogLevel::Warn => warn!(plugin = %plugin_id, "{}", message), 34 45 LogLevel::Error => error!(plugin = %plugin_id, "{}", message), 35 46 } 47 + 48 + let Some(db) = db else { return }; 49 + 50 + let severity = match level { 51 + LogLevel::Debug | LogLevel::Info => crate::event_log::Severity::Info, 52 + LogLevel::Warn => crate::event_log::Severity::Warn, 53 + LogLevel::Error => crate::event_log::Severity::Error, 54 + }; 55 + let level_str = match level { 56 + LogLevel::Debug => "debug", 57 + LogLevel::Info => "info", 58 + LogLevel::Warn => "warn", 59 + LogLevel::Error => "error", 60 + }; 61 + 62 + let event = crate::event_log::EventLog { 63 + event_type: "plugin.log".to_string(), 64 + severity, 65 + actor_did: None, 66 + subject: Some(plugin_id.to_string()), 67 + detail: serde_json::json!({ 68 + "level": level_str, 69 + "message": message, 70 + }), 71 + }; 72 + 73 + tokio::spawn(async move { 74 + crate::event_log::log_event(&db, event, db_backend).await; 75 + }); 36 76 } 37 77 38 78 #[cfg(test)] ··· 61 101 62 102 #[test] 63 103 fn test_log_does_not_panic() { 64 - // Verify log() runs without panicking for each level 65 - log("test-plugin", LogLevel::Debug, "debug message"); 66 - log("test-plugin", LogLevel::Info, "info message"); 67 - log("test-plugin", LogLevel::Warn, "warn message"); 68 - log("test-plugin", LogLevel::Error, "error message"); 104 + // With db=None, only the tracing path runs. Verifies each level does not panic. 105 + let backend = crate::db::DatabaseBackend::Sqlite; 106 + log("test-plugin", LogLevel::Debug, "debug message", None, backend); 107 + log("test-plugin", LogLevel::Info, "info message", None, backend); 108 + log("test-plugin", LogLevel::Warn, "warn message", None, backend); 109 + log("test-plugin", LogLevel::Error, "error message", None, backend); 69 110 } 70 111 }
+97
tests/plugin_logging.rs
··· 1 + //! Integration tests for plugin logging -> event_logs persistence. 2 + //! 3 + //! Requires TEST_DATABASE_URL to be set (see CLAUDE.md "Testing" section). 4 + 5 + mod common; 6 + 7 + use common::db::{test_backend, test_pool, truncate_all}; 8 + use happyview::db::adapt_sql; 9 + use happyview::plugin::host::{LogLevel, log}; 10 + use serde_json::Value; 11 + use serial_test::serial; 12 + 13 + /// Wait briefly for detached `tokio::spawn` tasks to flush writes. 14 + /// The log() function spawns fire-and-forget tasks; we need to yield 15 + /// until they complete before querying. 16 + async fn flush_spawned_tasks() { 17 + for _ in 0..20 { 18 + tokio::task::yield_now().await; 19 + tokio::time::sleep(tokio::time::Duration::from_millis(25)).await; 20 + } 21 + } 22 + 23 + #[tokio::test] 24 + #[serial] 25 + async fn plugin_log_writes_all_four_levels_to_event_logs() { 26 + let pool = test_pool().await; 27 + let backend = test_backend(); 28 + truncate_all(&pool).await; 29 + 30 + log("my-plugin", LogLevel::Debug, "dbg msg", Some(pool.clone()), backend); 31 + log("my-plugin", LogLevel::Info, "info msg", Some(pool.clone()), backend); 32 + log("my-plugin", LogLevel::Warn, "warn msg", Some(pool.clone()), backend); 33 + log("my-plugin", LogLevel::Error, "err msg", Some(pool.clone()), backend); 34 + 35 + flush_spawned_tasks().await; 36 + 37 + let sql = adapt_sql( 38 + "SELECT severity, subject, detail FROM event_logs WHERE event_type = ? ORDER BY created_at ASC", 39 + backend, 40 + ); 41 + let rows: Vec<(String, Option<String>, String)> = sqlx::query_as(&sql) 42 + .bind("plugin.log") 43 + .fetch_all(&pool) 44 + .await 45 + .expect("failed to query event_logs"); 46 + 47 + assert_eq!(rows.len(), 4, "expected 4 plugin.log rows, got {}", rows.len()); 48 + 49 + // Severity mapping: Debug->info, Info->info, Warn->warn, Error->error 50 + let severities: Vec<&str> = rows.iter().map(|(s, _, _)| s.as_str()).collect(); 51 + assert_eq!(severities, vec!["info", "info", "warn", "error"]); 52 + 53 + // All rows should have subject = plugin id 54 + for (_, subject, _) in &rows { 55 + assert_eq!(subject.as_deref(), Some("my-plugin")); 56 + } 57 + 58 + // detail.level preserves the original level; detail.message carries the message 59 + let details: Vec<Value> = rows 60 + .iter() 61 + .map(|(_, _, d)| serde_json::from_str(d).expect("detail not valid JSON")) 62 + .collect(); 63 + 64 + assert_eq!(details[0]["level"], "debug"); 65 + assert_eq!(details[0]["message"], "dbg msg"); 66 + assert_eq!(details[1]["level"], "info"); 67 + assert_eq!(details[1]["message"], "info msg"); 68 + assert_eq!(details[2]["level"], "warn"); 69 + assert_eq!(details[2]["message"], "warn msg"); 70 + assert_eq!(details[3]["level"], "error"); 71 + assert_eq!(details[3]["message"], "err msg"); 72 + } 73 + 74 + #[tokio::test] 75 + #[serial] 76 + async fn plugin_log_with_none_db_does_not_write_event_log() { 77 + let pool = test_pool().await; 78 + let backend = test_backend(); 79 + truncate_all(&pool).await; 80 + 81 + // db=None: should only emit to tracing, not persist. 82 + log("silent-plugin", LogLevel::Info, "should not persist", None, backend); 83 + 84 + flush_spawned_tasks().await; 85 + 86 + let sql = adapt_sql( 87 + "SELECT COUNT(*) FROM event_logs WHERE event_type = ?", 88 + backend, 89 + ); 90 + let count: i64 = sqlx::query_scalar(&sql) 91 + .bind("plugin.log") 92 + .fetch_one(&pool) 93 + .await 94 + .expect("failed to count event_logs"); 95 + 96 + assert_eq!(count, 0); 97 + }
+20
web/src/app/dashboard/events/page.tsx
··· 69 69 "caller_did", 70 70 "duration_ms", 71 71 "response_size", 72 + "message", 73 + "level", 72 74 ] as const; 73 75 74 76 function EventDetailBody({ event }: { event: EventLogEntry }) { ··· 130 132 </div> 131 133 )} 132 134 </div> 135 + 136 + {/* Plugin log message */} 137 + {d.message != null && ( 138 + <div> 139 + <div className="flex items-center gap-2"> 140 + <span className="text-muted-foreground text-sm">Message</span> 141 + {d.level != null && ( 142 + <Badge variant="outline" className="text-xs uppercase"> 143 + {String(d.level)} 144 + </Badge> 145 + )} 146 + </div> 147 + <p className="mt-1 whitespace-pre-wrap break-words font-mono text-xs"> 148 + {String(d.message)} 149 + </p> 150 + </div> 151 + )} 133 152 134 153 {/* Error section */} 135 154 {d.error != null && ( ··· 375 394 { label: "Script", value: "script" }, 376 395 { label: "Admin", value: "admin" }, 377 396 { label: "Backfill", value: "backfill" }, 397 + { label: "Plugin", value: "plugin" }, 378 398 ], 379 399 }, 380 400 },