Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

ロギングアーキテクチャ

ZeroClawにはロギング用のインターフェースが正確に1つだけ存在します。それがzeroclaw_log::record!マクロです。ワークスペース内のすべての出力、エージェントループのアクティビティ、チャネルI/O、cronの実行、ツール呼び出し、メモリ操作、セッションのライフサイクル、エラーは、すべてこのマクロを経由します。このマクロはtracingイベントを発火し、インストールされたサブスクライバーがそれを2つの兄弟レイヤーに渡します。stderr fmtレイヤー(ターミナル出力)とLogCaptureLayerです。fmtレイヤーは、stderr上に色付きでエイリアスをプレフィックスとした行を出力します(--verboseを指定しない限り抑制されます)。LogCaptureLayerは、構造化されたLogEventを実体化し、writer::record_eventを介して以下にファンアウトします。

  1. 型付き Prometheus / OTel イベントにマッピングされる一部のアクション向けのオプションの Observer ブリッジ(observer_bridge::forward)ですが、呼び出し元が set_observer_bridge でバインディングをインストールした場合に限られます。現在の本番ブートストラップでは、これをインストールしていません。
  2. observability-otel ビルドを backend = "otel" で実行したときに使われる、オプションのネイティブ OTLP ログエクスポーター(export_bridge および実行時の otel_logs)。
  3. ダッシュボードの SSE ストリームなど、ライブ購読者向けのプロセス全体ブロードキャストチャネル。
  4. <workspace>/state/runtime-trace.jsonl 用の非同期 JSONL ライター([observability] log_persistence が "rolling"、"full"、または "rotating" の場合)。

設定されている場合、Observer projection、OTLP キュー、broadcast 送信は、永続化のエンキュー試行より前に実行されます。これら4つの送信先は、完全性と耐久性の保証がそれぞれ異なります。同じ LogEvent を共有しているからといって、互換的に扱えるわけではありません。

最初にお読みください:attributionはattrsではありません

すべてのログイベントは、構造化データの完全に独立した2つのチャネルを持ちます。これらを混同することが、呼び出し箇所で最も多い間違いなので、何よりもまず先にこの区別を頭に入れてください。

属性情報 (zeroclaw.*)属性 (attributes.*)
回答誰が それを行い、どのような文脈で 行ったか具体的に何が起きたか
例channel, agent_alias, model_provider, tool, session_key, cron_job_idbytes_received、tokens_used、status_code、エラーペイロード
Sourceスパン。エントリーポイントで開始され、レイヤーによって走査されます。呼び出し箇所。 Event::with_attrs(json!({...}))。
呼び出し箇所で表示されますか?決して。 record! 引数ではありません。はい、それが唯一の供給元です。

ここから導かれるルール: ある値があるイベントの所属先や対象スコープを識別するものであれば、その値はスパンから取得されるべきであり、呼び出し箇所に現れてはならない。 属性情報は、スタックの上位で開かれた attribution_span! / scope! ラッパーから自動的に流れ込みます。イベント発火時にレイヤーはスパンスコープを葉→ルートの順にたどり、すべての寄与をイベントの zeroclaw.* ブロックにマージします。record! を発火する呼び出し箇所は、それらを一切指定しません。

配分はこの分割における中核的な半分であり、人々がつまずきやすい半分でもあるため、これを最初に説明します。

属性付け: すべてはスパンから生まれる

属性情報は決して呼び出し側の引数ではありません。 もう一度読んでください。Channel composite、agent_alias、model_provider、tool、session_key、cron_job_id:これらはいずれも record! 呼び出しに直接入力されることはありません。これらはエントリーポイントで開かれたトレーシングスパンを通じて流れ込み、イベント発火時にレイヤーによってたどられます。もし agent_alias や tool を record! に渡したくなったら、立ち止まってください:その値はすでにスパンを通じてスコープ内にあるか、あるいはそうあるべきです。修正方法は、値を呼び出し側に通すことではなく、スパンを開くか修正することです。

メカニズムの全体像(エンドツーエンド):

  1. 「もの」(チャンネル、プロバイダー、エージェント、ツール、cron ジョブ、メモリバックエンド、…)は、その構造体の隣で Attributable を一度だけ実装します。
  2. そのエントリポイントは処理を attribution_span!(self) でラップし、そのものの役割とエイリアスを保持するトレーシングスパンを開きます。
  3. そのスパン内のどこかで発火した record! は、直接であれ任意の深さでネストされていようとも、属性付けを自動的に継承します。
  4. イベントが発火すると、レイヤーはスパンスコープを葉→ルートの順にたどり、すべての Attributable の寄与をマージして、マージ済みの zeroclaw.* ブロックを書き込みます。呼び出し側はそのいずれも名前を指定していませんでした。

これがこの設計の真髄です。つまり、個々の処理ごとのログ記録コードはゼロです。トレイトを一度実装し、エントリーポイントを一度ラップすれば、その配下で発生するすべての出力が自動的に関連付けられます。

