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. L’exportateur de journaux OTLP natif optionnel (export_bridge ainsi que otel_logs à l’exécution) lorsqu’une version observability-otel s’exécute avec backend = "otel".
  3. Le canal de diffusion à l’échelle du processus pour les abonnés en temps réel, comme le flux SSE du tableau de bord.
  4. Le composant d’écriture JSONL asynchrone pour <workspace>/state/runtime-trace.jsonl (lorsque [observability] log_persistence vaut "rolling", "full" ou "rotating").

Lorsqu’ils sont configurés, la projection Observer, la file d’attente OTLP et l’envoi en diffusion ont lieu avant la tentative de mise en file d’attente de la persistance. Ces quatre destinations ont des garanties d’exhaustivité et de durabilité différentes ; partager un même 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.

Lors de l’héritage des attributs, un groupe composite appartient au span le plus proche dont les attributs stockés contiennent l’une de ses clés : <prefix>, <prefix>_type ou <prefix>_alias. Les membres manquants de ce groupe n’héritent pas d’un span externe, sauf si le groupe interne ne contient que <prefix>_type et que le groupe externe a le même type. Par exemple, un scope!(channel: "telegram", ...) interne conserve channel = "telegram.outer" et channel_alias = "outer" du span externe, tandis qu’un scope!(channel: "webhook", ...) interne ne garde que le type sous ce span Telegram externe. Un groupe composite explicite interne, comme telegram.inner, conserve sa propre instance même lorsque le type externe correspond. Les champs d’attributs simples et les groupes composites non liés continuent d’hériter normalement. L’enregistrement tardif des champs dans un même span continue de remplir les champs manquants de ce span.

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
}

L’exécution des étapes SOP utilise le même mécanisme avec sop_run_id. Contrairement au trace_id d’un tour, cette attribution reste stable pour chaque tour d’étape imbriqué dans l’exécution, de sorte que la passerelle, la CLI et la TUI peuvent toutes interroger une même clé de corrélation durable.

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é.
    • Pont de journalisation structuré natif (export_bridge.rs) pour chaque événement canonique lorsqu’un exportateur est installé ; le runtime observability-otel le convertit en LogRecord du SDK et met en file d’attente l’export OTLP/HTTP protobuf.
    • 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.6 },
  "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.
Journaux OTLP natifsexport_bridge.rs + observability/otel_logs.rsPrésent uniquement dans un build observability-otel avec backend = "otel". Chaque événement canonique est transmis de manière synchrone à la file de lots non bloquante du SDK OpenTelemetry, puis exporté au format OTLP/HTTP protobuf. Les échecs de la file ou de l’export ne bloquent ni ne désactivent JSONL/broadcast. La reconfiguration et le flush de l’Observer demandent un vidage par lots, mais un arrêt brutal du processus peut encore faire perdre des enregistrements en file d’attente. Les attributs éphémères de broadcast n’atteignent jamais ce pont.
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 livraison via Observer, OTLP ou SSE pour prouver que chaque événement canonique a été conservé. À l’inverse, ne supposez pas qu’une ligne absente du JSONL n’a jamais été émise : elle a pu atteindre OTLP ou la diffusion en direct, ainsi que le pont Observer lorsqu’il est lié, avant que la file de persistance ne l’abandonne ou que son écriture n’échoue.

Les événements Observer partagent le même bus

Les événements d’observateur typés (agent_start, agent_end, llm_request, tool_call, tool_call_start, history_trimmed, error) atteignent le même canal de diffusion par un chemin distinct : zeroclaw_runtime::observability::broadcast. Chaque observateur construit par create_observer est dupliqué vers un point d’accrochage de diffusion unique à l’échelle du processus, et un EventBus (l’émetteur actif plus un tampon d’historique de 500 trames) fournit ce point d’accrochage.

  • Sous le daemon, daemon::run crée le bus et installe son hook une seule fois, que la passerelle soit active ou non. Le RPC logs/subscribe transmet donc ces trames même lorsque la passerelle est désactivée. Une passerelle supervisée réutilise le bus du daemon et n’installe rien, si bien que chaque trame est livrée une fois et mise en mémoire tampon une seule fois.
  • Une passerelle autonome (zeroclaw gateway start) construit et installe son propre bus.
  • Historique : GET /api/events/history et la RPC events/history (autorisation Logs:Read) rejouent les trames d’observateur mises en mémoire tampon, de la plus ancienne à la plus récente. Comme pour la diffusion en direct, elles ne transportent jamais les identifiants d’appairage : les trames portant le marqueur éphémère sont exclues de l’historique, et logs/subscribe les exclut de la diffusion en direct.

