Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Arquitectura de registro

ZeroClaw tiene exactamente una superficie de logging: la macro zeroclaw_log::record!. Cada emisión en el workspace, actividad del bucle del agente, E/S de canales, ejecuciones de cron, llamadas a herramientas, operaciones de memoria, ciclo de vida de sesiones, errores, fluye a través de ella. La macro dispara un evento de tracing que el subscriber instalado alimenta a dos capas hermanas: la capa fmt de stderr (salida de terminal) y la LogCaptureLayer. La capa fmt imprime líneas coloreadas con prefijo de alias en stderr (silenciadas a menos que se use --verbose). La LogCaptureLayer materializa un LogEvent estructurado y lo distribuye, a través de writer::record_event, a:

  1. El puente de Observer opcional (observer_bridge::forward) para el subconjunto de acciones que se asignan a eventos tipados de Prometheus / OTel, pero solo cuando un llamador ha instalado un enlace con set_observer_bridge. El arranque de producción actual no instala ninguno.
  2. El exportador nativo opcional de registros OTLP (export_bridge más otel_logs en tiempo de ejecución) cuando una compilación de observability-otel se ejecuta con backend = "otel".
  3. El canal de difusión a nivel de proceso para suscriptores en tiempo real, como la secuencia SSE del panel.
  4. El escritor JSONL asíncrono para <workspace>/state/runtime-trace.jsonl (cuando [observability] log_persistence es "rolling", "full" o "rotating").

Cuando está configurado, la proyección al Observer, la cola OTLP y el envío por broadcast se realizan antes del intento de encolado para persistencia. Esos cuatro destinos tienen garantías de completitud y durabilidad distintas; compartir un LogEvent no los hace intercambiables.

Lea esto primero: atribución no es attrs

Cada evento de log lleva dos canales completamente separados de datos estructurados. Confundirlos es el error más común en un punto de llamada, así que interioriza la separación antes que cualquier otra cosa:

Atribución (zeroclaw.*)Attrs (attributes.*)
RespuestasQuién lo hizo y en qué contexto¿Qué pasó específicamente
Ejemploschannel, agent_alias, model_provider, tool, session_key, cron_job_idbytes_received, tokens_used, status_code, cargas útiles de error
FuenteSpans. Se abren en los puntos de entrada y son recorridos por la capa.El sitio de llamada. Event::with_attrs(json!({...})).
¿Aparece en el sitio de llamada?Nunca. No es un argumento de record!.Sí, ese es el único lugar del que puede venir.

La regla que se deriva de esto: si un valor identifica a quién o a qué ámbito pertenece un evento, proviene de un span y nunca debe aparecer en el punto de llamada. La atribución fluye automáticamente desde los wrappers attribution_span! / scope! abiertos más arriba en la pila; la capa recorre el ámbito del span de hoja→raíz cuando se dispara un evento y combina cada contribución en el bloque zeroclaw.* del evento. El punto de llamada que dispara record! no nombra nada de esto.

Dado que la atribución es la mitad estructural de esta división y la mitad que confunde a la gente, va primero.

Atribución: todo proviene de los spans

La atribución nunca es un argumento en el punto de llamada. Léelo de nuevo. Channel composite, agent_alias, model_provider, tool, session_key, cron_job_id: ninguno de estos se escribe jamás en una llamada a record!. Fluyen a través de spans de tracing abiertos en los puntos de entrada y recorridos por la capa cuando se dispara un evento. Si te encuentras queriendo pasar agent_alias o tool a record!, detente: el valor ya está en el alcance a través de un span, o debería estarlo, y la solución es abrir o corregir el span, no propagar el valor hasta el punto de llamada.

El mecanismo, de principio a fin:

  1. Una “cosa” (canal, proveedor, agente, herramienta, tarea cron, backend de memoria, …) implementa Attributable una vez, junto a su struct.
  2. Su punto de entrada envuelve el trabajo en attribution_span!(self), que abre un span de tracing que lleva el rol y el alias de ese elemento.
  3. Cada record! disparado en cualquier lugar dentro de ese span, directamente o anidado a cualquier profundidad, hereda la atribución automáticamente.
  4. Cuando se dispara el evento, la capa recorre el ámbito del span de hoja a raíz, fusiona la contribución de cada Attributable y escribe el bloque zeroclaw.* fusionado. El sitio de llamada no nombró nada de esto.

Este es el objetivo principal del diseño: el código de logging por elemento es cero. Implementas el trait una vez y envuelves el punto de entrada una vez; cada emisión por debajo queda atribuida gratis.