スパンの属性を継承する際、複合グループは、保存された属性に<prefix>、<prefix>_type、<prefix>_aliasのいずれかのキーを含む最も近いスパンに属します。このグループの欠落しているメンバーは、内側のグループが<prefix>_typeのみを含み、外側のグループが同じ型を持つ場合を除き、外側のスパンから継承されません。たとえば、内側のscope!(channel: "telegram", ...)は、外側のchannel = "telegram.outer"とchannel_alias = "outer"を保持します。一方、内側のscope!(channel: "webhook", ...)は、外側のTelegramスパンの下でも型のみの指定のままです。telegram.innerのような明示的な内側の複合グループは、外側の型が一致していても独自のインスタンスを保持します。通常の属性フィールドと無関係な複合グループは、通常どおり継承されます。1つのスパン内での遅延フィールド記録は、引き続きそのスパンの欠落フィールドを埋めます。

Attributable トレイト

すべてのクレートが zeroclaw-log に依存せずに実装できるよう、crates/zeroclaw-api/src/attribution.rs に配置されています:

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

ワークスペース内の各「もの」(TelegramChannel、AnthropicModelProvider、Agent、cronジョブ、ツール、メモリバックエンド、ピアグループ、スキルバンドル、MCPバンドル、セッション)は、その構造体の隣でAttributableを一度だけ実装します。

Role タクソノミー

ネストされた列挙型を閉じました:

#![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、および4つのProviderKindサブ列挙型(ModelProviderKind、TtsProviderKind、TranscriptionProviderKind、TunnelProviderKind)はすべてクローズドです。strum::IntoStaticStrによるバリアントのsnake_case形式が、<type>.<alias>複合体の正規の<type>部分となります。新しい実装を追加するには、該当するKind列挙型を拡張するだけです。

スパンを開始する。これをすべてのエントリーポイントで実行する

エントリーポイントの処理を attribution_span!(thing) でラップします。このマクロは、対象のロールとエイリアスを構造化フィールドとして保持する Span を返します。フューチャーには .instrument(span) を、同期コードでは let _g = span.entered() を使用してください。スパンを再確立しないspawnされたタスクは、アトリビューションを失います:emitを行うすべての tokio::spawn 本体は、親が使用したものと同じ attribution_span! / scope! を保持する必要があります。そうしないと、その emit はアトリビューションされないまま記録されます。

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

let span = zeroclaw_log::attribution_span!(self);  // self impls Attributable
async move {
    // すべての record! は内部でエイリアスにバインドされたフィールドを自動的に保持します
    record!(INFO, Event::new(module_path!(), Action::Start), "チャンネルオンライン");
    self.poll_loop().await
}.instrument(span).await
}

イベントが発生すると、レイヤーはspanスコープをleaf→rootの順にたどり、各Attributableの寄与をイベントのzeroclaw.*属性ブロックにマージし、合成結果(channel = "telegram.clamps"、channel_type = "telegram"、channel_alias = "clamps")を出力します。この際、呼び出し側はこれらのキーを一切指定する必要がありません。

scope! マクロ、非ロールコンテキスト

attribution_span! は、ロールを持つ Attributable なものに使用します。1 つに紐づかないスコープごとの識別子(送信者 ID、メッセージ ID、ターン ID、リクエスト ID)には、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
}

SOPのステップ実行でも、sop_run_id を使う同じ仕組みが用いられます。ターンごとの trace_id と異なり、この帰属情報は実行内のネストされたすべてのステップターンで安定しているため、ゲートウェイ、CLI、TUIのいずれもが、永続的な1つの相関キーを使って照会できます。

scope! は属性付け/属性の境界をあえてまたいでいます。エイリアスにバインドされた ATTRIBUTION_FIELDS / COMPOSITE_PREFIXES(crates/zeroclaw-log/src/event.rs 内)に一致するフィールドキーは、型付けされた zeroclaw.* 属性付けスロットに配置され、それ以外はすべての子孫の発行に対してイベントの attributes マップに配置されます。いずれの場合も、値は呼び出し側の引数になることなく、すべてのネストされた record! に引き継がれます。

record! マクロとその呼び出し側コントラクト

tracing クレートは zeroclaw-log の実装詳細です。record! / scope! / attribution_span! マクロは zeroclaw_log::__private::tracing に展開されるため、呼び出し側が tracing 型を直接指定することはありません。ログイベントマクロそのもの(tracing::{trace,debug,info,warn,error}、log::*、std::dbg、加えて裸の anyhow::anyhow!)は、clippy.toml で disallowed-macros としてワークスペース全体で完全に禁止されています。CI では -D warnings が設定されているため、tracing::info! などを直接使用するとビルドが失敗し、::zeroclaw_log::record! を代替として示す clippy メッセージが表示されます。これは慣習ではなく、強制されています。

唯一の例外は、パイプラインをブートストラップしてローカルの #![allow(clippy::disallowed_macros)] を持つ crates/zeroclaw-log/ 内のいくつかのファイルです。一部のクレート(zeroclaw-api、zeroclaw-spawn、zeroclaw-providers、zeroclaw-hardware、zeroclaw-log)は依然として Cargo.toml に tracing / tracing-subscriber をリストしていますが、これはスパンとサブスクライバーの配管のためだけであり、ログマクロを発行するためではありません。依存関係が存在していても、禁止されたマクロの呼び出しが許可されるわけではありません。(tokio::spawn は disallowed-methods によって同様に禁止されています。スポーンされたタスクが呼び出し元のアトリビューションスパンを継承するように、::zeroclaw_spawn::spawn! を使用してください。)

