Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Architecture de journalisation

ZeroClaw possède exactement une seule surface de journalisation : la macro zeroclaw_log::record!. Chaque émission dans l’espace de travail, activité de la boucle d’agent, E/S de canaux, exécutions cron, appels d’outils, opérations mémoire, cycle de vie des sessions, erreurs, passe par elle. La macro déclenche un événement tracing que le subscriber installé transmet à deux couches sœurs : la couche fmt stderr (sortie terminal) et la LogCaptureLayer. La couche fmt affiche sur stderr des lignes colorées préfixées par alias (masquées sauf avec --verbose). La LogCaptureLayer matérialise un LogEvent structuré et le distribue, via writer::record_event, vers :

  1. Le pont Observer facultatif (observer_bridge::forward) pour le sous-ensemble d’actions qui correspondent à des événements Prometheus / OTel typés, mais uniquement lorsqu’un appelant a installé une liaison avec set_observer_bridge. Le bootstrap de production actuel n’en installe pas.
  2. Le canal de diffusion à l’échelle du processus pour les abonnés en temps réel, comme le flux SSE du tableau de bord.
  3. Le composant d’écriture JSONL asynchrone pour <workspace>/state/runtime-trace.jsonl (lorsque [observability] log_persistence vaut "rolling", "full" ou "rotating").

Lorsque le pont Observer est lié, sa projection et l’envoi en diffusion ont lieu avant la tentative de mise en file d’attente pour la persistance. Ces trois destinations offrent des garanties différentes en matière de complétude et de durabilité ; le fait de partager un seul LogEvent ne les rend pas interchangeables.

À lire en premier : attribution n’est pas attrs

Chaque événement de journal transporte deux canaux de données structurées complètement distincts. Les confondre est l’erreur la plus courante au point d’appel, donc intériorisez cette distinction avant toute autre chose :

Attribution (zeroclaw.*)Attrs (attributes.*)
RéponsesQui l’a fait et dans quel contexteQue s’est-il passé exactement
Exempleschannel, agent_alias, model_provider, tool, session_key, cron_job_idbytes_received, tokens_used, status_code, charges utiles d’erreur
SourceSpans. Ouverts aux points d’entrée, parcourus par la couche.Le site d’appel. Event::with_attrs(json!({...})).
Apparaît au site d’appel ?Jamais. Pas un argument de record!.Oui, c’est le seul endroit d’où cela peut provenir.

La règle qui en découle : si une valeur identifie à qui ou à quelle portée un événement appartient, elle provient d’un span et ne doit jamais apparaître au point d’appel. L’attribution s’intègre automatiquement depuis les wrappers attribution_span! / scope! ouverts plus haut dans la pile ; la layer parcourt la portée des spans de la feuille vers la racine lorsqu’un événement se déclenche et fusionne chaque contribution dans le bloc zeroclaw.* de l’événement. Le point d’appel qui déclenche record! n’en nomme aucune.

Parce que l’attribution est la moitié porteuse de cette répartition et celle qui pose problème aux utilisateurs, elle est traitée en premier.

Attribution : tout provient des spans

L’attribution n’est jamais un argument du site d’appel. Relisez cela. Le composite de canal, agent_alias, model_provider, tool, session_key, cron_job_id : aucun de ceux-ci n’est jamais saisi dans un appel record!. Ils arrivent via les spans de tracing ouverts aux points d’entrée et parcourus par la layer lorsqu’un événement se déclenche. Si vous vous surprenez à vouloir passer agent_alias ou tool à record!, arrêtez-vous : la valeur est déjà dans la portée via un span, ou elle devrait l’être, et la solution est d’ouvrir ou de corriger le span, pas de faire transiter la valeur jusqu’au site d’appel.

Le mécanisme, de bout en bout :

  1. Une « chose » (canal, fournisseur, agent, outil, tâche cron, backend de mémoire, …) implémente Attributable une seule fois, à côté de sa struct.
  2. Son point d’entrée encapsule le travail dans attribution_span!(self), qui ouvre un span de traçage portant le rôle et l’alias de cet élément.
  3. Chaque record! déclenché n’importe où à l’intérieur de ce span, directement ou imbriqué à une profondeur arbitraire, hérite automatiquement de l’attribution.
  4. Lorsque l’événement se déclenche, la couche parcourt la portée du span leaf→root, fusionne la contribution de chaque Attributable et écrit le bloc zeroclaw.* fusionné. Le site d’appel n’a rien nommé de tout cela.

