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(
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 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}