マクロはlocked-shapeです。レベル、単一のEvent式、そしてメッセージリテラルを受け取ります。

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

record!(INFO, Event::new(module_path!(), Action::Start), 開始ステップ);
record!(WARN, Event::new(module_path!(), Action::Fail).with_outcome(EventOutcome::Failure).with_attrs(serde_json::json!({exit_code: 137})), ツールが失敗しました);
}

module_path!() はイベント名の正規のソースです。これは呼び出し元の Rust モジュールパス(例: zeroclaw_channels::telegram)であり、イベントは検索可能で、ソースへのジャンプが可能で、タイプミスが起こり得ません。同じ慣例がワークスペース内のすべての record! 箇所で使用されています。

このマクロは file!() と line!() を自動的に挿入します。LogCaptureLayer はそれらをイベントの attributes マップに _file と _line として付加するため、オペレーターはログビューアーからソースへジャンプできます。

コールサイト契約

すべての record! 呼び出しは、何が起きたかを示す単一行のコードであり、誰がどのような状況で実行したかを示すものではありません。

  • レベルの後に続く単一の位置引数は Event 式です。
  • 次の引数は、人間が読めるメッセージの文字列リテラルです。
  • これですべてです。Channel、agent_alias、provider、tool、session_key、cron_job_id、model:これらはいずれも呼び出し側の引数ではありません。これらはスパンから流れ込みます(Attribution: it all comes from spans を参照)。

この形状は Event 構造体によって強制されます。未知のフィールドはコンパイルエラーになります。

attrs が妥当な場合

Event::with_attrs(serde_json::json!({...})) は、周囲のスコープのどこにも存在しないイベントごとの計測値やアドホックなデータに使用します。具体的には:

  • イベントごとの計測値: bytes_received、tokens_used、retry_count、status_code、queue_depth。
  • エラー自体がイベントである場合のエラーペイロード: anyhow のチェーンテキスト、HTTP エラーボディ、パースエラーの詳細。
  • 外部システムの識別子: リモートAPIの request_id、上流のトレースヘッダー。
  • この時点でキャプチャされた派生状態: 処理中の件数、retry-after 秒数。

Attrs は以下の用途には使いません: 周囲のスコープに由来するもの、つまり channel composite、agent_alias、model_provider、tool、session_key、cron_job_id、sender、message_id などです。これらはラップする attribution_span! または scope! に属します。

serde のルール: 生の値を渡すこと。format!("{}", v) や format!("{:?}", v) は決して使わない。serde_json::json! は文字列を文字列として、数値を数値として、Vec<T> を配列として、Option<T> を null または値としてシリアライズします。.to_string() でラップするのは、その型が impl Serialize を持たない場合(例: anyhow::Error、reqwest::Error、std::io::Error、Path::Display、StatusCode)のみにすること。

プレースホルダールール

"raw error body: {body}" のような Rust の文字列リテラルプレースホルダーは、record! メッセージ内では使用禁止です。Rust 2021 の暗黙的なフォーマット文字列キャプチャは record! を通過しません。つまり、各 {var} は置換されずにそのままのリテラル部分文字列になります。変換ルール:

#![allow(unused)]
fn main() {
// 悪い例 — {body} はリテラルであり、補間されません
record!(WARN, Event::new(module_path!(), Action::Fail), raw error body: {body});

// GOOD — body in attrs, message is plain prose
record!(WARN, Event::new(module_path!(), Action::Fail).with_attrs(serde_json::json!({「body」: body})), raw error body);
}

Event、Action、EventOutcome、EventCategory

4つはすべて crates/zeroclaw-log/src/event.rs に定義されたクローズドな列挙型です。値の追加だけが変更箇所であり、呼び出し側で文字列を新たに作ることはありません。

  • Action: クローズドな動詞セットで、strum::IntoStaticStr を介してディスク上では snake_case 表記になります: 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 がデフォルトであり、シリアライズ時にスキップされます(ディスク上の event.outcome から省略されます)。そのため、outcome キーを持たない行は暗黙的に Unknown となります。
  • EventCategory: Agent、Channel、Cron、Memory、Tool、Provider、Session、System、Internal。Event::with_category(...) でオーバーライドされない限り、最も内側のロールスパンから導出されます。

ツールの入出力の伝播

