はじめに / 対象と前提
AI エージェント(Claude Code のヘッドレス実行や、自作のエージェントループ)を夜間に無人で回していると、朝になって「何が起きたか」を追えるのはログだけになる。ところが自分の環境では、ログはちゃんと仕込んだつもりなのに、肝心の時間帯だけ残っていないという事故が 3 回起きた。
この記事は、その 3 つの原因と回避策を再現コード付きでまとめたもの。
- 想定読者:Python で長時間動くワーカー/エージェントを書いている人
- 前提:
logging標準モジュールの基本(getLogger/Handler/Formatter)が分かる - 動作確認環境:macOS 15 / Python 3.14.3(3.11〜3.13 でも考え方は同じ。ただし後述の通りローテーションの挙動はバージョンで違う)
TL;DR
- 同じログファイルを 複数プロセスが
TimedRotatingFileHandlerで書くと、ローテーション後に片方が「昨日のファイル」に書き続ける(古い Python では前日分が消える)。ファイルはプロセスごとに分けるか、WatchedFileHandler+ 外部ローテーションにする -
logging.basicConfig()は ルートにハンドラーが 1 つでもあると何もしない。force=Trueを付ける - 別スレッドで落ちた例外は ログに残らない。
sys.excepthookとthreading.excepthookの両方を差し替える
手順 / 動かし方
最終的に落ち着いたセットアップがこれ。エージェント本体の一番最初に呼ぶ。
import logging
import logging.handlers
import os
import sys
import threading
from pathlib import Path
def setup_logging(role: str, log_dir: Path = Path("logs")) -> None:
log_dir.mkdir(exist_ok=True)
# ① プロセス(役割)ごとにファイルを分ける
path = log_dir / f"{role}.log"
handler = logging.handlers.TimedRotatingFileHandler(
path, when="midnight", backupCount=14, encoding="utf-8"
)
fmt = "%(asctime)s %(process)d %(threadName)s %(levelname)s %(name)s: %(message)s"
# ② 既存ハンドラーがあっても確実に上書きする
logging.basicConfig(level=logging.INFO, format=fmt,
handlers=[handler, logging.StreamHandler()], force=True)
log = logging.getLogger("uncaught")
# ③ メインスレッドの未捕捉例外
def _excepthook(exc_type, exc, tb):
if issubclass(exc_type, KeyboardInterrupt):
return sys.__excepthook__(exc_type, exc, tb)
log.critical("uncaught exception", exc_info=(exc_type, exc, tb))
sys.excepthook = _excepthook
# ③' 別スレッドの未捕捉例外(Python 3.8+)
def _thread_hook(args: threading.ExceptHookArgs):
log.critical("uncaught exception in thread %s",
args.thread.name if args.thread else "?",
exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
threading.excepthook = _thread_hook
使う側:
setup_logging(role=f"worker-{os.getpid()}")
logging.getLogger(__name__).info("agent loop start")
フォーマットに %(process)d と %(threadName)s を入れておくと、並列で動いたときにどのワーカーの出力か後から切り分けられる。
ハマりどころ
1. 複数プロセスで同じファイルをローテーションすると、片方が昨日のファイルに書き続ける
症状:日付が変わった後のログが agent.log に一部しか無い。探したら agent.log.2026-10-13 の末尾に今日の日付の行が延々と追記されていた。
再現(1 秒ごとにローテーションさせ、2 つのハンドラー=2 プロセス相当で同じファイルを書く):
import logging, logging.handlers, os, time
def mk(name):
lg = logging.getLogger(name); lg.propagate = False; lg.setLevel(logging.INFO)
h = logging.handlers.TimedRotatingFileHandler("agent.log", when="S", interval=1)
h.setFormatter(logging.Formatter(f"{name} %(message)s"))
lg.addHandler(h)
return lg
a, b = mk("A"), mk("B")
a.info("a1"); b.info("b1")
time.sleep(1.2)
a.info("a2-after-rollover")
b.info("b2-after-rollover")
b.info("b3")
実行結果:
== agent.log
A a2-after-rollover
== agent.log.2026-10-14_08-00-56
A a1
B b1
B b2-after-rollover
B b3
原因:ローテーションは「各ハンドラーが自分の判断で」行う。先に A が agent.log をリネームして新しいファイルを開く。B も時刻を過ぎたのでローテーションしようとするが、リネーム先がもう存在するので 何もせず return する(Python 3.14 の doRollover の実装)。B は古いファイルディスクリプタを握ったままなので、書き込みはリネーム後のファイル、つまり「昨日分」に入り続ける。
古いバージョンの実装では、リネーム先が存在すると それを削除してからリネーム していたので、A が退避した前日分のログがまるごと消える。どちらにしても複数プロセスで 1 ファイルを共有する構成は破綻する。
回避策(どれか 1 つ):
- ファイルをプロセス(役割)ごとに分ける(上の
setup_logging(role=...)。いちばん簡単) - Python 側ではローテーションせず
logging.handlers.WatchedFileHandlerを使い、newsyslog(macOS)やlogrotate(Linux)に回させる。WatchedFileHandlerはファイルの inode が変わったら開き直すので、外部ローテーションと相性がいい - 同じプロセス内の複数スレッドだけなら問題ない(ハンドラー 1 つをロックで共有するため)。非同期処理から書く場合は
QueueHandler+QueueListenerで書き込みを 1 スレッドに集約する
2. basicConfig() が黙って無視される
症状:level=logging.DEBUG にしたのに DEBUG が出ない。フォーマットも反映されない。
import logging
logging.getLogger().addHandler(logging.StreamHandler()) # どこかで先に付けられた
logging.basicConfig(level=logging.DEBUG, format="X %(message)s")
logging.getLogger().debug("debug visible?")
print("root level:", logging.getLogger().level)
logging.basicConfig(level=logging.DEBUG, format="X %(message)s", force=True)
logging.getLogger().debug("after force")
X after force
root level: 30
原因:basicConfig() は ルートロガーにハンドラーが既にあると何もしない 仕様。エラーも警告も出ない。import したライブラリや、テストランナー、別モジュールの logging.info() 呼び出し(ハンドラー未設定時にルートへ自動で付く)で先にハンドラーが付いていると、レベル設定ごとスキップされる(上の 30 は既定の WARNING)。
回避策:force=True(Python 3.8+)を付けて既存ハンドラーを外してから設定する。dictConfig を使う場合は、既定値 disable_existing_loggers=True によって 設定前に作られたロガーが無効化される ので、"disable_existing_loggers": False を明示するのを忘れないこと。
3. 別スレッドの例外がログに残らない
症状:ハートビート用のスレッドが止まっていたのに、ログには何も無い。プロセス自体は生きているので監視にも引っかからない。
原因:threading.Thread の中で投げられた未捕捉例外は threading.excepthook に渡され、既定では stderr に出るだけ。無人実行で stderr を捨てていると完全に消える。sys.excepthook だけ差し替えても、スレッド側には効かない。
回避策:上のコードの通り sys.excepthook と threading.excepthook の両方をロガーに流す。asyncio を使っている場合は、loop.set_exception_handler() で「回収されなかった Task の例外」も拾っておくと安心。
背景・補足
logging のハンドラーは「1 プロセスの中で 1 つのファイルを管理する」前提で作られている。スレッド間ではロックで守られるが、プロセスをまたぐロックは一切無い。エージェントを並列化してワーカーを増やした瞬間にこの前提が崩れるので、ログ設計は「何プロセスが同じファイルを触るか」から決めるのが安全。
まとめ
- 複数プロセスで同じファイルを
TimedRotatingFileHandler/RotatingFileHandlerしない。プロセスごとにファイルを分けるか、外部ローテーション +WatchedFileHandler -
basicConfig(force=True)、dictConfigならdisable_existing_loggers: False -
sys.excepthookとthreading.excepthookの両方をロガーにつなぐ - フォーマットに
%(process)d%(threadName)sを入れて、並列実行時の切り分けをしやすくする