ロギングアーキテクチャ
ZeroClawにはロギング用のインターフェースが正確に1つだけ存在します。それがzeroclaw_log::record!マクロです。ワークスペース内のすべての出力、エージェントループのアクティビティ、チャネルI/O、cronの実行、ツール呼び出し、メモリ操作、セッションのライフサイクル、エラーは、すべてこのマクロを経由します。このマクロはtracingイベントを発火し、インストールされたサブスクライバーがそれを2つの兄弟レイヤーに渡します。stderr fmtレイヤー(ターミナル出力)とLogCaptureLayerです。fmtレイヤーは、stderr上に色付きでエイリアスをプレフィックスとした行を出力します(--verboseを指定しない限り抑制されます)。LogCaptureLayerは、構造化されたLogEventを実体化し、writer::record_eventを介して以下にファンアウトします。
- 型付き Prometheus / OTel イベントにマッピングされる一部のアクション向けのオプションの Observer ブリッジ(
observer_bridge::forward)ですが、呼び出し元がset_observer_bridgeでバインディングをインストールした場合に限られます。現在の本番ブートストラップでは、これをインストールしていません。 - ダッシュボードの SSE ストリームなど、ライブ購読者向けのプロセス全体ブロードキャストチャネル。
<workspace>/state/runtime-trace.jsonl用の非同期 JSONL ライター([observability] log_persistenceが"rolling"、"full"、または"rotating"の場合)。
Observer ブリッジがバインドされると、そのプロジェクションとブロードキャスト送信は、永続化のキュー登録を試みる前に実行されます。これら3つの送信先では、完全性と耐久性の保証がそれぞれ異なるため、1つの LogEvent を共有しても、相互に置き換え可能になるわけではありません。
最初にお読みください:attributionはattrsではありません
すべてのログイベントは、構造化データの完全に独立した2つのチャネルを持ちます。これらを混同することが、呼び出し箇所で最も多い間違いなので、何よりもまず先にこの区別を頭に入れてください。
属性情報 (zeroclaw.*) | 属性 (attributes.*) | |
|---|---|---|
| 回答 | 誰が それを行い、どのような文脈で 行ったか | 具体的に何が起きたか |
| 例 | channel, agent_alias, model_provider, tool, session_key, cron_job_id | bytes_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! に渡したくなったら、立ち止まってください:その値はすでにスパンを通じてスコープ内にあるか、あるいはそうあるべきです。修正方法は、値を呼び出し側に通すことではなく、スパンを開くか修正することです。
メカニズムの全体像(エンドツーエンド):
- 「もの」(チャンネル、プロバイダー、エージェント、ツール、cron ジョブ、メモリバックエンド、…)は、その構造体の隣で
Attributableを一度だけ実装します。 - そのエントリポイントは処理を
attribution_span!(self)でラップし、そのものの役割とエイリアスを保持するトレーシングスパンを開きます。 - そのスパン内のどこかで発火した
record!は、直接であれ任意の深さでネストされていようとも、属性付けを自動的に継承します。 - イベントが発火すると、レイヤーはスパンスコープを葉→ルートの順にたどり、すべての
Attributableの寄与をマージして、マージ済みのzeroclaw.*ブロックを書き込みます。呼び出し側はそのいずれも名前を指定していませんでした。
これがこの設計の真髄です。つまり、個々の処理ごとのログ記録コードはゼロです。トレイトを一度実装し、エントリーポイントを一度ラップすれば、その配下で発生するすべての出力が自動的に関連付けられます。
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
}
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 と重大度によって区別されます。
- 実行前:
record!(DEBUG, Event::new(module_path!(), Action::Invoke).with_category(EventCategory::Tool).with_attrs(...))で、tool、tool_call_id、および完全なinputを attrs に含めます。 execute(args).awaitを実行します。- 成功時 (
r.success):Outcome::Success、実行時間、および attrs 内のtool/tool_call_id/input/outputを指定したrecord!(DEBUG, ... Action::Complete)。 - ツールが失敗を報告した場合(
!r.success):Outcome::Failure、実行時間、および属性内のtool/tool_call_id/input/error/outputを指定してrecord!(WARN, ... Action::Fail)を実行します。 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 です:
- ターゲット
"zeroclaw_log_internal_attribution"(attribution_span!マクロが開く際のターゲット)を指定した span の作成・記録時:role フィールドと alias フィールドを解析し、span の extensions に保存されるZeroclawAttributionスナップショットを生成します。 - ターゲット
"zeroclaw_log_internal_scope"(scope!でオープンされたもの)でのスパン作成・記録時:アドホックな kvp を解析し、同様に格納します。 - イベントが
"zeroclaw_log_event"ターゲット(record!マクロが発火するターゲット)で発行されると、次の処理を行います。zc_*フィールドセットからLogEventを構築し、スパンスコープを leaf→root へたどって見つかったすべての属性スナップショットをマージし、zc_attrsの JSON ブロブを解析してイベントのattributesに格納し、自動キャプチャされたソース位置から_file/_lineを付加して、最終的なイベントをwriter::record_eventに渡します。これは次の順序でファンアウトします。- Observer がバインドされたときにマッピングされた Prometheus / OTel 型付きイベントを処理する Observer ブリッジ (
observer_bridge.rs)。 - 送信者が設定されている場合に、現在の SSE/ダッシュボード購読者へブロードキャストするフック(
broadcast.rs)。 log_persistenceが有効な場合にのみ、JSONL の永続化(writer.rs)が非同期ライターキューに最後に渡されます。
- Observer がバインドされたときにマッピングされた Prometheus / OTel 型付きイベントを処理する Observer ブリッジ (
ディスク上の 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.5" },
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.rs | forward は Observer が明示的にバインドされるまで何も行わず、現在の本番ブートストラップでは Observer がバインドされていません。バインドされると同期的に転送しますが、project が認識するアクションのみを射影します。現在のマッピングでは、アクションやデフォルトフィールドが欠落する可能性があります。完全なイベント台帳ではなく、選択的なメトリクス/トレース射影として扱い、現在のフィールドマッピングについては project を確認してください。 |
| ライブ配信 | broadcast.rs とそのコンシューマー | 現在のプロセス内サブスクライバーに構造化イベントを送信します。サブスクライバーには購読開始後に発行されたイベントだけが表示され、有限容量の受信側では遅延が発生する可能性があり、ゲートウェイの SSE アダプターは遅延したフレームをスキップします。ブロードキャスト専用の一時的な属性は、認証済みのライブフレームに含まれることがありますが、永続化された JSONL からは除外されます。これはライブ通知経路であり、再生可能な証跡ではありません。 |
| 永続化された JSONL | writer.rs | シリアライズされたイベントをランタイムをブロックせずにキューに追加します。上限付きキューは満杯になるとイベントを破棄することがあり、ワーカーの書き込み失敗は警告として扱われ、定期的な sync_all は現在のアクティブファイルのみを対象とします。新しい UTC 日の最初の追記前に行われる日次ローテーションや、しきい値を超えた追記後に行われるサイズローテーションでは、先に同期せずにアクティブファイルの名前を変更することがあるため、この同期周期ではローテーション直後のアーカイブの永続性を保証できません。その後、永続化モードによって、アクティブファイルを切り詰めるか、無期限に保持するか、ローテーションするかが決まります。これはベストエフォートの運用履歴であり、トランザクション監査ログではありません。 |
すべての正規イベントが保持されたことを証明するために、Observer の出力や SSE 配信を使用しないでください。逆に、JSONL に存在しない行が一度も発行されなかったと推測しないでください。永続化キューによって破棄または処理に失敗する前に、ライブ配信や、バインドされている場合は Observer ブリッジに到達していた可能性があります。
読み取りカーソルは 1 つのアクティブファイルに属します
GET /api/logs は writer の現在のアクティブなパスを解決し、reader::load_page を呼び出します。reader はその 1 つの JSONL ファイルをスキャンし、条件に一致する最新のウィンドウを保持して、イベントを新しい順に返します。ローテーションされたアーカイブはマージしません。
主要なページネーションカーソルは next_cursor_line_offset で、現在のページで一致した最も古いイベントの直後にあるバイトオフセットです。呼び出し元はこれを until_line_offset として渡し直します。次のスキャンはその行の手前で停止し、より古い一致を返します。追記のみの場合、既存のカーソルが指すプレフィックスは保持されるため、後続のイベントが進行中の走査を妨げることはありません。
オフセットは、永続的なイベント識別子でも、ファイル間で使用できるチェックポイントでもありません。アクティブなファイルのバイト列が置き換えられるか、パスが変更されると、古くなります:
rollingtrim は、保持する末尾部分を一時ファイルにストリーミングし、そのファイルをアクティブなパスにリネームして置き換えます。rotatingはアクティブファイルの名前をアーカイブに変更し、次の追記で新しいアクティブファイルが作成されます。- スキーママイグレーションでは、一時ファイルを経由してアクティブファイルを書き換え、アトミックなリネームを行います。
- デーモンの設定を再読み込みすると、新しい永続化パスを設定できます。
そのような境界のいずれかを越えた後は、最新のページからページネーションを再開してください。API はカーソルにファイルの識別情報や生成メタデータを付加しないため、古い番号を再利用すると、行が重複したり、スキップされたり、無関係な行が返されたりする可能性があります。従来のタイムスタンプ/ID カーソルは互換性のために残されていますが、辞書順の ID 並べ替えによって同順位のイベントがスキップされる可能性があるため、#8012 により非推奨となっています。
永続化ポリシーが再書き込みと保持を担当します
config.rs の StoragePolicy は JSONL の宛先のみを制御します。Observer とブロードキャスト配信はこの設定とは独立しています。
| ポリシー | アクティブファイルの動作 | 保持担当者 |
|---|---|---|
none | 新しい JSONL の書き込みはありません。 | なし。 |
rolling | 追記によって max_entries を超えた場合は、最新の空でない行だけを一時ファイルにストリーミングし、その一時ファイルをアクティブなファイルにリネームして置き換えます。 | writer は設定されたアクティブウィンドウのサイズを維持します。アーカイブは作成せず、以前の rotating 設定によるアーカイブは管理対象外のままにします。 |
full | 書き込み側が管理するトリミングやローテーションを行わずに追記します。 | ファイルの増大と外部ローテーションはオペレーターが管理します。 |
rotating | 新しい UTC 日の最初の追記を行う前、または追記サイズがバイトしきい値に達した後に、アクティブファイルの名前をタイムスタンプ付きアーカイブに変更します。 | ローテーションが正常に完了するたびに、writer は該当するアーカイブを、まず経過時間、次に個数に基づいて整理します。削除はベストエフォートで行われ、外側の append 処理が失敗することはありません。 |
経過時間および件数に基づく保持処理は、ローテーション後にのみ実行されます。継続的に走査することはなく、full や rolling には適用されず、任意の隣接ファイルを削除することもありません。アーカイブの検出で受け入れられるのは、アクティブパスのタイムスタンプ付きアーカイブ形式から生成された名前だけです。ライブの /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_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 エクスポート)とクエリ構文については、ログと可観測性を参照してください。