Les trames de la couche de journalisation envoyées directement sur le canal ne sont disponibles qu’en direct ; le tampon d’historique ne contient que les trames de l’observateur.

Les abonnements RPC sont rejouables et ne se terminent jamais silencieusement

Les flux RPC ne lisent pas directement le canal de diffusion. Un SubscriptionHub (zeroclaw_runtime::rpc::subscription) copie le bus dans des anneaux bornés, un par source, et chaque abonné n’est qu’un curseur dans un anneau. Deux sources existent actuellement :

MéthodeSonnerNotification
logs/subscribechaque trame du buslogs/event
events/subscribetrames d’observateur uniquementevents/event
  • Limites : chaque anneau est plafonné à 2 048 trames et 4 MiB, et tous les anneaux partagent un budget global au niveau du processus de 16 MiB. Au-delà du budget, la trame la plus ancienne de tous les anneaux est évincée.
  • Les producteurs n’attendent jamais un abonné ; un abonné lent prend simplement du retard.
  • Numéros de séquence : commencent à 1 pour chaque source. Chaque notification contient subscription_id et son seq. Le résultat de l’abonnement renvoie le seq le plus récent ainsi que l’epoch du hub.
  • Époques : chaque hub (à chaque démarrage ou rechargement du daemon) reçoit une nouvelle epoch aléatoire, et la numérotation repart à 1 ; un numéro de séquence n’a donc de sens qu’au sein de son époque.
  • Reprise : X/subscribe{since_seq, epoch} rejoue les trames postérieures à since_seq qui sont encore en mémoire tampon, puis poursuit en direct, mais uniquement lorsque epoch correspond.
    • Avec une époque différente, ou aucune, le client reçoit subscription/lagged avec epoch_changed: true, puis chaque trame que le nouveau hub conserve encore dans son tampon.
    • Un since_seq supérieur à la trame la plus récente de la même époque est refusé avec INVALID_PARAMS.
  • Lacunes : une lacune est signalée, jamais ignorée. Un curseur qui pointe vers des trames disparues (évincées par les plafonds ou le budget, ou dépassées sur le bus) reçoit subscription/lagged{subscription_id, from_seq, resume_seq} et reprend à resume_seq. from_seq n’est jamais supérieur à resume_seq. Un lot de rejeu s’arrête à une lacune, de sorte que le curseur ne saute jamais une perte enregistrée.
  • Annuler : subscription/cancel{subscription_id} (autorisation Logs:Read, comme pour l’abonnement) met fin à un abonnement sur la connexion appelante. Fermer la connexion met fin à tous les abonnements.
  • Autorité : ces flux et events/history ne sont accessibles que sans périmètre. Leurs trames proviennent du travail de tous les principals (messages de session, résultats cron, lignes de journal), dont beaucoup n’indiquent aucun propriétaire, si bien qu’elles ne peuvent pas être filtrées par principal. L’appelant doit disposer de Logs:Read et être un administrateur ou l’opérateur partagé non authentifié, c’est-à-dire les mêmes principals qui contournent la vérification de propriété des sessions. Un principal authentifié non administrateur est refusé, même avec le sélecteur d’agent * : cibler tous les agents ne revient pas à posséder les sessions de tous les utilisateurs.
  • Revérification : chaque livraison, qu’il s’agisse d’une trame ou d’un avis lagged, est revérifiée par rapport aux identifiants actifs de l’appelant. Lorsque la politique change, le principal est à nouveau résolu, et sa rétrogradation ou la suppression de Logs:Read met fin au flux.
  • Portée de rejeu : un administrateur ou l’opérateur partagé peut rejouer les trames antérieures à sa connexion, dans la limite des plafonds du tampon circulaire.
  • Les identifiants d’appairage sont supprimés avant d’atteindre un anneau, de sorte qu’aucun des deux flux ne peut les transmettre ni les rejouer.

