systemprompt_logging/models/
log_entry.rs1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use systemprompt_identifiers::{InstanceId, LogId, SessionId, TraceId, UserId};
15use systemprompt_manifest::services::SystemAdmin;
16
17use super::{LogLevel, LoggingError};
18
19#[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 #[must_use]
46 pub fn platform(owner: &SystemAdmin, trace_id: TraceId) -> Self {
47 Self {
48 user_id: 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>,
64 pub user_id: UserId,
65 pub session_id: SessionId,
66 pub task_id: Option<systemprompt_identifiers::TaskId>,
67 pub trace_id: TraceId,
68 pub context_id: Option<systemprompt_identifiers::ContextId>,
69 pub client_id: Option<systemprompt_identifiers::ClientId>,
70 pub instance_id: Option<InstanceId>,
71}
72
73impl LogEntry {
74 pub fn new(
75 level: LogLevel,
76 module: impl Into<String>,
77 message: impl Into<String>,
78 actor: LogActor,
79 ) -> Self {
80 Self {
81 id: LogId::generate(),
82 timestamp: Utc::now(),
83 level,
84 module: module.into(),
85 message: message.into(),
86 metadata: None,
87 user_id: actor.user_id,
88 session_id: actor.session_id,
89 task_id: None,
90 trace_id: actor.trace_id,
91 context_id: None,
92 client_id: None,
93 instance_id: None,
94 }
95 }
96
97 #[must_use]
98 pub fn with_instance_id(mut self, instance_id: InstanceId) -> Self {
99 self.instance_id = Some(instance_id);
100 self
101 }
102
103 #[must_use]
104 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
106 self.metadata = Some(metadata);
107 self
108 }
109
110 #[must_use]
111 pub fn with_task_id(mut self, task_id: systemprompt_identifiers::TaskId) -> Self {
112 self.task_id = Some(task_id);
113 self
114 }
115
116 #[must_use]
117 pub fn with_context_id(mut self, context_id: systemprompt_identifiers::ContextId) -> Self {
118 self.context_id = Some(context_id);
119 self
120 }
121
122 #[must_use]
123 pub fn with_client_id(mut self, client_id: systemprompt_identifiers::ClientId) -> Self {
124 self.client_id = Some(client_id);
125 self
126 }
127
128 pub fn validate(&self) -> Result<(), LoggingError> {
129 if self.module.is_empty() {
130 return Err(LoggingError::EmptyModuleName);
131 }
132 if self.message.is_empty() {
133 return Err(LoggingError::EmptyMessage);
134 }
135 if let Some(metadata) = &self.metadata
136 && !metadata.is_object()
137 && !metadata.is_array()
138 && !metadata.is_string()
139 && !metadata.is_null()
140 {
141 return Err(LoggingError::InvalidMetadata);
142 }
143 Ok(())
144 }
145}
146
147impl std::fmt::Display for LogEntry {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 let level_str = match self.level {
150 LogLevel::Error => "ERROR",
151 LogLevel::Warn => "WARN ",
152 LogLevel::Info => "INFO ",
153 LogLevel::Debug => "DEBUG",
154 LogLevel::Trace => "TRACE",
155 };
156
157 let timestamp_str = self.timestamp.format("%H:%M:%S");
158
159 if let Some(metadata) = &self.metadata {
160 write!(
161 f,
162 "{} [{}] {}: {} {}",
163 timestamp_str,
164 level_str,
165 self.module,
166 self.message,
167 serde_json::to_string(metadata).unwrap_or_else(|_| String::new())
168 )
169 } else {
170 write!(
171 f,
172 "{} [{}] {}: {}",
173 timestamp_str, level_str, self.module, self.message
174 )
175 }
176 }
177}