systemprompt_logging/models/
log_entry.rs1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use systemprompt_identifiers::{LogId, SessionId, TraceId, UserId};
15
16use super::{LogLevel, LoggingError};
17use crate::attribution::{LogAttributionUnset, platform_owner_id};
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 pub fn platform(trace_id: TraceId) -> Result<Self, LogAttributionUnset> {
46 Ok(Self {
47 user_id: platform_owner_id()?.clone(),
48 session_id: SessionId::system(),
49 trace_id,
50 })
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct LogEntry {
56 pub id: LogId,
57 pub timestamp: DateTime<Utc>,
58 pub level: LogLevel,
59 pub module: String,
60 pub message: String,
61 pub metadata: Option<serde_json::Value>,
62 pub user_id: UserId,
63 pub session_id: SessionId,
64 pub task_id: Option<systemprompt_identifiers::TaskId>,
65 pub trace_id: TraceId,
66 pub context_id: Option<systemprompt_identifiers::ContextId>,
67 pub client_id: Option<systemprompt_identifiers::ClientId>,
68 pub instance_id: Option<systemprompt_identifiers::InstanceId>,
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 instance_id: crate::instance_id().cloned(),
92 }
93 }
94
95 #[must_use]
96 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
97 self.metadata = Some(metadata);
98 self
99 }
100
101 #[must_use]
102 pub fn with_task_id(mut self, task_id: systemprompt_identifiers::TaskId) -> Self {
103 self.task_id = Some(task_id);
104 self
105 }
106
107 #[must_use]
108 pub fn with_context_id(mut self, context_id: systemprompt_identifiers::ContextId) -> Self {
109 self.context_id = Some(context_id);
110 self
111 }
112
113 #[must_use]
114 pub fn with_client_id(mut self, client_id: systemprompt_identifiers::ClientId) -> Self {
115 self.client_id = Some(client_id);
116 self
117 }
118
119 pub fn validate(&self) -> Result<(), LoggingError> {
120 if self.module.is_empty() {
121 return Err(LoggingError::EmptyModuleName);
122 }
123 if self.message.is_empty() {
124 return Err(LoggingError::EmptyMessage);
125 }
126 if let Some(metadata) = &self.metadata
127 && !metadata.is_object()
128 && !metadata.is_array()
129 && !metadata.is_string()
130 && !metadata.is_null()
131 {
132 return Err(LoggingError::InvalidMetadata);
133 }
134 Ok(())
135 }
136}
137
138impl std::fmt::Display for LogEntry {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 let level_str = match self.level {
141 LogLevel::Error => "ERROR",
142 LogLevel::Warn => "WARN ",
143 LogLevel::Info => "INFO ",
144 LogLevel::Debug => "DEBUG",
145 LogLevel::Trace => "TRACE",
146 };
147
148 let timestamp_str = self.timestamp.format("%H:%M:%S");
149
150 if let Some(metadata) = &self.metadata {
151 write!(
152 f,
153 "{} [{}] {}: {} {}",
154 timestamp_str,
155 level_str,
156 self.module,
157 self.message,
158 serde_json::to_string(metadata).unwrap_or_else(|_| String::new())
159 )
160 } else {
161 write!(
162 f,
163 "{} [{}] {}: {}",
164 timestamp_str, level_str, self.module, self.message
165 )
166 }
167 }
168}