Les curseurs de lecture couvrent le fichier actif et les archives conservées

GET /api/logs et logs/query appellent reader::query_log_page, qui gère l’énumération des segments. Ce qu’il fusionne dépend de la politique de stockage installée : seul rotating gère des archives, donc seul sous rotating le lecteur fusionne le fichier actif avec les archives conservées en un seul flux d’événements logique. Sous rolling, full et none, il ne lit que le fichier actif, même lorsque des fichiers horodatés issus d’une configuration rotating antérieure sont encore présents à côté de lui ; ceux-ci ne sont pas gérés et ne font pas partie du flux logique de la politique. Les segments sont parcourus de l’archive la plus ancienne à la plus récente, et le résultat fusionné est renvoyé du plus récent au plus ancien, identique à la sémantique antérieure à fichier unique. La fusion est séquentielle et en processus ; aucun événement n’est re-sérialisé.

L’ordre des segments provient d’un numéro de séquence inscrit dans le nom de chaque archive au moment de la rotation, et non des mtime des fichiers. Un tri par mtime reviendrait à une observation faite au moment de l’énumération, et plusieurs rotations survenant pendant une même lecture peuvent faire qu’elle décrive un ordre qui n’est plus valide. Les archives écrites avant l’existence de la numérotation de séquence ne portent pas de numéro ; elles sont ordonnées par mtime et placées avant toutes les archives numérotées.

La curseur de pagination principal est next_segment_cursor, une chaîne opaque. Renvoyez-la telle quelle sous forme de until_segment_cursor pour reprendre ; ne la construisez pas et ne l’analysez pas. Sa structure interne dépend du segment dans lequel la page s’est terminée, car les trois types de segments ont des propriétés de stabilité différentes :

  • Une archive numérotée est adressée par le numéro de séquence inscrit dans son nom lors de la rotation. Un fichier sidecar en mode « best-effort » conserve des numéros monotones d’un redémarrage normal à l’autre. Si son écriture échoue, que la rétention supprime toutes les archives numérotées et que le démon redémarre, un numéro peut être réutilisé et un curseur en attente peut se rattacher à un historique plus récent.
  • Une archive héritée, écrite avant l’existence de la numérotation des séquences, est adressée par son nom de fichier. Un nom d’archive n’est jamais réattribué à un contenu différent, ce qui la rend stable de la même manière.
  • Le fichier actif est adressé par un décalage combiné à un identifiant d’événement d’ancrage. Son chemin est stable, mais son contenu est remplacé à chaque rotation. L’ancrage permet donc au lecteur de détecter qu’une rotation a eu lieu et de reprendre à partir de l’archive dans laquelle se trouve désormais l’événement ancré.

Le curseur historique à décalage d’octets next_cursor_line_offset / until_line_offset reste valide et se résout uniquement par rapport au fichier actif. Il vaut None lorsque l’événement le plus ancien de la page courante se trouve dans un fichier d’archive. Pour les déploiements multi-segments, utilisez plutôt next_segment_cursor.

L’offset n’est ni une identité d’événement durable ni un point de contrôle inter-fichiers. Un curseur de segment devient irrésoluble lorsque le segment qu’il désigne a été supprimé par la rétention ; le lecteur signale alors que l’historique est terminé plutôt que de reprendre à une position sans rapport, de sorte qu’un client paginant s’arrête là où il en était au lieu de revenir silencieusement aux événements les plus récents. Un curseur d’offset en octets devient obsolète dès que les octets du fichier actif sont remplacés ou que son chemin change :

  • 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, relancez la pagination à partir de la page la plus récente. Le curseur hérité timestamp/ID est conservé pour des raisons de compatibilité, mais il est déprécié sous #8012 car un ordre lexicographique des ID peut ignorer des événements à égalité.