中央のツール実行機構(crates/zeroclaw-runtime/src/agent/tool_execution.rs::execute_one_tool)は、すべての Tool::execute(args) 呼び出しを invoke/complete/fail イベントでラップします。各イベントの名前はハードコードされた文字列ではなく module_path!()(実行機構自身のモジュール)であり、Action と重大度によって区別されます。

  1. 実行前: record!(DEBUG, Event::new(module_path!(), Action::Invoke).with_category(EventCategory::Tool).with_attrs(...)) で、tool、tool_call_id、および完全な input を attrs に含めます。
  2. execute(args).await を実行します。
  3. 成功時 (r.success): Outcome::Success、実行時間、および attrs 内の tool / tool_call_id / input / output を指定した record!(DEBUG, ... Action::Complete)。
  4. ツールが失敗を報告した場合(!r.success): Outcome::Failure、実行時間、および属性内の tool / tool_call_id / input / error / output を指定して record!(WARN, ... Action::Fail) を実行します。
  5. execute から Err が返された場合: Outcome::Failure、処理時間、および attrs にデバッグフォーマットされたエラーを含む record!(ERROR, ... Action::Fail) を実行します。

これらのイベントは、呼び出しの周囲に開かれた scope! 形式のスパン(target = "zeroclaw_log_internal_scope"、フィールド tool = <name>)の内部で発行されるため、tool フィールドはすべての子孫の発行にも引き継がれます。ツールごとの Tool::execute 実装にロギングコードを追加する必要はありません。

LogCaptureLayer とディスク上のスキーマ

crates/zeroclaw-log/src/layer.rs のレイヤーは、以下を行う tracing-subscriber Layer です:

  1. ターゲット "zeroclaw_log_internal_attribution"(attribution_span! マクロが開く際のターゲット)を指定した span の作成・記録時:role フィールドと alias フィールドを解析し、span の extensions に保存される ZeroclawAttribution スナップショットを生成します。
  2. ターゲット "zeroclaw_log_internal_scope"(scope! でオープンされたもの)でのスパン作成・記録時:アドホックな kvp を解析し、同様に格納します。
  3. イベントが "zeroclaw_log_event" ターゲット(record! マクロが発火するターゲット)で発行されると、次の処理を行います。zc_* フィールドセットから LogEvent を構築し、スパンスコープを leaf→root へたどって見つかったすべての属性スナップショットをマージし、zc_attrs の JSON ブロブを解析してイベントの attributes に格納し、自動キャプチャされたソース位置から _file/_line を付加して、最終的なイベントを writer::record_event に渡します。これは次の順序でファンアウトします。
    • Observer がバインドされたときにマッピングされた Prometheus / OTel 型付きイベントを処理する Observer ブリッジ (observer_bridge.rs)。
    • Native構造化ログブリッジ(export_bridge.rs)は、エクスポーターがインストールされている場合に、すべての正規イベントを処理します。observability-otel ランタイムはこれをSDKのLogRecordにマッピングし、OTLP/HTTP protobufエクスポートをキューに入れます。
    • 送信者が設定されている場合に、現在の SSE/ダッシュボード購読者へブロードキャストするフック(broadcast.rs)。
    • log_persistence が有効な場合にのみ、JSONL の永続化(writer.rs)が非同期ライターキューに最後に渡されます。

ディスク上の JSON 形式(event.rs 内の LogEvent):

{
  "id": <uuid>,
  "@timestamp": 2026-05-16T10:08:59.002Z,
  "severity_number": 9,
  "severity_text": INFO,
  event: { category: channel, "action": インバウンド, アウトカム: 「成功」 },
  service: { "name": zeroclaw, version: 0.8.6 },
  trace_id: <turn id>,
  span_id: <sub-span id>,
  zeroclaw: {
    channel: telegram.clamps,
    "channel_type": "telegram",
    channel_alias: clamps,
    agent_alias: clamps,
    "model_provider": anthropic.clamps,
    "model_provider_type": anthropic,
    model_provider_alias: clamps,
    model: claude-sonnet-4-6
  },
  "message": インバウンドメッセージ,
  attributes: { sender: "...", _file: "...", _line: 42 },
  "schema_version": 2
}

@timestamp は RFC 3339 形式で Z 付きにシリアライズされる chrono::DateTime<Utc> です。スキーマバージョンは 2 で、古い version: 1 の行はデーモン起動時に migrate::migrate_legacy_jsonl_in_place によってインプレースで移行されます。

配信サーフェスごとに保証内容が異なります

writer::record_event は永続化される値を一度構築し、その後、同じ LogEvent から他の配信内容を導出します。各送信先には個別の契約があります:

宛先所有者契約と損失の境界
Optional 型の Observer ブリッジobserver_bridge.rsforward は Observer が明示的にバインドされるまで何も行わず、現在の本番ブートストラップでは Observer がバインドされていません。バインドされると同期的に転送しますが、project が認識するアクションのみを射影します。現在のマッピングでは、アクションやデフォルトフィールドが欠落する可能性があります。完全なイベント台帳ではなく、選択的なメトリクス/トレース射影として扱い、現在のフィールドマッピングについては project を確認してください。
ネイティブ OTLP ログexport_bridge.rs + observability/otel_logs.rsobservability-otel ビルドでのみ存在し、backend = "otel" を使用します。すべての正規イベントは、OpenTelemetry SDK のノンブロッキングバッチキューに同期的に渡され、その後 OTLP/HTTP protobuf としてエクスポートされます。キューまたはエクスポートの失敗が JSONL/ブロードキャストをブロックしたり無効化したりすることはありません。再構成と Observer のフラッシュではバッチのフラッシュが要求されますが、プロセスが突然終了した場合は、キューに入っているレコードが失われる可能性があります。一時的なブロードキャスト属性がこのブリッジに渡されることはありません。
ライブ配信broadcast.rs とそのコンシューマー現在のプロセス内サブスクライバーに構造化イベントを送信します。サブスクライバーには購読開始後に発行されたイベントだけが表示され、有限容量の受信側では遅延が発生する可能性があり、ゲートウェイの SSE アダプターは遅延したフレームをスキップします。ブロードキャスト専用の一時的な属性は、認証済みのライブフレームに含まれることがありますが、永続化された JSONL からは除外されます。これはライブ通知経路であり、再生可能な証跡ではありません。
永続化された JSONLwriter.rsシリアライズされたイベントをランタイムをブロックせずにキューに追加します。上限付きキューは満杯になるとイベントを破棄することがあり、ワーカーの書き込み失敗は警告として扱われ、定期的な sync_all は現在のアクティブファイルのみを対象とします。新しい UTC 日の最初の追記前に行われる日次ローテーションや、しきい値を超えた追記後に行われるサイズローテーションでは、先に同期せずにアクティブファイルの名前を変更することがあるため、この同期周期ではローテーション直後のアーカイブの永続性を保証できません。その後、永続化モードによって、アクティブファイルを切り詰めるか、無期限に保持するか、ローテーションするかが決まります。これはベストエフォートの運用履歴であり、トランザクション監査ログではありません。

Observer、OTLP、SSE配信を、すべての正規イベントが保持されたことの証明に使用しないでください。逆に、JSONLに存在しない行は一度も発行されなかったと想定しないでください。永続化キューがそれを破棄または失敗させる前に、OTLPやライブブロードキャスト、またバインドされている場合はObserverブリッジに到達していた可能性があります。

Observerイベントは同じバスを共有する

Typed observer events (agent_start, agent_end, llm_request, tool_call, tool_call_start, history_trimmed, error) は、別経路として zeroclaw_runtime::observability::broadcast を通じて同じブロードキャストチャネルに届きます。create_observer で構築されたすべての observer は、プロセス全体で共有される1つのブロードキャストフックに転送(tee)され、そのフックは EventBus(ライブ送信側と500フレームの履歴バッファ)によって提供されます。

  • デーモン配下では、daemon::run はゲートウェイの稼働有無にかかわらず、バスを作成しフックを一度だけインストールします。そのためRPC logs/subscribe は、ゲートウェイがオフの状態でもこれらのフレームを送出します。監視下のゲートウェイはデーモンのバスを再利用し、何もインストールしないため、各フレームは一度だけ配信され、一度だけバッファリングされます。
  • スタンドアロンのゲートウェイ(zeroclaw gateway start)は、独自のバスをビルドしてインストールします。
  • 履歴: GET /api/events/history および RPC events/history(権限 Logs:Read)は、バッファリングされた observer フレームを古い順に再生します。ライブ配信と同様に、これらがペアリング用の認証情報を含むことは決してありません。一時的マーカーを持つフレームは履歴から除外され、logs/subscribe はそれらをライブ配信からも除外します。

ログレイヤーのフレームはチャネルに直接送信され、ライブ表示のみです。履歴バッファには観測者フレームのみが保持されます。

RPCのサブスクリプションは再生可能で、無言で終了することはありません

RPC ストリームはブロードキャストチャネルを直接読み取りません。SubscriptionHub(zeroclaw_runtime::rpc::subscription)がバスの内容を、ソースごとに用意された有界リングへコピーし、各サブスクライバーはリング上のカーソルにすぎません。現在、ソースは次の2つです。

