Skip to main content

systemprompt_logging/models/
log_entry.rs

1//! Core log-record types.
2//!
3//! [`LogEntry`] is the persisted log row; [`LogActor`] bundles the mandatory
4//! attribution triple (user, session, trace) every entry must carry, with
5//! [`LogActor::platform`] resolving the system owner for originator-less
6//! platform telemetry. Optional task/context/client identifiers are layered on
7//! via the builder methods.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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/// Mandatory attribution for every log row: who did the work, in which
20/// session, on which trace. Bundled so every `LogEntry::new` call carries
21/// the full triple instead of relying on hidden defaults.
22#[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}