Skip to content

Commit 95ff466

Browse files
fix(obs): hand an agent's own loggers to the logs pipeline, not just agentex's
The hand-over added in #518 had two halves, and only one of them covered an agent's own modules. The latch in `make_logger` never looked at the logger's name, so anything created after init was fine. The sweep matched the `agentex` prefix, so a logger created BEFORE init under any other name kept its handler and went on printing a second, ungoverned copy of every record. That is not a corner case: agents call `make_logger(__name__)` from their own modules, and `project.acp` -- the module that builds the ACP server, in every scaffold -- logs at import, which is necessarily before `init_sgp_obs` runs. Measured on dbt-assistant running 0.27.0b1: 123 of 3361 log lines were the second copy, each 80 microseconds after its governed twin, carrying `name`/`request_id` but no `trace_id`, `span_id`, `source` or `agent_id`. Since it is emitted before the pipeline's filters, it also escapes the allowlist and the truncation. The SDK cannot know an agent's package name, so `make_logger` now marks each handler it attaches and the sweep takes back exactly those, on a logger of any name. Prefix matching stays for `agentex.*` itself, where every handler is ours by definition. A handler this module did not attach is still left alone -- litellm's three loggers and anything else keep what their owner set up, which is why sgp-obs warns about them rather than stripping them. Renamed `route_agentex_loggers_to_root` to `route_loggers_to_root`, since "agentex loggers" is what the bug was. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5271bb9 commit 95ff466

4 files changed

Lines changed: 156 additions & 57 deletions

File tree

‎src/agentex/lib/core/observability/sgp_obs_setup.py‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
from agentex.lib.utils.logging import (
5454
make_logger,
5555
_reset_for_tests as _logging_reset_for_tests,
56-
route_agentex_loggers_to_root,
56+
route_loggers_to_root,
5757
)
5858

5959
logger = make_logger(__name__)
@@ -145,7 +145,7 @@ def init_sgp_obs(app: Any = None) -> str:
145145
return _status
146146

147147
if "logs" in handles:
148-
_hand_agentex_logging_to_the_pipeline()
148+
_hand_logging_to_the_pipeline()
149149

150150
if "traces" in handles:
151151
_install_openai_agents_bridge()
@@ -156,8 +156,8 @@ def init_sgp_obs(app: Any = None) -> str:
156156
return _status
157157

158158

159-
def _hand_agentex_logging_to_the_pipeline() -> None:
160-
"""Stop agentex's own loggers printing a second, ungoverned copy of every record.
159+
def _hand_logging_to_the_pipeline() -> None:
160+
"""Stop a second, ungoverned copy of every log record being printed.
161161
162162
``agentex.lib.utils.logging.make_logger`` attaches a handler to each module's own
163163
(leaf) logger. sgp-obs' logs pipeline replaces the handlers on the ROOT logger and
@@ -169,27 +169,31 @@ def _hand_agentex_logging_to_the_pipeline() -> None:
169169
170170
The duplicate is not merely redundant: it is emitted before the pipeline's filters,
171171
so it carries no ``agent_id``/``task_id``, is not governed by the allowlist, and is
172-
not truncated.
173-
174-
Only agentex's loggers are handed over — see
175-
:func:`~agentex.lib.utils.logging.route_agentex_loggers_to_root` for why by prefix,
176-
why ``capture_loggers=`` is not the mechanism, and why a third party's handler is
177-
left where it is.
172+
not truncated. Measured on dbt-assistant running 0.27.0b1: 123 of 3361 log lines
173+
were the second copy, each one 80 microseconds after its governed twin.
174+
175+
An agent's OWN modules are covered, not just the SDK's. They call
176+
``make_logger(__name__)`` too, under the agent's package name, and that is where
177+
the dbt-assistant duplicate came from. See
178+
:func:`~agentex.lib.utils.logging.route_loggers_to_root` for how a handler is
179+
recognised as the SDK's on a logger whose name the SDK cannot predict, why
180+
``capture_loggers=`` is not the mechanism, and why a third party's handler is left
181+
where it is.
178182
"""
179183
try:
180-
cleared = route_agentex_loggers_to_root()
184+
cleared = route_loggers_to_root()
181185
except Exception: # pragma: no cover - telemetry must never break startup
182-
logger.debug("could not hand agentex logging to the sgp-obs pipeline", exc_info=True)
186+
logger.debug("could not hand agentex logging to the sgp-obs logs pipeline", exc_info=True)
183187
return
184188

185189
if cleared:
186190
# sgp-obs has already logged its "bypass log governance" warning by this point,
187191
# naming loggers this call has just fixed. Say so, or the two lines read as a
188192
# contradiction to whoever is looking at the pod's first second of output.
189193
logger.info(
190-
"routed %d agentex logger(s) through the sgp-obs logs pipeline; any "
191-
"'bypass log governance' warning above that names agentex.* loggers was "
192-
"emitted before this ran and no longer applies to them",
194+
"routed %d logger(s) through the sgp-obs logs pipeline; any 'bypass log "
195+
"governance' warning above that names an agentex.* logger, or one of this "
196+
"agent's own, was emitted before this ran and no longer applies to it",
193197
cleared,
194198
)
195199

‎src/agentex/lib/core/observability/tests/test_sgp_obs_setup.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -403,18 +403,18 @@ def boom():
403403

404404

405405
class TestLoggingHandover:
406-
"""agentex's make_logger attaches a handler to each module's own logger; sgp-obs'
407-
logs pipeline owns the ROOT logger and deliberately leaves named loggers alone. Both
408-
then print, so every record appears twice — and the agentex copy is emitted before
409-
the pipeline's filters, so it carries no agent_id/task_id, is not governed by the
410-
allowlist, and is not truncated.
406+
"""agentex's make_logger attaches a handler to each module's own logger — the
407+
agent's modules as well as the SDK's; sgp-obs' logs pipeline owns the ROOT logger and
408+
deliberately leaves named loggers alone. Both then print, so every record appears
409+
twice — and the leaf copy is emitted before the pipeline's filters, so it carries no
410+
agent_id/task_id, is not governed by the allowlist, and is not truncated.
411411
"""
412412