La énumération et la lecture ne sont pas atomiques : une rotation peut survenir entre les deux. La liste désigne alors le fichier actif d’avant rotation, le renommage transforme ce fichier en archive, et l’analyse lit à la place le nouveau fichier de remplacement. Les événements déplacés par la rotation resteraient alors non listés, entre deux segments que la page a bien lus, et resteraient inaccessibles à jamais, car la pagination ne remonte que vers l’historique plus ancien. Le lecteur revérifie l’ensemble des archives après la lecture et refait la page si cet ensemble a changé. Vérifier après plutôt qu’avant ferme la fenêtre, car une vérification effectuée avant l’ouverture laisse la possibilité d’une rotation entre la vérification et l’ouverture. La même course existe à l’intérieur de l’énumération elle-même : les archives sont listées en premier et le chemin actif est interrogé via stat en second. Une rotation survenant entre ces deux étapes masque les données aux deux moitiés, car le vide entre le renommage et le prochain ajout laisse le chemin actif absent. La revérification n’est donc ignorée que pour une page qui s’arrête avant un fichier actif listé : les archives sont immuables, si bien qu’une telle analyse ne peut avoir été en concurrence avec quoi que ce soit. Une liste sans aucun fichier actif, en revanche, est vérifiée comme les autres.

Un segment qui ne peut pas être lu est journalisé, exclu de la vue fusionnée et marqué dans la réponse avec incomplete: true plutôt que de faire échouer toute la requête, car un fichier défectueux ne devrait pas coûter à l’appelant tous les autres segments. at_end est alors limité à ce qui a pu être lu, et c’est le drapeau qui le signale à l’appelant ; le journal du daemon atteint l’opérateur, et non le client qui doit décider s’il faut continuer la pagination. La recherche d’un événement unique suit la même distinction : un résultat trouvé fait autorité quel que soit le nombre de segments ignorés, mais une absence de résultat sur un segment ignoré est signalée comme unreadable-history plutôt que comme not found.

La découverte des archives rejette les liens symboliques. Faire correspondre le format de nom de fichier de l’écrivain ne constitue pas un confinement des chemins, et suivre un lien permettrait à quiconque peut écrire dans le répertoire des journaux de faire pointer un nom de type archive vers n’importe quel fichier que le démon peut lire. La validation et l’ouverture sont deux opérations distinctes, si bien qu’un acteur capable de remplacer des entrées dans le répertoire des journaux peut encore, en course, transformer une archive validée en lien symbolique avant son ouverture. Les déploiements doivent donc maintenir le répertoire des journaux comme fiable tant que le lecteur n’utilise pas des ouvertures en mode no-follow ou stables par descripteur.

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

StoragePolicy dans config.rs contrôle uniquement la destination du JSONL. La diffusion vers l’observateur, OTLP et broadcast reste indépendante de celui-ci.

PolitiqueComportement du fichier actifResponsable de la conservation
noneAucune nouvelle écriture JSONL.Aucun.
rollingAprès chaque ajout au-delà de max_entries, réécrire les entrées conservées de la fin dans un fichier temporaire, puis le renommer 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 la première écriture d’un nouveau jour UTC, ou lorsqu’une écriture atteint le seuil d’octets ou de nombre d’entrées, renommez le fichier actif en une 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 par âge et par nombre ne s’exécute qu’après la rotation. Elle ne balaie pas en continu, ne s’applique pas à full ou rolling, et ne supprime pas de fichiers voisins arbitraires : la découverte des archives n’accepte que les noms générés à partir de la forme d’archive du chemin actif. Avec rotating, le lecteur actif /api/logs fusionne le fichier actif avec toutes les archives conservées ; avec les autres politiques, il ne lit que le fichier actif. Voir Curseurs de lecture.

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 sa propre structure LogConfig minimaliste (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_persistence_max_entries_per_segment, log_tool_io, log_tool_io_truncate_bytes, log_tool_io_denylist. Cela évite ce qui serait autrement un cycle de dépendances : zeroclaw-config::ObservabilityConfig contient le schéma complet (avec la désérialisation TOML et la validation), 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. Résultat : zeroclaw-config peut utiliser record! sans inverser l’arbre des dépendances, tout en garantissant que les modifications de la persistance des journaux et de la politique de rotation prennent 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é.