Al heredar la atribución, un grupo compuesto pertenece al span más cercano cuya atribución almacenada contenga cualquiera de sus claves: <prefix>, <prefix>_type o <prefix>_alias. Los miembros que faltan en ese grupo no heredan de un span externo, salvo que el grupo interno contenga solo <prefix>_type y el grupo externo tenga el mismo tipo. Por ejemplo, un scope!(channel: "telegram", ...) interno conserva el channel = "telegram.outer" y el channel_alias = "outer" externos, mientras que un scope!(channel: "webhook", ...) interno se mantiene solo con el tipo bajo ese span externo de Telegram. Un grupo compuesto explícito interno, como telegram.inner, conserva su propia instancia incluso cuando el tipo externo coincide. Los campos de atribución simples y los grupos compuestos no relacionados siguen heredando normalmente. El registro tardío de campos dentro de un mismo span sigue completando los campos que faltan en ese span.

El trait Attributable

Reside en crates/zeroclaw-api/src/attribution.rs para que cada crate pueda implementarlo sin depender de zeroclaw-log:

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

Cada “elemento” del workspace (un TelegramChannel, un AnthropicModelProvider, un Agent, un cron job, una herramienta, un backend de memoria, un grupo de peers, un bundle de skills, un bundle de MCP, una sesión) implementa Attributable una vez junto a su struct.

La taxonomía de Role

Enum anidado cerrado:

#![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),     // Model, Tts, Transcription, Tunnel
    Memory(MemoryKind),         // Sqlite, Json, InMemory, Markdown, Qdrant, ...
    PeerGroup,
    Skill,
    Mcp,
    Sop,
    Session,
    System,
}
}

ChannelKind, ToolKind, CronKind, MemoryKind y los cuatro subenumerados de ProviderKind (ModelProviderKind, TtsProviderKind, TranscriptionProviderKind, TunnelProviderKind) son todos cerrados. La forma snake_case de la variante mediante strum::IntoStaticStr es la porción canónica <type> del compuesto <type>.<alias>. Para agregar una nueva implementación: extiende el enumerado Kind correspondiente, eso es todo.

Abriendo un span, haz esto en cada punto de entrada

Envuelve el trabajo de un punto de entrada con attribution_span!(thing). La macro devuelve un Span que lleva el rol y el alias del elemento como campos estructurados. Aplica .instrument(span) al future (o let _g = span.entered() en código síncrono). Una tarea generada que no restablece el span pierde la atribución: cada cuerpo de tokio::spawn que emita debe llevar el mismo attribution_span! / scope! que usó el padre, o sus emisiones quedarán sin atribuir.

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

let span = zeroclaw_log::attribution_span!(self);  // self implementa Attributable
async move {
    // ¡cada record! lleva automáticamente los campos asociados al alias
    record!(INFO, Event::new(module_path!(), Action::Start), canal en línea);
    self.poll_loop().await
}.instrument(span).await
}

La capa recorre el ámbito del span de hoja→raíz cuando se dispara un evento, fusiona la contribución de cada Attributable en el bloque de atribución zeroclaw.* del evento, y emite el compuesto (channel = "telegram.clamps", channel_type = "telegram", channel_alias = "clamps") sin que el sitio de llamada nombre ninguna de esas claves.

La macro scope!, contexto sin rol

attribution_span! es para elementos Attributable con roles. Para identificadores por ámbito que no están vinculados a uno (sender id, message id, turn id, request id), use 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
}

La ejecución de pasos de SOP usa el mismo mecanismo con sop_run_id. A diferencia del trace_id de un turno, esta atribución es estable en todos los turnos de paso anidados dentro de la ejecución, de modo que el gateway, la CLI y la TUI pueden consultar una única clave de correlación duradera.

scope! se sitúa deliberadamente a ambos lados de la línea entre atribución y attrs: las claves de campo que coinciden con ATTRIBUTION_FIELDS / COMPOSITE_PREFIXES vinculados por alias (en crates/zeroclaw-log/src/event.rs) terminan en el slot de atribución tipado zeroclaw.*; todo lo demás termina en el mapa attributes del evento para cada emisión descendiente. En cualquier caso, el valor viaja en cada record! anidado sin ser un argumento del punto de llamada.

La macro record! y su contrato en el punto de llamada

El crate tracing es un detalle de implementación de zeroclaw-log: las macros record! / scope! / attribution_span! se expanden a zeroclaw_log::__private::tracing, de modo que un sitio de llamada nunca nombra un tipo de tracing. Las propias macros de eventos de log (tracing::{trace,debug,info,warn,error}, log::*, std::dbg, además de anyhow::anyhow! directo) están estrictamente prohibidas en todo el workspace como disallowed-macros en clippy.toml. Con -D warnings en CI, cualquier uso directo de tracing::info!, etc., hace fallar la compilación, con un mensaje de clippy que indica ::zeroclaw_log::record! como reemplazo. Esto no es una convención; se aplica de forma obligatoria.

Las únicas exenciones son los pocos archivos dentro de crates/zeroclaw-log/ que inicializan el pipeline y llevan un #![allow(clippy::disallowed_macros)] local. Un puñado de crates (zeroclaw-api, zeroclaw-spawn, zeroclaw-providers, zeroclaw-hardware, zeroclaw-log) todavía lista tracing / tracing-subscriber en Cargo.toml, pero solo para la infraestructura de spans y subscribers, no para emitir macros de logging. Que la dependencia esté presente no autoriza llamar a las macros prohibidas. (tokio::spawn está prohibido de la misma manera mediante disallowed-methods; usa ::zeroclaw_spawn::spawn! para que las tareas generadas hereden el span de atribución del llamador.)

