607 lines
20 KiB
Rust
607 lines
20 KiB
Rust
//! 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::sync::LazyLock;
|
|
use std::time::Duration;
|
|
#[cfg(all(feature = "remote_logs_local_file", not(target_family = "wasm")))]
|
|
use std::{
|
|
fs::OpenOptions,
|
|
io::Write,
|
|
path::PathBuf,
|
|
sync::{Mutex, OnceLock},
|
|
};
|
|
|
|
use chrono::Utc;
|
|
use galaxy_core::channel::ChannelState;
|
|
use galaxyui::{Entity, ModelContext, SingletonEntity, View, ViewContext};
|
|
use regex::Regex;
|
|
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);
|
|
// Authentication and endpoint selection remain scoped to each request.
|
|
static REMOTE_LOG_CLIENT: LazyLock<Result<reqwest::Client, String>> = LazyLock::new(|| {
|
|
reqwest::Client::builder()
|
|
.timeout(REMOTE_LOG_TIMEOUT)
|
|
.build()
|
|
.map_err(|error| format!("could not create HTTP client: {error}"))
|
|
});
|
|
const MAX_ERROR_CHARS: usize = 500;
|
|
const MAX_ERROR_INPUT_CHARS: usize = 16 * 1024;
|
|
static ERROR_URL: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"(?i)https?://[^\s\"'<>]+"#).expect("valid error URL pattern"));
|
|
static ERROR_CREDENTIAL: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r#"(?i)[\"'\\]*\b(?:authorization|proxy-authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|password|secret)\b[\"'\\\s]*[:=]\s*"#)
|
|
.expect("valid error credential pattern")
|
|
});
|
|
static ERROR_BEARER: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"(?i)\bbearer\s+[^\s,\"';}\]]+"#).expect("valid bearer pattern"));
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum RemoteLogLevel {
|
|
Info,
|
|
Warn,
|
|
Error,
|
|
}
|
|
|
|
impl RemoteLogLevel {
|
|
pub(crate) 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,
|
|
}
|
|
|
|
#[cfg(all(feature = "remote_logs_local_file", not(target_family = "wasm")))]
|
|
static LOCAL_LOG_FILE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
|
|
|
|
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,
|
|
{
|
|
super::activity::record_log(&record);
|
|
if is_raw_payload(&record) && !remote_payloads_enabled(AISettings::as_ref(ctx)) {
|
|
return;
|
|
}
|
|
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),
|
|
};
|
|
|
|
#[cfg(all(feature = "remote_logs_local_file", not(target_family = "wasm")))]
|
|
write_local_log(&payload);
|
|
|
|
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}");
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Views must log without updating AISettings: its singleton cannot be read
|
|
/// while it is temporarily borrowed by a model update.
|
|
pub(crate) fn log_view_event<V: View>(ctx: &mut ViewContext<V>, record: RemoteLogRecord) {
|
|
super::activity::record_log(&record);
|
|
if is_raw_payload(&record) && !remote_payloads_enabled(AISettings::as_ref(ctx)) {
|
|
return;
|
|
}
|
|
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),
|
|
};
|
|
#[cfg(all(feature = "remote_logs_local_file", not(target_family = "wasm")))]
|
|
write_local_log(&payload);
|
|
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}");
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(all(feature = "remote_logs_local_file", not(target_family = "wasm")))]
|
|
fn write_local_log(payload: &RemoteLogPayload) {
|
|
let state = LOCAL_LOG_FILE.get_or_init(|| Mutex::new(None));
|
|
let mut path = match state.lock() {
|
|
Ok(path) => path,
|
|
Err(error) => {
|
|
log::error!("[remote-logging] local log lock is poisoned: {error}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
if path.is_none() {
|
|
let directory = std::env::temp_dir().join("galaxy-remote-logs");
|
|
if let Err(error) = std::fs::create_dir_all(&directory) {
|
|
log::error!(
|
|
"[remote-logging] failed to create local log directory {}: {error}",
|
|
directory.display()
|
|
);
|
|
return;
|
|
}
|
|
let file_path = directory.join(format!("galaxy-remote-{}.jsonl", std::process::id()));
|
|
*path = Some(file_path.clone());
|
|
log::warn!(
|
|
"[remote-logging] local diagnostic log enabled: {}",
|
|
file_path.display()
|
|
);
|
|
}
|
|
|
|
let Some(file_path) = path.as_ref() else {
|
|
return;
|
|
};
|
|
let line = match serde_json::to_string(payload) {
|
|
Ok(line) => line,
|
|
Err(error) => {
|
|
log::error!("[remote-logging] failed to serialize local log record: {error}");
|
|
return;
|
|
}
|
|
};
|
|
match OpenOptions::new().create(true).append(true).open(file_path) {
|
|
Ok(mut file) => {
|
|
if let Err(error) = writeln!(file, "{line}") {
|
|
log::error!(
|
|
"[remote-logging] failed to write local log {}: {error}",
|
|
file_path.display()
|
|
);
|
|
}
|
|
}
|
|
Err(error) => log::error!(
|
|
"[remote-logging] failed to open local log {}: {error}",
|
|
file_path.display()
|
|
),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn sanitize_error(error: impl std::fmt::Display) -> String {
|
|
// Stop formatting before scanning/redacting an arbitrarily large upstream
|
|
// error body. A truncated diagnostic must never traverse the entire body.
|
|
struct ErrorPrefix {
|
|
text: String,
|
|
remaining: usize,
|
|
}
|
|
impl std::fmt::Write for ErrorPrefix {
|
|
fn write_str(&mut self, value: &str) -> std::fmt::Result {
|
|
if let Some((boundary, _)) = value.char_indices().nth(self.remaining) {
|
|
self.text.push_str(&value[..boundary]);
|
|
self.remaining = 0;
|
|
Err(std::fmt::Error)
|
|
} else {
|
|
self.text.push_str(value);
|
|
self.remaining -= value.chars().count();
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
let mut prefix = ErrorPrefix {
|
|
text: String::new(),
|
|
remaining: MAX_ERROR_INPUT_CHARS,
|
|
};
|
|
let _ = std::fmt::write(&mut prefix, format_args!("{error}"));
|
|
let text = prefix.text;
|
|
let safe_urls = ERROR_URL.replace_all(&text, |captures: ®ex::Captures<'_>| {
|
|
let matched = &captures[0];
|
|
let url_text = matched.trim_end_matches([')', ']', '}', ',', ';']);
|
|
let suffix = &matched[url_text.len()..];
|
|
match reqwest::Url::parse(url_text) {
|
|
Ok(mut url) => {
|
|
let _ = url.set_username("");
|
|
let _ = url.set_password(None);
|
|
url.set_query(None);
|
|
url.set_fragment(None);
|
|
format!("{url}{suffix}")
|
|
}
|
|
Err(_) => format!("[redacted URL]{suffix}"),
|
|
}
|
|
});
|
|
let safe_credentials = redact_error_credentials(&safe_urls);
|
|
let safe_bearers = ERROR_BEARER.replace_all(&safe_credentials, "Bearer [redacted]");
|
|
let compact = safe_bearers
|
|
.split_whitespace()
|
|
.map(redact_sensitive_token)
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
truncate_chars(&compact, MAX_ERROR_CHARS)
|
|
}
|
|
|
|
fn redact_error_credentials(text: &str) -> String {
|
|
let mut redacted = String::with_capacity(text.len());
|
|
let mut cursor = 0;
|
|
for assignment in ERROR_CREDENTIAL.find_iter(text) {
|
|
// A match inside an already-redacted credential is credential content,
|
|
// not a new assignment. Do not copy any of that content back out.
|
|
if assignment.start() < cursor {
|
|
continue;
|
|
}
|
|
redacted.push_str(&text[cursor..assignment.end()]);
|
|
redacted.push_str("[redacted]");
|
|
cursor = assignment.end() + error_credential_value_end(&text[assignment.end()..]);
|
|
}
|
|
redacted.push_str(&text[cursor..]);
|
|
redacted
|
|
}
|
|
|
|
fn error_credential_value_end(value: &str) -> usize {
|
|
let escapes = value.bytes().take_while(|byte| *byte == b'\\').count();
|
|
if let Some(quote @ (b'"' | b'\'')) = value.as_bytes().get(escapes).copied() {
|
|
// JSON strings can themselves contain escaped JSON. A delimiter at
|
|
// nesting depth N has 2^N-1 preceding slashes; an escaped quote inside
|
|
// its value has a different residue. Preserve the original escaping,
|
|
// so a quoted password cannot be broken open by global unescaping.
|
|
let span = escapes + 1;
|
|
if !span.is_power_of_two() {
|
|
return value.len();
|
|
}
|
|
let mut slashes = 0;
|
|
let start = escapes + 1;
|
|
for (index, character) in value[start..].char_indices() {
|
|
if character == '\\' {
|
|
slashes += 1;
|
|
continue;
|
|
}
|
|
if character as u32 == u32::from(quote) && slashes % (2 * span) == escapes {
|
|
return start + index + 1;
|
|
}
|
|
slashes = 0;
|
|
}
|
|
// An incomplete quoted value may be a truncated provider error body.
|
|
return value.len();
|
|
}
|
|
if value.starts_with('{') || value.starts_with('[') {
|
|
let mut values = serde_json::Deserializer::from_str(value).into_iter::<Value>();
|
|
return if values.next().is_some_and(|result| result.is_ok()) {
|
|
values.byte_offset()
|
|
} else {
|
|
value.len()
|
|
};
|
|
}
|
|
// An unquoted credential/header has no reliable value terminator. It may
|
|
// contain spaces, an authentication scheme, or multiple signed fields.
|
|
// Redact through the line boundary instead of guessing and exposing a suffix.
|
|
value.find(['\r', '\n']).unwrap_or(value.len())
|
|
}
|
|
|
|
pub(crate) fn raw_model_payload_context<M>(
|
|
ctx: &ModelContext<M>,
|
|
build_payload: impl FnOnce(usize) -> Value,
|
|
) -> Option<Value>
|
|
where
|
|
M: Entity,
|
|
{
|
|
let settings = AISettings::as_ref(ctx);
|
|
let remote_enabled = remote_payloads_enabled(settings);
|
|
let local_enabled = super::activity::is_recording();
|
|
if !remote_enabled && !local_enabled {
|
|
return None;
|
|
}
|
|
|
|
let remote_max = (*settings.remote_logging_model_payload_max_chars.value()).max(1);
|
|
let max_chars = if remote_enabled {
|
|
remote_max
|
|
} else {
|
|
super::activity::MAX_PAYLOAD_CHARS
|
|
};
|
|
Some(build_payload(max_chars))
|
|
}
|
|
|
|
fn remote_payloads_enabled(settings: &AISettings) -> bool {
|
|
*settings.remote_logging_enabled.value()
|
|
&& *settings.remote_logging_log_model_payloads.value()
|
|
&& !settings.remote_logging_api_key.value().trim().is_empty()
|
|
}
|
|
|
|
fn is_raw_payload(record: &RemoteLogRecord) -> bool {
|
|
matches!(
|
|
record.context.get("event").and_then(Value::as_str),
|
|
Some("raw_model_request" | "raw_model_response")
|
|
)
|
|
}
|
|
|
|
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(),
|
|
"source_commit": crate::build_info::SOURCE_COMMIT,
|
|
"source_modified": crate::build_info::source_is_dirty(),
|
|
})
|
|
}
|
|
|
|
async fn send_remote_log(config: RemoteLogConfig, payload: RemoteLogPayload) -> Result<(), String> {
|
|
let client = REMOTE_LOG_CLIENT.as_ref().map_err(Clone::clone)?;
|
|
|
|
let response = client
|
|
.post(&config.endpoint)
|
|
.header("x-api-key", config.api_key)
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|error| format!("request failed: {}", sanitize_error(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
|
|
}
|
|
|
|
/// Stop serialization once the diagnostic prefix is full. Unlike the tail
|
|
/// collector below, this does not walk the remaining history or tool archive.
|
|
/// A truncated prefix is diagnostic text, not a complete serialized request.
|
|
pub(crate) fn prefix_bounded_payload_context(
|
|
max_chars: usize,
|
|
serialize: impl FnOnce(&mut dyn std::io::Write) -> serde_json::Result<()>,
|
|
) -> Value {
|
|
struct PrefixWriter {
|
|
bytes: Vec<u8>,
|
|
max_chars: usize,
|
|
max_bytes: usize,
|
|
chars: usize,
|
|
truncated: bool,
|
|
}
|
|
|
|
impl std::io::Write for PrefixWriter {
|
|
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
|
if self.truncated {
|
|
return Err(std::io::Error::other("diagnostic prefix limit reached"));
|
|
}
|
|
let mut included = 0;
|
|
for byte in bytes {
|
|
let starts_character = byte & 0xc0 != 0x80;
|
|
if (starts_character && self.chars == self.max_chars)
|
|
|| self.bytes.len().saturating_add(included) == self.max_bytes
|
|
{
|
|
self.bytes.extend_from_slice(&bytes[..included]);
|
|
self.truncated = true;
|
|
return if included == 0 {
|
|
Err(std::io::Error::other("diagnostic prefix limit reached"))
|
|
} else {
|
|
// Respect Write's short-write contract. serde's write_all
|
|
// immediately retries the remainder and receives the error.
|
|
Ok(included)
|
|
};
|
|
}
|
|
if starts_character {
|
|
self.chars += 1;
|
|
}
|
|
included += 1;
|
|
}
|
|
self.bytes.extend_from_slice(bytes);
|
|
Ok(bytes.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
let mut writer = PrefixWriter {
|
|
bytes: Vec::new(),
|
|
max_chars,
|
|
max_bytes: max_chars.saturating_mul(4),
|
|
chars: 0,
|
|
truncated: false,
|
|
};
|
|
let result = serialize(&mut writer);
|
|
if let Err(error) = result {
|
|
if !writer.truncated {
|
|
return json!({ "serialization_error": sanitize_error(error) });
|
|
}
|
|
}
|
|
// Writers may split a UTF-8 character across calls. Keep only a complete
|
|
// prefix if serialization stopped before the last character was finished.
|
|
let valid_length = match std::str::from_utf8(&writer.bytes) {
|
|
Ok(_) => writer.bytes.len(),
|
|
Err(error) => error.valid_up_to(),
|
|
};
|
|
writer.bytes.truncate(valid_length);
|
|
let payload = String::from_utf8(writer.bytes).expect("validated diagnostic UTF-8 prefix");
|
|
let included_chars = payload.chars().count();
|
|
json!({
|
|
"payload": payload,
|
|
"payload_total_chars": if writer.truncated { None } else { Some(included_chars) },
|
|
"payload_included_chars": included_chars,
|
|
"payload_max_chars": max_chars,
|
|
"truncated": writer.truncated,
|
|
"truncation_strategy": if writer.truncated { Some("prefix") } else { None },
|
|
"serialization_stopped_early": writer.truncated,
|
|
})
|
|
}
|
|
|
|
/// Serialize directly into a bounded UTF-8 tail instead of materializing the full
|
|
/// request and its archive as a JSON Value and then another large String.
|
|
pub(crate) fn bounded_payload_context(
|
|
max_chars: usize,
|
|
serialize: impl FnOnce(&mut dyn std::io::Write) -> serde_json::Result<()>,
|
|
) -> Value {
|
|
struct TailWriter {
|
|
bytes: std::collections::VecDeque<u8>,
|
|
max_bytes: usize,
|
|
total_chars: usize,
|
|
}
|
|
|
|
impl std::io::Write for TailWriter {
|
|
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
|
self.total_chars += bytes.iter().filter(|byte| **byte & 0xc0 != 0x80).count();
|
|
let retained = &bytes[bytes.len().saturating_sub(self.max_bytes)..];
|
|
let discard = (self.bytes.len() + retained.len()).saturating_sub(self.max_bytes);
|
|
self.bytes.drain(..discard);
|
|
self.bytes.extend(retained);
|
|
Ok(bytes.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
let mut writer = TailWriter {
|
|
bytes: std::collections::VecDeque::new(),
|
|
max_bytes: max_chars.saturating_mul(4),
|
|
total_chars: 0,
|
|
};
|
|
if let Err(error) = serialize(&mut writer) {
|
|
return json!({ "serialization_error": sanitize_error(error) });
|
|
}
|
|
// A byte-limited tail can begin in the middle of a multibyte character.
|
|
while writer.bytes.front().is_some_and(|byte| byte & 0xc0 == 0x80) {
|
|
writer.bytes.pop_front();
|
|
}
|
|
let bytes = writer.bytes.into_iter().collect::<Vec<_>>();
|
|
let payload = String::from_utf8_lossy(&bytes);
|
|
let mut context = tail_limited_payload_context(&payload, max_chars);
|
|
context["payload_total_chars"] = json!(writer.total_chars);
|
|
context["truncated"] = json!(writer.total_chars > max_chars);
|
|
context["truncation_strategy"] = json!(if writer.total_chars > max_chars {
|
|
Some("tail")
|
|
} else {
|
|
None
|
|
});
|
|
context
|
|
}
|
|
|
|
pub(crate) 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)]
|
|
#[path = "remote_logging_tests.rs"]
|
|
mod tests;
|