C’est tout l’intérêt de cette conception : le code de journalisation par élément est nul. Vous implémentez le trait une fois et encapsulez le point d’entrée une fois ; chaque émission en dessous est attribuée gratuitement.

Le trait Attributable

Réside dans crates/zeroclaw-api/src/attribution.rs afin que chaque crate puisse l’implémenter sans dépendre de zeroclaw-log :

#![allow(unused)]
fn main() {
pub trait Attributable {
    fn role(&self) -> Role;
    fn alias(&self) -> &str;
}
}

Chaque « élément » du workspace (un TelegramChannel, un AnthropicModelProvider, un Agent, une tâche cron, un outil, un backend de mémoire, un groupe de pairs, un bundle de compétences, un bundle MCP, une session) implémente Attributable une seule fois à côté de sa structure.

La taxonomie Role

Énumération imbriquée fermée :

#![allow(unused)]
fn main() {
pub enum Role {
    Swarm,
    Agent,
    Channel(ChannelKind),       // Telegram, Discord, Slack, Matrix, Lark, ...
    Tool(ToolKind),             // Shell, HttpRequest, FetchUrl, ...
    Cron(CronKind),             // Interval, At, Cron, Once
    Provider(ProviderKind),     // Modèle, Tts, Transcription, Tunnel
    Memory(MemoryKind),         // Sqlite, Json, InMemory, Markdown, Qdrant, ...
    PeerGroup,
    Skill,
    Mcp,
    Sop,
    Session,
    System,
}
}

ChannelKind, ToolKind, CronKind, MemoryKind et les quatre sous-énumérations ProviderKind (ModelProviderKind, TtsProviderKind, TranscriptionProviderKind, TunnelProviderKind) sont toutes fermées. La forme snake_case de la variante via strum::IntoStaticStr est la portion <type> canonique du composite <type>.<alias>. Pour ajouter une nouvelle implémentation : étendez l’énumération Kind concernée, c’est tout.

Ouverture d’un span, à faire à chaque point d’entrée

Encapsulez le travail d’un point d’entrée avec attribution_span!(thing). La macro renvoie un Span portant le rôle et l’alias de l’élément sous forme de champs structurés. Appliquez .instrument(span) au future (ou let _g = span.entered() en code synchrone). Une tâche lancée qui ne rétablit pas le span perd l’attribution : chaque corps de tokio::spawn qui émet doit porter le même attribution_span! / scope! que celui utilisé par le parent, sinon ses émissions se retrouvent sans attribution.

#![allow(unused)]
fn main() {
use zeroclaw_log::Instrument;

let span = zeroclaw_log::attribution_span!(self);  // self implémente Attributable
async move {
    // chaque record ! transporte automatiquement les champs liés à l'alias
    record!(INFO, Event::new(module_path!(), Action::Start), canal en ligne);
    self.poll_loop().await
}.instrument(span).await
}

La couche parcourt la portée du span de la feuille à la racine (leaf→root) quand un événement se déclenche, fusionne la contribution de chaque Attributable dans le bloc d’attribution zeroclaw.* de l’événement, et émet le composite (channel = "telegram.clamps", channel_type = "telegram", channel_alias = "clamps") sans que le site d’appel ne nomme aucune de ces clés.

La macro scope!, contexte hors rôle

attribution_span! est destiné aux éléments Attributable porteurs de rôle. Pour les identifiants par portée qui ne sont pas liés à un seul élément (sender id, message id, turn id, request id), utilisez scope! :

#![allow(unused)]
fn main() {
zeroclaw_log::scope!(
    sender: msg.sender.as_str(),
    message_id: msg.id.as_str(),
    => async move { process_message(msg).await }
).await
}

scope! chevauche délibérément la frontière attribution/attrs : les clés de champ qui correspondent aux ATTRIBUTION_FIELDS / COMPOSITE_PREFIXES liés par alias (dans crates/zeroclaw-log/src/event.rs) atterrissent dans l’emplacement d’attribution typé zeroclaw.* ; tout le reste atterrit dans la map attributes de l’événement pour chaque émission descendante. Dans les deux cas, la valeur accompagne chaque record! imbriqué sans être un argument de site d’appel.

La macro record! et son contrat au site d’appel