La macro es de forma fija: toma un nivel, una única expresión Event y un literal de mensaje.

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

record!(INFO, Event::new(module_path!(), Action::Start), paso inicial);
record!(WARN, Event::new(module_path!(), Action::Fail).with_outcome(EventOutcome::Failure).with_attrs(serde_json::json!({"exit_code": 137})), error en la herramienta);
}

module_path!() es la fuente canónica del nombre del evento: es la ruta de módulo de Rust del punto de llamada (p. ej., zeroclaw_channels::telegram), por lo que los eventos son buscables, permiten saltar al código fuente y es imposible escribirlos mal. La misma convención se usa en cada punto de record! del workspace.

La macro inyecta file!() y line!() automáticamente. El LogCaptureLayer los adjunta al mapa attributes del evento como _file y _line para que los operadores salten al código fuente desde un visor de registros.

Contrato del punto de llamada

Cada llamada a record! es una sola línea de código que indica qué sucedió, no quién lo hizo ni en qué contexto.

  • El único argumento posicional después del nivel es una expresión Event.
  • El siguiente argumento es un literal de cadena para el mensaje legible por humanos.
  • Eso es todo. Channel, agent_alias, provider, tool, session_key, cron_job_id, model: ninguno de esos son argumentos del punto de llamada. Fluyen desde los spans (consulte Atribución: todo proviene de los spans).

La forma se impone mediante la estructura Event: los campos desconocidos son un error de compilación.

Cuando los atributos están justificados

Event::with_attrs(serde_json::json!({...})) es para mediciones por evento y datos ad-hoc que no existen en ningún lugar del ámbito circundante. Concretamente:

  • Mediciones por evento: bytes_received, tokens_used, retry_count, status_code, queue_depth.
  • Cargas de error cuando el error es el evento en sí: texto de cadena de anyhow, cuerpo de error HTTP, detalles de error de análisis.
  • Identificadores de sistemas externos: el request_id de una API remota, una cabecera de rastreo upstream.
  • Estado derivado capturado en este instante: número de solicitudes en curso, segundos de retry-after.

Los attrs NO son para nada que provenga del ámbito circundante: channel composite, agent_alias, model_provider, tool, session_key, cron_job_id, sender, message_id, etc. Esos pertenecen a un attribution_span! o scope! envolvente.

La regla de serde: pasa el valor en bruto, nunca format!("{}", v) o format!("{:?}", v). serde_json::json! serializa cadenas como cadenas, números como números, Vec<T> como arreglos, Option<T> como null o valor. Envuelve con .to_string() solo cuando el tipo no implementa impl Serialize (p. ej. anyhow::Error, reqwest::Error, std::io::Error, Path::Display, StatusCode).

Regla de marcador de posición

Los marcadores de posición en literales de cadena de Rust como "raw error body: {body}" están prohibidos dentro de los mensajes de record!. La captura implícita de cadenas de formato de Rust 2021 no se propaga a través de record!: cada {var} se convierte en una subcadena literal sin sustitución. La regla de conversión:

#![allow(unused)]
fn main() {
// BAD — {body} es un literal, nunca se interpola
record!(WARN, Event::new(module_path!(), Action::Fail), raw error body: {body});

// GOOD — cuerpo en attrs, el mensaje es texto plano
record!(WARN, Event::new(module_path!(), Action::Fail).with_attrs(serde_json::json!({"body": body})), cuerpo de error sin procesar);
}

Event, Action, EventOutcome, EventCategory

Las cuatro son enumeraciones cerradas definidas en crates/zeroclaw-log/src/event.rs. Agregar un valor es el único punto de cambio: los puntos de llamada no inventan cadenas.

  • Action: conjunto cerrado de verbos, en snake_case en disco mediante 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 es el valor predeterminado y se omite en la serialización (se excluye del event.outcome en disco), por lo que una fila sin clave outcome es implícitamente Unknown.
  • EventCategory: Agent, Channel, Cron, Memory, Tool, Provider, Session, System, Internal. Derivado del span de rol más interno, a menos que se anule mediante Event::with_category(...).

Propagación de entrada/salida de herramientas

