plugin logging

This commit is contained in:
Bradley Nelson
2026-06-16 23:00:23 -06:00
parent 803150635c
commit 4427b1613b
17 changed files with 1928 additions and 12 deletions
+96 -2
View File
@@ -1,9 +1,9 @@
//! DTOs for the admin plugin-management API.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use crate::application::ports::plugin_ports::PluginInfo;
use crate::application::ports::plugin_ports::{LogEntry, LogPage, PluginInfo, RetentionSettings};
/// A single installed plugin as returned by `GET /api/admin/plugins`.
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -35,3 +35,97 @@ impl From<PluginInfo> for PluginInfoDto {
pub struct SetEnabledDto {
pub enabled: bool,
}
/// A single structured log entry as returned by the admin log viewer / stream.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PluginLogEntryDto {
/// RFC 3339 timestamp.
pub ts: String,
pub invocation_id: String,
/// `"plugin"` (plugin-emitted line) or `"outcome"` (host invocation result).
pub kind: String,
/// `debug` | `info` | `warn` | `error`.
pub level: String,
/// Stable outcome key for `kind = "outcome"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub msg: String,
}
impl From<LogEntry> for PluginLogEntryDto {
fn from(e: LogEntry) -> Self {
Self {
ts: e.ts,
invocation_id: e.invocation_id,
kind: e.kind,
level: e.level,
reason: e.reason,
msg: e.msg,
}
}
}
/// One page of log entries, newest first.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PluginLogPageDto {
pub entries: Vec<PluginLogEntryDto>,
/// Total entries matching the filter (across all pages).
pub total: usize,
pub limit: usize,
pub offset: usize,
}
impl PluginLogPageDto {
pub fn from_page(page: LogPage, limit: usize, offset: usize) -> Self {
Self {
entries: page
.entries
.into_iter()
.map(PluginLogEntryDto::from)
.collect(),
total: page.total,
limit,
offset,
}
}
}
/// Query string for `GET /api/admin/plugins/{id}/logs`.
#[derive(Debug, Deserialize, IntoParams)]
pub struct PluginLogQueryDto {
/// Keep only entries at this level (`debug`/`info`/`warn`/`error`).
pub level: Option<String>,
/// Case-insensitive substring filter on the message.
pub search: Option<String>,
/// Max entries to return (clamped server-side).
pub limit: Option<usize>,
/// Newest-first entries to skip.
pub offset: Option<usize>,
}
/// Per-plugin retention policy (request + response body).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
pub struct PluginRetentionDto {
/// Delete rotated segments older than this many days.
pub retention_days: u32,
/// Aggregate byte ceiling on kept segments for the plugin.
pub max_bytes: u64,
}
impl From<RetentionSettings> for PluginRetentionDto {
fn from(s: RetentionSettings) -> Self {
Self {
retention_days: s.retention_days,
max_bytes: s.max_bytes,
}
}
}
impl From<PluginRetentionDto> for RetentionSettings {
fn from(d: PluginRetentionDto) -> Self {
Self {
retention_days: d.retention_days,
max_bytes: d.max_bytes,
}
}
}
+88
View File
@@ -12,7 +12,9 @@
//! `on_user_login`;
//! - one host import `log` (observe-only — the only authority a plugin has).
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
/// The single ABI version this host speaks. A breaking change bumps this and
/// the namespace suffix ([`HOST_NAMESPACE`]); plugins built against a different
@@ -61,6 +63,7 @@ pub trait PluginDispatchPort: Send + Sync + 'static {
/// `ExtismPluginManager`) owns the same in-memory plugin set the dispatch port
/// reads, so a toggle or install takes effect on the live dispatch path with no
/// restart. All operations are admin-gated at the HTTP layer.
#[async_trait]
pub trait PluginManagementPort: Send + Sync + 'static {
/// Every installed plugin, enabled or not, with its load-time metadata.
fn list(&self) -> Vec<PluginInfo>;
@@ -83,6 +86,91 @@ pub trait PluginManagementPort: Send + Sync + 'static {
/// Unload a plugin and delete its directory.
fn remove(&self, id: &str) -> Result<(), PluginMgmtError>;
/// Read a filtered, paginated page of a plugin's structured log entries
/// (newest first). `NotFound` if no such plugin is installed.
async fn read_logs(&self, id: &str, query: LogQuery) -> Result<LogPage, PluginMgmtError>;
/// Delete all persisted log files for a plugin (keeps the plugin installed).
async fn clear_logs(&self, id: &str) -> Result<(), PluginMgmtError>;
/// The plugin's effective per-plugin retention (its on-disk override, or the
/// configured defaults when none is set).
async fn get_retention(&self, id: &str) -> Result<RetentionSettings, PluginMgmtError>;
/// Persist a per-plugin retention override (age + aggregate size).
async fn set_retention(
&self,
id: &str,
settings: RetentionSettings,
) -> Result<(), PluginMgmtError>;
/// Subscribe to newly-written log entries across *all* plugins, for live
/// tailing. Callers filter by `plugin_id`. A lagging receiver loses the
/// oldest buffered events (`RecvError::Lagged`) but never blocks the writer.
fn subscribe_logs(&self) -> broadcast::Receiver<PluginLogEvent>;
}
/// A single structured log entry — both the on-disk JSONL row and the unit the
/// admin viewer / live stream surfaces.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
/// RFC 3339 timestamp the entry was recorded at.
pub ts: String,
/// The dispatch invocation this entry belongs to (correlates lines with the
/// outcome row of the same invocation).
pub invocation_id: String,
/// `"plugin"` for a line the plugin emitted via `log`, `"outcome"` for the
/// host's record of how the invocation ended.
pub kind: String,
/// `debug` | `info` | `warn` | `error`.
pub level: String,
/// Stable outcome key (`InvokeOutcome::reason()`) for `kind = "outcome"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// Human-readable message.
pub msg: String,
}
/// Filter + pagination for [`PluginManagementPort::read_logs`].
#[derive(Debug, Clone, Default)]
pub struct LogQuery {
/// Keep only entries at this level (exact match) when set.
pub level: Option<String>,
/// Keep only entries whose message contains this substring (case-insensitive).
pub search: Option<String>,
/// Number of newest-first entries to skip.
pub offset: usize,
/// Maximum number of entries to return.
pub limit: usize,
}
/// One page of log entries plus the total number matching the filter.
#[derive(Debug, Clone)]
pub struct LogPage {
/// Entries for this page, newest first.
pub entries: Vec<LogEntry>,
/// Total entries matching the filter (across all pages).
pub total: usize,
}
/// Per-plugin log retention policy. Persisted next to the plugin's logs.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RetentionSettings {
/// Delete rotated segments older than this many days.
pub retention_days: u32,
/// Aggregate byte ceiling on kept segments for the plugin (oldest deleted
/// first past this).
pub max_bytes: u64,
}
/// A newly-written entry published on the live-tail broadcast channel.
#[derive(Debug, Clone)]
pub struct PluginLogEvent {
/// The plugin the entry belongs to (subscribers filter on this).
pub plugin_id: String,
/// The entry itself.
pub entry: LogEntry,
}
/// A single installed plugin's load-time metadata, as surfaced to the admin UI.