v0.1.3-pre.005

This commit is contained in:
2026-08-15 20:22:02 +02:00
parent 29660fd9f0
commit 063b24ee1c
17 changed files with 957 additions and 244 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-logging-lib/README.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# ksp-logging-lib
@@ -12,17 +12,34 @@ La crate possède :
- les cinq niveaux KSP `error`, `warn`, `info`, `debug` et `trace` ;
- les macros d'événements et de spans qui préservent le callsite du consommateur ;
- `LoggingSettings`, la console explicite et les settings fichier indépendants de Config ;
- les formats publics `Human/Compact/Pretty/Json` et les filtres par output (`level`, targets KSP et domains) ;
- zéro, un ou plusieurs descriptors fichier identifiés par `output_id` unique ;
- les formats runtime `Human/Compact/Pretty/Json` ;
- zéro, un ou plusieurs outputs fichier actifs simultanément, identifiés par `output_id` unique ;
- le routing par output sur niveau et target KSP ;
- le contrat de filtre `domain`, dont l'activation runtime structurée est volontairement séparée dans la tranche suivante ;
- l'installation unique du subscriber global ;
- le hot reload via `reinitialize` sans second subscriber global ;
- le takeover des logs : les targets externes sont silencieux par défaut ;
- le runtime non bloquant console/fichier existant, avec activation multi-sink/routing enrichie finalisée dans la tranche runtime dédiée ;
- les `WorkerGuard`, compteurs de lignes abandonnées, rotation fichier et stripping ANSI ;
- les writers non bloquants console/fichier et leurs `WorkerGuard` ;
- les compteurs agrégés de lignes abandonnées et le compteur cumulatif par `output_id` fichier ;
- la rotation fichier, le stripping ANSI persistant et l'ANSI configurable pour la console ;
- l'instrumentation de scopes synchrones et de `Future` async.
L'API async de production reste indépendante de tout executor. Tokio est utilisé uniquement comme `dev-dependency` afin de valider `instrument(...)` sur un executor réel en mode current-thread et multi-thread ; il ne fait pas partie des dépendances runtime de la crate.
## Routing `domain`
`OutputFilter` représente déjà :
```text
level
targets[]
domains[]
```
`0.1.3-pre.005` active réellement `level` et `targets[]`. Un output actif dont `domains[]` est différent de `[*]` est refusé explicitement par `initialize/reinitialize` jusqu'à la tranche dédiée au routing structuré par champ `domain`.
Ce refus évite d'assimiler artificiellement `domain` au target ou d'ignorer silencieusement la dimension demandée.
## Frontières
Une crate KSP comportementale qui journalise son activité dépend de `ksp-logging-lib` et n'utilise pas directement `tracing`, `tracing-subscriber` ou `tracing-appender`.

View File

