v0.1.3-pre.004

This commit is contained in:
2026-08-15 19:58:18 +02:00
parent 0629a48e97
commit 29660fd9f0
13 changed files with 1134 additions and 174 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 43
# version: 44
[workspace]
resolver = "3"
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
[workspace.package]
version = "0.1.3-pre.3"
version = "0.1.3-pre.4"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-logging-lib/README.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# ksp-logging-lib
@@ -11,11 +11,13 @@ 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` et les settings console/fichier indépendants de Config ;
- `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 ;
- 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 ;
- les sorties console et fichier non bloquantes ;
- 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 ;
- l'instrumentation de scopes synchrones et de `Future` async.

View File

@@ -1,22 +1,26 @@
<!-- file: crates/ksp-logging-lib/TODO.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# TODO ksp-logging-lib
## À fermer avant la stable 0.1.2
## À fermer pendant `0.1.3`
- exécuter les validations Cargo complètes de `pre.006`, y compris les tests Tokio current-thread/multi-thread ;
- vérifier que `cargo tree -p ksp-logging-lib -e normal` ne contient pas Tokio et que Tokio apparaît uniquement dans le graphe dev attendu ;
- refaire l'audit final du graphe/features et de l'ownership de la stack tracing ;
- après validation de la prerelease finale, préparer `rel.001`, publier `workspace.package.version = "0.1.2"` et taguer `v0.1.2` conformément aux règles de release.
`0.1.3-pre.004` expose désormais le modèle public multi-output. La tranche runtime suivante doit encore :
- 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.
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.
## Capacités différées
Ces éléments ne font pas partie du contrat `0.1.2` et ne doivent être ajoutés qu'après besoin concret :
Ces éléments ne sont pas requis par la fondation Config `0.1.3` :
- plusieurs routes fichier indépendantes ;
- rotation par taille, rétention/compression et symlink `latest` ;
- formats JSON ou autres formats structurés alternatifs ;
- OpenTelemetry/export réseau ;
- watcher de fichiers de configuration, qui appartient à Config ou à une couche supérieure ;
- benchmark/profiling de précision destiné aux chemins de trading sensibles à la latence.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-logging-lib/USAGE.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# Utilisation de ksp-logging-lib
@@ -21,20 +21,57 @@ ksp_logging_lib::trace!(
Les champs `domain`, `component`, `operation` et autres champs structurés sont ajoutés par le caller lorsqu'ils sont utiles ; ils ne remplacent pas le target propriétaire.
## Initialisation
## Contrat des outputs
`initialize` installe le subscriber global KSP une seule fois et retourne le `LoggingGuard` qui doit rester vivant pendant la durée du runtime :
`LoggingSettings` possède :
- le `default_filter` global de takeover KSP ;
- les `TargetFilter` globaux ;
- la politique `SpanEvents` ;
- une console déclarée optionnellement ;
- zéro, un ou plusieurs `FileSettings`.
Chaque output possède un `OutputFilter` indépendant. Son niveau, ses targets et ses domains s'appliquent **en plus** de la politique globale. `*` signifie « tous » dans la dimension concernée.
Les formats publics sont :
```text
Human
Compact
Pretty
Json
```
Une sortie fichier possède un `output_id` stable et unique dans `LoggingSettings`. Cet identifiant appartient au runtime Logging et ne doit pas être confondu avec les `file_id` utilisés par `ksp-config-lib` pour identifier ses documents.
Les fichiers persistants interdisent `ansi = true`.
## Compatibilité runtime de `pre.004`
`0.1.3-pre.004` étend d'abord le **contrat public**. Le runtime multi-sink/routing est livré séparément en `pre.005`.
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.
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.
## Initialisation compatible avec le runtime actuel
```rust
let file = ksp_logging_lib::FileSettings::new(
"file.worker",
true,
"logs",
"worker.log",
ksp_logging_lib::FileRotation::Daily,
ksp_logging_lib::LogFormat::Human,
ksp_logging_lib::OutputFilter::unrestricted(),
);
let settings = ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::NewAndClose,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
std::option::Option::Some(ksp_logging_lib::FileSettings::new(
"logs",
"worker.log",
ksp_logging_lib::FileRotation::Daily,
)),
std::vec![file],
);
let initialize_result = ksp_logging_lib::initialize(&settings);
@@ -44,18 +81,61 @@ let mut logging_guard = match initialize_result {
};
```
Une configuration sans console ni fichier est valide et installe une infrastructure initialement silencieuse qui pourra être activée plus tard par hot reload.
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
Le contrat public peut déjà représenter la future configuration Config complète :
```rust
let console = ksp_logging_lib::ConsoleSettings::new(
true,
ksp_logging_lib::ConsoleOutput::Stderr,
true,
ksp_logging_lib::LogFormat::Compact,
ksp_logging_lib::OutputFilter::new(
ksp_logging_lib::LogFilterLevel::Debug,
std::vec!["*".to_string()],
std::vec!["*".to_string()],
),
);
let error_file = ksp_logging_lib::FileSettings::new(
"file.config.error",
true,
"logs/config",
"error.jsonl",
ksp_logging_lib::FileRotation::Daily,
ksp_logging_lib::LogFormat::Json,
ksp_logging_lib::OutputFilter::new(
ksp_logging_lib::LogFilterLevel::Error,
std::vec!["ksp-config-lib".to_string()],
std::vec!["config".to_string()],
),
);
let settings = ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Warn,
ksp_logging_lib::SpanEvents::NewAndClose,
std::option::Option::Some(console),
std::vec![error_file],
);
let validation = settings.validate();
```
`validate()` vérifie le contrat structurel. L'activation runtime de ce routage enrichi appartient à `pre.005`.
## Hot reload
Une nouvelle configuration peut être appliquée sans redémarrer le processus ou le worker :
Une configuration compatible avec les capacités runtime actives peut être appliquée sans redémarrer le processus ou le worker :
```rust
let debug_settings = ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::NewAndClose,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
std::option::Option::None,
std::vec::Vec::new(),
)
.with_target_filter(ksp_logging_lib::TargetFilter::new(
"ksp-store-lib",
@@ -104,7 +184,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 pertes restent observables :
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 :
```rust
let dropped = logging_guard.dropped_lines();
@@ -119,12 +199,4 @@ ksp_logging_lib::warn!(
## Instrumentation async et executor
`instrument(span, future)` accepte une `Future` standard et ne dépend d'aucun executor particulier :
```rust
let span = ksp_logging_lib::trace_span!(target: LOGGING_TARGET, "load_transactions");
let result = ksp_logging_lib::instrument(span, async_operation()).await;
```
La crate ne requiert pas Tokio en production. 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. Un consumer peut donc utiliser l'executor adapté à son propre contexte sans que Logging lui en impose un.
`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: 4
// version: 5
#![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. `0.1.2-pre.005` owns the single global subscriber, KSP takeover filtering, hot reload, non-blocking console/file outputs, rolling file
//! appenders, ANSI stripping, dropped-line counters and the worker guards required to flush active queues. The integration surface is hardened by
//! deterministic saturation, concurrent reload and ownership audits before final release validation.
//! `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.
mod error;
mod macros;
@@ -44,8 +44,12 @@ pub use self::settings::FileRotation;
pub use self::settings::FileSettings;
/// Runtime filter level used by KSP logging settings.
pub use self::settings::LogFilterLevel;
/// Output format requested for one Logging sink.
pub use self::settings::LogFormat;
/// Complete runtime settings consumed by Logging initialization and reload.
pub use self::settings::LoggingSettings;
/// Per-output routing filter applied in addition to the global KSP takeover policy.
pub use self::settings::OutputFilter;
/// Lifecycle events emitted for spans by the formatted subscriber.
pub use self::settings::SpanEvents;
/// Per-target filter override owned by Logging.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/src/runtime.rs
// version: 8
// version: 9
use tracing_subscriber::Layer; // rust-rules: trait-import
use tracing_subscriber::layer::SubscriberExt; // rust-rules: trait-import
@@ -165,17 +165,27 @@ fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<Pr
if let std::option::Option::Some(error) = validation_error {
return std::result::Result::Err(error);
}
if settings.console().is_none() && settings.file().is_none() {
let runtime_validation = validate_current_runtime_capabilities(settings);
if let std::result::Result::Err(error) = runtime_validation {
return std::result::Result::Err(error);
}
let enabled_console = settings.console().filter(|console| -> bool {
return console.enabled();
});
let enabled_file = settings.files().iter().find(|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 settings.file() {
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 = settings.console().map(|console| -> PreparedOutput {
let prepared_console = enabled_console.map(|console| -> PreparedOutput {
return build_console_output(console, settings);
});
let mut output_layers = RuntimeLayers::new();
@@ -193,6 +203,37 @@ fn prepare_runtime(settings: &crate::LoggingSettings) -> ksp_core_lib::Result<Pr
return std::result::Result::Ok(PreparedRuntime { layers, 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())
{
return runtime_capability_error("console ANSI, selectable formats and per-output routing require the multi-sink runtime");
}
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");
}
}
return std::result::Result::Ok(());
}
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"),
);
}
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),

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/src/settings.rs
// version: 2
// version: 3
/// Runtime filter level used by KSP logging settings.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -18,7 +18,7 @@ pub enum LogFilterLevel {
Trace,
}
/// Per-target filter override owned by Logging.
/// Per-target filter override owned by Logging for the global KSP takeover policy.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TargetFilter {
target_prefix: std::string::String,
@@ -56,6 +56,74 @@ pub enum SpanEvents {
Full,
}
/// Output format requested for one Logging sink.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum LogFormat {
/// Standard human-readable formatter with metadata.
Human,
/// Compact human-readable formatter.
Compact,
/// Expanded pretty human-readable formatter.
Pretty,
/// Structured JSON formatter.
Json,
}
/// Per-output routing filter applied in addition to the global KSP takeover policy.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OutputFilter {
level: crate::LogFilterLevel,
targets: std::vec::Vec<std::string::String>,
domains: std::vec::Vec<std::string::String>,
}
impl OutputFilter {
/// Creates an explicit per-output filter.
///
/// `targets` contains KSP target prefixes or the single wildcard `*`. `domains` contains domain prefixes or the single wildcard `*`.
#[must_use]
pub fn new(level: crate::LogFilterLevel, targets: std::vec::Vec<std::string::String>, domains: std::vec::Vec<std::string::String>) -> Self {
return Self { level, targets, domains };
}
/// Creates an unrestricted routing filter that does not further constrain the global KSP takeover policy.
#[must_use]
pub fn unrestricted() -> Self {
return Self::new(crate::LogFilterLevel::Trace, std::vec!["*".to_string()], std::vec!["*".to_string()]);
}
/// Returns the maximum verbosity accepted by this output.
#[must_use]
pub const fn level(&self) -> crate::LogFilterLevel {
return self.level;
}
/// Returns target selectors in declaration order.
#[must_use]
pub fn targets(&self) -> &[std::string::String] {
return self.targets.as_slice();
}
/// Returns domain selectors in declaration order.
#[must_use]
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.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ConsoleOutput {
@@ -66,22 +134,38 @@ pub enum ConsoleOutput {
}
/// Runtime settings for the optional console output.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConsoleSettings {
enabled: bool,
output: crate::ConsoleOutput,
ansi: bool,
format: crate::LogFormat,
filter: crate::OutputFilter,
}
impl ConsoleSettings {
/// Creates console settings targeting standard output.
/// Creates explicit console settings.
#[must_use]
pub const fn stdout() -> Self {
return Self { output: crate::ConsoleOutput::Stdout };
pub fn new(enabled: bool, output: crate::ConsoleOutput, ansi: bool, format: crate::LogFormat, filter: crate::OutputFilter) -> Self {
return Self { enabled, output, ansi, format, filter };
}
/// Creates console settings targeting standard error.
/// Creates enabled standard-output settings compatible with the `0.1.2` runtime behavior.
#[must_use]
pub const fn stderr() -> Self {
return Self { output: crate::ConsoleOutput::Stderr };
pub fn stdout() -> Self {
return Self::new(true, crate::ConsoleOutput::Stdout, false, crate::LogFormat::Human, crate::OutputFilter::unrestricted());
}
/// Creates enabled standard-error settings compatible with the `0.1.2` runtime behavior.
#[must_use]
pub fn stderr() -> Self {
return Self::new(true, crate::ConsoleOutput::Stderr, false, crate::LogFormat::Human, crate::OutputFilter::unrestricted());
}
/// Returns whether this console output is enabled.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns the selected console stream.
@@ -89,9 +173,27 @@ impl ConsoleSettings {
pub const fn output(&self) -> crate::ConsoleOutput {
return self.output;
}
/// Returns whether ANSI formatting is requested for the console output.
#[must_use]
pub const fn ansi(&self) -> bool {
return self.ansi;
}
/// Returns the requested console format.
#[must_use]
pub const fn format(&self) -> crate::LogFormat {
return self.format;
}
/// Returns the routing filter associated with the console output.
#[must_use]
pub const fn filter(&self) -> &crate::OutputFilter {
return &self.filter;
}
}
/// Rotation cadence for the optional file output.
/// Rotation cadence for a file output.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum FileRotation {
/// Keeps a single non-rotating file.
@@ -102,23 +204,62 @@ pub enum FileRotation {
Daily,
}
/// Runtime settings for the optional file output.
/// Runtime settings for one file output.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FileSettings {
output_id: std::string::String,
enabled: bool,
directory: std::path::PathBuf,
file_name_prefix: std::string::String,
rotation: crate::FileRotation,
format: crate::LogFormat,
ansi: bool,
filter: crate::OutputFilter,
}
impl FileSettings {
/// Creates file output settings.
/// Creates explicit file output settings.
#[must_use]
pub fn new(
output_id: impl std::convert::Into<std::string::String>,
enabled: bool,
directory: impl std::convert::Into<std::path::PathBuf>,
file_name_prefix: impl std::convert::Into<std::string::String>,
rotation: crate::FileRotation,
format: crate::LogFormat,
filter: crate::OutputFilter,
) -> Self {
return Self { directory: directory.into(), file_name_prefix: file_name_prefix.into(), rotation };
return Self {
output_id: output_id.into(),
enabled,
directory: directory.into(),
file_name_prefix: file_name_prefix.into(),
rotation,
format,
ansi: false,
filter,
};
}
/// Sets whether ANSI formatting is requested and returns the updated settings.
///
/// Persistent file outputs are required to keep this value `false`; [`crate::LoggingSettings::validate`] rejects `true`.
#[must_use]
pub fn with_ansi(mut self, ansi: bool) -> Self {
self.ansi = ansi;
return self;
}
/// Returns the stable output identifier used for diagnostics and reload accounting.
#[must_use]
pub fn output_id(&self) -> &str {
return self.output_id.as_str();
}
/// Returns whether this file output is enabled.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns the directory containing log files.
@@ -138,6 +279,24 @@ impl FileSettings {
pub const fn rotation(&self) -> crate::FileRotation {
return self.rotation;
}
/// Returns the requested file format.
#[must_use]
pub const fn format(&self) -> crate::LogFormat {
return self.format;
}
/// Returns whether ANSI formatting was requested for this file output.
#[must_use]
pub const fn ansi(&self) -> bool {
return self.ansi;
}
/// Returns the routing filter associated with this file output.
#[must_use]
pub const fn filter(&self) -> &crate::OutputFilter {
return &self.filter;
}
}
/// Complete runtime settings consumed by `ksp-logging-lib` initialization and reload.
@@ -147,29 +306,29 @@ pub struct LoggingSettings {
target_filters: std::vec::Vec<crate::TargetFilter>,
span_events: crate::SpanEvents,
console: std::option::Option<crate::ConsoleSettings>,
file: std::option::Option<crate::FileSettings>,
files: std::vec::Vec<crate::FileSettings>,
}
impl LoggingSettings {
/// Creates explicit Logging settings without any target override.
/// Creates explicit Logging settings without any global target override.
#[must_use]
pub fn new(
default_filter: crate::LogFilterLevel,
span_events: crate::SpanEvents,
console: std::option::Option<crate::ConsoleSettings>,
file: std::option::Option<crate::FileSettings>,
files: std::vec::Vec<crate::FileSettings>,
) -> Self {
return Self { default_filter, target_filters: std::vec::Vec::new(), span_events, console, file };
return Self { default_filter, target_filters: std::vec::Vec::new(), span_events, console, files };
}
/// Adds one target-prefix override and returns the updated settings.
/// Adds one target-prefix override to the global KSP takeover policy and returns the updated settings.
#[must_use]
pub fn with_target_filter(mut self, target_filter: crate::TargetFilter) -> Self {
self.target_filters.push(target_filter);
return self;
}
/// Returns the default level applied to KSP-owned targets.
/// Returns the default level applied to KSP-owned targets by the global takeover policy.
#[must_use]
pub const fn default_filter(&self) -> crate::LogFilterLevel {
return self.default_filter;
@@ -187,16 +346,16 @@ impl LoggingSettings {
return self.span_events;
}
/// Returns console settings when console output is enabled.
/// Returns console settings when the console output is declared.
#[must_use]
pub fn console(&self) -> std::option::Option<&crate::ConsoleSettings> {
return self.console.as_ref();
}
/// Returns file settings when file output is enabled.
/// Returns all declared file outputs in declaration order.
#[must_use]
pub fn file(&self) -> std::option::Option<&crate::FileSettings> {
return self.file.as_ref();
pub fn files(&self) -> &[crate::FileSettings] {
return self.files.as_slice();
}
/// Validates backend-independent invariants of the runtime settings.
@@ -216,18 +375,142 @@ impl LoggingSettings {
);
}
}
if let std::option::Option::Some(file) = self.file.as_ref()
&& file.file_name_prefix().is_empty()
{
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file name prefix must not be empty")
.with_context("field", "file.file_name_prefix"),
);
if let std::option::Option::Some(console) = self.console.as_ref() {
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);
}
}
for (index, file) in self.files.iter().enumerate() {
let output_id_validation = validate_output_id(file.output_id(), index);
if let std::result::Result::Err(error) = output_id_validation {
return std::result::Result::Err(error);
}
if file.directory().as_os_str().is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output directory must not be empty")
.with_context("field", format!("files[{index}].directory"))
.with_context("output_id", file.output_id()),
);
}
if file.file_name_prefix().is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file name prefix must not be empty")
.with_context("field", format!("files[{index}].file_name_prefix"))
.with_context("output_id", file.output_id()),
);
}
if file.ansi() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "ANSI sequences are not allowed in persistent file outputs")
.with_context("field", format!("files[{index}].ansi"))
.with_context("output_id", file.output_id()),
);
}
let filter_validation = validate_output_filter(file.filter(), format!("files[{index}].filter").as_str());
if let std::result::Result::Err(error) = filter_validation {
return std::result::Result::Err(error.with_context("output_id", file.output_id()));
}
for previous in &self.files[..index] {
if previous.output_id() == file.output_id() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output identifiers must be unique")
.with_context("field", format!("files[{index}].output_id"))
.with_context("output_id", file.output_id()),
);
}
}
}
return std::result::Result::Ok(());
}
}
fn validate_output_id(output_id: &str, index: usize) -> ksp_core_lib::Result<()> {
let mut previous_was_separator = true;
if output_id.is_empty() {
return invalid_output_id(output_id, index);
}
for byte in output_id.bytes() {
if byte == b'.' {
if previous_was_separator {
return invalid_output_id(output_id, index);
}
previous_was_separator = true;
continue;
}
if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-') {
return invalid_output_id(output_id, index);
}
previous_was_separator = false;
}
if previous_was_separator {
return invalid_output_id(output_id, index);
}
return std::result::Result::Ok(());
}
fn invalid_output_id(output_id: &str, index: usize) -> ksp_core_lib::Result<()> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "file output identifier is invalid")
.with_context("field", format!("files[{index}].output_id"))
.with_context("output_id", output_id),
);
}
fn validate_output_filter(filter: &crate::OutputFilter, field: &str) -> ksp_core_lib::Result<()> {
let target_validation = validate_selectors(filter.targets(), true, format!("{field}.targets").as_str());
if let std::result::Result::Err(error) = target_validation {
return std::result::Result::Err(error);
}
let domain_validation = validate_selectors(filter.domains(), false, format!("{field}.domains").as_str());
if let std::result::Result::Err(error) = domain_validation {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
fn validate_selectors(selectors: &[std::string::String], target_dimension: bool, field: &str) -> ksp_core_lib::Result<()> {
if selectors.is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selector list must not be empty").with_context("field", field),
);
}
if selectors.len() > 1
&& selectors.iter().any(|selector| -> bool {
return selector == "*";
})
{
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "wildcard selector must be used alone").with_context("field", field),
);
}
for (index, selector) in selectors.iter().enumerate() {
if selector.trim().is_empty() {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selector must not be empty")
.with_context("field", format!("{field}[{index}]")),
);
}
if target_dimension && selector != "*" && !selector.starts_with("ksp-") {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output target selector must identify a KSP-owned target")
.with_context("field", format!("{field}[{index}]"))
.with_context("selector", selector),
);
}
for previous in &selectors[..index] {
if previous == selector {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "output selectors must be unique")
.with_context("field", format!("{field}[{index}]"))
.with_context("selector", selector),
);
}
}
}
return std::result::Result::Ok(());
}
#[cfg(test)]
#[path = "../unit_tests/settings.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/tests/public_api.rs
// version: 4
// version: 5
//! Integration tests for the public crate-root surface of `ksp-logging-lib`.
@@ -7,16 +7,41 @@ const TEST_TARGET: &str = "ksp-logging-lib";
#[test]
fn public_settings_surface_is_usable() {
let console_filter =
ksp_logging_lib::OutputFilter::new(ksp_logging_lib::LogFilterLevel::Debug, std::vec![TEST_TARGET.to_string()], std::vec!["logging".to_string()]);
let file_filter = ksp_logging_lib::OutputFilter::new(
ksp_logging_lib::LogFilterLevel::Error,
std::vec![TEST_TARGET.to_string()],
std::vec!["logging.runtime".to_string()],
);
let settings = ksp_logging_lib::LoggingSettings::new(
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::NewAndClose,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stdout()),
std::option::Option::Some(ksp_logging_lib::FileSettings::new("logs", "ksp", ksp_logging_lib::FileRotation::Daily)),
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::new(
true,
ksp_logging_lib::ConsoleOutput::Stdout,
true,
ksp_logging_lib::LogFormat::Compact,
console_filter,
)),
std::vec![ksp_logging_lib::FileSettings::new(
"file.logging.error",
true,
"logs",
"error.jsonl",
ksp_logging_lib::FileRotation::Daily,
ksp_logging_lib::LogFormat::Json,
file_filter,
)],
)
.with_target_filter(ksp_logging_lib::TargetFilter::new(TEST_TARGET, ksp_logging_lib::LogFilterLevel::Trace));
assert!(settings.validate().is_ok());
assert_eq!(settings.default_filter(), ksp_logging_lib::LogFilterLevel::Info);
assert_eq!(settings.console().map(ksp_logging_lib::ConsoleSettings::output), std::option::Option::Some(ksp_logging_lib::ConsoleOutput::Stdout));
assert_eq!(settings.console().map(ksp_logging_lib::ConsoleSettings::format), std::option::Option::Some(ksp_logging_lib::LogFormat::Compact));
assert_eq!(settings.files().len(), 1);
assert_eq!(settings.files()[0].output_id(), "file.logging.error");
assert_eq!(settings.files()[0].format(), ksp_logging_lib::LogFormat::Json);
}
#[test]

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/tests/runtime.rs
// version: 4
// version: 5
//! Integration tests for global initialization, takeover filtering, non-blocking outputs and hot reload.
@@ -67,7 +67,7 @@ fn exercise_concurrent_reload(guard: &mut ksp_logging_lib::LoggingGuard, disable
ksp_logging_lib::LogFilterLevel::Off,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
std::option::Option::None,
std::vec::Vec::new(),
);
let quiet_reload = ksp_logging_lib::reinitialize(guard, &quiet_console);
assert!(quiet_reload.is_ok());
@@ -116,7 +116,7 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::None,
std::option::Option::None,
std::vec::Vec::new(),
);
let initialize_result = ksp_logging_lib::initialize(&disabled);
assert!(initialize_result.is_ok());
@@ -132,7 +132,7 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::NewAndClose,
std::option::Option::Some(ksp_logging_lib::ConsoleSettings::stderr()),
std::option::Option::None,
std::vec::Vec::new(),
)
.with_target_filter(ksp_logging_lib::TargetFilter::new(LOGGING_TARGET, ksp_logging_lib::LogFilterLevel::Trace));
let reload_result = ksp_logging_lib::reinitialize(&mut guard, &console_enabled);
@@ -151,7 +151,15 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
ksp_logging_lib::LogFilterLevel::Error,
ksp_logging_lib::SpanEvents::Full,
std::option::Option::None,
std::option::Option::Some(ksp_logging_lib::FileSettings::new(blocked_directory.as_path(), "invalid", ksp_logging_lib::FileRotation::Daily)),
std::vec![ksp_logging_lib::FileSettings::new(
"file.invalid",
true,
blocked_directory.as_path(),
"invalid",
ksp_logging_lib::FileRotation::Daily,
ksp_logging_lib::LogFormat::Human,
ksp_logging_lib::OutputFilter::unrestricted(),
)],
);
let failed_reload = ksp_logging_lib::reinitialize(&mut guard, &invalid_file);
assert!(failed_reload.is_err());
@@ -169,7 +177,15 @@ fn global_runtime_supports_takeover_non_blocking_outputs_hot_reload_and_single_i
ksp_logging_lib::LogFilterLevel::Info,
ksp_logging_lib::SpanEvents::Off,
std::option::Option::None,
std::option::Option::Some(ksp_logging_lib::FileSettings::new(log_directory.as_path(), "runtime-test.log", ksp_logging_lib::FileRotation::Never)),
std::vec![ksp_logging_lib::FileSettings::new(
"file.runtime",
true,
log_directory.as_path(),
"runtime-test.log",
ksp_logging_lib::FileRotation::Never,
ksp_logging_lib::LogFormat::Human,
ksp_logging_lib::OutputFilter::unrestricted(),
)],
);
let file_reload = ksp_logging_lib::reinitialize(&mut guard, &file_enabled);
assert!(file_reload.is_ok());

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-logging-lib/unit_tests/runtime.rs
// version: 5
// version: 6
#[test]
fn level_mapping_covers_all_ksp_levels() {
@@ -17,7 +17,7 @@ fn takeover_filter_silences_external_targets_and_applies_ksp_overrides() {
crate::LogFilterLevel::Info,
crate::SpanEvents::Off,
std::option::Option::Some(crate::ConsoleSettings::stdout()),
std::option::Option::None,
std::vec::Vec::new(),
)
.with_target_filter(crate::TargetFilter::new("ksp-logging-lib", crate::LogFilterLevel::Trace));
let filter = super::build_target_filter(&settings);
@@ -47,7 +47,7 @@ fn file_rotation_mapping_covers_supported_cadences() {
#[test]
fn disabled_runtime_has_no_layers_or_outputs() {
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None);
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new());
let result = super::prepare_runtime(&settings);
assert!(result.is_ok());
let prepared = match result {
@@ -65,7 +65,7 @@ fn console_runtime_composes_takeover_filter_before_formatter_and_owns_guard() {
crate::LogFilterLevel::Info,
crate::SpanEvents::Off,
std::option::Option::Some(crate::ConsoleSettings::stdout()),
std::option::Option::None,
std::vec::Vec::new(),
);
let result = super::prepare_runtime(&settings);
assert!(result.is_ok());
@@ -91,7 +91,7 @@ fn dropped_line_snapshots_add_saturating_by_sink() {
#[test]
fn takeover_filter_prefers_more_specific_ksp_prefixes_and_supports_off() {
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None)
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new())
.with_target_filter(crate::TargetFilter::new("ksp-store-", crate::LogFilterLevel::Debug))
.with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace))
.with_target_filter(crate::TargetFilter::new("ksp-wallet-lib", crate::LogFilterLevel::Off));
@@ -102,6 +102,43 @@ fn takeover_filter_prefers_more_specific_ksp_prefixes_and_supports_off() {
assert!(!filter.would_enable("ksp-wallet-lib", &tracing::Level::ERROR));
}
#[test]
fn pre_multi_sink_runtime_rejects_new_capabilities_instead_of_ignoring_them() {
let routed_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()]),
);
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",
"first.log",
crate::FileRotation::Never,
crate::LogFormat::Human,
crate::OutputFilter::unrestricted(),
);
let second_file = crate::FileSettings::new(
"file.second",
true,
"logs",
"second.log",
crate::FileRotation::Never,
crate::LogFormat::Human,
crate::OutputFilter::unrestricted(),
);
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());
}
struct BlockingWriter {
first_write: bool,
started: std::sync::mpsc::SyncSender<()>,

View File

@@ -1,5 +1,17 @@
// file: crates/ksp-logging-lib/unit_tests/settings.rs
// version: 1
// version: 2
fn unrestricted_file(output_id: &str) -> crate::FileSettings {
return crate::FileSettings::new(
output_id,
true,
"logs",
format!("{output_id}.log"),
crate::FileRotation::Daily,
crate::LogFormat::Human,
crate::OutputFilter::unrestricted(),
);
}
#[test]
fn level_variants_are_distinct() {
@@ -10,6 +22,13 @@ fn level_variants_are_distinct() {
assert_ne!(crate::LogFilterLevel::Debug, crate::LogFilterLevel::Trace);
}
#[test]
fn format_variants_are_distinct() {
assert_ne!(crate::LogFormat::Human, crate::LogFormat::Compact);
assert_ne!(crate::LogFormat::Compact, crate::LogFormat::Pretty);
assert_ne!(crate::LogFormat::Pretty, crate::LogFormat::Json);
}
#[test]
fn target_filter_preserves_prefix_and_level() {
let filter = crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace);
@@ -18,85 +37,188 @@ fn target_filter_preserves_prefix_and_level() {
}
#[test]
fn console_settings_select_requested_stream() {
assert_eq!(crate::ConsoleSettings::stdout().output(), crate::ConsoleOutput::Stdout);
assert_eq!(crate::ConsoleSettings::stderr().output(), crate::ConsoleOutput::Stderr);
fn output_filter_preserves_level_targets_and_domains() {
let filter = crate::OutputFilter::new(
crate::LogFilterLevel::Debug,
std::vec!["ksp-config-lib".to_string(), "ksp-logging-lib".to_string()],
std::vec!["config".to_string(), "logging.runtime".to_string()],
);
assert_eq!(filter.level(), crate::LogFilterLevel::Debug);
assert_eq!(filter.targets().len(), 2);
assert_eq!(filter.targets()[0], "ksp-config-lib");
assert_eq!(filter.targets()[1], "ksp-logging-lib");
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());
}
#[test]
fn file_settings_preserve_values() {
let settings = crate::FileSettings::new("logs", "ksp", crate::FileRotation::Daily);
assert_eq!(settings.directory(), std::path::Path::new("logs"));
assert_eq!(settings.file_name_prefix(), "ksp");
fn console_settings_preserve_enabled_stream_ansi_format_and_filter() {
let filter = crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["config".to_string()]);
let settings = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stderr, true, crate::LogFormat::Compact, filter.clone());
assert!(settings.enabled());
assert_eq!(settings.output(), crate::ConsoleOutput::Stderr);
assert!(settings.ansi());
assert_eq!(settings.format(), crate::LogFormat::Compact);
assert_eq!(settings.filter(), &filter);
}
#[test]
fn compatibility_console_constructors_are_unrestricted_human_and_non_ansi() {
let stdout = crate::ConsoleSettings::stdout();
let stderr = crate::ConsoleSettings::stderr();
assert_eq!(stdout.output(), crate::ConsoleOutput::Stdout);
assert_eq!(stderr.output(), crate::ConsoleOutput::Stderr);
assert!(stdout.enabled());
assert!(!stdout.ansi());
assert_eq!(stdout.format(), crate::LogFormat::Human);
assert!(stdout.filter().is_unrestricted());
}
#[test]
fn file_settings_preserve_multi_output_contract() {
let filter = crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]);
let settings =
crate::FileSettings::new("file.config.error", true, "logs/config", "error.jsonl", crate::FileRotation::Daily, crate::LogFormat::Json, filter.clone());
assert_eq!(settings.output_id(), "file.config.error");
assert!(settings.enabled());
assert_eq!(settings.directory(), std::path::Path::new("logs/config"));
assert_eq!(settings.file_name_prefix(), "error.jsonl");
assert_eq!(settings.rotation(), crate::FileRotation::Daily);
assert_eq!(settings.format(), crate::LogFormat::Json);
assert!(!settings.ansi());
assert_eq!(settings.filter(), &filter);
}
#[test]
fn logging_settings_preserve_explicit_values() {
fn logging_settings_represent_multiple_file_outputs() {
let first = unrestricted_file("file.debug");
let second = crate::FileSettings::new(
"file.config.error",
false,
"logs/config",
"error.jsonl",
crate::FileRotation::Hourly,
crate::LogFormat::Json,
crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]),
);
let settings = crate::LoggingSettings::new(
crate::LogFilterLevel::Info,
crate::SpanEvents::NewAndClose,
std::option::Option::Some(crate::ConsoleSettings::stdout()),
std::option::Option::Some(crate::FileSettings::new("logs", "ksp", crate::FileRotation::Hourly)),
std::vec![first, second],
)
.with_target_filter(crate::TargetFilter::new("ksp-store-lib", crate::LogFilterLevel::Trace));
assert_eq!(settings.default_filter(), crate::LogFilterLevel::Info);
assert_eq!(settings.span_events(), crate::SpanEvents::NewAndClose);
assert_eq!(settings.target_filters().len(), 1);
assert_eq!(settings.target_filters()[0].target_prefix(), "ksp-store-lib");
assert_eq!(settings.console(), std::option::Option::Some(&crate::ConsoleSettings::stdout()));
assert_eq!(settings.file().map(crate::FileSettings::rotation), std::option::Option::Some(crate::FileRotation::Hourly));
assert_eq!(settings.files().len(), 2);
assert_eq!(settings.files()[0].output_id(), "file.debug");
assert_eq!(settings.files()[1].format(), crate::LogFormat::Json);
assert_eq!(settings.console().map(crate::ConsoleSettings::output), std::option::Option::Some(crate::ConsoleOutput::Stdout));
assert!(settings.validate().is_ok());
}
#[test]
fn validation_rejects_empty_target_prefix() {
let settings = crate::LoggingSettings::new(
crate::LogFilterLevel::Info,
crate::SpanEvents::Off,
std::option::Option::Some(crate::ConsoleSettings::stdout()),
std::option::Option::None,
)
.with_target_filter(crate::TargetFilter::new("", crate::LogFilterLevel::Debug));
assert!(settings.validate().is_err());
fn validation_rejects_empty_or_external_global_target_prefix() {
let empty = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new())
.with_target_filter(crate::TargetFilter::new("", crate::LogFilterLevel::Debug));
let external = crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec::Vec::new())
.with_target_filter(crate::TargetFilter::new("sqlx", crate::LogFilterLevel::Debug));
assert!(empty.validate().is_err());
assert!(external.validate().is_err());
}
#[test]
fn validation_rejects_external_target_prefix() {
let settings = crate::LoggingSettings::new(
crate::LogFilterLevel::Info,
crate::SpanEvents::Off,
std::option::Option::Some(crate::ConsoleSettings::stdout()),
std::option::Option::None,
)
.with_target_filter(crate::TargetFilter::new("sqlx", crate::LogFilterLevel::Debug));
assert!(settings.validate().is_err());
}
#[test]
fn validation_rejects_empty_file_prefix() {
let settings = crate::LoggingSettings::new(
fn validation_rejects_invalid_file_identity_and_duplicate_output_ids() {
let invalid_id =
crate::LoggingSettings::new(crate::LogFilterLevel::Info, crate::SpanEvents::Off, std::option::Option::None, std::vec![unrestricted_file("File.Debug")]);
let duplicate_id = crate::LoggingSettings::new(
crate::LogFilterLevel::Info,
crate::SpanEvents::Off,
std::option::Option::None,
std::option::Option::Some(crate::FileSettings::new("logs", "", crate::FileRotation::Never)),
std::vec![unrestricted_file("file.debug"), unrestricted_file("file.debug")],
);
assert!(settings.validate().is_err());
assert!(invalid_id.validate().is_err());
assert!(duplicate_id.validate().is_err());
}
#[test]
fn validation_accepts_ksp_outputs_and_filters() {
let settings = crate::LoggingSettings::new(
crate::LogFilterLevel::Info,
crate::SpanEvents::Full,
std::option::Option::Some(crate::ConsoleSettings::stderr()),
std::option::Option::Some(crate::FileSettings::new("logs", "worker", crate::FileRotation::Daily)),
fn validation_rejects_empty_file_paths_and_persistent_ansi() {
let empty_directory = crate::FileSettings::new(
"file.empty.dir",
true,
"",
"debug.log",
crate::FileRotation::Never,
crate::LogFormat::Human,
crate::OutputFilter::unrestricted(),
);
let empty_prefix = crate::FileSettings::new(
"file.empty.prefix",
true,
"logs",
"",
crate::FileRotation::Never,
crate::LogFormat::Human,
crate::OutputFilter::unrestricted(),
);
let ansi_file = crate::FileSettings::new(
"file.ansi",
true,
"logs",
"ansi.log",
crate::FileRotation::Never,
crate::LogFormat::Human,
crate::OutputFilter::unrestricted(),
)
.with_target_filter(crate::TargetFilter::new("ksp-worker-", crate::LogFilterLevel::Debug));
assert!(settings.validate().is_ok());
.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()
);
}
#[test]
fn settings_allow_logging_to_be_disabled() {
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Off, crate::SpanEvents::Off, std::option::Option::None, std::option::Option::None);
fn validation_rejects_invalid_output_selectors() {
let external_target = crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["sqlx".to_string()], std::vec!["*".to_string()]);
let wildcard_mix =
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string(), "ksp-config-lib".to_string()], std::vec!["*".to_string()]);
let duplicate_domain =
crate::OutputFilter::new(crate::LogFilterLevel::Debug, std::vec!["*".to_string()], std::vec!["config".to_string(), "config".to_string()]);
for filter in [external_target, wildcard_mix, duplicate_domain] {
let console = crate::ConsoleSettings::new(true, crate::ConsoleOutput::Stdout, false, crate::LogFormat::Human, filter);
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]
fn settings_allow_all_outputs_to_be_disabled() {
let console = crate::ConsoleSettings::new(false, crate::ConsoleOutput::Stderr, true, crate::LogFormat::Pretty, crate::OutputFilter::unrestricted());
let file = crate::FileSettings::new(
"file.disabled",
false,
"logs",
"disabled.jsonl",
crate::FileRotation::Daily,
crate::LogFormat::Json,
crate::OutputFilter::new(crate::LogFilterLevel::Error, std::vec!["ksp-config-lib".to_string()], std::vec!["config".to_string()]),
);
let settings = crate::LoggingSettings::new(crate::LogFilterLevel::Off, crate::SpanEvents::Off, std::option::Option::Some(console), std::vec![file]);
assert!(settings.validate().is_ok());
}

337
deltas/0.1.3/pre.004.md Normal file
View File

@@ -0,0 +1,337 @@
<!-- file: deltas/0.1.3/pre.004.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.004
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.003
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.3"
Cargo.toml header version = 43
```
Validations utilisateur de `pre.003` :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test --workspace OK
```
Les tests Config livrés en `pre.003` réussissent : 21 tests unitaires et 4 tests d'API publique.
## Objet de pre.004
Cette tranche complète uniquement les **contrats/settings publics** de `ksp-logging-lib` nécessaires au futur `config/std.logging.json`.
Elle ne livre pas encore le runtime multi-sink complet. Le but est de stabiliser ce que Logging sait représenter avant de figer le schema JSON Config.
## Modèle public ajouté
### `LogFormat`
Formats publics représentables :
```text
Human
Compact
Pretty
Json
```
L'activation backend de tous ces formats appartient à la tranche runtime suivante.
### `OutputFilter`
Chaque sink peut maintenant déclarer un filtre propre contenant :
```text
level
targets[]
domains[]
```
Les selectors `targets` sont des préfixes de targets KSP ou le wildcard `*`.
Les selectors `domains` sont des préfixes de domain ou le wildcard `*`.
Le wildcard doit être utilisé seul dans sa dimension. Les listes vides, selectors vides, doublons et targets externes à KSP sont refusés par la validation publique.
`OutputFilter::unrestricted()` représente l'absence de restriction supplémentaire par rapport au takeover global :
```text
level = Trace
targets = ["*"]
domains = ["*"]
```
## Console explicite
`ConsoleSettings` représente désormais explicitement :
```text
enabled
output = stdout | stderr
ansi
format
filter
```
Les helpers :
```text
ConsoleSettings::stdout()
ConsoleSettings::stderr()
```
restent disponibles et produisent un contrat compatible avec le runtime `0.1.2` : console activée, format `Human`, ANSI désactivé et `OutputFilter::unrestricted()`.
## Plusieurs outputs fichier
`LoggingSettings` ne contient plus un unique :
```text
Option<FileSettings>
```
mais :
```text
Vec<FileSettings>
```
Chaque `FileSettings` porte :
```text
output_id
enabled
directory
file_name_prefix
rotation
format
ansi
filter
```
`output_id` est une identité Logging stable, unique parmi les fichiers d'un même `LoggingSettings`.
Il ne doit pas être confondu avec le `file_id` de `ksp-config-lib` :
```text
file_id -> identité d'un document/schema Config
output_id -> identité d'une sortie Logging
```
La syntaxe initiale d'un `output_id` accepte l'ASCII minuscule, chiffres, `_`, `-` et des segments séparés par `.` sans segment vide.
Exemples :
```text
file.debug
file.config.error
file.ksp-config-lib.error
```
## ANSI fichier
Une sortie persistante avec :
```text
ansi = true
```
est refusée par `LoggingSettings::validate()`.
Le contrat KSP conserve donc l'invariant qu'un fichier de logs ne persiste pas de séquences ANSI.
## Deux niveaux de filtrage conservés
Le nouveau filtre par sink ne remplace pas la politique existante.
`LoggingSettings` conserve :
```text
default_filter
target_filters[]
```
qui définissent le takeover global KSP.
Puis chaque console/fichier possède son `OutputFilter`, appliqué conceptuellement **en plus** du takeover global.
Cette séparation permet de conserver les overrides généraux tout en préparant un routing indépendant par sink, target, domain et niveau.
## Compatibilité runtime transitoire
Cette tranche ne doit jamais accepter silencieusement un contrat que le runtime `0.1.2` ne sait pas encore appliquer.
Après la validation structurelle, `initialize/reinitialize` refusent donc temporairement :
- plusieurs fichiers **actifs** simultanément ;
- ANSI console ;
- format console différent de `Human` ;
- format fichier différent de `Human` ;
- filtre propre à un output différent de `OutputFilter::unrestricted()`.
Les outputs désactivés peuvent déjà porter leur future configuration sans influencer le runtime actif.
Le refus utilise actuellement `logging.invalid_settings` avec un contexte `runtime_contract = single-output-compatibility`.
Ce garde-fou transitoire sera supprimé/remplacé lorsque `pre.005` implémentera réellement les capacités représentées.
## Lifecycle Logging préservé
Cette tranche ne modifie pas les responsabilités suivantes :
```text
initialize(...)
reinitialize(...)
LoggingGuard
subscriber global unique
takeover KSP
hot reload transactionnel
writers non bloquants
DroppedLines agrégé console/fichier
```
`DroppedLines` reste temporairement agrégé par type de sink ; sa généralisation éventuelle par `output_id` appartient à `pre.005`.
## Frontière Config préservée
Aucune dépendance inverse n'est introduite :
```text
ksp-logging-lib -X-> ksp-config-lib
```
`ksp-logging-lib` ne lit toujours aucun fichier JSON, aucun profil, aucun `.env` et aucune variable applicative.
Le futur `ksp-config-lib` construira ces contrats publics après résolution de `std.logging.json`.
## Dépendances
Aucune dépendance externe ou KSP supplémentaire n'est ajoutée.
Le graphe direct de `ksp-config-lib` reste inchangé dans cette tranche.
## Tests modifiés/ajoutés
Les tests settings couvrent notamment :
- les quatre formats ;
- niveau/targets/domains de `OutputFilter` ;
- wildcard unrestricted ;
- console enabled/output/ANSI/format/filter ;
- plusieurs `FileSettings` ;
- unicité et syntaxe des `output_id` ;
- fichiers sans ANSI ;
- selectors invalides, doublons et targets externes ;
- outputs déclarés mais désactivés.
Les tests runtime couvrent également le garde-fou transitoire :
- une console utilisant ANSI/format/routing enrichi est structurellement valide mais refusée par le backend courant ;
- plusieurs fichiers actifs sont structurellement valides mais refusés par le backend courant ;
- les contrats historiques compatibles continuent d'être préparés normalement.
Le test d'intégration d'API publique construit un contrat avec console enrichie et fichier JSON filtré afin de vérifier que la nouvelle surface est bien accessible depuis le crate-root.
## Documentation Logging
`README.md`, `USAGE.md` et `TODO.md` sont alignés sur la séparation :
```text
pre.004 = contrat public multi-output
pre.005 = activation runtime multi-sink/routing
```
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.4"
```
Le manifest racine devient :
```text
# version: 44
```
Le plan Config devient :
```text
<!-- version: 7 -->
```
## Fichiers ajoutés
```text
deltas/0.1.3/pre.004.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-logging-lib/README.md
crates/ksp-logging-lib/TODO.md
crates/ksp-logging-lib/USAGE.md
crates/ksp-logging-lib/src/lib.rs
crates/ksp-logging-lib/src/runtime.rs
crates/ksp-logging-lib/src/settings.rs
crates/ksp-logging-lib/tests/public_api.rs
crates/ksp-logging-lib/tests/runtime.rs
crates/ksp-logging-lib/unit_tests/runtime.rs
crates/ksp-logging-lib/unit_tests/settings.rs
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Hors scope confirmé
Cette tranche n'implémente pas :
- le runtime multi-sink complet ;
- le routing réel par target/domain de chaque sink ;
- les formatters Compact/Pretty/JSON runtime ;
- l'ANSI console runtime ;
- la généralisation complète des guards/drop counters par `output_id` ;
- `serde`, `serde_json` ou `jsonschema` ;
- `std.logging.json` ou son schema ;
- profils Config ;
- `.env` ou interpolation ;
- modification de `ksp-config-lib`.
## Validations à exécuter par l'utilisateur
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test --workspace
cargo tree -p ksp-logging-lib
cargo tree -p ksp-logging-lib -d
cargo tree -p ksp-logging-lib -e features
```
Les commandes Cargo ne sont pas déclarées réussies dans le delta tant qu'elles n'ont pas été exécutées sur l'environnement utilisateur.
## Suite
Après validation de cette tranche :
```text
0.1.3-pre.005 — ksp-logging-lib : runtime multi-sink + routing
```
Cette tranche suivante devra activer réellement les contrats stabilisés ici sans réintroduire Config dans Logging.

View File

@@ -1,11 +1,11 @@
<!-- file: docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md -->
<!-- version: 6 -->
<!-- version: 7 -->
# Plan `0.1.3` — Configuration foundation
## 1. Statut et objectif
Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001`, complété par `0.1.3-pre.001-fix.002`, puis regranularisé par `0.1.3-pre.001-fix.003`. `0.1.3-pre.002` ouvre maintenant le développement fonctionnel avec la seule fondation bootstrap de `ksp-config-lib`.
Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, et `pre.004` étend maintenant les contrats publics de `ksp-logging-lib` nécessaires au futur `std.logging.json` sans ouvrir encore le runtime multi-sink complet.
La base auditée reste la release stable `v0.1.2`.
@@ -230,25 +230,25 @@ La complétion Logging n'ouvre ni une nouvelle façade ni une dépendance invers
## 5. Matrice de responsabilités
| Responsabilité | Propriétaire | Consommateurs | Interdit |
|---------------------------------------------------------------------|--------------------------------------|--------------------------------------------|-----------------------------------------------------------|
| Définir les `file_id` connus et leur mapping par défaut | `ksp-config-lib` | bootstrap Config | noms physiques codés dans les consumers |
| Résoudre `file_id -> filename` | `ksp-config-lib` | session Config | référence inter-document par filename |
| Interpréter `--cfgpath`, `--schemapath` et overrides de mapping KSP | `ksp-config-lib` | applications qui transmettent argv/options | parser ces options différemment dans chaque binaire |
| Lire un document Config JSON | `ksp-config-lib` | crates/apps via API Config | lecture directe par consumer |
| Valider JSON Schema | `ksp-config-lib` | orchestration/management | validation divergente dans chaque crate |
| Résoudre globals/profils/compositions | `ksp-config-lib` | orchestration | résolution locale dans un binaire |
| Lire `KSP_*` / `KSPB_*` du processus | `ksp-config-lib` | crates/apps via API Config | `std::env::var*` applicatif hors Config |
| Lire `.env` | `ksp-config-lib` | crates/apps via API Config | loader dotenv direct hors Config |
| Résoudre `${...}` et fallback | `ksp-config-lib` | tous les consumers | interpolation locale dans les consumers |
| Classer sensibilité et construire une valeur sûre | `ksp-config-lib` | runtime/logging/diagnostics | redaction ad hoc dans chaque crate |
| Modifier/sauvegarder JSON Config | `ksp-config-lib` | management explicite | écriture directe par application |
| Créer/modifier/supprimer une entrée `.env` | `ksp-config-lib` | management explicite | édition directe par application |
| Modifier l'environnement externe du shell/systemd/parent | propriétaire externe de ce processus | Config le lit seulement | prétendre qu'un child process peut administrer son parent |
| Posséder les settings/sinks/routing Logging | `ksp-logging-lib` | Config construit les contrats publics | logique de routing dupliquée dans Config |
| Posséder `LoggingGuard` | orchestration/application | lifecycle Logging | singleton Config global |
| Initialiser/recharger Logging | orchestration | Config fournit settings/changement | Logging lisant Config |
| DTO/bindings Tauri | application Tauri | frontend | TS-RS automatique dans Config |
| Responsabilité | Propriétaire | Consommateurs | Interdit |
|---|---|---|---|
| Définir les `file_id` connus et leur mapping par défaut | `ksp-config-lib` | bootstrap Config | noms physiques codés dans les consumers |
| Résoudre `file_id -> filename` | `ksp-config-lib` | session Config | référence inter-document par filename |
| Interpréter `--cfgpath`, `--schemapath` et overrides de mapping KSP | `ksp-config-lib` | applications qui transmettent argv/options | parser ces options différemment dans chaque binaire |
| Lire un document Config JSON | `ksp-config-lib` | crates/apps via API Config | lecture directe par consumer |
| Valider JSON Schema | `ksp-config-lib` | orchestration/management | validation divergente dans chaque crate |
| Résoudre globals/profils/compositions | `ksp-config-lib` | orchestration | résolution locale dans un binaire |
| Lire `KSP_*` / `KSPB_*` du processus | `ksp-config-lib` | crates/apps via API Config | `std::env::var*` applicatif hors Config |
| Lire `.env` | `ksp-config-lib` | crates/apps via API Config | loader dotenv direct hors Config |
| Résoudre `${...}` et fallback | `ksp-config-lib` | tous les consumers | interpolation locale dans les consumers |
| Classer sensibilité et construire une valeur sûre | `ksp-config-lib` | runtime/logging/diagnostics | redaction ad hoc dans chaque crate |
| Modifier/sauvegarder JSON Config | `ksp-config-lib` | management explicite | écriture directe par application |
| Créer/modifier/supprimer une entrée `.env` | `ksp-config-lib` | management explicite | édition directe par application |
| Modifier l'environnement externe du shell/systemd/parent | propriétaire externe de ce processus | Config le lit seulement | prétendre qu'un child process peut administrer son parent |
| Posséder les settings/sinks/routing Logging | `ksp-logging-lib` | Config construit les contrats publics | logique de routing dupliquée dans Config |
| Posséder `LoggingGuard` | orchestration/application | lifecycle Logging | singleton Config global |
| Initialiser/recharger Logging | orchestration | Config fournit settings/changement | Logging lisant Config |
| DTO/bindings Tauri | application Tauri | frontend | TS-RS automatique dans Config |
## 6. Registre de fichiers, bootstrap, arborescence et nomenclature
@@ -580,23 +580,23 @@ Décisions :
### 8.1 Écart `ksp-logging-lib 0.1.2` à fermer
| Capacité | `0.1.2` | Requise par `std.logging.json` |
|-----------------------------------|----------------:|-------------------------------------:|
| filtre global | oui | oui |
| overrides par target | oui | oui |
| lifecycle spans | oui | oui |
| console stdout/stderr | oui | oui |
| console enabled | via `Option` | oui explicite |
| console ANSI configurable | non | oui |
| format console configurable | non | oui |
| plusieurs fichiers | non | oui |
| rotation par fichier | un seul fichier | oui par sink |
| format par fichier | non | oui |
| filtre par sink/target | non | oui |
| filtre par sink/domain | non | oui |
| filtre par sink/niveau | non indépendant | oui |
| hot reload transactionnel | oui | à conserver |
| non-blocking/guards/drop counters | oui | à conserver et généraliser par sinks |
| Capacité | `0.1.2` | Requise par `std.logging.json` |
|---|---:|---:|
| filtre global | oui | oui |
| overrides par target | oui | oui |
| lifecycle spans | oui | oui |
| console stdout/stderr | oui | oui |
| console enabled | via `Option` | oui explicite |
| console ANSI configurable | non | oui |
| format console configurable | non | oui |
| plusieurs fichiers | non | oui |
| rotation par fichier | un seul fichier | oui par sink |
| format par fichier | non | oui |
| filtre par sink/target | non | oui |
| filtre par sink/domain | non | oui |
| filtre par sink/niveau | non indépendant | oui |
| hot reload transactionnel | oui | à conserver |
| non-blocking/guards/drop counters | oui | à conserver et généraliser par sinks |
Ce tableau est un **gap identifié**, pas une invitation à déplacer Logging dans Config. La tranche qui le ferme modifie `ksp-logging-lib` uniquement dans son domaine propriétaire.
@@ -1612,7 +1612,7 @@ Implémentation candidate livrée par `pre.003` :
- l'unicité des `file_id`, les erreurs d'ID/mapping et les overrides config/schema sont couverts par tests unitaires et API publique ;
- aucune dépendance externe, lecture JSON, validation schema ou `.env` n'est introduite par cette tranche.
La validation utilisateur de `pre.003` reste requise avant l'ouverture de `pre.004`.
Validation utilisateur acquise pour `pre.003` : `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets` et `cargo test --workspace` sont propres, avec 21 tests unitaires Config et 4 tests d'API publique Config réussis.
### `0.1.3-pre.004` — `ksp-logging-lib` : modèle public multi-output
@@ -1631,6 +1631,23 @@ Objectif unique : compléter les **contrats/settings publics** de Logging sans e
Cette tranche ne doit pas être gonflée par le runtime multi-sink complet. Si même le modèle public dépasse le budget, il est scindé avant de poursuivre.
Implémentation candidate livrée par `pre.004` :
- `LogFormat::{Human, Compact, Pretty, Json}` devient le contrat de format public ;
- `OutputFilter` porte un niveau et des listes explicites de selectors target/domain, avec `*` comme wildcard exclusif d'une dimension ;
- les selectors target restent bornés aux targets KSP (`ksp-*`) ;
- `ConsoleSettings` porte explicitement `enabled`, `output`, `ansi`, `format` et `filter` ;
- `FileSettings` porte `output_id`, `enabled`, destination actuelle `directory/file_name_prefix`, rotation, format, ANSI et filtre ;
- les `output_id` fichier sont uniques, ASCII minuscules segmentables par `.`, et distincts des `file_id` Config ;
- les fichiers persistants refusent `ansi = true` ;
- `LoggingSettings` représente désormais `files: Vec<FileSettings>` au lieu d'un seul fichier optionnel, tout en conservant `default_filter`, `TargetFilter`, `SpanEvents` et la console ;
- `validate()` valide le modèle multi-output indépendamment du backend runtime ;
- les helpers `ConsoleSettings::stdout/stderr()` conservent un profil compatible avec le runtime historique ;
- jusqu'à `pre.005`, `initialize/reinitialize` refusent explicitement les nouvelles capacités qu'ils ne savent pas encore appliquer (plusieurs fichiers actifs, formats enrichis, ANSI console ou routing par output) plutôt que de les ignorer silencieusement ;
- aucune dépendance Config n'entre dans Logging et aucune dépendance externe supplémentaire n'est ajoutée.
La validation utilisateur de `pre.004` reste requise avant l'ouverture de `pre.005`.
### `0.1.3-pre.005` — `ksp-logging-lib` : runtime multi-sink + routing
Objectif unique : implémenter derrière les contrats de `pre.004` le comportement runtime manquant.