Skip to main content

systemprompt_logging/models/
log_entry.rs

1//! Core log-record types.
2//!
3//! [`LogEntry`] is the persisted log row; [`LogActor`] bundles the mandatory
4//! attribution triple (user, session, trace) every entry must carry, with
5//! [`LogActor::platform`] resolving the system owner for originator-less
6//! platform telemetry. Optional task/context/client identifiers are layered on
7//! via the builder methods.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use systemprompt_identifiers::{LogId, SessionId, TraceId, UserId};
12
13use super::{LogLevel, LoggingError};
14use crate::attribution::{LogAttributionUnset, platform_owner_id};
15
16/// Mandatory attribution for every log row: who did the work, in which
17/// session, on which trace. Bundled so every `LogEntry::new` call carries
18/// the full triple instead of relying on hidden defaults.
19// Why allow `struct_field_names`: the `_id` suffix is load-bearing here —
20// it pairs each field with its typed identifier and matches the LogEntry
21// field names so the constructor reads `entry.user_id = actor.user_id`.
22#[expect(
23    clippy::struct_field_names,
24    reason = "the `_id` suffix is load-bearing — it pairs each field with its typed identifier \
25              and matches the LogEntry field names so the constructor reads `entry.user_id = \
26              actor.user_id`"
27)]
28#[derive(Debug, Clone)]
29pub struct LogActor {
30    pub user_id: UserId,
31    pub session_id: SessionId,
32    pub trace_id: TraceId,
33}
34
35impl LogActor {
36    #[must_use]
37    pub const fn new(user_id: UserId, session_id: SessionId, trace_id: TraceId) -> Self {
38        Self {
39            user_id,
40            session_id,
41            trace_id,
42        }
43    }
44
45    /// Platform telemetry (gateway access logs, OTLP ingest) has no human
46    /// originator, so it declares the resolved system-admin owner. Fails
47    /// when the runtime has not yet installed the logging attribution; the
48    /// caller must propagate the error rather than fabricating a sentinel.
49    pub fn platform(trace_id: TraceId) -> Result<Self, LogAttributionUnset> {
50        Ok(Self {
51            user_id: platform_owner_id()?.clone(),
52            session_id: SessionId::system(),
53            trace_id,
54        })
55    }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct LogEntry {
60    pub id: LogId,
61    pub timestamp: DateTime<Utc>,
62    pub level: LogLevel,
63    pub module: String,
64    pub message: String,
65    pub metadata: Option<serde_json::Value>,
66    pub user_id: UserId,
67    pub session_id: SessionId,
68    pub task_id: Option<systemprompt_identifiers::TaskId>,
69    pub trace_id: TraceId,
70    pub context_id: Option<systemprompt_identifiers::ContextId>,
71    pub client_id: Option<systemprompt_identifiers::ClientId>,
72}
73
74impl LogEntry {
75    pub fn new(
76        level: LogLevel,
77        module: impl Into<String>,
78        message: impl Into<String>,
79        actor: LogActor,
80    ) -> Self {
81        Self {
82            id: LogId::generate(),
83            timestamp: Utc::now(),
84            level,
85            module: module.into(),
86            message: message.into(),
87            metadata: None,
88            user_id: actor.user_id,
89            session_id: actor.session_id,
90            task_id: None,
91            trace_id: actor.trace_id,
92            context_id: None,
93            client_id: None,
94        }
95    }
96
97    #[must_use]
98    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
99        self.metadata = Some(metadata);
100        self
101    }
102
103    #[must_use]
104    pub fn with_task_id(mut self, task_id: systemprompt_identifiers::TaskId) -> Self {
105        self.task_id = Some(task_id);
106        self
107    }
108
109    #[must_use]
110    pub fn with_context_id(mut self, context_id: systemprompt_identifiers::ContextId) -> Self {
111        self.context_id = Some(context_id);
112        self
113    }
114
115    #[must_use]
116    pub fn with_client_id(mut self, client_id: systemprompt_identifiers::ClientId) -> Self {
117        self.client_id = Some(client_id);
118        self
119    }
120
121    pub fn validate(&self) -> Result<(), LoggingError> {
122        if self.module.is_empty() {
123            return Err(LoggingError::EmptyModuleName);
124        }
125        if self.message.is_empty() {
126            return Err(LoggingError::EmptyMessage);
127        }
128        if let Some(metadata) = &self.metadata {
129            if !metadata.is_object()
130                && !metadata.is_array()
131                && !metadata.is_string()
132                && !metadata.is_null()
133            {
134                return Err(LoggingError::InvalidMetadata);
135            }
136        }
137        Ok(())
138    }
139}
140
141impl std::fmt::Display for LogEntry {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        let level_str = match self.level {
144            LogLevel::Error => "ERROR",
145            LogLevel::Warn => "WARN ",
146            LogLevel::Info => "INFO ",
147            LogLevel::Debug => "DEBUG",
148            LogLevel::Trace => "TRACE",
149        };
150
151        let timestamp_str = self.timestamp.format("%H:%M:%S");
152
153        if let Some(metadata) = &self.metadata {
154            write!(
155                f,
156                "{} [{}] {}: {} {}",
157                timestamp_str,
158                level_str,
159                self.module,
160                self.message,
161                serde_json::to_string(metadata).unwrap_or_else(|_| String::new())
162            )
163        } else {
164            write!(
165                f,
166                "{} [{}] {}: {}",
167                timestamp_str, level_str, self.module, self.message
168            )
169        }
170    }
171}