systemprompt_logging/models/
log_entry.rs1use 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#[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 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}