Skip to main content

systemprompt_logging/models/
log_entry.rs

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