systemprompt_logging/models/
log_entry.rs1use 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#[expect(
15 clippy::struct_field_names,
16 reason = "the `_id` suffix is load-bearing — it pairs each field with its typed identifier \
17 and matches the LogEntry field names so the constructor reads `entry.user_id = \
18 actor.user_id`"
19)]
20#[derive(Debug, Clone)]
21pub struct LogActor {
22 pub user_id: UserId,
23 pub session_id: SessionId,
24 pub trace_id: TraceId,
25}
26
27impl LogActor {
28 #[must_use]
29 pub const fn new(user_id: UserId, session_id: SessionId, trace_id: TraceId) -> Self {
30 Self {
31 user_id,
32 session_id,
33 trace_id,
34 }
35 }
36
37 pub fn platform(trace_id: TraceId) -> Result<Self, LogAttributionUnset> {
42 Ok(Self {
43 user_id: platform_owner_id()?.clone(),
44 session_id: SessionId::system(),
45 trace_id,
46 })
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct LogEntry {
52 pub id: LogId,
53 pub timestamp: DateTime<Utc>,
54 pub level: LogLevel,
55 pub module: String,
56 pub message: String,
57 pub metadata: Option<serde_json::Value>,
58 pub user_id: UserId,
59 pub session_id: SessionId,
60 pub task_id: Option<systemprompt_identifiers::TaskId>,
61 pub trace_id: TraceId,
62 pub context_id: Option<systemprompt_identifiers::ContextId>,
63 pub client_id: Option<systemprompt_identifiers::ClientId>,
64}
65
66impl LogEntry {
67 pub fn new(
68 level: LogLevel,
69 module: impl Into<String>,
70 message: impl Into<String>,
71 actor: LogActor,
72 ) -> Self {
73 Self {
74 id: LogId::generate(),
75 timestamp: Utc::now(),
76 level,
77 module: module.into(),
78 message: message.into(),
79 metadata: None,
80 user_id: actor.user_id,
81 session_id: actor.session_id,
82 task_id: None,
83 trace_id: actor.trace_id,
84 context_id: None,
85 client_id: None,
86 }
87 }
88
89 #[must_use]
90 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
91 self.metadata = Some(metadata);
92 self
93 }
94
95 #[must_use]
96 pub fn with_task_id(mut self, task_id: systemprompt_identifiers::TaskId) -> Self {
97 self.task_id = Some(task_id);
98 self
99 }
100
101 #[must_use]
102 pub fn with_context_id(mut self, context_id: systemprompt_identifiers::ContextId) -> Self {
103 self.context_id = Some(context_id);
104 self
105 }
106
107 #[must_use]
108 pub fn with_client_id(mut self, client_id: systemprompt_identifiers::ClientId) -> Self {
109 self.client_id = Some(client_id);
110 self
111 }
112
113 pub fn validate(&self) -> Result<(), LoggingError> {
114 if self.module.is_empty() {
115 return Err(LoggingError::EmptyModuleName);
116 }
117 if self.message.is_empty() {
118 return Err(LoggingError::EmptyMessage);
119 }
120 if let Some(metadata) = &self.metadata {
121 if !metadata.is_object()
122 && !metadata.is_array()
123 && !metadata.is_string()
124 && !metadata.is_null()
125 {
126 return Err(LoggingError::InvalidMetadata);
127 }
128 }
129 Ok(())
130 }
131}
132
133impl std::fmt::Display for LogEntry {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 let level_str = match self.level {
136 LogLevel::Error => "ERROR",
137 LogLevel::Warn => "WARN ",
138 LogLevel::Info => "INFO ",
139 LogLevel::Debug => "DEBUG",
140 LogLevel::Trace => "TRACE",
141 };
142
143 let timestamp_str = self.timestamp.format("%H:%M:%S");
144
145 if let Some(metadata) = &self.metadata {
146 write!(
147 f,
148 "{} [{}] {}: {} {}",
149 timestamp_str,
150 level_str,
151 self.module,
152 self.message,
153 serde_json::to_string(metadata).unwrap_or_else(|_| String::new())
154 )
155 } else {
156 write!(
157 f,
158 "{} [{}] {}: {}",
159 timestamp_str, level_str, self.module, self.message
160 )
161 }
162 }
163}