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`] attributing originator-less platform telemetry to
6//! the system owner. 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::{InstanceId, LogId, SessionId, TraceId, UserId};
15use systemprompt_manifest::services::SystemAdmin;
16
17use super::{LogLevel, LoggingError};
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    #[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    // JSON: JSONB log `metadata` column — structured tracing fields, open-shaped.
63    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    // JSON: JSONB log `metadata` column — structured tracing fields, open-shaped.
105    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}