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