adding logging, cleaning up configs
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
//! Opt-in remote AI diagnostics logger.
|
||||
//!
|
||||
//! This is intentionally separate from product telemetry. It is controlled
|
||||
//! exclusively by local settings and should only receive operational metadata:
|
||||
//! provider/model IDs, lifecycle states, counts, timings, and sanitized errors.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use settings::Setting;
|
||||
|
||||
use crate::AISettings;
|
||||
|
||||
const DEFAULT_LOGS_PATH: &str = "/api/logs";
|
||||
const REMOTE_LOG_SERVICE: &str = "galaxy-ai";
|
||||
const REMOTE_LOG_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_ERROR_CHARS: usize = 500;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RemoteLogLevel {
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl RemoteLogLevel {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "info",
|
||||
Self::Warn => "warn",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct RemoteLogRecord {
|
||||
pub(crate) level: RemoteLogLevel,
|
||||
pub(crate) message: String,
|
||||
pub(crate) context: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct RemoteLogConfig {
|
||||
endpoint: String,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RemoteLogPayload {
|
||||
level: &'static str,
|
||||
message: String,
|
||||
service: &'static str,
|
||||
context: Value,
|
||||
}
|
||||
|
||||
impl RemoteLogConfig {
|
||||
fn from_settings(settings: &AISettings) -> Option<Self> {
|
||||
if !*settings.remote_logging_enabled.value() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let api_key = settings.remote_logging_api_key.value().trim();
|
||||
if api_key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let endpoint = normalize_endpoint_url(settings.remote_logging_endpoint.value())?;
|
||||
Some(Self {
|
||||
endpoint,
|
||||
api_key: api_key.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn log_model_event<M>(ctx: &mut ModelContext<M>, record: RemoteLogRecord)
|
||||
where
|
||||
M: Entity,
|
||||
{
|
||||
let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let payload = RemoteLogPayload {
|
||||
level: record.level.as_str(),
|
||||
message: record.message,
|
||||
service: REMOTE_LOG_SERVICE,
|
||||
context: enrich_context(record.context),
|
||||
};
|
||||
|
||||
let _ = ctx.spawn(
|
||||
async move { send_remote_log(config, payload).await },
|
||||
|_, result, _| {
|
||||
if let Err(error) = result {
|
||||
log::warn!("[remote-logging] Failed to send remote AI log: {error}");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_error(error: impl std::fmt::Display) -> String {
|
||||
let compact = error
|
||||
.to_string()
|
||||
.split_whitespace()
|
||||
.map(redact_sensitive_token)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
truncate_chars(&compact, MAX_ERROR_CHARS)
|
||||
}
|
||||
|
||||
pub(crate) fn raw_model_payload_context<M>(
|
||||
ctx: &ModelContext<M>,
|
||||
raw_payload: impl AsRef<str>,
|
||||
) -> Option<Value>
|
||||
where
|
||||
M: Entity,
|
||||
{
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
if !*settings.remote_logging_enabled.value()
|
||||
|| !*settings.remote_logging_log_model_payloads.value()
|
||||
|| settings.remote_logging_api_key.value().trim().is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let max_chars = (*settings.remote_logging_model_payload_max_chars.value()).max(1);
|
||||
Some(tail_limited_payload_context(
|
||||
raw_payload.as_ref(),
|
||||
max_chars,
|
||||
))
|
||||
}
|
||||
|
||||
fn enrich_context(mut context: Value) -> Value {
|
||||
let Value::Object(ref mut map) = context else {
|
||||
return json!({
|
||||
"timestamp": Utc::now().to_rfc3339(),
|
||||
"app": app_context(),
|
||||
"details": context,
|
||||
});
|
||||
};
|
||||
|
||||
map.insert("timestamp".to_string(), json!(Utc::now().to_rfc3339()));
|
||||
map.insert("app".to_string(), app_context());
|
||||
context
|
||||
}
|
||||
|
||||
fn app_context() -> Value {
|
||||
json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"channel": ChannelState::channel().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_remote_log(config: RemoteLogConfig, payload: RemoteLogPayload) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REMOTE_LOG_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|error| format!("could not create HTTP client: {error}"))?;
|
||||
|
||||
let response = client
|
||||
.post(&config.endpoint)
|
||||
.header("x-api-key", config.api_key)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("server returned HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_endpoint_url(endpoint: &str) -> Option<String> {
|
||||
let endpoint = endpoint.trim().trim_end_matches('/');
|
||||
if endpoint.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if endpoint.ends_with(DEFAULT_LOGS_PATH) {
|
||||
Some(endpoint.to_string())
|
||||
} else {
|
||||
Some(format!("{endpoint}{DEFAULT_LOGS_PATH}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_sensitive_token(token: &str) -> &str {
|
||||
let trimmed = token.trim_matches(|character: char| {
|
||||
matches!(character, '"' | '\'' | ',' | ';' | ')' | ']' | '}')
|
||||
});
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if trimmed.starts_with("sk-")
|
||||
|| trimmed.starts_with("log_sk_")
|
||||
|| lower.starts_with("bearer.")
|
||||
|| lower.starts_with("bearer:")
|
||||
|| lower == "bearer"
|
||||
|| lower == "authorization:"
|
||||
|| lower == "x-api-key:"
|
||||
{
|
||||
"[redacted]"
|
||||
} else {
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut truncated = value.chars().take(max_chars).collect::<String>();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
|
||||
fn tail_limited_payload_context(payload: &str, max_chars: usize) -> Value {
|
||||
let total_chars = payload.chars().count();
|
||||
let truncated = total_chars > max_chars;
|
||||
let payload = if truncated {
|
||||
payload
|
||||
.chars()
|
||||
.skip(total_chars - max_chars)
|
||||
.collect::<String>()
|
||||
} else {
|
||||
payload.to_string()
|
||||
};
|
||||
|
||||
json!({
|
||||
"payload": payload,
|
||||
"payload_total_chars": total_chars,
|
||||
"payload_included_chars": if truncated { max_chars } else { total_chars },
|
||||
"payload_max_chars": max_chars,
|
||||
"truncated": truncated,
|
||||
"truncation_strategy": if truncated { Some("tail") } else { None },
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{normalize_endpoint_url, sanitize_error, tail_limited_payload_context};
|
||||
|
||||
#[test]
|
||||
fn endpoint_accepts_base_or_logs_path() {
|
||||
assert_eq!(
|
||||
normalize_endpoint_url("https://logging.ryserve.net").as_deref(),
|
||||
Some("https://logging.ryserve.net/api/logs")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_endpoint_url("https://logging.ryserve.net/api/logs/").as_deref(),
|
||||
Some("https://logging.ryserve.net/api/logs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_compacted_truncated_and_lightly_redacted() {
|
||||
let error = format!("failed\nAuthorization: Bearer sk-test {}", "x".repeat(700));
|
||||
let sanitized = sanitize_error(error);
|
||||
|
||||
assert!(!sanitized.contains("sk-test"));
|
||||
assert!(!sanitized.contains('\n'));
|
||||
assert!(sanitized.chars().count() <= 501);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_payload_cap_keeps_tail() {
|
||||
assert_eq!(
|
||||
tail_limited_payload_context("0123456789", 4),
|
||||
json!({
|
||||
"payload": "6789",
|
||||
"payload_total_chars": 10,
|
||||
"payload_included_chars": 4,
|
||||
"payload_max_chars": 4,
|
||||
"truncated": true,
|
||||
"truncation_strategy": "tail",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user