La crate tracing est un détail d’implémentation de zeroclaw-log : les macros record! / scope! / attribution_span! se développent en zeroclaw_log::__private::tracing, de sorte qu’un site d’appel ne nomme jamais un type de tracing. Les macros d’événements de log elles-mêmes (tracing::{trace,debug,info,warn,error}, log::*, std::dbg, ainsi que anyhow::anyhow! utilisée directement) sont strictement bannies à l’échelle du workspace en tant que disallowed-macros dans clippy.toml. Avec -D warnings en CI, tout appel direct à tracing::info! etc. fait échouer le build, avec un message clippy indiquant ::zeroclaw_log::record! comme remplacement. Ce n’est pas une convention ; c’est appliqué de manière contraignante.

Les seules exemptions sont les quelques fichiers à l’intérieur de crates/zeroclaw-log/ qui amorcent le pipeline et portent un #![allow(clippy::disallowed_macros)] local. Une poignée de crates (zeroclaw-api, zeroclaw-spawn, zeroclaw-providers, zeroclaw-hardware, zeroclaw-log) listent encore tracing / tracing-subscriber dans Cargo.toml, mais uniquement pour la plomberie des spans et des subscribers, pas pour émettre des macros de journalisation. La présence de la dépendance n’autorise pas l’appel des macros interdites. (tokio::spawn est interdit de la même manière via disallowed-methods ; utilisez ::zeroclaw_spawn::spawn! afin que les tâches lancées héritent du span d’attribution de l’appelant.)

La macro est de forme verrouillée : elle prend un niveau, une seule expression Event et un littéral de message.