@@ -1,20 +1,33 @@
<!-- file: crates/ksp-logging-lib/TODO.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# TODO ksp-logging-lib
## À fermer pendant `0.1.3`
`0.1.3-pre.004` expose désormais le modèle public multi-output. La tranche runtime suivante doit encore :
`0.1.3-pre.005` active le runtime multi-sink pour les dimensions disponibles directement dans les métadonnées `tracing` : niveau et target.
- activer simultanément zéro, un ou plusieurs fichiers ;
- appliquer réellement `OutputFilter` par sink sur niveau, target et domain ;
- appliquer `LogFormat::{Human, Compact, Pretty, Json}` par sink ;
- appliquer l'ANSI console configurable tout en gardant les fichiers sans séquences ANSI ;
- généraliser les writers/guards et compteurs de lignes abandonnées par output sans perdre la vue agrégée utile ;
- conserver le subscriber global unique, le takeover KSP et le hot reload transactionnel.
La tranche Logging suivante doit encore :
Tant que cette tranche runtime n'est pas livrée, `initialize/reinitialize` refusent explicitement les capacités du nouveau contrat qu'ils ne savent pas encore appliquer.
- appliquer réellement `OutputFilter.domains[]` à partir du champ structuré `domain` ;
- définir l'héritage du domain pour les events émis dans un span lorsqu'ils ne portent pas eux-mêmes le champ ;
- conserver une sémantique cohérente pour les événements de lifecycle de span ;
- couvrir ce routing par des tests dédiés avant de figer `std.logging.schema.json`.
Tant que cette tranche n'est pas livrée, un output actif avec `domains[] != ["*"]` est refusé explicitement par `initialize/reinitialize`.
## Capacités désormais actives
Le runtime supporte maintenant :
- zéro, un ou plusieurs outputs fichier simultanés ;
- une console indépendante ;
- routing par niveau et target pour chaque output ;
- `Human`, `Compact`, `Pretty` et `Json` ;
- ANSI console configurable et fichiers persistants sans ANSI ;
- guards non bloquants indépendants ;
- compteurs agrégés et compteurs cumulatifs par `output_id` fichier ;
- hot reload transactionnel du groupe de sinks.
## Capacités différées

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-logging-lib/USAGE.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# Utilisation de ksp-logging-lib
@@ -46,15 +46,22 @@ Une sortie fichier possède un `output_id` stable et unique dans `LoggingSetting
Les fichiers persistants interdisent `ansi = true`.
## Compatibilité runtime de `pre.004`
## Runtime multi-output de `pre.005`
`0.1.3-pre.004` étend d'abord le **contrat public**. Le runtime multi-sink/routing est livré séparément en `pre.005`.
`0.1.3-pre.005` active réellement :
Jusqu'à cette tranche suivante, `initialize`/`reinitialize` refusent explicitement toute capacité nouvellement représentée qu'ils ne savent pas encore appliquer : plusieurs fichiers actifs, format non `Human`, ANSI console ou filtre propre à un output. Ces valeurs ne sont donc jamais acceptées puis ignorées silencieusement.
- plusieurs fichiers simultanés ;
- les formats `Human`, `Compact`, `Pretty` et `Json` ;
- l'ANSI console ;
- le routing par niveau et target pour chaque output ;
- le comptage cumulatif des lignes abandonnées par `output_id` fichier ;
- le hot reload de ces sorties sans réinstaller le subscriber global.
Les helpers `ConsoleSettings::stdout()` et `ConsoleSettings::stderr()` construisent une console compatible avec le runtime actuel : activée, `Human`, sans ANSI et sans filtre supplémentaire par rapport au takeover global.
Le routing `domain` est volontairement séparé. `OutputFilter` continue de représenter `domains[]`, mais un output actif dont cette dimension est différente de `["*"]` est refusé par `initialize/reinitialize` jusqu'à la tranche suivante. La configuration n'est donc jamais acceptée puis partiellement ignorée.
## Initialisation compatible avec le runtime actuel
Les helpers `ConsoleSettings::stdout()` et `ConsoleSettings::stderr()` restent des raccourcis `Human`, sans ANSI et sans restriction supplémentaire par output.
## Initialisation
```rust
let file = ksp_logging_lib::FileSettings::new(
@@ -83,9 +90,9 @@ let mut logging_guard = match initialize_result {
Une configuration sans output actif est valide et installe une infrastructure initialement silencieuse qui pourra être activée plus tard par hot reload.
## Construction d'un contrat multi-output
## Construction d'un runtime multi-output
Le contrat public peut déjà représenter la future configuration Config complète :
Le runtime peut maintenant activer plusieurs sorties ayant des formats et filtres metadata distincts :
```rust
let console = ksp_logging_lib::ConsoleSettings::new(
@@ -110,7 +117,7 @@ let error_file = ksp_logging_lib::FileSettings::new(
ksp_logging_lib::OutputFilter::new(
ksp_logging_lib::LogFilterLevel::Error,
std::vec!["ksp-config-lib".to_string()],
std::vec!["config".to_string()],
std::vec!["*".to_string()],
),
);
@@ -124,11 +131,11 @@ let settings = ksp_logging_lib::LoggingSettings::new(
let validation = settings.validate();
```
`validate()` vérifie le contrat structurel. L'activation runtime de ce routage enrichi appartient à `pre.005`.
`validate()` vérifie le contrat structurel. `initialize/reinitialize` appliquent le niveau et les targets par output ; les domains spécifiques restent refusés jusqu'à la tranche de routing structuré.
## Hot reload
Une configuration compatible avec les capacités runtime actives peut être appliquée sans redémarrer le processus ou le worker :
Une configuration peut être appliquée sans redémarrer le processus ou le worker :
```rust
let debug_settings = ksp_logging_lib::LoggingSettings::new(
@@ -184,7 +191,7 @@ La future instrumentée entre/sort du span pendant ses polls et lors de son `Dro
## Lignes abandonnées
Les sorties utilisent des queues lossy afin de ne pas appliquer de backpressure au hot path. Les compteurs restent encore agrégés console/fichier pendant `pre.004` ; leur généralisation par output appartient au runtime multi-sink :
Les sorties utilisent des queues lossy afin de ne pas appliquer de backpressure au hot path. La vue agrégée console/fichier reste disponible :
```rust
let dropped = logging_guard.dropped_lines();
@@ -197,6 +204,14 @@ ksp_logging_lib::warn!(
);
```
Pour un fichier précis :
```rust
let dropped_for_output = logging_guard.dropped_file_lines("file.worker");
```
Le compteur par `output_id` reste cumulatif à travers les hot reloads tant que le même `LoggingGuard` est conservé.
## Instrumentation async et executor
`instrument(span, future)` accepte une `Future` standard et ne dépend d'aucun executor particulier. Tokio n'est présent qu'en `dev-dependency` pour valider la surface sur un executor réel, y compris après plusieurs suspensions et sur un runtime multi-thread.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/src/lib.rs
// version: 5
// version: 6
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -7,9 +7,9 @@
//! KSP-owned logging and tracing facade.
//!
//! This crate owns the KSP runtime logging contract. Behavioral KSP crates emit events and spans through this facade rather than depending directly on the
//! `tracing` stack. The crate owns the single global subscriber, KSP takeover filtering, hot reload and non-blocking outputs. `0.1.3-pre.004` extends
//! the public settings contract with explicit console properties, multiple file descriptors, per-output level/target/domain filters and selectable formats;
//! runtime activation of the newly represented multi-output routing is completed separately so unsupported capabilities are never ignored silently.
//! `tracing` stack. The crate owns the single global subscriber, KSP takeover filtering, hot reload and non-blocking outputs. `0.1.3-pre.005` activates
//! multiple simultaneous file outputs, per-output level/target routing, selectable formats, console ANSI and per-file dropped-line accounting. Structured
//! `domain` routing remains explicitly rejected until its dedicated runtime tranche so field-based routing is never approximated or ignored silently.
mod error;
mod macros;
@@ -38,9 +38,9 @@ pub use self::runtime::reinitialize;
pub use self::settings::ConsoleOutput;
/// Runtime settings for the optional console output.
pub use self::settings::ConsoleSettings;
/// Rotation cadence for the optional file output.
/// Rotation cadence for one file output.
pub use self::settings::FileRotation;
/// Runtime settings for the optional file output.
/// Runtime settings for one file output.
pub use self::settings::FileSettings;
/// Runtime filter level used by KSP logging settings.
pub use self::settings::LogFilterLevel;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/src/runtime.rs
// version: 9
// version: 10
use tracing_subscriber::Layer; // rust-rules: trait-import
use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import
@@ -47,6 +47,7 @@ pub struct LoggingGuard {
settings: crate::LoggingSettings,
outputs: RuntimeOutputs,
retired_dropped_lines: crate::DroppedLines,
retired_file_dropped_lines: std::collections::HashMap<std::string::String, usize>,
}
impl LoggingGuard {
@@ -61,6 +62,19 @@ impl LoggingGuard {
pub fn dropped_lines(&self) -> crate::DroppedLines {
return self.retired_dropped_lines.saturating_add(self.outputs.dropped_lines());
}
/// Returns cumulative dropped-line counters for one file `output_id` when that output has existed in the runtime.
#[must_use]
pub fn dropped_file_lines(&self, output_id: &str) -> std::option::Option<usize> {
let retired = self.retired_file_dropped_lines.get(output_id).copied();
let active = self.outputs.file_dropped_lines(output_id);
return match (retired, active) {
(std::option::Option::Some(retired), std::option::Option::Some(active)) => std::option::Option::Some(retired.saturating_add(active)),
(std::option::Option::Some(retired), std::option::Option::None) => std::option::Option::Some(retired),
(std::option::Option::None, std::option::Option::Some(active)) => std::option::Option::Some(active),
(std::option::Option::None, std::option::Option::None) => std::option::Option::None,
};
}
}
type BoxedRuntimeLayer = std::boxed::Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + std::marker::Send + std::marker::Sync + 'static>;
@@ -75,7 +89,7 @@ struct PreparedRuntime {
#[derive(Default)]
struct RuntimeOutputs {
console: std::option::Option<RuntimeOutput>,
file: std::option::Option<RuntimeOutput>,
files: std::vec::Vec<RuntimeFileOutput>,
}
impl RuntimeOutputs {
@@ -84,12 +98,44 @@ impl RuntimeOutputs {
std::option::Option::Some(output) => output.dropped_lines(),
std::option::Option::None => 0,
};
let file = match self.file.as_ref() {
std::option::Option::Some(output) => output.dropped_lines(),
std::option::Option::None => 0,
};
let mut file = 0_usize;
for output in &self.files {
file = file.saturating_add(output.output.dropped_lines());
}
return crate::DroppedLines { console, file };
}
fn file_dropped_lines(&self, output_id: &str) -> std::option::Option<usize> {
return self
.files
.iter()
.find(|output| -> bool {
return output.output_id == output_id;
})
.map(|output| -> usize {
return output.output.dropped_lines();
});
}
fn accumulate_file_dropped_lines(&self, destination: &mut std::collections::HashMap<std::string::String, usize>) {
for output in &self.files {
let dropped = output.output.dropped_lines();
match destination.entry(output.output_id.clone()) {
std::collections::hash_map::Entry::Occupied(mut entry) => {
let cumulative = entry.get().saturating_add(dropped);
*entry.get_mut() = cumulative;
},
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(dropped);
},
}
}
}
}
struct RuntimeFileOutput {
output_id: std::string::String,
output: RuntimeOutput,
}
struct RuntimeOutput {
@@ -108,6 +154,12 @@ struct PreparedOutput {
output: RuntimeOutput,
}
struct PreparedFileOutput {
output_id: std::string::String,
layer: BoxedRuntimeLayer,
output: RuntimeOutput,
}
/// Installs the global KSP tracing subscriber.
///
/// This function may succeed only once for the lifetime of the process. The returned guard owns all non-blocking writer guards and is then used by
@@ -124,6 +176,7 @@ pub fn initialize(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<cra
settings: settings.clone(),
outputs,
retired_dropped_lines: crate::DroppedLines::zero(),
retired_file_dropped_lines: std::collections::HashMap::new(),
}),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_ALREADY_INITIALIZED, "the global KSP tracing subscriber is already installed").with_source(error),
@@ -147,6 +200,7 @@ pub fn reinitialize(guard: &mut crate::LoggingGuard, settings: &crate::LoggingSe
return match reload_result {
std::result::Result::Ok(()) => {
guard.retired_dropped_lines = guard.retired_dropped_lines.saturating_add(guard.outputs.dropped_lines());
guard.outputs.accumulate_file_dropped_lines(&mut guard.retired_file_dropped_lines);
let retired_outputs = std::mem::replace(&mut guard.outputs, outputs);
guard.settings = settings.clone();
drop(retired_layers);
@@ -172,76 +226,84 @@ fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<Pr
let enabled_console = settings.console().filter(|console| -> bool {
return console.enabled();
});
let enabled_file = settings.files().iter().find(|file| -> bool {
let enabled_files = settings.files().iter().filter(|file| -> bool {
return file.enabled();
});
if enabled_console.is_none() && enabled_file.is_none() {
return std::result::Result::Ok(PreparedRuntime { layers: RuntimeLayers::new(), outputs: RuntimeOutputs::default() });
}
let prepared_file = match enabled_file {
std::option::Option::Some(file) => match build_file_output(file, settings) {
std::result::Result::Ok(output) => std::option::Option::Some(output),
std::result::Result::Err(error) => return std::result::Result::Err(error),
},
std::option::Option::None => std::option::Option::None,
};
let prepared_console = enabled_console.map(|console| -> PreparedOutput {
return build_console_output(console, settings);
});
let mut output_layers = RuntimeLayers::new();
let mut outputs = RuntimeOutputs::default();
if let std::option::Option::Some(console) = prepared_console {
output_layers.push(console.layer);
outputs.console = std::option::Option::Some(console.output);
if let std::option::Option::Some(console) = enabled_console {
let prepared_console = build_console_output(console, settings);
output_layers.push(prepared_console.layer);
outputs.console = std::option::Option::Some(prepared_console.output);
}
if let std::option::Option::Some(file) = prepared_file {
output_layers.push(file.layer);
outputs.file = std::option::Option::Some(file.output);
for file in enabled_files {
let prepared_file_result = build_file_output(file, settings);
let prepared_file = match prepared_file_result {
std::result::Result::Ok(output) => output,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
output_layers.push(prepared_file.layer);
outputs.files.push(RuntimeFileOutput { output_id: prepared_file.output_id, output: prepared_file.output });
}
if output_layers.is_empty() {
return std::result::Result::Ok(PreparedRuntime { layers: RuntimeLayers::new(), outputs });
}
let takeover_layer = build_target_filter(settings).and_then(output_layers).boxed();
let layers = vec![takeover_layer];
return std::result::Result::Ok(PreparedRuntime { layers, outputs });
return std::result::Result::Ok(PreparedRuntime { layers: vec![takeover_layer], outputs });
}
fn validate_current_runtime_capabilities(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<()> {
let enabled_file_count = settings
.files()
.iter()
.filter(|file| -> bool {
return file.enabled();
})
.count();
if enabled_file_count > 1 {
return runtime_capability_error("multiple enabled file outputs require the multi-sink runtime");
}
if let std::option::Option::Some(console) = settings.console()
&& console.enabled()
&& (console.ansi() || console.format() != crate::LogFormat::Human || !console.filter().is_unrestricted())
&& !domains_are_unrestricted(console.filter())
{
return runtime_capability_error("console ANSI, selectable formats and per-output routing require the multi-sink runtime");
return runtime_capability_error("domain routing requires the dedicated structured-domain runtime tranche");
}
for file in settings.files() {
if file.enabled() && (file.format() != crate::LogFormat::Human || !file.filter().is_unrestricted()) {
return runtime_capability_error("file formats and per-output routing require the multi-sink runtime");
if file.enabled() && !domains_are_unrestricted(file.filter()) {
return runtime_capability_error("domain routing requires the dedicated structured-domain runtime tranche");
}
}
return std::result::Result::Ok(());
}
fn domains_are_unrestricted(filter: &crate::OutputFilter) -> bool {
return match filter.domains() {
[selector] => selector == "*",
_ => false,
};
}
fn runtime_capability_error(message: &str) -> ksp_core_lib::Result<()> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("runtime_contract", "single-output-compatibility"),
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("runtime_contract", "metadata-routing-before-domain-routing"),
);
}
fn build_console_output(console: &crate::ConsoleSettings, settings: &crate::LoggingSettings) -> PreparedOutput {
return match console.output() {
crate::ConsoleOutput::Stdout => build_non_blocking_output(std::io::stdout(), "ksp-logging-console", settings, true),
crate::ConsoleOutput::Stderr => build_non_blocking_output(std::io::stderr(), "ksp-logging-console", settings, true),
crate::ConsoleOutput::Stdout => build_non_blocking_output(
std::io::stdout(),
"ksp-logging-console",
settings.span_events(),
true,
console.ansi(),
console.format(),
console.filter(),
),
crate::ConsoleOutput::Stderr => build_non_blocking_output(
std::io::stderr(),
"ksp-logging-console",
settings.span_events(),
true,
console.ansi(),
console.format(),
console.filter(),
),
};
}
fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettings) -> ksp_core_lib::Result<PreparedOutput> {
fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettings) -> ksp_core_lib::Result<PreparedFileOutput> {
let appender_result = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(map_file_rotation(file.rotation()))
.filename_prefix(file.file_name_prefix())
@@ -251,6 +313,7 @@ fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettin
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED, "unable to initialize the KSP rolling file appender")
.with_context("output_id", file.output_id())
.with_context("directory", file.directory().display().to_string())
.with_context("file_name_prefix", file.file_name_prefix())
.with_source(error),
@@ -258,16 +321,26 @@ fn build_file_output(file: &crate::FileSettings, settings: &crate::LoggingSettin
},
};
let stripped_writer = crate::writer::StripAnsiWriter::new(appender);
return std::result::Result::Ok(build_non_blocking_output(stripped_writer, "ksp-logging-file", settings, false));
let thread_name = format!("ksp-logging-{}", file.output_id());
let prepared = build_non_blocking_output(stripped_writer, thread_name.as_str(), settings.span_events(), false, false, file.format(), file.filter());
return std::result::Result::Ok(PreparedFileOutput { output_id: file.output_id().to_string(), layer: prepared.layer, output: prepared.output });
}
fn build_non_blocking_output<W>(writer: W, thread_name: &str, settings: &crate::LoggingSettings, ansi_sanitization: bool) -> PreparedOutput
fn build_non_blocking_output<W>(
writer: W,
thread_name: &str,
span_events: crate::SpanEvents,
ansi_sanitization: bool,
ansi: bool,
format: crate::LogFormat,
filter: &crate::OutputFilter,
) -> PreparedOutput
where
W: std::io::Write + std::marker::Send + 'static,
{
let (non_blocking, worker_guard) = non_blocking_builder(thread_name).finish(writer);
let error_counter = non_blocking.error_counter();
let layer = build_format_layer(non_blocking, settings, ansi_sanitization);
let layer = build_format_layer(non_blocking, span_events, ansi_sanitization, ansi, format, filter);
return PreparedOutput { layer, output: RuntimeOutput { _worker_guard: worker_guard, error_counter } };
}
@@ -275,16 +348,55 @@ fn non_blocking_builder(thread_name: &str) -> tracing_appender::non_blocking::No
return tracing_appender::non_blocking::NonBlockingBuilder::default().lossy(true).thread_name(thread_name);
}
fn build_format_layer(writer: tracing_appender::non_blocking::NonBlocking, settings: &crate::LoggingSettings, ansi_sanitization: bool) -> BoxedRuntimeLayer {
return tracing_subscriber::fmt::layer()
.with_writer(writer)
.with_ansi(false)
.with_ansi_sanitization(ansi_sanitization)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_span_events(map_span_events(settings.span_events()))
.boxed();
fn build_format_layer(
writer: tracing_appender::non_blocking::NonBlocking,
span_events: crate::SpanEvents,
ansi_sanitization: bool,
ansi: bool,
format: crate::LogFormat,
filter: &crate::OutputFilter,
) -> BoxedRuntimeLayer {
let span_events = map_span_events(span_events);
return match format {
crate::LogFormat::Human => tracing_subscriber::fmt::layer()
.with_writer(crate::writer::RouteMakeWriter::new(writer, filter.clone()))
.with_ansi(ansi)
.with_ansi_sanitization(ansi_sanitization)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_span_events(span_events)
.boxed(),
crate::LogFormat::Compact => tracing_subscriber::fmt::layer()
.compact()
.with_writer(crate::writer::RouteMakeWriter::new(writer, filter.clone()))
.with_ansi(ansi)
.with_ansi_sanitization(ansi_sanitization)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_span_events(span_events)
.boxed(),
crate::LogFormat::Pretty => tracing_subscriber::fmt::layer()
.pretty()
.with_writer(crate::writer::RouteMakeWriter::new(writer, filter.clone()))
.with_ansi(ansi)
.with_ansi_sanitization(ansi_sanitization)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_span_events(span_events)
.boxed(),
crate::LogFormat::Json => tracing_subscriber::fmt::layer()
.json()
.with_writer(crate::writer::RouteMakeWriter::new(writer, filter.clone()))
.with_ansi(false)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_span_events(span_events)
.boxed(),
};
}
fn build_target_filter(settings: &crate::LoggingSettings) -> tracing_subscriber::filter::Targets {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/src/settings.rs
// version: 3
// version: 4
/// Runtime filter level used by KSP logging settings.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -109,19 +109,6 @@ impl OutputFilter {
pub fn domains(&self) -> &[std::string::String] {
return self.domains.as_slice();
}
/// Returns whether this filter leaves routing entirely to the global takeover policy.
pub(crate) fn is_unrestricted(&self) -> bool {
let targets_all = match self.targets.as_slice() {
[selector] => selector == "*",
_ => false,
};
let domains_all = match self.domains.as_slice() {
[selector] => selector == "*",
_ => false,
};
return self.level == crate::LogFilterLevel::Trace && targets_all && domains_all;
}
}
/// Console stream selected for human-readable logs.
@@ -376,6 +363,12 @@ impl LoggingSettings {
}
}
if let std::option::Option::Some(console) = self.console.as_ref() {
if console.ansi() && console.format() == crate::LogFormat::Json {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "ANSI formatting is not compatible with JSON console output")
.with_context("field", "console.ansi"),
);
}
let validation_result = validate_output_filter(console.filter(), "console.filter");
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/src/writer.rs
// version: 1
// version: 2
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum StripAnsiState {
@@ -100,6 +100,96 @@ impl<W> StripAnsiWriter<W> {
}
}
#[derive(Clone)]
pub(crate) struct RouteMakeWriter<W> {
inner: W,
filter: crate::OutputFilter,
}
impl<W> RouteMakeWriter<W> {
pub(crate) fn new(inner: W, filter: crate::OutputFilter) -> Self {
return Self { inner, filter };
}
}
pub(crate) enum RoutedWriter<W> {
Enabled(W),
Disabled,
}
impl<W> std::io::Write for RoutedWriter<W>
where
W: std::io::Write,
{
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
return match self {
Self::Enabled(writer) => std::io::Write::write(writer, buffer),
Self::Disabled => std::result::Result::Ok(buffer.len()),
};
}
fn flush(&mut self) -> std::io::Result<()> {
return match self {
Self::Enabled(writer) => std::io::Write::flush(writer),
Self::Disabled => std::result::Result::Ok(()),
};
}
}
impl<'writer, W> tracing_subscriber::fmt::MakeWriter<'writer> for RouteMakeWriter<W>
where
W: tracing_subscriber::fmt::MakeWriter<'writer>,
{
type Writer = RoutedWriter<W::Writer>;
fn make_writer(&'writer self) -> Self::Writer {
return RoutedWriter::Enabled(tracing_subscriber::fmt::MakeWriter::make_writer(&self.inner));
}
fn make_writer_for(&'writer self, metadata: &tracing::Metadata<'_>) -> Self::Writer {
if metadata_matches_filter(metadata, &self.filter) {
return RoutedWriter::Enabled(tracing_subscriber::fmt::MakeWriter::make_writer_for(&self.inner, metadata));
}
return RoutedWriter::Disabled;
}
}
fn metadata_matches_filter(metadata: &tracing::Metadata<'_>, filter: &crate::OutputFilter) -> bool {
if !level_is_enabled(metadata.level(), filter.level()) {
return false;
}
return filter.targets().iter().any(|selector| -> bool {
return selector == "*" || metadata.target().starts_with(selector.as_str());
});
}
fn level_is_enabled(level: &tracing::Level, filter: crate::LogFilterLevel) -> bool {
return match filter {
crate::LogFilterLevel::Off => false,
crate::LogFilterLevel::Error => level_rank(level) <= 1,
crate::LogFilterLevel::Warn => level_rank(level) <= 2,
crate::LogFilterLevel::Info => level_rank(level) <= 3,
crate::LogFilterLevel::Debug => level_rank(level) <= 4,
crate::LogFilterLevel::Trace => level_rank(level) <= 5,
};
}
fn level_rank(level: &tracing::Level) -> u8 {
if level == &tracing::Level::ERROR {
return 1;
}
if level == &tracing::Level::WARN {
return 2;
}
if level == &tracing::Level::INFO {
return 3;
}
if level == &tracing::Level::DEBUG {
return 4;
}
return 5;
}
#[cfg(test)]
#[path = "../unit_tests/writer.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/tests/public_api.rs
// version: 5
// version: 6
//! Integration tests for the public crate-root surface of `ksp-logging-lib`.
@@ -85,6 +85,7 @@ fn public_runtime_surface_is_addressable_without_installing_it() {
let _already_initialized = ksp_logging_lib::ERROR_CODE_ALREADY_INITIALIZED;
let _reload_failed = ksp_logging_lib::ERROR_CODE_RELOAD_FAILED;
let _file_initialization_failed = ksp_logging_lib::ERROR_CODE_FILE_OUTPUT_INITIALIZATION_FAILED;
let _per_file_counter = ksp_logging_lib::LoggingGuard::dropped_file_lines;
let dropped = ksp_logging_lib::DroppedLines::zero();
assert_eq!(dropped.console(), 0);
assert_eq!(dropped.file(), 0);

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/tests/runtime.rs
// version: 5
// version: 6
//! Integration tests for global initialization, takeover filtering, non-blocking outputs and hot reload.
@@ -172,33 +172,103 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
assert!(logging_trace_enabled());
assert!(!external_error_enabled());
exercise_concurrent_reload(&mut guard, &disabled);
let log_directory = root.join("logs");
let file_enabled = ksp_logging_lib::LoggingSettings::new(
let domain_routed = ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::None,
std::vec![ksp_logging_lib::FileSettings::new(
"file.runtime",
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::new(
true,
log_directory.as_path(),
"runtime-test.log",
ksp_logging_lib::FileRotation::Never,
ksp_logging_lib::ConsoleOutput::Stderr,
false,
ksp_logging_lib::LogFormat::Human,
ksp_logging_lib::OutputFilter::unrestricted(),
)],
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Info, std::vec!["*".to_string()], std::vec!["logging".to_string()]),
)),
std::vec::Vec::new(),
);
let file_reload = ksp_logging_lib::reinitialize(&mut guard, &file_enabled);
let domain_reload = ksp_logging_lib::reinitialize(&mut guard, &domain_routed);
assert!(domain_reload.is_err());
assert_ne!(guard.settings(), &domain_routed);
let human_directory = root.join("human");
let compact_directory = root.join("compact");
let pretty_directory = root.join("pretty");
let json_directory = root.join("json");
let files_enabled = ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Trace,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::None,
std::vec![
ksp_logging_lib::FileSettings::new(
"file.human.logging",
true,
human_directory.as_path(),
"human.log",
ksp_logging_lib::FileRotation::Never,
ksp_logging_lib::LogFormat::Human,
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Info, std::vec![LOGGING_TARGET.to_string()], std::vec!["*".to_string()],),
),
ksp_logging_lib::FileSettings::new(
"file.compact.store",
true,
compact_directory.as_path(),
"compact.log",
ksp_logging_lib::FileRotation::Never,
ksp_logging_lib::LogFormat::Compact,
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Warn, std::vec![OTHER_KSP_TARGET.to_string()], std::vec!["*".to_string()],),
),
ksp_logging_lib::FileSettings::new(
"file.pretty.error",
true,
pretty_directory.as_path(),
"pretty.log",
ksp_logging_lib::FileRotation::Never,
ksp_logging_lib::LogFormat::Pretty,
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Error, std::vec![LOGGING_TARGET.to_string()], std::vec!["*".to_string()],),
),
ksp_logging_lib::FileSettings::new(
"file.json.logging",
true,
json_directory.as_path(),
"runtime.jsonl",
ksp_logging_lib::FileRotation::Never,
ksp_logging_lib::LogFormat::Json,
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Trace, std::vec![LOGGING_TARGET.to_string()], std::vec!["*".to_string()],),
),
],
);
let file_reload = ksp_logging_lib::reinitialize(&mut guard, &files_enabled);
assert!(file_reload.is_ok());
ksp_logging_lib::info!(target: LOGGING_TARGET, "file \x1b[31moutput\x1b[0m marker");
ksp_logging_lib::info!(target: LOGGING_TARGET, "logging info \x1b[31mmarker\x1b[0m");
ksp_logging_lib::error!(target: LOGGING_TARGET, "logging error marker");
ksp_logging_lib::warn!(target: OTHER_KSP_TARGET, "store warning marker");
ksp_logging_lib::info!(target: OTHER_KSP_TARGET, "store info must be filtered");
tracing::error!(target: EXTERNAL_TARGET, "external marker must remain silent");
let disable_after_file = ksp_logging_lib::reinitialize(&mut guard, &disabled);
assert!(disable_after_file.is_ok());
let file_text = read_directory_text(log_directory.as_path());
assert!(file_text.contains("file output marker"));
assert!(file_text.contains(LOGGING_TARGET));
assert!(file_text.contains("runtime.rs"));
assert!(!file_text.contains("\x1b["));
assert!(!file_text.contains("external marker must remain silent"));
let human_text = read_directory_text(human_directory.as_path());
let compact_text = read_directory_text(compact_directory.as_path());
let pretty_text = read_directory_text(pretty_directory.as_path());
let json_text = read_directory_text(json_directory.as_path());
assert!(human_text.contains("logging info marker"));
assert!(human_text.contains("logging error marker"));
assert!(!human_text.contains("store warning marker"));
assert!(!human_text.contains("\x1b["));
assert!(compact_text.contains("store warning marker"));
assert!(!compact_text.contains("store info must be filtered"));
assert!(!compact_text.contains("logging info marker"));
assert!(pretty_text.contains("logging error marker"));
assert!(!pretty_text.contains("logging info marker"));
assert!(json_text.contains("logging info marker"));
assert!(json_text.contains("logging error marker"));
assert!(json_text.contains(LOGGING_TARGET));
assert!(!json_text.contains("store warning marker"));
assert!(!human_text.contains("external marker must remain silent"));
assert!(!compact_text.contains("external marker must remain silent"));
assert!(!pretty_text.contains("external marker must remain silent"));
assert!(!json_text.contains("external marker must remain silent"));
assert_eq!(guard.dropped_file_lines("file.human.logging"), std::option::Option::Some(0));
assert_eq!(guard.dropped_file_lines("file.compact.store"), std::option::Option::Some(0));
assert_eq!(guard.dropped_file_lines("file.pretty.error"), std::option::Option::Some(0));
assert_eq!(guard.dropped_file_lines("file.json.logging"), std::option::Option::Some(0));
assert_eq!(guard.dropped_file_lines("file.unknown"), std::option::Option::None);
let dropped = guard.dropped_lines();
assert_eq!(dropped.total(), dropped.console().saturating_add(dropped.file()));
let second_initialize = ksp_logging_lib::initialize(&disabled);

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/unit_tests/runtime.rs
// version: 6
// version: 8
#[test]
fn level_mapping_covers_all_ksp_levels() {
@@ -56,7 +56,7 @@ fn disabled_runtime_has_no_layers_or_outputs() {
};
assert!(prepared.layers.is_empty());
assert!(prepared.outputs.console.is_none());
assert!(prepared.outputs.file.is_none());
assert!(prepared.outputs.files.is_empty());
}
#[test]
@@ -75,7 +75,7 @@ fn console_runtime_composes_takeover_filter_before_formatter_and_owns_guard() {
};
assert_eq!(prepared.layers.len(), 1);
assert!(prepared.outputs.console.is_some());
assert!(prepared.outputs.file.is_none());
assert!(prepared.outputs.files.is_empty());
assert_eq!(prepared.outputs.dropped_lines(), crate::DroppedLines::zero());
}
@@ -103,40 +103,74 @@ fn takeover_filter_prefers_more_specific_ksp_prefixes_and_supports_off() {
}
#[test]
fn pre_multi_sink_runtime_rejects_new_capabilities_instead_of_ignoring_them() {
let routed_console = crate::ConsoleSettings::new(
fn multi_sink_runtime_accepts_metadata_routing_formats_and_console_ansi() {
let root = std::env::temp_dir().join(format!("ksp-pre005-unit-{}", std::process::id()));
let _cleanup_before = std::fs::remove_dir_all(root.as_path());
let console = crate::ConsoleSettings::new(
true,
crate::ConsoleOutput::Stdout,
true,
crate::LogFormat::Compact,
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["ksp-logging-lib".to_string()], std::vec!["logging".to_string()]),
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["ksp-logging-lib".to_string()], std::vec!["*".to_string()]),
);
let routed_settings =
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(routed_console), std::vec::Vec::new());
assert!(routed_settings.validate().is_ok());
assert!(super::prepare_runtime(&routed_settings).is_err());
let first_file = crate::FileSettings::new(
"file.first",
true,
"logs",
root.join("first"),
"first.log",
crate::FileRotation::Never,
crate::LogFormat::Human,
crate::OutputFilter::unrestricted(),
crate::LogFormat::Pretty,
crate::OutputFilter::new(crate::LogFilterLevel::Info, std::vec!["ksp-logging-lib".to_string()], std::vec!["*".to_string()]),
);
let second_file = crate::FileSettings::new(
"file.second",
true,
"logs",
"second.log",
root.join("second"),
"second.jsonl",
crate::FileRotation::Never,
crate::LogFormat::Human,
crate::OutputFilter::unrestricted(),
crate::LogFormat::Json,
crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["*".to_string()], std::vec!["*".to_string()]),
);
let multi_file_settings =
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![first_file, second_file]);
assert!(multi_file_settings.validate().is_ok());
assert!(super::prepare_runtime(&multi_file_settings).is_err());
let settings = crate::LoggingSettings::new(
crate::LogFilterLevel::Trace,
crate::SpanEvents::Off,
std::option::Option::Some(console),
std::vec![first_file, second_file],
);
let result = super::prepare_runtime(&settings);
assert!(result.is_ok());
let prepared = match result {
std::result::Result::Ok(prepared) => prepared,
std::result::Result::Err(_) => return,
};
assert_eq!(prepared.layers.len(), 1);
assert!(prepared.outputs.console.is_some());
assert_eq!(prepared.outputs.files.len(), 2);
assert_eq!(prepared.outputs.file_dropped_lines("file.first"), std::option::Option::Some(0));
assert_eq!(prepared.outputs.file_dropped_lines("file.second"), std::option::Option::Some(0));
drop(prepared);
let cleanup_after = std::fs::remove_dir_all(root.as_path());
assert!(cleanup_after.is_ok());
}
#[test]
fn domain_routing_is_explicitly_deferred_instead_of_ignored() {
let console = crate::ConsoleSettings::new(
true,
crate::ConsoleOutput::Stdout,
false,
crate::LogFormat::Human,
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["logging".to_string()]),
);
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec::Vec::new());
assert!(settings.validate().is_ok());
let result = super::prepare_runtime(&settings);
assert!(result.is_err());
let error = match result {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
struct BlockingWriter {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/unit_tests/settings.rs
// version: 2
// version: 3
fn unrestricted_file(output_id: &str) -> crate::FileSettings {
return crate::FileSettings::new(
@@ -50,8 +50,10 @@ fn output_filter_preserves_level_targets_and_domains() {
assert_eq!(filter.domains().len(), 2);
assert_eq!(filter.domains()[0], "config");
assert_eq!(filter.domains()[1], "logging.runtime");
assert!(!filter.is_unrestricted());
assert!(crate::OutputFilter::unrestricted().is_unrestricted());
let unrestricted = crate::OutputFilter::unrestricted();
assert_eq!(unrestricted.level(), crate::LogFilterLevel::Trace);
assert_eq!(unrestricted.targets(), &["*".to_string()]);
assert_eq!(unrestricted.domains(), &["*".to_string()]);
}
#[test]
@@ -74,7 +76,7 @@ fn compatibility_console_constructors_are_unrestricted_human_and_non_ansi() {
assert!(stdout.enabled());
assert!(!stdout.ansi());
assert_eq!(stdout.format(), crate::LogFormat::Human);
assert!(stdout.filter().is_unrestricted());
assert_eq!(stdout.filter(), &crate::OutputFilter::unrestricted());
}
#[test]
@@ -175,21 +177,21 @@ fn validation_rejects_empty_file_paths_and_persistent_ansi() {
crate::OutputFilter::unrestricted(),
)
.with_ansi(true);
assert!(
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![empty_directory])
.validate()
.is_err()
);
assert!(
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![empty_prefix])
.validate()
.is_err()
);
assert!(
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![ansi_file])
.validate()
.is_err()
);
let empty_directory_settings =
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![empty_directory]);
let empty_prefix_settings =
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![empty_prefix]);
let ansi_file_settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![ansi_file]);
assert!(empty_directory_settings.validate().is_err());
assert!(empty_prefix_settings.validate().is_err());
assert!(ansi_file_settings.validate().is_err());
}
#[test]
fn validation_rejects_ansi_json_console() {
let console = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stdout, true, crate::LogFormat::Json, crate::OutputFilter::unrestricted());
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec::Vec::new());
assert!(settings.validate().is_err());
}
#[test]

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/unit_tests/writer.rs
// version: 1
// version: 2
#[test]
fn ansi_writer_strips_csi_sequences() {
@@ -28,3 +28,24 @@ fn ansi_writer_strips_osc_sequences_terminated_by_bell_or_st() {
assert!(second.is_ok());
assert_eq!(writer.into_inner(), b"abcd");
}
#[test]
fn output_level_routing_covers_all_filter_levels() {
assert!(!super::level_is_enabled(&tracing::Level::ERROR, crate::LogFilterLevel::Off));
assert!(super::level_is_enabled(&tracing::Level::ERROR, crate::LogFilterLevel::Error));
assert!(!super::level_is_enabled(&tracing::Level::WARN, crate::LogFilterLevel::Error));
assert!(super::level_is_enabled(&tracing::Level::WARN, crate::LogFilterLevel::Warn));
assert!(!super::level_is_enabled(&tracing::Level::INFO, crate::LogFilterLevel::Warn));
assert!(super::level_is_enabled(&tracing::Level::INFO, crate::LogFilterLevel::Info));
assert!(super::level_is_enabled(&tracing::Level::DEBUG, crate::LogFilterLevel::Debug));
assert!(super::level_is_enabled(&tracing::Level::TRACE, crate::LogFilterLevel::Trace));
}
#[test]
fn disabled_routed_writer_discards_bytes_without_error() {
let mut writer = super::RoutedWriter::<std::vec::Vec<u8>>::Disabled;
let write_result = std::io::Write::write_all(&mut writer, b"discarded");
let flush_result = std::io::Write::flush(&mut writer);
assert!(write_result.is_ok());
assert!(flush_result.is_ok());
}