方法Ring通知
logs/subscribeevery bus framelogs/event
events/subscribeオブザーバーフレームのみevents/event
  • 上限: 各リングは 2,048 フレームおよび 4 MiB までに制限され、すべてのリングは 16 MiB のプロセス全体の予算を共有します。予算を超過した場合、すべてのリングを通じて最も古いフレームが削除されます。
  • 生産者は購読者を待つことはありません。 処理の遅い購読者は、単に遅れるだけです。
  • シーケンス番号: 各ソースごとに 1 から開始します。すべての通知には subscription_id と、その seq が含まれます。subscribe の結果では、最新の seq とハブの epoch が返されます。
  • エポック: ハブ(デーモンの起動またはリロードのたびの各ハブ)には新しいランダムな epoch が割り当てられ、番号は 1 から再開されるため、シーケンス番号はそのエポックの外では意味を持ちません。
  • 再開: X/subscribe{since_seq, epoch} は、バッファに残っている since_seq より後のフレームを再生し、その後ライブ配信を継続します。ただし epoch が一致する場合に限ります。
    • 異なるエポック、またはエポックなしの場合、クライアントは epoch_changed: true を伴う subscription/lagged を受け取り、その後、新しいハブが保持しているすべてのフレームを受け取ります。

    • A same-epoch since_seq that is ahead of the newest frame is refused with INVALID_PARAMS.

      Wait, I need to correct that: the task is to translate into Japanese, so here is the translation.

      since_seq が同一エポック内の最新フレームより先を指している場合、INVALID_PARAMS で拒否されます。

  • ギャップ: ギャップは報告されるものであり、スキップされることはありません。上限または予算によって削除された、あるいはバス上でオーバーランしたフレームを指すカーソルは、subscription/lagged{subscription_id, from_seq, resume_seq} を受け取り、resume_seq から継続します。from_seq が resume_seq より大きくなることはありません。リプレイバッチはギャップの位置で停止するため、カーソルが記録済みの損失を飛び越えることはありません。
  • キャンセル: subscription/cancel{subscription_id}(Logs:Read 権限が必要。購読時と同じ)は、呼び出し元の接続上の購読を1つ終了します。接続を閉じると、すべての購読が終了します。
  • 権限: これらのストリームと events/history はスコープなし専用です。そのフレームはすべてのプリンシパルの作業(セッションメッセージ、cron の結果、ログ行)に由来し、多くは所有者を示さないため、プリンシパルごとにフィルタリングすることはできません。呼び出し元には Logs:Read が必要で、管理者または認証なしの共有オペレーターである必要があります。これらはセッションの所有権を迂回するのと同じプリンシパルです。認証済みの非管理者プリンシパルは、* エージェントセレクターを指定しても拒否されます。すべてのエージェントを指定することは、すべてのユーザーのセッションを所有することとは同じではないためです。
  • 再確認: フレームか lagged 通知かを問わず、すべての配信は呼び出し元の有効な認証情報に対して再確認されます。ポリシーが変更されると、プリンシパルは再度解決され、そのダウングレードまたは Logs:Read の削除によってストリームは終了します。
  • リプレイの到達範囲: 管理者または共有オペレーターは、接続前のフレームを、リングバッファの上限まで再生できます。
  • ペアリング資格情報は、リングに渡される前に破棄されるため、どちらのストリームも配信やリプレイを行えません。

Reader cursors は、アクティブなファイルと保持されたアーカイブの両方にまたがります

GET /api/logs と logs/query はどちらも reader::query_log_page を呼び出し、セグメントの列挙を担当します。マージする内容はインストールされたストレージポリシーによって決まります。アーカイブを所有するのは rotating のみであるため、rotating の下でのみ、リーダーはアクティブファイルと保持されているアーカイブを1つの論理イベントストリームにマージします。rolling、full、none の下では、以前の rotating 構成から残っているタイムスタンプ付きファイルが隣に存在する場合でも、アクティブファイルのみを読み取ります。それらは管理対象外であり、ポリシーの論理ストリームには含まれません。セグメントは最も古いアーカイブから順にスキャンされ、マージ結果は以前の単一ファイルの動作と同一になるよう、新しい順に返されます。マージは逐次的かつプロセス内で行われ、イベントの再シリアライズは行われません。

セグメントの順序は、ローテーション時に各アーカイブ名へ書き込まれた連番によって決まり、ファイルの mtime では決まりません。mtime で並べ替えると、列挙時点での観測に基づく順序になります。読み取り中に複数のローテーションが行われた場合、その順序はすでに成り立たなくなっているおそれがあります。連番が導入される前に書き込まれたアーカイブには番号がないため、mtime で順序付けされ、番号付きのすべてのアーカイブより前に並びます。

The主要なページネーションカーソルはnext_segment_cursorで、不透明な文字列です。再開するには、これをuntil_segment_cursorとしてそのまま渡し直してください。構築も解析もしないでください。その内部形式は、ページが終了したセグメントの種類によって異なります。3つのセグメントの種類は安定性の特性が異なるためです。

  • 番号付きアーカイブは、ローテーション時にその名前へ書き込まれた連番によって参照されます。ベストエフォートのサイドカーファイルにより、通常の再起動をまたいでも番号の単調増加が維持されます。ただし、そのサイドカーへの書き込みが失敗し、保持ポリシーによってすべての番号付きアーカイブが削除され、その後デーモンが再起動した場合には、番号が再利用される可能性があり、未処理のカーソルが新しい履歴に紐づいてしまう可能性があります。
  • legacy archive(シーケンス番号の仕組みが導入される前に書かれたもの)は、ファイル名によって参照されます。アーカイブ名が別の内容に再割り当てされることは決してないため、この参照は同じ意味で安定しています。
  • アクティブファイルは、オフセットと anchor イベント ID によって指定されます。そのパスは安定していますが、ローテーションのたびに内容が置き換えられます。そのため anchor によって、リーダーはローテーションが発生したことを検出し、anchor イベントが現在存在しているアーカイブから読み取りを再開できます。

レガシーのバイトオフセットカーソルnext_cursor_line_offset/until_line_offsetは引き続き有効で、アクティブなファイルに対してのみ解決されます。現在のページ上の最も古いイベントがアーカイブファイル内にある場合はNoneになります。複数セグメント構成のデプロイメントでは、代わりにnext_segment_cursorを使用してください。