El ejecutor central de herramientas (crates/zeroclaw-runtime/src/agent/tool_execution.rs::execute_one_tool) envuelve cada llamada a Tool::execute(args) con eventos de invocación/finalización/fallo. El nombre de cada evento es module_path!() (el propio módulo del ejecutor), no una cadena codificada de forma fija; el Action y la severidad los distinguen:

  1. Antes de ejecutar: record!(DEBUG, Event::new(module_path!(), Action::Invoke).with_category(EventCategory::Tool).with_attrs(...)) con tool, tool_call_id y el input completo en attrs.
  2. Ejecuta execute(args).await.
  3. En caso de éxito (r.success): record!(DEBUG, ... Action::Complete) con Outcome::Success, la duración, y tool / tool_call_id / input / output en los attrs.
  4. En caso de fallo reportado por la herramienta (!r.success): record!(WARN, ... Action::Fail) con Outcome::Failure, la duración, y tool / tool_call_id / input / error / output en los attrs.
  5. En caso de Err de execute: record!(ERROR, ... Action::Fail) con Outcome::Failure, la duración y el error formateado con debug en los atributos.

Estos eventos se emiten dentro de un span estilo scope! (target = "zeroclaw_log_internal_scope", campo tool = <name>) que se abre alrededor de la llamada, por lo que el campo tool también acompaña a cada emisión descendiente. Las implementaciones de Tool::execute de cada herramienta no añaden ningún código de logging.

LogCaptureLayer y el esquema en disco

La capa en crates/zeroclaw-log/src/layer.rs es una Layer de tracing-subscriber que:

  1. En la creación/registro de un span con el target "zeroclaw_log_internal_attribution" (el target con el que abre la macro attribution_span!): analiza los campos role + alias en una instantánea de ZeroclawAttribution almacenada en las extensiones del span.
  2. Al crear/registrar un span con el destino "zeroclaw_log_internal_scope" (abierto con scope!): analiza los kvps ad-hoc y los almacena de forma similar.
  3. Al emitir un evento con target "zeroclaw_log_event" (el target a través del cual se dispara el macro record!): construye un LogEvent a partir del conjunto de campos zc_*, recorre el ámbito de spans de hoja→raíz fusionando cada snapshot de atribución que encuentra, parsea el blob JSON de zc_attrs en los attributes del evento, adjunta _file/_line desde la ubicación de origen capturada automáticamente, y entrega el evento final a writer::record_event, que lo distribuye en este orden:
    • Puente de Observer (observer_bridge.rs) para eventos tipados de Prometheus / OTel mapeados cuando se vincula un Observer.
    • Puente nativo de logs estructurados (export_bridge.rs) para cada evento canónico cuando hay un exportador instalado; el runtime de observability-otel lo convierte en un LogRecord del SDK y encola la exportación OTLP/HTTP en formato protobuf.
    • Hook de difusión (broadcast.rs) para los suscriptores actuales de SSE/panel cuando se instala un emisor.
    • Persistencia de JSONL (writer.rs), ofrecida en último lugar a la cola de escritura asíncrona solo cuando log_persistence está habilitado.

La estructura JSON en disco (LogEvent en event.rs):

{
  "id": <uuid>,
  "@timestamp": 2026-05-16T10:08:59.002Z,
  "severity_number": 9,
  "severity_text": INFO,
  "event": { "category": canal, acción: inbound, resultado: "éxito" },
  servicio: { "nombre": zeroclaw, versión: "0.8.6" },
  "trace_id": <turn id>,
  "span_id": <sub-span id>,
  zeroclaw: {
    canal: telegram.clamps,
    "channel_type": "telegram",
    "channel_alias": clamps,
    agent_alias: clamps,
    "model_provider": anthropic.clamps,
    "model_provider_type": anthropic,
    "model_provider_alias": clamps,
    "modelo": claude-sonnet-4-6
  },
  "mensaje": mensaje entrante,
  atributos: { "sender": "...", "_file": "...", _line: 42 },
  "schema_version": 2
}

@timestamp es chrono::DateTime<Utc> serializado como RFC 3339 con Z. La versión del esquema es 2; las filas más antiguas con version: 1 se migran in situ al iniciar el daemon mediante migrate::migrate_legacy_jsonl_in_place.

Las superficies de entrega ofrecen garantías diferentes

writer::record_event construye una vez el valor persistido y, a continuación, deriva las demás entregas del mismo LogEvent. Cada destino tiene un contrato independiente:

DestinoPropietarioLímite del contrato y de la pérdida
Puente Observer tipado opcionalobserver_bridge.rsforward no hace nada hasta que se vincula explícitamente un Observer, y el arranque actual de producción no vincula ninguno. Cuando está vinculado, reenvía de forma síncrona, pero solo proyecta las acciones reconocidas por project; la asignación actual puede omitir acciones o campos predeterminados. Trátalo como una proyección selectiva de métricas/trazas, no como un registro completo de eventos, y consulta project para ver la asignación de campos actual.
Registros OTLP nativosexport_bridge.rs + observability/otel_logs.rsPresente solo en una compilación observability-otel con backend = "otel". Cada evento canónico se entrega de forma síncrona a la cola de lotes no bloqueante del SDK de OpenTelemetry y luego se exporta como protobuf sobre OTLP/HTTP. Los fallos de la cola o de la exportación no bloquean ni desactivan JSONL/broadcast. La reconfiguración y el vaciado del Observer solicitan un vaciado por lotes, pero una terminación abrupta del proceso aún puede perder registros que estén en cola. Los atributos efímeros de broadcast nunca llegan a este puente.
Transmisión en directobroadcast.rs y su consumidorEnvía el evento estructurado a los suscriptores actuales dentro del proceso. Un suscriptor solo ve los eventos emitidos después de suscribirse, los receptores con búfer limitado pueden quedarse rezagados y el adaptador SSE de gateway omite las tramas atrasadas. Los atributos efímeros exclusivos de difusión pueden aparecer en una trama en vivo autenticada, pero se excluyen del JSONL persistido. Esta es una vía de notificaciones en vivo, no evidencia reproducible.
JSONL persistentewriter.rsEncola el evento serializado sin bloquear el tiempo de ejecución. La cola acotada puede descartar un evento cuando está llena, los errores de escritura del trabajador son advertencias y el sync_all periódico solo cubre el archivo activo actual. La rotación diaria antes de la primera adición de un nuevo día UTC y la rotación por tamaño después de una adición que supera el umbral pueden cambiar el nombre del archivo activo sin sincronizarlo primero, por lo que esta periodicidad no garantiza la durabilidad de un archivo que acaba de rotarse. El modo de persistencia determina entonces si el archivo activo se trunca, se conserva indefinidamente o se rota. Esto es un historial operativo de mejor esfuerzo, no un registro de auditoría transaccional.

No use Observer, OTLP ni la entrega SSE para demostrar que todos los eventos canónicos fueron retenidos. Por el contrario, no asumas que una fila ausente del JSONL nunca fue emitida: pudo haber llegado a OTLP o a la difusión en vivo, y al puente Observer cuando está vinculado, antes de que la cola de persistencia la descartara o fallara.

Los eventos del observador comparten el mismo bus

Los eventos tipados del observador (agent_start, agent_end, llm_request, tool_call, tool_call_start, history_trimmed, error) llegan al mismo canal de difusión por una ruta separada: zeroclaw_runtime::observability::broadcast. Cada observador creado por create_observer se replica en un único hook de difusión de todo el proceso, y un EventBus (el emisor activo más un búfer de historial de 500 tramas) proporciona ese hook.

  • Bajo el daemon, daemon::run crea el bus e instala su hook una sola vez, tanto si el gateway se ejecuta como si no. Por tanto, el RPC logs/subscribe transporta estos frames con el gateway desactivado. Un gateway supervisado reutiliza el bus del daemon y no instala nada, de modo que cada frame se entrega una vez y se almacena en búfer una vez.
  • Una puerta de enlace independiente (zeroclaw gateway start) compila e instala su propio bus.
  • Historial: GET /api/events/history y el RPC events/history (permiso Logs:Read) reproducen los fotogramas del observador almacenados en búfer, del más antiguo al más reciente. Al igual que la entrega en vivo, nunca incluyen credenciales de emparejamiento: los fotogramas con el marcador efímero se excluyen del historial, y logs/subscribe los excluye de la entrega en vivo.

Los fotogramas de la capa de registro enviados directamente por el canal son solo en vivo; el búfer de historial contiene únicamente fotogramas del observador.

Las suscripciones RPC son reproducibles y nunca terminan en silencio

Los flujos RPC no leen el canal de difusión directamente. Un SubscriptionHub (zeroclaw_runtime::rpc::subscription) copia el bus en anillos acotados, uno por fuente, y cada suscriptor es solo un cursor dentro de un anillo. Actualmente existen dos fuentes:

MétodoRingNotificación
logs/subscribeCada trama del buslogs/event
events/subscribesolo marcos de observadorevents/event
  • Límites: cada anillo está limitado a 2048 fotogramas y 4 MiB, y todos los anillos comparten un presupuesto de 16 MiB a nivel de proceso. Si se supera el presupuesto, se descarta el fotograma más antiguo de todos los anillos.
  • Los productores nunca esperan a un suscriptor; un suscriptor lento solo se queda atrás.
  • Números de secuencia: comienzan en 1 por fuente. Cada notificación incluye subscription_id y su seq. El resultado de la suscripción devuelve el seq más reciente y el epoch del hub.
  • Épocas: cada hub (cada inicio o recarga del daemon) recibe una nueva epoch aleatoria, y la numeración se reinicia en 1, por lo que un número de secuencia no tiene significado fuera de su época.
  • Reanudar: X/subscribe{since_seq, epoch} reproduce los frames posteriores a since_seq que aún están en el búfer y luego continúa en vivo, pero solo cuando epoch coincide.
    • Con una época distinta, o sin ninguna, el cliente recibe subscription/lagged con epoch_changed: true, y luego cada trama que el nuevo hub todavía tiene en el búfer.
    • Un since_seq posterior al frame más reciente de la misma época se rechaza con INVALID_PARAMS.
  • Huecos: un hueco se informa, nunca se omite. Un cursor que apunta a frames que ya no existen (desalojados por los límites o el presupuesto, o desbordados en el bus) recibe subscription/lagged{subscription_id, from_seq, resume_seq} y continúa en resume_seq. from_seq nunca es mayor que resume_seq. Un lote de reproducción se detiene en un hueco, de modo que el cursor nunca salta una pérdida registrada.
  • Cancelar: subscription/cancel{subscription_id} (concede Logs:Read, igual que al suscribirse) finaliza una suscripción en la conexión que realiza la llamada. Cerrar la conexión finaliza todas.
  • Autoridad: estos streams y events/history son solo sin ámbito. Sus frames provienen del trabajo de todos los principales (mensajes de sesión, resultados de cron, líneas de log), y muchos no nombran un propietario, por lo que no pueden filtrarse por principal. El llamador necesita Logs:Read, y debe ser un administrador o el operador compartido no autenticado, los mismos principales que omiten la propiedad de sesiones. Un principal autenticado que no es administrador es rechazado, incluso con el selector de agente *: direccionar todos los agentes no equivale a poseer las sesiones de todos los usuarios.
  • Revalidación: cada entrega, ya sea un fotograma o un aviso lagged, se revalida contra la credencial activa del llamador. Cuando la política cambia, el principal se vuelve a resolver, y degradarlo o eliminar Logs:Read finaliza el stream.
  • Alcance de reproducción: un administrador o el operador compartido puede reproducir fotogramas anteriores a su conexión, hasta los límites del búfer circular.
  • Las credenciales de emparejamiento se descartan antes de llegar a un anillo, por lo que ninguno de los dos flujos puede entregarlas ni reproducirlas.

Los cursores de lectura abarcan el archivo activo y los archivos históricos conservados.

GET /api/logs y logs/query llaman a reader::query_log_page, que gestiona la enumeración de segmentos. Lo que se fusiona depende de la política de almacenamiento instalada: solo rotating gestiona archivos archivados, por lo que solo bajo rotating el lector fusiona el archivo activo con los archivos archivados conservados en un único flujo lógico de eventos. Bajo rolling, full y none, lee únicamente el archivo activo, incluso cuando todavía existen junto a él archivos con marca de tiempo de una configuración rotating anterior; estos no están gestionados y no forman parte del flujo lógico de la política. Los segmentos se recorren desde el archivo archivado más antiguo y el resultado fusionado se devuelve del más reciente al más antiguo, idéntico a la semántica previa de un único archivo. La fusión es secuencial y en proceso; ningún evento se vuelve a serializar.

El orden de los segmentos se determina por un número de secuencia incluido en el nombre de cada archivo en el momento de la rotación, no por las fechas de modificación (mtime) de los archivos. Ordenar por mtime equivaldría a una observación hecha en el momento de la enumeración, y si varias rotaciones se completan durante una misma lectura, esa observación podría describir un orden que ya no es válido. Los archivos escritos antes de que existiera la numeración de secuencia no llevan número; se ordenan por mtime y van antes que todos los archivos numerados.

El cursor de paginación principal es next_segment_cursor, una cadena opaca. Pásalo sin modificaciones como until_segment_cursor para reanudar; no lo construyas ni lo analices. Su estructura interna depende del segmento en el que terminó la página, porque los tres tipos de segmento tienen propiedades de estabilidad distintas:

  • Un archivo numerado se direcciona mediante el número de secuencia escrito en su nombre durante la rotación. Un archivo auxiliar de mejor esfuerzo mantiene los números monótonos en reinicios normales. Si falla su escritura, la retención elimina todos los archivos numerados y el demonio se reinicia, un número puede reutilizarse y un cursor pendiente puede quedar vinculado a un historial más reciente.
  • Un archivo heredado, escrito antes de que existiera la numeración de secuencias, se direcciona por nombre de archivo. Un nombre de archivo nunca se reasigna a contenido diferente, por lo que esto es estable de la misma manera.
  • El archivo activo se direcciona mediante un desplazamiento y un identificador de evento de anclaje. Su ruta es estable, pero su contenido se reemplaza en cada rotación; por eso el ancla es lo que permite al lector detectar que se produjo una rotación y reanudar desde el archivo de archivo en el que se encuentra ahora el evento anclado.

El cursor heredado de desplazamiento por bytes next_cursor_line_offset / until_line_offset sigue siendo válido y se resuelve solo contra el archivo activo. Es None cuando el evento más antiguo de la página actual está en un archivo de archivo. Para implementaciones con varios segmentos, use next_segment_cursor en su lugar.