413413
@staticmethod
414414
def _spy(monkeypatch):
415415
calls = []
416416
monkeypatch.setattr(
417-
sgp_obs_setup, "route_agentex_loggers_to_root", lambda: calls.append(True) or 1
417+
sgp_obs_setup, "route_loggers_to_root", lambda: calls.append(True) or 1
418418
)
419419
return calls
420420

@@ -442,6 +442,6 @@ def test_a_failing_handover_does_not_stop_startup(self, monkeypatch):
442442
def boom():
443443
raise RuntimeError("logging registry is in a strange state")
444444

445-
monkeypatch.setattr(sgp_obs_setup, "route_agentex_loggers_to_root", boom)
445+
monkeypatch.setattr(sgp_obs_setup, "route_loggers_to_root", boom)
446446
_fake_sgp_obs(monkeypatch, lambda **_kwargs: {"logs": object()})
447447
assert init_sgp_obs() == "wired:logs"

‎src/agentex/lib/utils/logging.py‎

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,29 @@
2424
#
2525
# While this is True, ``make_logger`` attaches nothing and the record reaches the root
2626
# pipeline by propagation alone. ``sgp_obs_setup`` sets it via
27-
# :func:`route_agentex_loggers_to_root` -- nothing else may.
27+
# :func:`route_loggers_to_root` -- nothing else may.
2828
_ROOT_PIPELINE_OWNS_LOGGING = False
2929

3030
# Handlers are cleared by prefix rather than by an enumerated list: the names are
3131
# module paths, several agentex modules are imported LAZILY, and any list would be a
3232
# snapshot that goes stale the moment one of them loads.
3333
_PACKAGE_ROOT = "agentex"
3434

35+
# ``make_logger`` stamps every handler it attaches, so the hand-over can find its own
36+
# handlers again on a logger of ANY name.
37+
#
38+
# The prefix above cannot reach them all, and that gap was a measured duplicate rather
39+
# than a theoretical one: agents call ``make_logger(__name__)`` from their own modules,
40+
# whose names come from the agent's package (``project.acp`` in every scaffold), so the
41+
# prefix does not match and the leaf handler stayed attached. On dbt-assistant, 123 of
42+
# 3361 log lines were a second, ungoverned copy carrying ``name``/``request_id`` but no
43+
# ``trace_id``, ``span_id``, ``source`` or ``agent_id``. The SDK cannot know an agent's
44+
# package name, so ownership is recorded on the handler at the moment it is attached.
45+
#
46+
# Marking the handler rather than keeping a registry of logger names means there is no
47+
# bookkeeping to go stale, and a handler moved to another logger is still recognised.
48+
_OWNED_BY_MAKE_LOGGER = "_agentex_make_logger_owned"
49+
3550