このオフセットは、永続的なイベントIDでも、ファイルをまたぐチェックポイントでもありません。セグメントカーソルは、指定先のセグメントが保持ポリシーによって削除されると解決できなくなります。その場合リーダーは、無関係な位置から再開するのではなく、履歴が終了したと報告するため、ページング中のクライアントは最新イベントへ黙って戻ることなく、その位置で停止します。バイトオフセットカーソルは、アクティブファイルのバイト列が置き換えられた場合、またはそのパスが変更された場合に、必ず陳腐化します:

  • rotating はアクティブファイルの名前をアーカイブに変更し、次の追記で新しいアクティブファイルが作成されます。
  • スキーママイグレーションでは、一時ファイルを経由してアクティブファイルを書き換え、アトミックなリネームを行います。
  • デーモンの設定を再読み込みすると、新しい永続化パスを設定できます。

これらの境界のいずれかを過ぎた後は、最新のページからページネーションを再開します。従来のタイムスタンプ/IDカーソルは互換性のために残されていますが、#8012 で非推奨となっています。辞書順のID順序では同点のイベントをスキップする可能性があるためです。

列挙と読み取りはアトミックではないため、列挙と読み取りの間にローテーションが発生することがあります。たとえば、一覧にはローテーション前のアクティブファイルが含まれ、その後のリネームによってそのファイルがアーカイブに変わり、スキャンは代わりに新しく作られた後継ファイルを読み取ってしまいます。すると、ローテーションで移動したイベントは、読み取られた2つのセグメントの間に一覧されないまま残ります。ページネーションは古い履歴の方向にしか進まないため、そのイベントには二度と到達できません。リーダーは読み取りの後にアーカイブ集合を再確認し、変化していればそのページをやり直します。開く前に確認すると、確認と開く操作の間にローテーションが入り込む余地が残るため、確認は事後に行うことで窓を閉じられます。同じ競合は列挙の内部にもあります。アーカイブを先に列挙し、アクティブパスを後から stat します。その2つのステップの間にローテーションが発生すると、リネームから次の追記までの空白期間にはアクティブパスが存在しないため、データは両方の処理から隠れてしまいます。したがって、再確認を省略してよいのは、列挙済み のアクティブファイルより手前で止まるページだけです。アーカイブは不変なので、そのようなスキャンが競合を起こすことはありえません。一方、アクティブファイルが一切列挙されていない場合は、他のケースと同様に検証します。

読み取れないセグメントはログに記録され、マージされたビューからは除外されます。また、レスポンスでは incomplete: true とマークされ、クエリ全体は失敗させません。1つの不良ファイルのせいで、呼び出し元が他のすべてのセグメントを失うべきではないからです。at_end は読み取れたものの範囲に限定され、そのことを呼び出し元に伝えるのがこのフラグです。デーモン自身のログは運用者に届くものであり、ページングを続けるかどうかを判断しなければならないクライアントには届きません。単一イベントの検索にも同じ区別が適用されます。ヒットは、スキップされたセグメントがいくつあっても確定的な結果です。一方、スキップされたセグメントにまたがるミスは not found ではなく、読み取り不能な履歴として報告されます。

アーカイブの探索はシンボリックリンクを拒否します。ライターのファイル名の形式に一致させることはパスの閉じ込め(confinement)にはなりません。また、リンクをたどると、ログディレクトリに書き込める者は誰でも、アーカイブ形式の名前を、デーモンが読み取れる任意のファイルに向けられてしまいます。検証とオープンは別々の操作であるため、ログディレクトリ内のエントリを置き換えられる攻撃者は、検証済みのアーカイブがオープンされる前にシンボリックリンクへと差し替えるレースを仕掛けることができます。リーダーがno-followまたはdescriptor-stableなオープンを使うようになるまで、デプロイ環境ではログディレクトリを信頼できる状態に保つ必要があります。

永続化ポリシーが再書き込みと保持を担当します

config.rs の StoragePolicy は JSONL の出力先のみを制御します。Observer、OTLP、およびブロードキャストによる配信は、これとは独立して動作し続けます。

ポリシーアクティブファイルの動作保持担当者
none新しい JSONL の書き込みはありません。なし。
rollingmax_entries を超えて追記するたびに、保持対象の末尾エントリを一時ファイルに書き出し、そのファイルをアクティブなファイルにリネームして置き換えます。writer は設定されたアクティブウィンドウのサイズを維持します。アーカイブは作成せず、以前の rotating 設定によるアーカイブは管理対象外のままにします。
full書き込み側が管理するトリミングやローテーションを行わずに追記します。ファイルの増大と外部ローテーションはオペレーターが管理します。
rotatingUTC の新しい日における最初の追記の前、または追記によってバイト数またはエントリ数のしきい値に達した後に、アクティブなファイルをタイムスタンプ付きのアーカイブにリネームする。ローテーションが正常に完了するたびに、writer は該当するアーカイブを、まず経過時間、次に個数に基づいて整理します。削除はベストエフォートで行われ、外側の append 処理が失敗することはありません。

保持期間とファイル数による保持処理は、ローテーションの後にのみ実行されます。継続的なスキャンは行わず、full と rolling には適用されません。また、無関係な隣接ファイルは削除せず、アーカイブの検出はアクティブパスのアーカイブ形式から生成された名前のみを対象とします。rotating の場合、ライブの /api/logs リーダーはアクティブファイルと保持されているすべてのアーカイブをマージして読み取ります。それ以外のポリシーでは、アクティブファイルのみを読み取ります。詳細は 読み取りカーソル を参照してください。

