Skip to main content

systemprompt_logging/models/
log_entry.rs

1use 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/// Mandatory attribution for every log row: who did the work, in which
9/// session, on which trace. Bundled so every `LogEntry::new` call carries
10/// the full triple instead of relying on hidden defaults.
11// Why allow `struct_field_names`: the `_id` suffix is load-bearing here —
12// it pairs each field with its typed identifier and matches the LogEntry
13// field names so the constructor reads `entry.user_id = actor.user_id`.
14#[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    /// Platform telemetry (gateway access logs, OTLP ingest) has no human
38    /// originator, so it declares the resolved system-admin owner. Fails
39    /// when the runtime has not yet installed the logging attribution; the
40    /// caller must propagate the error rather than fabricating a sentinel.
41    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}