3651
def resolve_log_level() -> int:
3752
"""Read the log level from ``LOG_LEVEL``, falling back to INFO.
@@ -81,6 +96,17 @@ def json_record(self, message: str, extra: dict, record: logging.LogRecord) -> d
8196

8297
return extra
8398

99+
100+
def _attach(logger: logging.Logger, handler: logging.Handler) -> None:
101+
"""Attach ``handler`` and record that this module owns it.
102+
103+
The mark is what lets :func:`route_loggers_to_root` take this handler back off a
104+
logger whose name it could not have predicted.
105+
"""
106+
setattr(handler, _OWNED_BY_MAKE_LOGGER, True)
107+
logger.addHandler(handler)
108+
109+
84110
def make_logger(name: str) -> logging.Logger:
85111
"""
86112
Creates a logger object with a RichHandler to print colored text.
@@ -102,13 +128,15 @@ def make_logger(name: str) -> logging.Logger:
102128
if environment == "local":
103129
console = Console()
104130
# Add the RichHandler to the logger to print colored text
105-
handler = RichHandler(
106-
console=console,
107-
show_level=False,
108-
show_path=False,
109-
show_time=False,
131+
_attach(
132+
logger,
133+
RichHandler(
134+
console=console,
135+
show_level=False,
136+
show_path=False,
137+
show_time=False,
138+
),
110139
)
111-
logger.addHandler(handler)
112140
return logger
113141

114142
stream_handler = logging.StreamHandler()
@@ -119,21 +147,22 @@ def make_logger(name: str) -> logging.Logger:
119147
logging.Formatter("%(asctime)s %(levelname)s [%(name)s] [%(filename)s:%(lineno)d] - %(message)s")
120148
)
121149

122-
logger.addHandler(stream_handler)
150+
_attach(logger, stream_handler)
123151
# Create a logger object with the name of the current module
124152
return logger
125153

126154

127-
def route_agentex_loggers_to_root() -> int:
128-
"""Hand agentex's logging over to whatever owns the root logger. Returns the
129-
number of loggers cleared.
155+
def route_loggers_to_root() -> int:
156+
"""Hand logging over to whatever owns the root logger. Returns the number of
157+
loggers a handler was taken off.
130158
131159
Two halves, and BOTH are needed -- measured, one line per ``logger.info()`` only
132160
when they run together:
133161
134-
* the sweep below fixes the loggers that ALREADY exist, i.e. every agentex module
135-
imported before this ran;
136-
* the flag fixes every logger created AFTER it, which a sweep cannot reach.
162+
* the sweep below fixes the loggers that ALREADY exist, i.e. every module whose
163+
``make_logger`` call ran before this did -- the whole of an agent's own code,
164+
since the ACP server is constructed from a module that logs;
165+
* the latch fixes every logger created AFTER it, which a sweep cannot reach.
137166
agentex imports several modules lazily (the adk ``_claude_code_sync`` /
138167
``_codex_sync`` / ``_pydantic_ai_sync`` harnesses among them), so their
139168
``make_logger`` call happens later and would attach a fresh duplicate handler.
@@ -144,9 +173,17 @@ def route_agentex_loggers_to_root() -> int:
144173
paths; and passing anything at all replaces its uvicorn default, which would put
145174
uvicorn's access log back to printing twice.
146175
147-
Only agentex's own loggers are touched. A third party's handler may be there on
148-
purpose -- which is exactly why sgp-obs warns about them rather than stripping them
149-
-- so litellm's three loggers and anything else keep whatever they have.
176+
A handler is taken off only when it is ours, on one of two grounds:
177+
178+
* anything under the ``agentex`` prefix is this package's own logger, so every
179+
handler on it is ours to move;
180+
* on a logger of any other name -- an agent's ``project.acp``, or any third
181+
party's -- only a handler carrying :data:`_OWNED_BY_MAKE_LOGGER` is touched.
182+
183+
That second rule is the fix for the duplicate measured on dbt-assistant, and it is
184+
narrow on purpose. A third party's handler may be there deliberately -- which is
185+
exactly why sgp-obs warns about them rather than stripping them -- so litellm's
186+
three loggers and anything else keep whatever they set up themselves.
150187
"""
151188
global _ROOT_PIPELINE_OWNS_LOGGING
152189
_ROOT_PIPELINE_OWNS_LOGGING = True
@@ -157,22 +194,26 @@ def route_agentex_loggers_to_root() -> int:
157194
for name, existing in list(logging.Logger.manager.loggerDict.items()):
158195
if not isinstance(existing, logging.Logger):
159196
continue # a PlaceHolder for a name whose children exist but itself does not
160-
if name != _PACKAGE_ROOT and not name.startswith(_PACKAGE_ROOT + "."):
161-
continue
162197
if not existing.handlers:
163198
continue
164199
if not existing.propagate:
165200
# Deliberately cut off from root, so nothing of its reaches the pipeline.
166201
# Clearing its handlers would send its records NOWHERE -- worse than a
167202
# duplicate. Leave it exactly as its owner set it up.
168203
continue
204+
ours = name == _PACKAGE_ROOT or name.startswith(_PACKAGE_ROOT + ".")
205+
removed = 0
169206
for handler in list(existing.handlers):
207+
if not ours and not getattr(handler, _OWNED_BY_MAKE_LOGGER, False):
208+
continue
170209
try:
171210
handler.flush() # a buffering handler must not lose records on removal
172211
except Exception:
173212
pass
174213
existing.removeHandler(handler)
175-
cleared += 1
214+
removed += 1
215+
if removed:
216+
cleared += 1
176217
return cleared
177218

178219

0 commit comments

Comments
 (0)