スキーマ移行は、アクティブファイルの書き換えです

永続化が有効でアクティブパスが存在する場合、writer::init_from_config はディスクワーカーを開始する前に migrate::migrate_legacy_jsonl_in_place を実行します。移行処理は空でない行を一時ファイルにストリーミングし、timestamp はあるものの @timestamp がないレガシー行を変換し、すでに現行形式の行は保持し、不正な JSON を警告付きでスキップし、一時ファイルを同期してから、それをアクティブパスにアトミックにリネームします。

マイグレーションはベストエフォートです。簡易スキーマチェックは、最初の空でない行で停止します。その行が不正な形式であるか、timestamp があるのに @timestamp がない場合にマイグレーションが実行されます。それ以外の解析可能な JSON は、未知または無効なスキーマであっても現行形式として扱われるため、後続のレガシー行が未移行のまま残る可能性があります。マイグレーションがエラーを返した場合、初期化では警告を出して処理を続行するため、後続の v2 追記が、v2 リーダーではデシリアライズできない古い行と共存する可能性があります。ローテーション済みアーカイブはマイグレーションされません。

LogEvent はスキーマの唯一の信頼できる定義です。スキーマを変更するたびに、マイグレーションの互換性、アクティブファイルのデシリアライズ、HTTP および RPC のシリアライズ/コンシューマー、アーキテクチャとオペレーター向けドキュメントを評価し、動作または互換性が変わる境界だけを更新してください。RPC ログの公開面は crates/zeroclaw-runtime/src/rpc/types.rs と dispatch.rs にあります。アクティブファイルを置き換えるマイグレーションではバイトオフセットカーソルが無効になりますが、既存のバイトを書き換えない追加的で互換性のある変更では無効になりません。

LogConfig と ObservabilityConfig の比較

zeroclaw-log は、独自の最小限の LogConfig(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 です。これは、そうしなければ生じる依存関係の循環を断ち切るためのものです。zeroclaw-config::ObservabilityConfig は、TOML のデシリアライズと検証を含む完全なスキーマを保持しています。ランタイムは起動時およびデーモンの設定再読み込み後に、crates/zeroclaw-runtime/src/observability/runtime_trace.rs::to_log_config を通じて LogConfig へ変換します。その結果、zeroclaw-config は依存ツリーを逆転させることなく record! を呼び出せ、ログの永続化とローテーションのポリシー変更は次回のデーモン再読み込み時に反映されます。

サブスクライバーのインストール

デーモンは次の方法でグローバルサブスクライバーをインストールします。

#![allow(unused)]
fn main() {
zeroclaw_log::install_global_subscriber(
    recording_filter.as_deref(),   // Option<&str> — --log-level フラグ(設定されている場合)
    &default_filter,               // &str — フラグも RUST_LOG もない場合のフォールバックフィルター
    cli.verbose,                   // bool — stderr fmt(ターミナル)レイヤーを制御します
);
}

2つの独立した軸があります。記録フロア(LogCaptureLayerに到達する内容で、フラグ → RUST_LOG → デフォルトの順に解決されます)と、ターミナル表示(stderr fmtレイヤーで、verboseがtrueでない限り完全にミュートされます)です。この単一の呼び出しが、エージェントエイリアスをプレフィックスとするターミナルフォーマッタと、tracing-subscriber::Registry上のLogCaptureLayerをセットアップします。これを呼び出すのはsrc/main.rsのみです。テストではzeroclaw_log::try_install_capture_subscriber()とzeroclaw_log::subscribe_or_install()を使用して、テストクレート内でtracing型を一切名指しすることなく、ブロードキャストフックを介して発行されたイベントをドレインします。

クローズドな列挙型を拡張すべき場合

  • 新しいチャネルの実装: ChannelKind にバリアントを追加します。snake_case 形式がディスク上の channel_type 文字列になります。バリアント名を snake_case にしても目的の値にならない場合(例: OpenAi → "openai")にのみ #[strum(serialize = "...")] を追加してください。
  • 新しいツールの実装 (ワークスペース組み込み): ToolKind に追加します。
  • 新しい cron スケジュール形状: CronKind に追加します。
  • 新しいモデル / TTS / 文字起こし / トンネルプロバイダー: ProviderKind 配下の該当する *ProviderKind サブ列挙型に追加します。
  • 新しいメモリバックエンド: MemoryKind に追加します。
  • まったく新しい Role ファミリー(PeerGroup / Skill / Mcp がサブタイプを獲得): 独自の Kind を使ってその場でネストできます。パターンは統一されています。

次に、新しい構造体の隣に impl Attributable for X を追加し(fn role() -> Role::Family(Kind::Variant)、fn alias() -> &str { &self.alias })、そのエントリポイントを attribution_span!(self) でラップします。残りはレイヤーが自動的に処理します。

オペレーターの懸念事項

設定オプション(log_persistence、log_tool_io、OTel エクスポート)とクエリ構文については、ログと可観測性を参照してください。