#![allow(unused)]
fn main() {
use zeroclaw_log::{record, Event, Action, EventCategory, EventOutcome};

record!(INFO, Event::new(module_path!(), Action::Start), étape de démarrage);
record!(WARN, Event::new(module_path!(), Action::Fail).with_outcome(EventOutcome::Failure).with_attrs(serde_json::json!({"exit_code": 137})), « échec de l'outil »);
}

module_path!() est la source canonique du nom de l’événement : il s’agit du chemin de module Rust du site d’appel (p. ex. zeroclaw_channels::telegram), de sorte que les événements sont consultables par recherche, accessibles par saut vers la source, et impossibles à mal orthographier. La même convention est utilisée à chaque site record! du workspace.

La macro injecte file!() et line!() automatiquement. Le LogCaptureLayer les attache à la carte attributes de l’événement sous les noms _file et _line, afin que les opérateurs puissent accéder à la source depuis une visionneuse de logs.

Contrat du site d’appel

Chaque appel record! est une seule ligne de code qui indique ce qui s’est passé, et non qui l’a fait ou dans quel contexte.

  • L’unique argument positionnel après le niveau est une expression Event.
  • L’argument suivant est un littéral de chaîne pour le message lisible par l’utilisateur.
  • C’est tout. Channel, agent_alias, provider, tool, session_key, cron_job_id, model : aucun de ces éléments n’est un argument de site d’appel. Ils proviennent des spans (voir Attribution : tout provient des spans).

La structure est imposée par le struct Event : les champs inconnus provoquent une erreur de compilation.

Quand les attrs sont justifiés

Event::with_attrs(serde_json::json!({...})) est destiné aux mesures par événement et aux données ad hoc qui n’existent nulle part dans la portée environnante. Concrètement :

  • Mesures par événement : bytes_received, tokens_used, retry_count, status_code, queue_depth.
  • Charges utiles d’erreur lorsque l’erreur est l’événement lui-même : texte de chaîne anyhow, corps d’erreur HTTP, détails d’erreur d’analyse.
  • Identifiants de systèmes externes : le request_id d’une API distante, un en-tête de trace en amont.
  • État dérivé capturé à cet instant : nombre de requêtes en cours, secondes de retry-after.

Les attrs ne servent PAS pour tout ce qui provient de la portée environnante : channel composite, agent_alias, model_provider, tool, session_key, cron_job_id, sender, message_id, etc. Ces éléments appartiennent à un attribution_span! ou scope! englobant.

La règle serde : passez la valeur brute, jamais format!("{}", v) ni format!("{:?}", v). serde_json::json! sérialise les chaînes en chaînes, les nombres en nombres, Vec<T> en tableaux, Option<T> en null-ou-valeur. N’utilisez .to_string() que lorsque le type n’implémente pas Serialize (par exemple anyhow::Error, reqwest::Error, std::io::Error, Path::Display, StatusCode).

Règle d’espace réservé

Les espaces réservés de littéraux de chaîne Rust comme "raw error body: {body}" sont interdits dans les messages record!. La capture implicite des chaînes de format de Rust 2021 ne se propage pas à travers record! : chaque {var} devient une sous-chaîne littérale sans aucune substitution. La règle de conversion :

#![allow(unused)]
fn main() {
// MAUVAIS — {body} est un littéral, jamais interpolé
record!(WARN, Event::new(module_path!(), Action::Fail), raw error body : {body});

// CORRECT — corps dans attrs, message en prose simple
record!(WARN, Event::new(module_path!(), Action::Fail).with_attrs(serde_json::json!({"corps": body})), corps d'erreur brut);
}

Event, Action, EventOutcome, EventCategory

Les quatre sont des énumérations fermées définies dans crates/zeroclaw-log/src/event.rs. L’ajout d’une valeur constitue le seul point de modification : les sites d’appel n’inventent pas de chaînes.

  • Action : ensemble fermé de verbes, en snake_case sur disque via strum::IntoStaticStr : Start, Complete, Fail, Cancel, Skip, Timeout, Retry, Inbound, Outbound, Send, Receive, Connect, Disconnect, Reconnect, Spawn, Kill, Tick, Trigger, Schedule, Approve, Reject, Defer, Read, Write, Delete, List, Query, Invoke, Dispatch, Resolve, Register, Unregister, Load, Save, Migrate, Validate, Note.
  • EventOutcome : Success, Failure, Unknown. Unknown est la valeur par défaut et est ignorée lors de la sérialisation (omise du event.outcome sur disque), donc une ligne sans clé outcome est implicitement Unknown.
  • EventCategory : Agent, Channel, Cron, Memory, Tool, Provider, Session, System, Internal. Dérivé du span de rôle le plus interne, sauf s’il est remplacé via Event::with_category(...).

Propagation des entrées/sorties d’outils

L’exécuteur central d’outils (crates/zeroclaw-runtime/src/agent/tool_execution.rs::execute_one_tool) encapsule chaque appel à Tool::execute(args) avec des événements invoke/complete/fail. Le nom de chaque événement est module_path!() (le module propre de l’exécuteur), et non une chaîne codée en dur ; l’Action et la sévérité les distinguent :

  1. Avant l’exécution : record!(DEBUG, Event::new(module_path!(), Action::Invoke).with_category(EventCategory::Tool).with_attrs(...)) avec tool, tool_call_id et l’input complet dans les attributs.
  2. Exécute execute(args).await.
  3. En cas de succès (r.success) : record!(DEBUG, ... Action::Complete) avec Outcome::Success, la durée, et tool / tool_call_id / input / output dans les attributs.
  4. En cas d’échec signalé par l’outil (!r.success) : record!(WARN, ... Action::Fail) avec Outcome::Failure, la durée, et tool / tool_call_id / input / error / output dans les attributs.
  5. En cas de Err retourné par execute : record!(ERROR, ... Action::Fail) avec Outcome::Failure, la durée, et l’erreur formatée en mode debug dans les attributs.

Ces événements sont émis à l’intérieur d’un span de type scope! (target = "zeroclaw_log_internal_scope", champ tool = <name>) ouvert autour de l’appel, de sorte que le champ tool accompagne également chaque émission descendante. Les implémentations de Tool::execute par outil n’ajoutent aucun code de journalisation.

LogCaptureLayer et le schéma sur disque

La couche dans crates/zeroclaw-log/src/layer.rs est une couche tracing-subscriber qui :

  1. Lors de la création/l’enregistrement d’un span avec la cible "zeroclaw_log_internal_attribution" (la cible avec laquelle la macro attribution_span! s’ouvre) : analyse les champs role + alias dans un instantané ZeroclawAttribution stocké dans les extensions du span.
  2. Lors de la création/enregistrement d’un span avec la cible "zeroclaw_log_internal_scope" (ouverte par scope!) : analyse les kvps ad hoc et les stocke de la même manière.
  3. Lors de l’émission d’événement avec la cible "zeroclaw_log_event" (la cible par laquelle la macro record! se déclenche) : construit un LogEvent à partir de l’ensemble de champs zc_*, parcourt la portée des spans de la feuille vers la racine en fusionnant chaque instantané d’attribution trouvé, analyse le blob JSON zc_attrs pour le placer dans les attributes de l’événement, attache _file/_line à partir de l’emplacement source capturé automatiquement, et transmet l’événement final à writer::record_event, qui le distribue dans cet ordre :
    • Pont Observer (observer_bridge.rs) pour les événements typés Prometheus / OTel mappés lorsqu’un Observer est lié.
    • Hook de diffusion (broadcast.rs) pour les abonnés SSE/du tableau de bord actuels lorsqu’un émetteur est installé.
    • Persistance JSONL (writer.rs), proposée en dernier à la file d’écriture asynchrone uniquement lorsque log_persistence est activé.

La structure JSON sur disque (LogEvent dans event.rs) :

{
  "id": <uuid>,
  "@timestamp": 2026-05-16T10:08:59.002Z,
  "severity_number": 9,
  "severity_text": INFO,
  "event": { "category": canal, action: "entrant", résultat: « succès » },
  service: { « nom »: zeroclaw, version: 0.8.5 },
  "trace_id": "<turn id>",
  "span_id": <sub-span id>,
  zeroclaw: {
    canal: telegram.clamps,
    "channel_type": "telegram",
    "channel_alias": "limite",
    agent_alias: "limite",
    "model_provider": anthropic.clamps,
    "model_provider_type": anthropic,
    "model_provider_alias": "limite",
    "modèle": claude-sonnet-4-6
  },
  "message": message entrant,
  "attributes": { "sender": "...", _file: "...", _line: 42 },
  "schema_version": 2
}

@timestamp est un chrono::DateTime<Utc> sérialisé au format RFC 3339 avec Z. La version du schéma est 2 ; les anciennes lignes version: 1 sont migrées sur place au démarrage du démon par migrate::migrate_legacy_jsonl_in_place.

Les surfaces de diffusion offrent des garanties différentes

writer::record_event construit la valeur persistée une seule fois, puis dérive les autres livraisons à partir du même LogEvent. Chaque destination possède un contrat distinct :

DestinationPropriétaireLimite du contrat et des pertes
Pont Observer typé facultatifobserver_bridge.rsforward est une opération sans effet tant qu’un Observer n’est pas explicitement lié, et l’amorçage actuel de la production n’en lie aucun. Lorsqu’il est lié, il relaie les actions de manière synchrone, mais ne projette que les actions reconnues par project ; le mappage actuel peut omettre des actions ou utiliser des champs par défaut. Considérez-le comme une projection sélective des métriques et du traçage, et non comme un registre complet des événements, et consultez project pour connaître le mappage actuel des champs.
Diffusion en directbroadcast.rs et son consommateurEnvoie l’événement structuré aux abonnés actuels du processus. Un abonné ne voit que les événements émis après son abonnement, les récepteurs à capacité limitée peuvent prendre du retard, et l’adaptateur SSE de la passerelle ignore les trames en retard. Des attributs éphémères réservés à la diffusion peuvent apparaître dans une trame en direct authentifiée, mais sont exclus du JSONL persistant. Il s’agit d’un chemin de notification en direct, et non d’un élément de preuve rejouable.
JSONL persistantwriter.rsMet l’événement sérialisé en file d’attente sans bloquer le runtime. La file d’attente bornée peut abandonner un événement lorsqu’elle est pleine, les échecs d’écriture du worker sont signalés comme des avertissements, et sync_all périodique ne couvre que le fichier actif courant. La rotation quotidienne avant le premier ajout d’une nouvelle journée UTC et la rotation par taille après un ajout faisant dépasser le seuil peuvent renommer le fichier actif sans le synchroniser au préalable ; la cadence ne garantit donc pas la durabilité d’une archive venant d’être soumise à une rotation. Le mode de persistance détermine ensuite si le fichier actif est tronqué, conservé indéfiniment ou soumis à une rotation. Il s’agit d’un historique opérationnel au mieux, et non d’un journal d’audit transactionnel.

N’utilisez pas la sortie d’Observer ni la diffusion SSE pour prouver que chaque événement canonique a été conservé. À l’inverse, ne supposez pas qu’une ligne absente de JSONL n’a jamais été émise : elle a peut-être atteint la diffusion en direct et le pont Observer lorsqu’il était lié, avant que la file d’attente de persistance ne la rejette ou n’échoue à la traiter.

Les curseurs de lecture appartiennent à un seul fichier actif

GET /api/logs résout le chemin actif actuel du writer et appelle reader::load_page. Le reader analyse ce fichier JSONL unique, conserve la fenêtre correspondante la plus récente et renvoie les événements du plus récent au plus ancien. Il ne fusionne pas les archives ayant subi une rotation.

Le curseur de pagination principal est next_cursor_line_offset, le décalage en octets situé immédiatement après l’événement correspondant le plus ancien de la page actuelle. L’appelant le transmet de nouveau sous la forme de until_line_offset ; l’analyse suivante s’arrête avant cette ligne et renvoie les correspondances plus anciennes. Les ajouts uniquement préservent le préfixe désigné par un curseur existant, de sorte que les événements ultérieurs ne perturbent pas un parcours en cours.

L’offset n’est pas une identité d’événement durable ni un point de contrôle inter-fichiers. Il devient obsolète dès que les octets du fichier actif sont remplacés ou que son chemin change :

  • La troncature rolling écrit en flux la fin conservée dans un fichier temporaire, puis le renomme pour remplacer le chemin actif.
  • rotating renomme le fichier actif en archive ; le prochain ajout crée un nouveau fichier actif.
  • la migration de schéma réécrit le fichier actif via un fichier temporaire et un renommage atomique.
  • un rechargement de la configuration du démon peut installer un nouveau chemin de persistance.

Après l’une de ces limites, recommencez la pagination à partir de la page la plus récente. La réutilisation de l’ancien numéro peut dupliquer, ignorer ou renvoyer des lignes sans rapport, car l’API n’associe au curseur ni l’identité du fichier ni les métadonnées de génération. L’ancien curseur horodatage/ID reste disponible pour assurer la compatibilité, mais il est obsolète dans le cadre de #8012, car l’ordre lexicographique des ID peut ignorer des événements ex æquo.

La politique de persistance est responsable des réécritures et de la rétention

StoragePolicy dans config.rs contrôle uniquement la destination JSONL. La livraison à Observer et la diffusion restent indépendantes de celle-ci.

PolitiqueComportement du fichier actifResponsable de la conservation
noneAucune nouvelle écriture JSONL.Aucun.
rollingLorsqu’un ajout dépasse max_entries, écrivez en flux uniquement les lignes non vides les plus récentes dans un fichier temporaire, puis renommez-le pour remplacer le fichier actif.Le writer conserve la taille configurée de la fenêtre active. Il ne crée aucune archive et laisse les archives d’une configuration rotating précédente non gérées.
fullAjouter sans troncature ni rotation gérées par le writer.La croissance des fichiers et toute rotation externe relèvent de l’opérateur.
rotatingAvant le premier ajout d’un nouveau jour UTC, ou lorsqu’un ajout atteint le seuil d’octets, renommer le fichier actif en archive horodatée.Après chaque rotation réussie, le composant d’écriture émonde les archives correspondantes en fonction de leur ancienneté, puis de leur nombre. La suppression est effectuée au mieux et n’entraîne jamais l’échec de l’opération d’ajout englobante.

La rétention selon l’âge et le nombre ne s’exécute qu’après la rotation. Elle n’effectue pas de balayage continu, ne s’applique pas à full ou rolling et ne supprime pas de fichiers voisins arbitraires : la détection des archives n’accepte que les noms générés selon le format d’archive horodaté du chemin actif. Le lecteur en direct de /api/logs ne voit toujours que le fichier actif ; les archives sont des artefacts de diagnostic hors ligne.

La migration du schéma est une réécriture du fichier actif

Lorsque la persistance est activée et que le chemin actif existe, writer::init_from_config exécute migrate::migrate_legacy_jsonl_in_place avant de démarrer le worker de disque. L’outil de migration traite en flux les lignes non vides dans un fichier temporaire, convertit les lignes héritées contenant timestamp mais pas @timestamp, conserve les lignes déjà au format actuel, ignore les JSON mal formés avec un avertissement, synchronise le fichier temporaire, puis le renomme atomiquement pour remplacer le chemin actif.

La migration est effectuée dans la mesure du possible. Sa vérification légère du schéma s’arrête à la première ligne non vide. La migration s’exécute lorsque cette ligne est mal formée ou contient timestamp sans @timestamp ; tout autre JSON analysable est considéré comme appartenant au format actuel, même lorsqu’il correspond à un schéma inconnu ou invalide, de sorte que des lignes héritées ultérieures peuvent rester non migrées. Si la migration renvoie une erreur, l’initialisation émet un avertissement et continue ; des ajouts v2 ultérieurs peuvent donc coexister avec d’anciennes lignes que le lecteur v2 ne peut pas désérialiser. Les archives ayant fait l’objet d’une rotation ne sont pas migrées.

LogEvent est la source de vérité du schéma. Pour chaque modification du schéma, évaluez la compatibilité de la migration, la désérialisation du fichier actif, la sérialisation HTTP et RPC ainsi que leurs consommateurs, et la documentation de l’architecture et de l’exploitation ; ne mettez à jour que les interfaces dont le comportement ou la compatibilité change. Les interfaces de journalisation RPC se trouvent dans crates/zeroclaw-runtime/src/rpc/types.rs et dispatch.rs. Une migration qui remplace le fichier actif invalide les curseurs de décalage en octets, tandis qu’une modification additive compatible qui ne réécrit pas les octets existants ne les invalide pas.

LogConfig vs ObservabilityConfig

zeroclaw-log définit son propre LogConfig minimal (dans crates/zeroclaw-log/src/config.rs) : log_persistence, log_persistence_path, log_persistence_max_entries, log_persistence_max_bytes, log_persistence_rotate_daily, log_persistence_retention_max_files, log_persistence_retention_max_age_days, log_tool_io, log_tool_io_truncate_bytes, log_tool_io_denylist. Cela rompt ce qui serait autrement un cycle de dépendances : zeroclaw-config::ObservabilityConfig porte le schéma complet (avec désérialisation et validation TOML), et le runtime convertit en LogConfig au démarrage et après le rechargement de la configuration du daemon via crates/zeroclaw-runtime/src/observability/runtime_trace.rs::to_log_config. Le résultat : zeroclaw-config peut record! sans inverser l’arbre de dépendances, tandis que les modifications de la politique de persistance et de rotation des logs prennent toujours effet au prochain rechargement du daemon.

Installation de l’abonné

Le démon installe le souscripteur global via :

#![allow(unused)]
fn main() {
zeroclaw_log::install_global_subscriber(
    recording_filter.as_deref(),   // Option<&str> — l'option --log-level, si elle est définie
    &default_filter,               // &str — filtre de repli lorsqu'aucun flag ni RUST_LOG n'est défini
    cli.verbose,                   // bool — conditionne la couche fmt (terminal) de stderr
);
}

Deux axes indépendants : le plancher d’enregistrement (ce qui atteint LogCaptureLayer, résolu comme flag → RUST_LOG → défaut) et l’affichage terminal (la couche fmt stderr, entièrement coupée sauf si verbose est à true). Ce seul appel met en place le formateur terminal préfixé par l’alias de l’agent + le LogCaptureLayer sur un tracing-subscriber::Registry. src/main.rs est le seul endroit qui l’appelle. Les tests utilisent zeroclaw_log::try_install_capture_subscriber() + zeroclaw_log::subscribe_or_install() pour drainer les événements émis via le hook de diffusion sans qu’aucun type tracing ne soit nommé dans le crate de test.

Quand étendre les énumérations fermées

  • Nouvelle implémentation de canal : ajoutez une variante à ChannelKind. La forme snake_case correspond à la chaîne channel_type stockée sur disque. Ajoutez #[strum(serialize = "...")] uniquement lorsque le nom de la variante ne se convertit pas en snake_case vers la valeur souhaitée (par exemple OpenAi"openai").
  • New tool impl (intégré au workspace) : ajouter à ToolKind.
  • Nouvelle forme de planification cron : ajouter à CronKind.
  • Nouveau fournisseur de modèle / TTS / transcription / tunnel : ajoutez-le au sous-énumération *ProviderKind approprié sous ProviderKind.
  • Nouveau backend mémoire : ajouter à MemoryKind.
  • Toute nouvelle famille Role (PeerGroup / Skill / Mcp gagnent des sous-types) : imbriquez-la avec son propre Kind à la volée : le modèle est uniforme.

Ajoutez ensuite impl Attributable for X à côté de la nouvelle struct (fn role() -> Role::Family(Kind::Variant), fn alias() -> &str { &self.alias }) et encapsulez son point d’entrée avec attribution_span!(self). La couche prend en charge tout le reste automatiquement.

Préoccupations relatives à l’opérateur

Pour les paramètres de configuration (log_persistence, log_tool_io, export OTel) et la syntaxe des requêtes, consultez Logs & observabilité.