La señal de desplazamiento no es una identidad de evento duradera ni un punto de control entre archivos. Un cursor de segmento se vuelve irresoluble cuando el segmento al que hace referencia ha sido eliminado por la retención; en ese caso, el lector informa de que el historial ha terminado en lugar de reanudar en una posición no relacionada, de modo que un cliente de paginación se detiene donde estaba en vez de saltar silenciosamente de vuelta a los eventos más recientes. Un cursor de desplazamiento en bytes queda obsoleto siempre que se reemplazan los bytes del archivo activo o cambia su ruta:

  • rotating cambia el nombre del archivo activo para archivarlo; la siguiente operación de anexado crea un nuevo archivo activo.
  • la migración del esquema reescribe el archivo activo mediante un archivo temporal y un renombrado atómico.
  • Una recarga de la configuración del demonio puede instalar una nueva ruta de persistencia.

Después de uno de esos límites, reinicie la paginación desde la página más reciente. El cursor heredado de marca de tiempo/ID se mantiene por compatibilidad, pero está obsoleto según #8012, porque el orden lexicográfico por ID puede omitir eventos empatados.

La enumeración y la lectura no son atómicas, así que una rotación puede producirse entre ambas: el listado nombra el archivo activo anterior a la rotación, el renombrado convierte ese archivo en un archivo histórico, y el escaneo lee en su lugar el nuevo reemplazo. Los eventos desplazados por la rotación quedarían entonces sin listar entre dos segmentos que la página sí leyó, y serían inaccesibles para siempre, porque la paginación solo recorre hacia el historial más antiguo. El lector vuelve a comprobar el conjunto de archivos archivados después de la lectura y rehace la página si dicho conjunto ha cambiado. Comprobar después y no antes es lo que cierra la ventana, porque una comprobación previa a la apertura deja espacio para una rotación entre la comprobación y la apertura. La misma condición de carrera existe dentro de la propia enumeración: primero se listan los archivos archivados y después se consulta la ruta activa con stat, y una rotación que se produce entre esos pasos oculta los datos en ambas mitades, porque el vacío entre el renombrado y el siguiente append deja la ruta activa ausente. Por tanto, la nueva comprobación solo se omite en una página que termina antes de llegar a un archivo activo listado; los archivos archivados son inmutables, así que un escaneo de ese tipo no puede haber sufrido ninguna condición de carrera, pero un listado sin ningún archivo activo se verifica como cualquier otro.

Un segmento que no se puede leer se registra en el log, se excluye de la vista combinada y se marca en la respuesta con incomplete: true, en lugar de hacer fallar toda la consulta, ya que un archivo defectuoso no debería costarle al cliente todos los demás segmentos. at_end se limita entonces a lo que se pudo leer, y es el flag el que le indica esto al cliente. El log propio del daemon llega al operador, no al cliente, que es quien debe decidir si sigue paginando.

La búsqueda de un solo evento mantiene esta misma distinción: un acierto es autoritativo sin importar cuántos segmentos se hayan omitido, pero una falta sobre un segmento omitido se informa como historial ilegible y no como not found.

La detección de archivos rechaza los enlaces simbólicos. Coincidir con el formato de nombre de archivo del escritor no equivale a confinamiento de rutas, y seguir un enlace permitiría que cualquiera que pueda escribir en el directorio de registros apunte un nombre con forma de archivo a cualquier archivo que el demonio pueda leer. La validación y la apertura son operaciones separadas, por lo que un actor que puede reemplazar entradas en el directorio de registros todavía puede aprovechar una condición de carrera para convertir un archivo validado en un enlace simbólico antes de que se abra; los despliegues deben mantener el directorio de registros como confiable hasta que el lector use aperturas no-follow o aperturas estables por descriptor.

La política de persistencia gestiona las reescrituras y la retención

StoragePolicy en config.rs controla únicamente el destino del JSONL. La entrega a Observer, OTLP y broadcast sigue siendo independiente de él.

PolíticaComportamiento del archivo activoResponsable de retención
noneNo se realizarán nuevas escrituras en JSONL.Ninguno.
rollingTras cada adición que supere max_entries, reescribe las entradas retenidas al final en un archivo temporal y renómbralo sobre el archivo activo.El escritor mantiene el tamaño configurado de la ventana activa. No crea archivos archivados y deja sin gestionar los archivos archivados de una configuración rotating anterior.
fullAñadir al final sin recorte ni rotación gestionados por el escritor.El operador se encarga del crecimiento de los archivos y de cualquier rotación externa.
rotatingAntes de la primera escritura de un nuevo día UTC, o después de que una escritura alcance el umbral de bytes o de número de entradas, renombra el archivo activo a un archivo de archivo con marca de tiempo.Después de cada rotación correcta, el escritor depura los archivos coincidentes primero por antigüedad y luego por cantidad. La eliminación se realiza como mejor esfuerzo y nunca provoca el fallo de la operación de anexado que la contiene.

La retención por antigüedad y por número de archivos solo se ejecuta después de la rotación. No hace un barrido continuo, no se aplica a full ni a rolling, y no elimina archivos vecinos arbitrarios: la detección de archivos de archivo solo acepta nombres generados a partir de la forma del archivo activo. Con rotating, el lector en vivo de /api/logs combina el archivo activo con todos los archivos conservados; con las demás políticas, lee únicamente el archivo activo. Consulte Cursores de lectura.

La migración del esquema es una reescritura del archivo activo

Cuando la persistencia está habilitada y la ruta activa existe, writer::init_from_config ejecuta migrate::migrate_legacy_jsonl_in_place antes de iniciar el trabajador de disco. El migrador transmite los registros no vacíos a través de un archivo temporal, convierte los registros heredados con timestamp pero sin @timestamp, conserva los registros que ya están actualizados, omite el JSON mal formado con una advertencia, sincroniza el archivo temporal y le cambia el nombre atómicamente para reemplazar la ruta activa.

La migración se realiza con el mejor esfuerzo. Su comprobación de esquema de bajo coste se detiene en la primera fila no vacía. La migración se ejecuta cuando esa fila está mal formada o contiene timestamp sin @timestamp; cualquier otro JSON analizable se trata como actual, incluso si tiene un esquema desconocido o no válido, por lo que las filas heredadas posteriores pueden quedar sin migrar. Si la migración devuelve un error, la inicialización emite una advertencia y continúa, por lo que las adiciones posteriores de v2 pueden coexistir con filas antiguas que el lector de v2 no puede deserializar. Los archivos de archivo rotados no se migran.

LogEvent es la fuente de verdad del esquema. Para cada cambio de esquema, evalúa la compatibilidad de la migración, la deserialización del archivo activo, la serialización y los consumidores de HTTP y RPC, así como la documentación de arquitectura y de operadores; actualiza únicamente los límites cuyo comportamiento o compatibilidad cambie. Las interfaces de registro de RPC se encuentran en crates/zeroclaw-runtime/src/rpc/types.rs y dispatch.rs. Una migración que reemplaza el archivo activo invalida los cursores de desplazamiento de bytes, mientras que un cambio aditivo compatible que no reescribe los bytes existentes no lo hace.

LogConfig vs ObservabilityConfig

zeroclaw-log define su propio LogConfig mínimo (en 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. Esto evita un ciclo de dependencias que, de otro modo, se produciría: zeroclaw-config::ObservabilityConfig contiene el esquema completo (con deserialización TOML y validación), y el runtime convierte a LogConfig al iniciar y tras la recarga de la configuración del daemon mediante crates/zeroclaw-runtime/src/observability/runtime_trace.rs::to_log_config. El resultado: zeroclaw-config puede usar record! sin invertir el árbol de dependencias, mientras que los cambios en la persistencia de logs y en la política de rotación surten efecto en la siguiente recarga del daemon.

Instalación del suscriptor

El daemon instala el suscriptor global mediante:

#![allow(unused)]
fn main() {
zeroclaw_log::install_global_subscriber(
    recording_filter.as_deref(),   // Option<&str> — el flag --log-level, si está establecido
    &default_filter,               // &str — filtro de respaldo cuando no hay flag ni RUST_LOG
    cli.verbose,                   // bool — controla la capa fmt (terminal) de stderr
);
}

Dos ejes independientes: el piso de registro (lo que llega a LogCaptureLayer, resuelto como flag → RUST_LOG → valor predeterminado) y la visualización en terminal (la capa fmt de stderr, completamente silenciada a menos que verbose sea true). Esa única llamada configura el formateador de terminal con prefijo de alias de agente + el LogCaptureLayer sobre un tracing-subscriber::Registry. src/main.rs es el único lugar que la llama. Las pruebas usan zeroclaw_log::try_install_capture_subscriber() + zeroclaw_log::subscribe_or_install() para drenar los eventos emitidos a través del hook de broadcast sin que se nombre ningún tipo de tracing en el crate de pruebas.

Cuándo extender las enumeraciones cerradas

  • Nueva implementación de canal: agrega una variante a ChannelKind. La forma snake_case es la cadena channel_type almacenada en disco. Agrega #[strum(serialize = "...")] solo cuando el nombre de la variante no se convierte en snake-case al valor deseado (p. ej., OpenAi → "openai").
  • Nueva implementación de herramienta (integrada en el workspace): agregar a ToolKind.
  • Nueva forma de programación de cron: añadir a CronKind.
  • Nuevo proveedor de modelo / TTS / transcripción / túnel: agregar al subenum *ProviderKind correspondiente bajo ProviderKind.
  • Nuevo backend de memoria: añadir a MemoryKind.
  • Familia Role completamente nueva (PeerGroup / Skill / Mcp obtienen subtipos): se anida con su propio Kind sobre la marcha: el patrón es uniforme.

Luego añade impl Attributable for X junto a la nueva estructura (fn role() -> Role::Family(Kind::Variant), fn alias() -> &str { &self.alias }) y envuelve su punto de entrada con attribution_span!(self). La capa recoge todo lo demás automáticamente.

Preocupaciones del operador

Para los parámetros de configuración (log_persistence, log_tool_io, exportación de OTel) y la sintaxis de consultas, consulta Logs y observabilidad.