LastNoob commited on
Commit
df4cedd
·
1 Parent(s): 5d17e07

feat(logging): structured TRACE events and end-to-end request correlation

Browse files

Add core/trace.py with trace_event, traced_async_stream, and payload snapshots.
Merge TRACE fields into JSON logs; promote claude_session_id, http path/method.
Instrument API, messaging/CLI, and OpenAI-compat/native provider paths.
Harden log sink with enqueue and stdlib intercept re-entrancy guard.
Document behavior in .env.example and README; extend tests.

.env.example CHANGED
@@ -136,6 +136,11 @@ WEB_FETCH_ALLOWED_SCHEMES=http,https
136
  WEB_FETCH_ALLOW_PRIVATE_NETWORKS=false
137
 
138
 
 
 
 
 
 
139
  # Verbose diagnostics (avoid logging raw prompts / SSE bodies in production)
140
  DEBUG_PLATFORM_EDITS=false
141
  DEBUG_SUBAGENT_STACK=false
@@ -144,7 +149,7 @@ LOG_RAW_API_PAYLOADS=false
144
  LOG_RAW_SSE_EVENTS=false
145
  # When true, log full exception text and tracebacks for unhandled errors (may leak request-derived data).
146
  LOG_API_ERROR_TRACEBACKS=false
147
- # When true, log message/transcription text previews in messaging adapters (may leak user content).
148
  LOG_RAW_MESSAGING_CONTENT=false
149
  # When true, log full Claude CLI stderr, non-JSON stdout lines, and parser error text.
150
  LOG_RAW_CLI_DIAGNOSTICS=false
 
136
  WEB_FETCH_ALLOW_PRIVATE_NETWORKS=false
137
 
138
 
139
+ # Structured TRACE logs: lines with `"trace": true` merge ingress/routing/cli/provider/egress
140
+ # stages. Conversation text is logged in those payloads (verbatim). Values under keys named
141
+ # like ``api_key`` / ``authorization`` are redacted. Raw transport payloads still require
142
+ # the LOG_RAW_* toggles below.
143
+ #
144
  # Verbose diagnostics (avoid logging raw prompts / SSE bodies in production)
145
  DEBUG_PLATFORM_EDITS=false
146
  DEBUG_SUBAGENT_STACK=false
 
149
  LOG_RAW_SSE_EVENTS=false
150
  # When true, log full exception text and tracebacks for unhandled errors (may leak request-derived data).
151
  LOG_API_ERROR_TRACEBACKS=false
152
+ # When true, log message/transcription text previews in messaging adapters only (handler ingress always TRACEs verbatim text separately).
153
  LOG_RAW_MESSAGING_CONTENT=false
154
  # When true, log full Claude CLI stderr, non-JSON stdout lines, and parser error text.
155
  LOG_RAW_CLI_DIAGNOSTICS=false
README.md CHANGED
@@ -406,6 +406,8 @@ LOG_MESSAGING_ERROR_DETAILS=false
406
 
407
  Raw logging flags can expose prompts, tool arguments, paths, and model output. Keep them off unless you are debugging locally.
408
 
 
 
409
  ### 6. Local Web Tools
410
 
411
  ```dotenv
 
406
 
407
  Raw logging flags can expose prompts, tool arguments, paths, and model output. Keep them off unless you are debugging locally.
408
 
409
+ Structured TRACE rows append fields such as `"trace": true`, `stage`, `event`, and `source` and include conversation context needed to follow Claude Code flows end-to-end. Dictionary keys resembling credentials (for example `api_key` / `authorization` values nested in structured payloads) are redacted; arbitrary prose you type into prompts may still appear verbatim.
410
+
411
  ### 6. Local Web Tools
412
 
413
  ```dotenv
api/app.py CHANGED
@@ -13,6 +13,7 @@ from starlette.types import Receive, Scope, Send
13
 
14
  from config.logging_config import configure_logging
15
  from config.settings import get_settings
 
16
  from providers.exceptions import ProviderError
17
 
18
  from .admin_routes import router as admin_router
@@ -95,6 +96,18 @@ def create_app(*, lifespan_enabled: bool = True) -> FastAPI:
95
  app_kwargs["lifespan"] = lifespan
96
  app = FastAPI(**app_kwargs)
97
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  # Register routes
99
  app.include_router(admin_router)
100
  app.include_router(router)
@@ -111,14 +124,16 @@ def create_app(*, lifespan_enabled: bool = True) -> FastAPI:
111
 
112
  message_summary, tool_names = summarize_request_validation_body(body)
113
 
114
- logger.debug(
115
- "Request validation failed: path={} query={} error_locs={} error_types={} message_summary={} tool_names={}",
116
- request.url.path,
117
- str(request.url.query),
118
- [list(error.get("loc", ())) for error in exc.errors()],
119
- [str(error.get("type", "")) for error in exc.errors()],
120
- message_summary,
121
- tool_names,
 
 
122
  )
123
  return await request_validation_exception_handler(request, exc)
124
 
 
13
 
14
  from config.logging_config import configure_logging
15
  from config.settings import get_settings
16
+ from core.trace import extract_claude_session_id_from_headers, trace_event
17
  from providers.exceptions import ProviderError
18
 
19
  from .admin_routes import router as admin_router
 
96
  app_kwargs["lifespan"] = lifespan
97
  app = FastAPI(**app_kwargs)
98
 
99
+ @app.middleware("http")
100
+ async def trace_http_correlation(request: Request, call_next):
101
+ """Attach HTTP identifiers and optional Claude session id to logs."""
102
+ claude_sid = extract_claude_session_id_from_headers(request.headers)
103
+ with logger.contextualize(
104
+ http_method=request.method,
105
+ http_path=request.url.path,
106
+ claude_session_id=claude_sid,
107
+ ):
108
+ response = await call_next(request)
109
+ return response
110
+
111
  # Register routes
112
  app.include_router(admin_router)
113
  app.include_router(router)
 
124
 
125
  message_summary, tool_names = summarize_request_validation_body(body)
126
 
127
+ trace_event(
128
+ stage="ingress",
129
+ event="server.request.validation_failed",
130
+ source="api",
131
+ path=request.url.path,
132
+ query=dict(request.query_params),
133
+ error_locs=[list(error.get("loc", ())) for error in exc.errors()],
134
+ error_types=[str(error.get("type", "")) for error in exc.errors()],
135
+ message_summary=message_summary,
136
+ tool_names=tool_names,
137
  )
138
  return await request_validation_exception_handler(request, exc)
139
 
api/routes.py CHANGED
@@ -5,6 +5,7 @@ from loguru import logger
5
 
6
  from config.settings import Settings
7
  from core.anthropic import get_token_count
 
8
  from providers.registry import ProviderRegistry
9
 
10
  from . import dependencies
@@ -231,6 +232,7 @@ async def list_models(
231
  _auth=Depends(require_api_key),
232
  ):
233
  """List the model ids this proxy advertises to Claude-compatible clients."""
 
234
  registry = getattr(request.app.state, "provider_registry", None)
235
  provider_registry = registry if isinstance(registry, ProviderRegistry) else None
236
  return _build_models_list_response(settings, provider_registry)
@@ -250,5 +252,11 @@ async def stop_cli(request: Request, _auth=Depends(require_api_key)):
250
  raise HTTPException(status_code=503, detail="Messaging system not initialized")
251
 
252
  count = await handler.stop_all_tasks()
 
 
 
 
 
 
253
  logger.info("STOP_CLI: source=handler cancelled_count={}", count)
254
  return {"status": "stopped", "cancelled_count": count}
 
5
 
6
  from config.settings import Settings
7
  from core.anthropic import get_token_count
8
+ from core.trace import trace_event
9
  from providers.registry import ProviderRegistry
10
 
11
  from . import dependencies
 
232
  _auth=Depends(require_api_key),
233
  ):
234
  """List the model ids this proxy advertises to Claude-compatible clients."""
235
+ trace_event(stage="ingress", event="api.models.list", source="api")
236
  registry = getattr(request.app.state, "provider_registry", None)
237
  provider_registry = registry if isinstance(registry, ProviderRegistry) else None
238
  return _build_models_list_response(settings, provider_registry)
 
252
  raise HTTPException(status_code=503, detail="Messaging system not initialized")
253
 
254
  count = await handler.stop_all_tasks()
255
+ trace_event(
256
+ stage="ingress",
257
+ event="api.cli.stop_via_handler",
258
+ source="api",
259
+ cancelled_nodes=count,
260
+ )
261
  logger.info("STOP_CLI: source=handler cancelled_count={}", count)
262
  return {"status": "stopped", "cancelled_count": count}
api/services.py CHANGED
@@ -14,6 +14,7 @@ from loguru import logger
14
  from config.settings import Settings
15
  from core.anthropic import get_token_count, get_user_facing_error_message
16
  from core.anthropic.sse import ANTHROPIC_SSE_RESPONSE_HEADERS
 
17
  from providers.base import BaseProvider
18
  from providers.exceptions import InvalidRequestError, ProviderError
19
 
@@ -118,7 +119,12 @@ class ClaudeProxyService:
118
  input_tokens = self._token_counter(
119
  routed.request.messages, routed.request.system, routed.request.tools
120
  )
121
- logger.info("Optimization: Handling Anthropic web server tool")
 
 
 
 
 
122
  egress = WebFetchEgressPolicy(
123
  allow_private_network_targets=self._settings.web_fetch_allow_private_networks,
124
  allowed_schemes=self._settings.web_fetch_allowed_scheme_set(),
@@ -134,6 +140,12 @@ class ClaudeProxyService:
134
 
135
  optimized = try_optimizations(routed.request, self._settings)
136
  if optimized is not None:
 
 
 
 
 
 
137
  return optimized
138
  logger.debug("No optimization matched, routing to provider")
139
 
@@ -143,29 +155,57 @@ class ClaudeProxyService:
143
  thinking_enabled=routed.resolved.thinking_enabled,
144
  )
145
 
146
- request_id = f"req_{uuid.uuid4().hex[:12]}"
147
- logger.info(
148
- "API_REQUEST: request_id={} model={} messages={}",
149
- request_id,
150
- routed.request.model,
151
- len(routed.request.messages),
 
 
 
152
  )
153
- if self._settings.log_raw_api_payloads:
154
- logger.debug(
155
- "FULL_PAYLOAD [{}]: {}", request_id, routed.request.model_dump()
 
 
 
 
 
 
156
  )
157
 
158
- input_tokens = self._token_counter(
159
- routed.request.messages, routed.request.system, routed.request.tools
160
- )
161
- return anthropic_sse_streaming_response(
162
- provider.stream_response(
163
- routed.request,
164
- input_tokens=input_tokens,
165
- request_id=request_id,
166
- thinking_enabled=routed.resolved.thinking_enabled,
167
- ),
168
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  except ProviderError:
171
  raise
@@ -188,12 +228,23 @@ class ClaudeProxyService:
188
  tokens = self._token_counter(
189
  routed.request.messages, routed.request.system, routed.request.tools
190
  )
191
- logger.info(
192
- "COUNT_TOKENS: request_id={} model={} messages={} input_tokens={}",
193
- request_id,
194
- routed.request.model,
195
- len(routed.request.messages),
196
- tokens,
 
 
 
 
 
 
 
 
 
 
 
197
  )
198
  return TokenCountResponse(input_tokens=tokens)
199
  except ProviderError:
 
14
  from config.settings import Settings
15
  from core.anthropic import get_token_count, get_user_facing_error_message
16
  from core.anthropic.sse import ANTHROPIC_SSE_RESPONSE_HEADERS
17
+ from core.trace import api_messages_request_snapshot, trace_event, traced_async_stream
18
  from providers.base import BaseProvider
19
  from providers.exceptions import InvalidRequestError, ProviderError
20
 
 
119
  input_tokens = self._token_counter(
120
  routed.request.messages, routed.request.system, routed.request.tools
121
  )
122
+ trace_event(
123
+ stage="routing",
124
+ event="api.optimization.web_server_tool",
125
+ source="api",
126
+ model=routed.request.model,
127
+ )
128
  egress = WebFetchEgressPolicy(
129
  allow_private_network_targets=self._settings.web_fetch_allow_private_networks,
130
  allowed_schemes=self._settings.web_fetch_allowed_scheme_set(),
 
140
 
141
  optimized = try_optimizations(routed.request, self._settings)
142
  if optimized is not None:
143
+ trace_event(
144
+ stage="routing",
145
+ event="api.optimization.short_circuit",
146
+ source="api",
147
+ model=routed.request.model,
148
+ )
149
  return optimized
150
  logger.debug("No optimization matched, routing to provider")
151
 
 
155
  thinking_enabled=routed.resolved.thinking_enabled,
156
  )
157
 
158
+ trace_event(
159
+ stage="routing",
160
+ event="api.route.resolved",
161
+ source="api",
162
+ provider_id=routed.resolved.provider_id,
163
+ provider_model=routed.resolved.provider_model,
164
+ provider_model_ref=routed.resolved.provider_model_ref,
165
+ gateway_model=routed.request.model,
166
+ thinking_enabled=routed.resolved.thinking_enabled,
167
  )
168
+
169
+ request_id = f"req_{uuid.uuid4().hex[:12]}"
170
+ with logger.contextualize(request_id=request_id):
171
+ trace_event(
172
+ stage="ingress",
173
+ event="api.request.received",
174
+ source="api",
175
+ message_count=len(routed.request.messages),
176
+ snapshot=api_messages_request_snapshot(routed.request),
177
  )
178
 
179
+ if self._settings.log_raw_api_payloads:
180
+ logger.debug(
181
+ "FULL_PAYLOAD [{}]: {}", request_id, routed.request.model_dump()
182
+ )
183
+
184
+ input_tokens = self._token_counter(
185
+ routed.request.messages,
186
+ routed.request.system,
187
+ routed.request.tools,
188
+ )
189
+
190
+ streamed = traced_async_stream(
191
+ provider.stream_response(
192
+ routed.request,
193
+ input_tokens=input_tokens,
194
+ request_id=request_id,
195
+ thinking_enabled=routed.resolved.thinking_enabled,
196
+ ),
197
+ stage="egress",
198
+ source="api",
199
+ complete_event="api.response.stream_completed",
200
+ interrupted_event="api.response.stream_interrupted",
201
+ chunk_event=None,
202
+ extra={
203
+ "request_id": request_id,
204
+ "provider_id": routed.resolved.provider_id,
205
+ "gateway_model": routed.request.model,
206
+ },
207
+ )
208
+ return anthropic_sse_streaming_response(streamed)
209
 
210
  except ProviderError:
211
  raise
 
228
  tokens = self._token_counter(
229
  routed.request.messages, routed.request.system, routed.request.tools
230
  )
231
+ trace_event(
232
+ stage="routing",
233
+ event="api.route.resolved",
234
+ source="api",
235
+ kind="count_tokens",
236
+ provider_id=routed.resolved.provider_id,
237
+ provider_model=routed.resolved.provider_model,
238
+ provider_model_ref=routed.resolved.provider_model_ref,
239
+ gateway_model=routed.request.model,
240
+ )
241
+ trace_event(
242
+ stage="ingress",
243
+ event="api.count_tokens.completed",
244
+ source="api",
245
+ message_count=len(routed.request.messages),
246
+ input_tokens=tokens,
247
+ snapshot=api_messages_request_snapshot(routed.request),
248
  )
249
  return TokenCountResponse(input_tokens=tokens)
250
  except ProviderError:
cli/manager.py CHANGED
@@ -56,8 +56,6 @@ class CLISessionManager:
56
  self._real_to_temp: dict[str, str] = {}
57
  self._lock = asyncio.Lock()
58
 
59
- logger.info("CLISessionManager initialized")
60
-
61
  async def get_or_create_session(
62
  self, session_id: str | None = None
63
  ) -> tuple[CLISession, str, bool]:
@@ -87,7 +85,6 @@ class CLISessionManager:
87
  log_raw_cli_diagnostics=self._log_raw_cli_diagnostics,
88
  )
89
  self._pending_sessions[temp_id] = new_session
90
- logger.info(f"Created new session: {temp_id}")
91
 
92
  return new_session, temp_id, True
93
 
 
56
  self._real_to_temp: dict[str, str] = {}
57
  self._lock = asyncio.Lock()
58
 
 
 
59
  async def get_or_create_session(
60
  self, session_id: str | None = None
61
  ) -> tuple[CLISession, str, bool]:
 
85
  log_raw_cli_diagnostics=self._log_raw_cli_diagnostics,
86
  )
87
  self._pending_sessions[temp_id] = new_session
 
88
 
89
  return new_session, temp_id, True
90
 
cli/session.py CHANGED
@@ -9,6 +9,8 @@ from typing import Any
9
 
10
  from loguru import logger
11
 
 
 
12
  from .process_registry import register_pid, unregister_pid
13
 
14
  # Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained.
@@ -136,7 +138,6 @@ class CLISession:
136
  "--dangerously-skip-permissions",
137
  "--verbose",
138
  ]
139
- logger.info(f"Resuming Claude session {session_id}")
140
  else:
141
  cmd = [
142
  self.claude_bin,
@@ -147,7 +148,6 @@ class CLISession:
147
  "--dangerously-skip-permissions",
148
  "--verbose",
149
  ]
150
- logger.info("Starting new Claude session")
151
 
152
  if self.allowed_dirs:
153
  for d in self.allowed_dirs:
@@ -157,6 +157,22 @@ class CLISession:
157
  settings_json = json.dumps({"plansDirectory": self.plans_directory})
158
  cmd.extend(["--settings", settings_json])
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  try:
161
  self.process = await asyncio.create_subprocess_exec(
162
  *cmd,
 
9
 
10
  from loguru import logger
11
 
12
+ from core.trace import trace_event
13
+
14
  from .process_registry import register_pid, unregister_pid
15
 
16
  # Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained.
 
138
  "--dangerously-skip-permissions",
139
  "--verbose",
140
  ]
 
141
  else:
142
  cmd = [
143
  self.claude_bin,
 
148
  "--dangerously-skip-permissions",
149
  "--verbose",
150
  ]
 
151
 
152
  if self.allowed_dirs:
153
  for d in self.allowed_dirs:
 
157
  settings_json = json.dumps({"plansDirectory": self.plans_directory})
158
  cmd.extend(["--settings", settings_json])
159
 
160
+ trace_event(
161
+ stage="claude_cli",
162
+ event="claude_cli.process.launch",
163
+ source="claude_cli",
164
+ resume_session_id=(
165
+ session_id
166
+ if session_id and not session_id.startswith("pending_")
167
+ else None
168
+ ),
169
+ fork_session=fork_session,
170
+ prompt=prompt,
171
+ cwd=self.workspace,
172
+ claude_binary=self.claude_bin,
173
+ cli_argv=cmd,
174
+ )
175
+
176
  try:
177
  self.process = await asyncio.create_subprocess_exec(
178
  *cmd,
config/logging_config.py CHANGED
@@ -9,14 +9,26 @@ included at top level for easy grep/filter.
9
  import json
10
  import logging
11
  import re
 
12
  from pathlib import Path
13
 
14
  from loguru import logger
15
 
16
  _configured = False
17
 
18
- # Context keys we promote to top-level JSON for traceability
19
- _CONTEXT_KEYS = ("request_id", "node_id", "chat_id")
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  _TELEGRAM_BOT_RE = re.compile(
22
  r"(https?://api\.telegram\.org/)bot([0-9]+:[A-Za-z0-9_-]+)(/?)",
@@ -48,9 +60,16 @@ def _serialize_with_context(record) -> str:
48
  "function": record["function"],
49
  "line": record["line"],
50
  }
 
51
  for key in _CONTEXT_KEYS:
52
  if key in extra and extra[key] is not None:
53
  out[key] = extra[key]
 
 
 
 
 
 
54
  record["_json"] = json.dumps(out, default=str)
55
  return "{_json}\n"
56
 
@@ -58,20 +77,31 @@ def _serialize_with_context(record) -> str:
58
  class InterceptHandler(logging.Handler):
59
  """Redirect stdlib logging to loguru."""
60
 
 
 
 
 
61
  def emit(self, record: logging.LogRecord) -> None:
 
 
 
 
62
  try:
63
- level = logger.level(record.levelname).name
64
- except ValueError:
65
- level = record.levelno
 
66
 
67
- frame, depth = logging.currentframe(), 2
68
- while frame is not None and frame.f_code.co_filename == logging.__file__:
69
- frame = frame.f_back
70
- depth += 1
71
 
72
- logger.opt(depth=depth, exception=record.exc_info).log(
73
- level, record.getMessage()
74
- )
 
 
75
 
76
 
77
  def configure_logging(
@@ -104,6 +134,7 @@ def configure_logging(
104
  encoding="utf-8",
105
  mode="a",
106
  rotation="50 MB",
 
107
  )
108
 
109
  # Intercept stdlib logging: route all root logger output to loguru
 
9
  import json
10
  import logging
11
  import re
12
+ import threading
13
  from pathlib import Path
14
 
15
  from loguru import logger
16
 
17
  _configured = False
18
 
19
+ # Loguru ``logger.bind()`` key used by structured TRACE payloads; ``core/trace.py``
20
+ # uses the identical string constant ``TRACE_PAYLOAD_BINDING``.
21
+ _TRACE_PAYLOAD_BINDING = "trace_payload"
22
+
23
+ # Context keys we promote to top-level JSON for traceability / grep
24
+ _CONTEXT_KEYS = (
25
+ "request_id",
26
+ "node_id",
27
+ "chat_id",
28
+ "claude_session_id",
29
+ "http_method",
30
+ "http_path",
31
+ )
32
 
33
  _TELEGRAM_BOT_RE = re.compile(
34
  r"(https?://api\.telegram\.org/)bot([0-9]+:[A-Za-z0-9_-]+)(/?)",
 
60
  "function": record["function"],
61
  "line": record["line"],
62
  }
63
+ trace_payload = extra.get(_TRACE_PAYLOAD_BINDING)
64
  for key in _CONTEXT_KEYS:
65
  if key in extra and extra[key] is not None:
66
  out[key] = extra[key]
67
+ if isinstance(trace_payload, dict):
68
+ for tk, tv in trace_payload.items():
69
+ if tk in out:
70
+ continue
71
+ out[tk] = tv
72
+ out["trace"] = True
73
  record["_json"] = json.dumps(out, default=str)
74
  return "{_json}\n"
75
 
 
77
  class InterceptHandler(logging.Handler):
78
  """Redirect stdlib logging to loguru."""
79
 
80
+ def __init__(self) -> None:
81
+ super().__init__()
82
+ self._local = threading.local()
83
+
84
  def emit(self, record: logging.LogRecord) -> None:
85
+ if getattr(self._local, "active", False):
86
+ # Avoid deadlock when nested stdlib records fire during a loguru emit.
87
+ return
88
+ self._local.active = True
89
  try:
90
+ try:
91
+ level = logger.level(record.levelname).name
92
+ except ValueError:
93
+ level = record.levelno
94
 
95
+ frame, depth = logging.currentframe(), 2
96
+ while frame is not None and frame.f_code.co_filename == logging.__file__:
97
+ frame = frame.f_back
98
+ depth += 1
99
 
100
+ logger.opt(depth=depth, exception=record.exc_info).log(
101
+ level, record.getMessage()
102
+ )
103
+ finally:
104
+ self._local.active = False
105
 
106
 
107
  def configure_logging(
 
134
  encoding="utf-8",
135
  mode="a",
136
  rotation="50 MB",
137
+ enqueue=True,
138
  )
139
 
140
  # Intercept stdlib logging: route all root logger output to loguru
core/anthropic/sse.py CHANGED
@@ -187,12 +187,6 @@ class SSEBuilder:
187
  event_str = format_sse_event(event_type, data)
188
  if self._log_raw_events:
189
  logger.debug("SSE_EVENT: {} - {}", event_type, event_str.strip())
190
- else:
191
- logger.debug(
192
- "SSE_EVENT: event_type={} serialized_bytes={}",
193
- event_type,
194
- len(event_str.encode("utf-8")),
195
- )
196
  return event_str
197
 
198
  def message_start(self) -> str:
 
187
  event_str = format_sse_event(event_type, data)
188
  if self._log_raw_events:
189
  logger.debug("SSE_EVENT: {} - {}", event_type, event_str.strip())
 
 
 
 
 
 
190
  return event_str
191
 
192
  def message_start(self) -> str:
core/trace.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured TRACE events for end-to-end request / CLI / provider logging.
2
+
3
+ Emitted lines are merged into JSON log rows by ``config.logging_config``.
4
+ Conversation and Claude Code prompts are logged verbatim unless values live under
5
+ sanitized credential keys (e.g. ``api_key``, ``authorization``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import AsyncIterator, Mapping
12
+ from typing import Any
13
+
14
+ from loguru import logger
15
+
16
+ TRACE_PAYLOAD_BINDING = "trace_payload"
17
+
18
+ _SECRET_VALUE_KEYS = frozenset(
19
+ k.lower()
20
+ for k in (
21
+ "authorization",
22
+ "x-api-key",
23
+ "anthropic-auth-token",
24
+ "api_key",
25
+ "password",
26
+ "secret",
27
+ "token",
28
+ "bearer_token",
29
+ "openapi_token",
30
+ "nvidia-api-key",
31
+ )
32
+ )
33
+
34
+
35
+ def _sanitize_trace_value(obj: Any) -> Any:
36
+ """Recursively copy JSON-like structures redacting credential-shaped keys."""
37
+ if isinstance(obj, Mapping):
38
+ out: dict[str, Any] = {}
39
+ for k, v in obj.items():
40
+ if str(k).lower() in _SECRET_VALUE_KEYS:
41
+ out[str(k)] = "<redacted>"
42
+ else:
43
+ out[str(k)] = _sanitize_trace_value(v)
44
+ return out
45
+ if isinstance(obj, tuple | list):
46
+ return [_sanitize_trace_value(x) for x in obj]
47
+ return obj
48
+
49
+
50
+ def trace_event(*, stage: str, event: str, source: str, **fields: Any) -> None:
51
+ """Emit one structured TRACE row (merged into JSON by the log sink)."""
52
+ payload = _sanitize_trace_value(
53
+ {
54
+ "stage": stage,
55
+ "event": event,
56
+ "source": source,
57
+ **fields,
58
+ },
59
+ )
60
+ logger.bind(trace_payload=payload).info("TRACE {}", event)
61
+
62
+
63
+ def api_messages_request_snapshot(req: Any) -> dict[str, Any]:
64
+ """Return a sanitized snapshot of an Anthropic ``MessagesRequest``-like body."""
65
+ if hasattr(req, "model_dump"):
66
+ data = req.model_dump(mode="python")
67
+ elif isinstance(req, Mapping):
68
+ data = dict(req)
69
+ else:
70
+ data = {}
71
+
72
+ snapshot: dict[str, Any] = {}
73
+ for key in (
74
+ "model",
75
+ "messages",
76
+ "system",
77
+ "tools",
78
+ "tool_choice",
79
+ "max_tokens",
80
+ "thinking",
81
+ "temperature",
82
+ "top_p",
83
+ "top_k",
84
+ "stop_sequences",
85
+ "metadata",
86
+ "stream",
87
+ "thinking_enabled",
88
+ ):
89
+ if key in data and data[key] is not None:
90
+ snapshot[key] = data[key]
91
+ return _sanitize_trace_value(snapshot)
92
+
93
+
94
+ def extract_claude_session_id_from_headers(headers: Mapping[str, str]) -> str | None:
95
+ """Best-effort session id forwarded by Claude Code / SDK via HTTP."""
96
+ lowered = {str(k).lower(): v for k, v in headers.items() if isinstance(v, str)}
97
+ for key in (
98
+ "anthropic-session-id",
99
+ "x-anthropic-session-id",
100
+ "claude-session-id",
101
+ "x-claude-session-id",
102
+ ):
103
+ candidate = lowered.get(key)
104
+ if candidate:
105
+ return candidate
106
+ return None
107
+
108
+
109
+ async def traced_async_stream(
110
+ agen: AsyncIterator[str],
111
+ *,
112
+ stage: str,
113
+ source: str,
114
+ complete_event: str,
115
+ interrupted_event: str,
116
+ chunk_event: str | None = None,
117
+ chunk_interval: int = 250,
118
+ extra: Mapping[str, Any] | None = None,
119
+ ) -> AsyncIterator[str]:
120
+ """Emit TRACE rows when a text stream completes, fails, cancels, or periodically."""
121
+ common = dict(extra or {})
122
+ count = 0
123
+ nbytes = 0
124
+ interrupted = False
125
+ try:
126
+ async for chunk in agen:
127
+ count += 1
128
+ nbytes += len(chunk.encode("utf-8", errors="replace"))
129
+ if chunk_event and chunk_interval > 0 and count % chunk_interval == 0:
130
+ trace_event(
131
+ stage=stage,
132
+ event=chunk_event,
133
+ source=source,
134
+ stream_chunks_so_far=count,
135
+ stream_bytes_so_far=nbytes,
136
+ **common,
137
+ )
138
+ yield chunk
139
+ except asyncio.CancelledError:
140
+ interrupted = True
141
+ trace_event(
142
+ stage=stage,
143
+ event=interrupted_event,
144
+ source=source,
145
+ stream_chunks=count,
146
+ stream_bytes=nbytes,
147
+ outcome="cancelled",
148
+ **common,
149
+ )
150
+ raise
151
+ except BaseExceptionGroup as grp:
152
+ interrupted = True
153
+ trace_event(
154
+ stage=stage,
155
+ event=interrupted_event,
156
+ source=source,
157
+ stream_chunks=count,
158
+ stream_bytes=nbytes,
159
+ outcome="exception_group",
160
+ note=str(grp),
161
+ **common,
162
+ )
163
+ raise
164
+ except BaseException as exc:
165
+ interrupted = True
166
+ trace_event(
167
+ stage=stage,
168
+ event=interrupted_event,
169
+ source=source,
170
+ stream_chunks=count,
171
+ stream_bytes=nbytes,
172
+ outcome="error",
173
+ exc_type=type(exc).__name__,
174
+ **common,
175
+ )
176
+ raise
177
+
178
+ if not interrupted:
179
+ trace_event(
180
+ stage=stage,
181
+ event=complete_event,
182
+ source=source,
183
+ stream_chunks=count,
184
+ stream_bytes=nbytes,
185
+ outcome="ok",
186
+ **common,
187
+ )
188
+
189
+
190
+ def provider_chat_body_snapshot(body: Mapping[str, Any]) -> dict[str, Any]:
191
+ """Sanitized OpenAI-compat chat body subset for traces (conversation text verbatim)."""
192
+ keys = ("model", "messages", "tools", "tool_choice", "temperature", "max_tokens")
193
+ snap = {k: body[k] for k in keys if k in body and body[k] is not None}
194
+ return _sanitize_trace_value(snap)
195
+
196
+
197
+ def provider_native_messages_body_snapshot(body: Mapping[str, Any]) -> dict[str, Any]:
198
+ """Sanitized Anthropic Messages API body subset for traces."""
199
+ keys = (
200
+ "model",
201
+ "messages",
202
+ "system",
203
+ "tools",
204
+ "tool_choice",
205
+ "max_tokens",
206
+ "thinking",
207
+ "metadata",
208
+ "temperature",
209
+ "top_p",
210
+ "top_k",
211
+ "stop_sequences",
212
+ )
213
+ snap = {k: body[k] for k in keys if k in body and body[k] is not None}
214
+ return _sanitize_trace_value(snap)
messaging/handler.py CHANGED
@@ -11,6 +11,7 @@ import asyncio
11
  from loguru import logger
12
 
13
  from core.anthropic import format_user_error_preview, get_user_facing_error_message
 
14
 
15
  from .cli_event_constants import STATUS_MESSAGE_PREFIXES
16
  from .command_dispatcher import (
@@ -102,26 +103,17 @@ class ClaudeMessageHandler:
102
  Determines if this is a new conversation or reply,
103
  creates/extends the message tree, and queues for processing.
104
  """
105
- raw = incoming.text or ""
106
- if self._log_raw_messaging_content:
107
- text_preview = raw[:80]
108
- if len(raw) > 80:
109
- text_preview += "..."
110
- logger.info(
111
- "HANDLER_ENTRY: chat_id={} message_id={} reply_to={} text_preview={!r}",
112
- incoming.chat_id,
113
- incoming.message_id,
114
- incoming.reply_to_message_id,
115
- text_preview,
116
- )
117
- else:
118
- logger.info(
119
- "HANDLER_ENTRY: chat_id={} message_id={} reply_to={} text_len={}",
120
- incoming.chat_id,
121
- incoming.message_id,
122
- incoming.reply_to_message_id,
123
- len(raw),
124
- )
125
 
126
  with logger.contextualize(
127
  chat_id=incoming.chat_id, node_id=incoming.message_id
@@ -240,8 +232,16 @@ class ClaudeMessageHandler:
240
  )
241
 
242
  if was_queued and status_msg_id:
243
- # Update status to show queue position
244
  queue_size = self.tree_queue.get_queue_size(node_id)
 
 
 
 
 
 
 
 
 
245
  await self.platform.queue_edit_message(
246
  incoming.chat_id,
247
  status_msg_id,
@@ -343,10 +343,18 @@ class ClaudeMessageHandler:
343
  last_status: str | None = None
344
 
345
  parent_session_id = None
 
346
  if tree and node.parent_id:
347
  parent_session_id = tree.get_parent_session_id(node_id)
348
  if parent_session_id:
349
- logger.info(f"Will fork from parent session: {parent_session_id}")
 
 
 
 
 
 
 
350
 
351
  editor = ThrottledTranscriptEditor(
352
  platform=self.platform,
@@ -377,6 +385,33 @@ class ClaudeMessageHandler:
377
  temp_session_id = session_or_temp_id
378
  else:
379
  captured_session_id = session_or_temp_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  except RuntimeError as e:
381
  error_message = get_user_facing_error_message(e)
382
  transcript.apply({"type": "error", "message": error_message})
@@ -390,10 +425,15 @@ class ClaudeMessageHandler:
390
  MessageState.ERROR,
391
  error_message=error_message,
392
  )
 
 
 
 
 
 
 
393
  return
394
 
395
- logger.info(f"HANDLER: Starting CLI task processing for node {node_id}")
396
- event_count = 0
397
  async for event_data in cli_session.start_task(
398
  incoming.text,
399
  session_id=parent_session_id,
@@ -404,9 +444,6 @@ class ClaudeMessageHandler:
404
  f"HANDLER: Non-dict event received: {type(event_data)}"
405
  )
406
  continue
407
- event_count += 1
408
- if event_count % 10 == 0:
409
- logger.debug(f"HANDLER: Processed {event_count} events so far")
410
 
411
  (
412
  captured_session_id,
@@ -426,7 +463,6 @@ class ClaudeMessageHandler:
426
  parsed_list = parse_cli_event(
427
  event_data, log_raw_cli=self._log_raw_cli_diagnostics
428
  )
429
- logger.debug(f"HANDLER: Parsed {len(parsed_list)} events from CLI")
430
 
431
  for parsed in parsed_list:
432
  (
@@ -448,6 +484,13 @@ class ClaudeMessageHandler:
448
  )
449
 
450
  except asyncio.CancelledError:
 
 
 
 
 
 
 
451
  logger.warning(f"HANDLER: Task cancelled for node {node_id}")
452
  cancel_reason = None
453
  if isinstance(node.context, dict):
@@ -466,6 +509,14 @@ class ClaudeMessageHandler:
466
  node_id, MessageState.ERROR, error_message="Cancelled by user"
467
  )
468
  except Exception as e:
 
 
 
 
 
 
 
 
469
  logger.error(
470
  "HANDLER: Task failed with exception: {}",
471
  format_exception_for_log(
@@ -480,7 +531,14 @@ class ClaudeMessageHandler:
480
  node_id, error_msg, "Parent task failed"
481
  )
482
  finally:
483
- logger.info(f"HANDLER: _process_node completed for node {node_id}")
 
 
 
 
 
 
 
484
  # Free the session-manager slot. Session IDs are persisted in the tree and
485
  # can be resumed later by ID; we don't need to keep a CLISession instance
486
  # around after this node completes.
 
11
  from loguru import logger
12
 
13
  from core.anthropic import format_user_error_preview, get_user_facing_error_message
14
+ from core.trace import trace_event
15
 
16
  from .cli_event_constants import STATUS_MESSAGE_PREFIXES
17
  from .command_dispatcher import (
 
103
  Determines if this is a new conversation or reply,
104
  creates/extends the message tree, and queues for processing.
105
  """
106
+ platform_name = getattr(self.platform, "name", "messaging")
107
+ trace_event(
108
+ stage="ingress",
109
+ event="turn.received",
110
+ source=platform_name,
111
+ chat_id=incoming.chat_id,
112
+ platform_message_id=incoming.message_id,
113
+ reply_to_message_id=incoming.reply_to_message_id,
114
+ thread_id=getattr(incoming, "message_thread_id", None),
115
+ message_text=incoming.text or "",
116
+ )
 
 
 
 
 
 
 
 
 
117
 
118
  with logger.contextualize(
119
  chat_id=incoming.chat_id, node_id=incoming.message_id
 
232
  )
233
 
234
  if was_queued and status_msg_id:
 
235
  queue_size = self.tree_queue.get_queue_size(node_id)
236
+ trace_event(
237
+ stage="routing",
238
+ event="turn.queued",
239
+ source=getattr(self.platform, "name", "messaging"),
240
+ chat_id=incoming.chat_id,
241
+ platform_message_id=node_id,
242
+ status_message_id=status_msg_id,
243
+ queue_size=queue_size,
244
+ )
245
  await self.platform.queue_edit_message(
246
  incoming.chat_id,
247
  status_msg_id,
 
343
  last_status: str | None = None
344
 
345
  parent_session_id = None
346
+ platform_nm = getattr(self.platform, "name", "messaging")
347
  if tree and node.parent_id:
348
  parent_session_id = tree.get_parent_session_id(node_id)
349
  if parent_session_id:
350
+ trace_event(
351
+ stage="claude_cli",
352
+ event="claude_cli.fork.from_parent_session",
353
+ source=platform_nm,
354
+ chat_id=chat_id,
355
+ node_id=node_id,
356
+ parent_session_id=parent_session_id,
357
+ )
358
 
359
  editor = ThrottledTranscriptEditor(
360
  platform=self.platform,
 
385
  temp_session_id = session_or_temp_id
386
  else:
387
  captured_session_id = session_or_temp_id
388
+
389
+ sess_evt = (
390
+ "claude_cli.session.pending_created"
391
+ if is_new
392
+ else "claude_cli.session.reused"
393
+ )
394
+ trace_event(
395
+ stage="claude_cli",
396
+ event=sess_evt,
397
+ source=platform_nm,
398
+ chat_id=chat_id,
399
+ node_id=node_id,
400
+ status_message_id=status_msg_id,
401
+ session_handle=str(session_or_temp_id),
402
+ parent_resume_session_id=parent_session_id,
403
+ fork_requested=bool(parent_session_id),
404
+ )
405
+ trace_event(
406
+ stage="claude_cli",
407
+ event="claude_cli.request.sent",
408
+ source=platform_nm,
409
+ chat_id=chat_id,
410
+ node_id=node_id,
411
+ prompt=incoming.text,
412
+ fork_session_arg=bool(parent_session_id),
413
+ resume_session_arg=parent_session_id,
414
+ )
415
  except RuntimeError as e:
416
  error_message = get_user_facing_error_message(e)
417
  transcript.apply({"type": "error", "message": error_message})
 
425
  MessageState.ERROR,
426
  error_message=error_message,
427
  )
428
+ trace_event(
429
+ stage="claude_cli",
430
+ event="claude_cli.session.limit_reached",
431
+ source=platform_nm,
432
+ chat_id=chat_id,
433
+ node_id=node_id,
434
+ )
435
  return
436
 
 
 
437
  async for event_data in cli_session.start_task(
438
  incoming.text,
439
  session_id=parent_session_id,
 
444
  f"HANDLER: Non-dict event received: {type(event_data)}"
445
  )
446
  continue
 
 
 
447
 
448
  (
449
  captured_session_id,
 
463
  parsed_list = parse_cli_event(
464
  event_data, log_raw_cli=self._log_raw_cli_diagnostics
465
  )
 
466
 
467
  for parsed in parsed_list:
468
  (
 
484
  )
485
 
486
  except asyncio.CancelledError:
487
+ trace_event(
488
+ stage="claude_cli",
489
+ event="turn.processor.cancelled",
490
+ source=platform_nm,
491
+ chat_id=chat_id,
492
+ node_id=node_id,
493
+ )
494
  logger.warning(f"HANDLER: Task cancelled for node {node_id}")
495
  cancel_reason = None
496
  if isinstance(node.context, dict):
 
509
  node_id, MessageState.ERROR, error_message="Cancelled by user"
510
  )
511
  except Exception as e:
512
+ trace_event(
513
+ stage="claude_cli",
514
+ event="turn.processor.exception",
515
+ source=platform_nm,
516
+ chat_id=chat_id,
517
+ node_id=node_id,
518
+ exc_type=type(e).__name__,
519
+ )
520
  logger.error(
521
  "HANDLER: Task failed with exception: {}",
522
  format_exception_for_log(
 
531
  node_id, error_msg, "Parent task failed"
532
  )
533
  finally:
534
+ trace_event(
535
+ stage="routing",
536
+ event="turn.processor.finished",
537
+ source=platform_nm,
538
+ chat_id=chat_id,
539
+ node_id=node_id,
540
+ claude_session_id=captured_session_id or temp_session_id,
541
+ )
542
  # Free the session-manager slot. Session IDs are persisted in the tree and
543
  # can be resumed later by ID; we don't need to keep a CLISession instance
544
  # around after this node completes.
messaging/node_event_pipeline.py CHANGED
@@ -7,6 +7,8 @@ from typing import Any
7
 
8
  from loguru import logger
9
 
 
 
10
  from .cli_event_constants import TRANSCRIPT_EVENT_TYPES, get_status_for_event
11
  from .platforms.base import SessionManagerInterface
12
  from .safe_diagnostics import text_len_hint
@@ -34,6 +36,15 @@ async def handle_session_info_event(
34
  return captured_session_id, temp_session_id
35
 
36
  await cli_manager.register_real_session_id(temp_session_id, real_session_id)
 
 
 
 
 
 
 
 
 
37
  if tree and real_session_id:
38
  await tree.update_state(
39
  node_id,
@@ -76,7 +87,13 @@ async def process_parsed_cli_event(
76
  elif ptype == "complete":
77
  if not had_transcript_events:
78
  transcript.apply({"type": "text_chunk", "text": "Done."})
79
- logger.info("HANDLER: Task complete, updating UI")
 
 
 
 
 
 
80
  await update_ui(format_status("✅", "Complete"), force=True)
81
  if tree and captured_session_id:
82
  await tree.update_state(
@@ -87,15 +104,22 @@ async def process_parsed_cli_event(
87
  session_store.save_tree(tree.root_id, tree.to_dict())
88
  elif ptype == "error":
89
  error_msg = parsed.get("message", "Unknown error")
 
 
 
 
 
 
 
 
 
90
  if log_messaging_error_details:
91
  logger.error("HANDLER: Error event received: {}", error_msg)
92
  else:
93
- em = error_msg if isinstance(error_msg, str) else str(error_msg)
94
  logger.error(
95
  "HANDLER: Error event received: message_chars={}",
96
  text_len_hint(em),
97
  )
98
- logger.info("HANDLER: Updating UI with error status")
99
  await update_ui(format_status("❌", "Error"), force=True)
100
  if tree:
101
  await propagate_error_to_children(node_id, error_msg, "Parent task failed")
 
7
 
8
  from loguru import logger
9
 
10
+ from core.trace import trace_event
11
+
12
  from .cli_event_constants import TRANSCRIPT_EVENT_TYPES, get_status_for_event
13
  from .platforms.base import SessionManagerInterface
14
  from .safe_diagnostics import text_len_hint
 
36
  return captured_session_id, temp_session_id
37
 
38
  await cli_manager.register_real_session_id(temp_session_id, real_session_id)
39
+ trace_event(
40
+ stage="claude_cli",
41
+ event="claude_cli.session.registered",
42
+ source="claude_cli",
43
+ node_id=node_id,
44
+ temp_session_id=temp_session_id,
45
+ real_session_id=real_session_id,
46
+ tree_root_id=tree.root_id if tree else None,
47
+ )
48
  if tree and real_session_id:
49
  await tree.update_state(
50
  node_id,
 
87
  elif ptype == "complete":
88
  if not had_transcript_events:
89
  transcript.apply({"type": "text_chunk", "text": "Done."})
90
+ trace_event(
91
+ stage="claude_cli",
92
+ event="turn.completed",
93
+ source="cli_event",
94
+ node_id=node_id,
95
+ claude_session_id=captured_session_id,
96
+ )
97
  await update_ui(format_status("✅", "Complete"), force=True)
98
  if tree and captured_session_id:
99
  await tree.update_state(
 
104
  session_store.save_tree(tree.root_id, tree.to_dict())
105
  elif ptype == "error":
106
  error_msg = parsed.get("message", "Unknown error")
107
+ em = error_msg if isinstance(error_msg, str) else str(error_msg)
108
+ trace_event(
109
+ stage="claude_cli",
110
+ event="turn.failed",
111
+ source="cli_event",
112
+ node_id=node_id,
113
+ claude_session_id=captured_session_id,
114
+ cli_error_message=em,
115
+ )
116
  if log_messaging_error_details:
117
  logger.error("HANDLER: Error event received: {}", error_msg)
118
  else:
 
119
  logger.error(
120
  "HANDLER: Error event received: message_chars={}",
121
  text_len_hint(em),
122
  )
 
123
  await update_ui(format_status("❌", "Error"), force=True)
124
  if tree:
125
  await propagate_error_to_children(node_id, error_msg, "Parent task failed")
providers/anthropic_messages.py CHANGED
@@ -21,6 +21,7 @@ from core.anthropic.native_sse_block_policy import (
21
  NativeSseBlockPolicyState,
22
  transform_native_sse_block_event,
23
  )
 
24
  from providers.base import BaseProvider, ProviderConfig
25
  from providers.error_mapping import (
26
  map_error,
@@ -338,13 +339,16 @@ class AnthropicMessagesTransport(BaseProvider):
338
  body = self._build_request_body(request, thinking_enabled=thinking_enabled)
339
  thinking_enabled = self._is_thinking_enabled(request, thinking_enabled)
340
 
341
- logger.info(
342
- "{}_STREAM:{} natively passing Anthropic request model={} msgs={} tools={}",
343
- tag,
344
- req_tag,
345
- body.get("model"),
346
- len(body.get("messages", [])),
347
- len(body.get("tools", [])),
 
 
 
348
  )
349
 
350
  response: httpx.Response | None = None
@@ -373,28 +377,48 @@ class AnthropicMessagesTransport(BaseProvider):
373
  _validated_stream_send
374
  )
375
 
 
 
 
376
  async for chunk in self._iter_stream_chunks(
377
  response,
378
  state=state,
379
  thinking_enabled=thinking_enabled,
380
  ):
 
 
381
  sent_any_event = True
382
  emitted_tracker.feed(chunk)
383
  yield chunk
384
 
 
 
 
 
 
 
 
 
 
 
385
  except Exception as error:
386
  if not isinstance(error, httpx.HTTPStatusError):
387
- self._log_stream_transport_error(tag, req_tag, error)
 
 
388
  error_message = self._get_error_message(error, request_id)
389
 
390
  if response is not None and not response.is_closed:
391
  await response.aclose()
392
 
393
- logger.info(
394
- "{}_STREAM: Emitting native SSE error event for {}{}",
395
- tag,
396
- type(error).__name__,
397
- req_tag,
 
 
 
398
  )
399
  if sent_any_event:
400
  for event in emitted_tracker.iter_close_unclosed_blocks():
 
21
  NativeSseBlockPolicyState,
22
  transform_native_sse_block_event,
23
  )
24
+ from core.trace import provider_native_messages_body_snapshot, trace_event
25
  from providers.base import BaseProvider, ProviderConfig
26
  from providers.error_mapping import (
27
  map_error,
 
339
  body = self._build_request_body(request, thinking_enabled=thinking_enabled)
340
  thinking_enabled = self._is_thinking_enabled(request, thinking_enabled)
341
 
342
+ trace_event(
343
+ stage="provider",
344
+ event="provider.request.sent",
345
+ source="provider",
346
+ provider=self._provider_name,
347
+ gateway_model=request.model,
348
+ downstream_model=body.get("model"),
349
+ message_count=len(body.get("messages", [])),
350
+ tool_count=len(body.get("tools", [])),
351
+ body=provider_native_messages_body_snapshot(body),
352
  )
353
 
354
  response: httpx.Response | None = None
 
377
  _validated_stream_send
378
  )
379
 
380
+ chunk_count = 0
381
+ chunk_bytes = 0
382
+
383
  async for chunk in self._iter_stream_chunks(
384
  response,
385
  state=state,
386
  thinking_enabled=thinking_enabled,
387
  ):
388
+ chunk_count += 1
389
+ chunk_bytes += len(chunk.encode("utf-8", errors="replace"))
390
  sent_any_event = True
391
  emitted_tracker.feed(chunk)
392
  yield chunk
393
 
394
+ trace_event(
395
+ stage="provider",
396
+ event="provider.response.completed",
397
+ source="provider",
398
+ provider=self._provider_name,
399
+ gateway_model=request.model,
400
+ sse_chunks_out=chunk_count,
401
+ sse_bytes_out=chunk_bytes,
402
+ )
403
+
404
  except Exception as error:
405
  if not isinstance(error, httpx.HTTPStatusError):
406
+ self._log_stream_transport_error(
407
+ tag, req_tag, error, request_id=request_id
408
+ )
409
  error_message = self._get_error_message(error, request_id)
410
 
411
  if response is not None and not response.is_closed:
412
  await response.aclose()
413
 
414
+ trace_event(
415
+ stage="provider",
416
+ event="provider.response.error",
417
+ source="provider",
418
+ provider=self._provider_name,
419
+ error_message=error_message,
420
+ exc_type=type(error).__name__,
421
+ mid_stream=sent_any_event,
422
  )
423
  if sent_any_event:
424
  for event in emitted_tracker.iter_close_unclosed_blocks():
providers/base.py CHANGED
@@ -80,26 +80,43 @@ class BaseProvider(ABC):
80
  build(request, thinking_enabled=thinking_enabled)
81
 
82
  def _log_stream_transport_error(
83
- self, tag: str, req_tag: str, error: Exception
 
 
 
 
 
84
  ) -> None:
85
  """Log streaming transport failures (metadata-only unless verbose is enabled)."""
86
  from loguru import logger
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  if self._config.log_api_error_tracebacks:
89
  logger.error(
90
  "{}_ERROR:{} {}: {}", tag, req_tag, type(error).__name__, error
91
  )
92
  return
93
- response = getattr(error, "response", None)
94
- status_code = (
95
- getattr(response, "status_code", None) if response is not None else None
96
- )
97
  logger.error(
98
  "{}_ERROR:{} exc_type={} http_status={}",
99
  tag,
100
  req_tag,
101
  type(error).__name__,
102
- status_code,
103
  )
104
 
105
  @abstractmethod
 
80
  build(request, thinking_enabled=thinking_enabled)
81
 
82
  def _log_stream_transport_error(
83
+ self,
84
+ tag: str,
85
+ req_tag: str,
86
+ error: Exception,
87
+ *,
88
+ request_id: str | None = None,
89
  ) -> None:
90
  """Log streaming transport failures (metadata-only unless verbose is enabled)."""
91
  from loguru import logger
92
 
93
+ from core.trace import trace_event
94
+
95
+ response = getattr(error, "response", None)
96
+ http_status = (
97
+ getattr(response, "status_code", None) if response is not None else None
98
+ )
99
+ trace_event(
100
+ stage="provider",
101
+ event="provider.response.transport_error",
102
+ source="provider",
103
+ provider=tag,
104
+ request_id=request_id,
105
+ exc_type=type(error).__name__,
106
+ http_status=http_status,
107
+ )
108
+
109
  if self._config.log_api_error_tracebacks:
110
  logger.error(
111
  "{}_ERROR:{} {}: {}", tag, req_tag, type(error).__name__, error
112
  )
113
  return
 
 
 
 
114
  logger.error(
115
  "{}_ERROR:{} exc_type={} http_status={}",
116
  tag,
117
  req_tag,
118
  type(error).__name__,
119
+ http_status,
120
  )
121
 
122
  @abstractmethod
providers/openai_compat.py CHANGED
@@ -23,6 +23,7 @@ from core.anthropic import (
23
  append_request_id,
24
  map_stop_reason,
25
  )
 
26
  from providers.base import BaseProvider, ProviderConfig
27
  from providers.error_mapping import (
28
  map_error,
@@ -353,13 +354,16 @@ class OpenAIChatTransport(BaseProvider):
353
  body = self._build_request_body(request, thinking_enabled=thinking_enabled)
354
  thinking_enabled = self._is_thinking_enabled(request, thinking_enabled)
355
  req_tag = f" request_id={request_id}" if request_id else ""
356
- logger.info(
357
- "{}_STREAM:{} model={} msgs={} tools={}",
358
- tag,
359
- req_tag,
360
- body.get("model"),
361
- len(body.get("messages", [])),
362
- len(body.get("tools", [])),
 
 
 
363
  )
364
 
365
  yield sse.message_start()
@@ -455,7 +459,7 @@ class OpenAIChatTransport(BaseProvider):
455
  except asyncio.CancelledError, GeneratorExit:
456
  raise
457
  except Exception as e:
458
- self._log_stream_transport_error(tag, req_tag, e)
459
  mapped_e = map_error(e, rate_limiter=self._global_rate_limiter)
460
  base_message = user_visible_message_for_mapped_provider_error(
461
  mapped_e,
@@ -463,11 +467,13 @@ class OpenAIChatTransport(BaseProvider):
463
  read_timeout_s=self._config.http_read_timeout,
464
  )
465
  error_message = append_request_id(base_message, request_id)
466
- logger.info(
467
- "{}_STREAM: Emitting SSE error event for {}{}",
468
- tag,
469
- type(e).__name__,
470
- req_tag,
 
 
471
  )
472
  for event in sse.close_all_blocks():
473
  yield event
@@ -552,5 +558,14 @@ class OpenAIChatTransport(BaseProvider):
552
  provider_input,
553
  provider_input - input_tokens,
554
  )
 
 
 
 
 
 
 
 
 
555
  yield sse.message_delta(map_stop_reason(finish_reason), output_tokens)
556
  yield sse.message_stop()
 
23
  append_request_id,
24
  map_stop_reason,
25
  )
26
+ from core.trace import provider_chat_body_snapshot, trace_event
27
  from providers.base import BaseProvider, ProviderConfig
28
  from providers.error_mapping import (
29
  map_error,
 
354
  body = self._build_request_body(request, thinking_enabled=thinking_enabled)
355
  thinking_enabled = self._is_thinking_enabled(request, thinking_enabled)
356
  req_tag = f" request_id={request_id}" if request_id else ""
357
+ trace_event(
358
+ stage="provider",
359
+ event="provider.request.sent",
360
+ source="provider",
361
+ provider=self._provider_name,
362
+ gateway_model=request.model,
363
+ downstream_model=body.get("model"),
364
+ message_count=len(body.get("messages", [])),
365
+ tool_count=len(body.get("tools", [])),
366
+ body=provider_chat_body_snapshot(body),
367
  )
368
 
369
  yield sse.message_start()
 
459
  except asyncio.CancelledError, GeneratorExit:
460
  raise
461
  except Exception as e:
462
+ self._log_stream_transport_error(tag, req_tag, e, request_id=request_id)
463
  mapped_e = map_error(e, rate_limiter=self._global_rate_limiter)
464
  base_message = user_visible_message_for_mapped_provider_error(
465
  mapped_e,
 
467
  read_timeout_s=self._config.http_read_timeout,
468
  )
469
  error_message = append_request_id(base_message, request_id)
470
+ trace_event(
471
+ stage="provider",
472
+ event="provider.response.error",
473
+ source="provider",
474
+ provider=tag,
475
+ error_message=error_message,
476
+ mapped_error_type=type(mapped_e).__name__,
477
  )
478
  for event in sse.close_all_blocks():
479
  yield event
 
558
  provider_input,
559
  provider_input - input_tokens,
560
  )
561
+ trace_event(
562
+ stage="provider",
563
+ event="provider.response.completed",
564
+ source="provider",
565
+ provider=self._provider_name,
566
+ finish_reason=(None if finish_reason is None else str(finish_reason)),
567
+ output_tokens=output_tokens,
568
+ prompt_tokens_estimate=input_tokens,
569
+ )
570
  yield sse.message_delta(map_stop_reason(finish_reason), output_tokens)
571
  yield sse.message_stop()
tests/api/test_safe_logging.py CHANGED
@@ -70,11 +70,7 @@ def test_sse_builder_default_debug_has_no_serialized_json_content():
70
  sse = SSEBuilder("msg_x", "m", 1, log_raw_events=False)
71
  sse.message_start()
72
 
73
- assert mock_debug.call_count == 1
74
- message = str(mock_debug.call_args)
75
- assert "serialized_bytes=" in message
76
- assert "role" not in message
77
- assert "assistant" not in message
78
 
79
 
80
  def test_sse_builder_raw_logging_includes_event_body_when_enabled():
 
70
  sse = SSEBuilder("msg_x", "m", 1, log_raw_events=False)
71
  sse.message_start()
72
 
73
+ assert mock_debug.call_count == 0
 
 
 
 
74
 
75
 
76
  def test_sse_builder_raw_logging_includes_event_body_when_enabled():
tests/core/test_trace.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured TRACE logging assertions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from loguru import logger
9
+
10
+ from config.logging_config import configure_logging
11
+ from core.trace import TRACE_PAYLOAD_BINDING, trace_event
12
+
13
+
14
+ def test_trace_payload_merged_into_json_line(tmp_path) -> None:
15
+ log_file = str(tmp_path / "t.log")
16
+ configure_logging(log_file, force=True)
17
+ trace_event(stage="s", event="e.v1", source="unit", hello="world", n=42)
18
+ logger.complete()
19
+ text = Path(log_file).read_text(encoding="utf-8").strip().split("\n")[-1]
20
+ row = json.loads(text)
21
+ assert row["trace"] is True
22
+ assert row["stage"] == "s"
23
+ assert row["event"] == "e.v1"
24
+ assert row["source"] == "unit"
25
+ assert row["hello"] == "world"
26
+ assert row["n"] == 42
27
+ assert TRACE_PAYLOAD_BINDING == "trace_payload"
28
+
29
+
30
+ def test_sanitize_masks_nested_api_key_strings() -> None:
31
+ """Credential-shaped keys redact without touching normal message text."""
32
+ from core.trace import _sanitize_trace_value
33
+
34
+ out = _sanitize_trace_value(
35
+ {"outer": {"api_key": "secret", "text": "visible"}},
36
+ )
37
+ assert out["outer"]["api_key"] == "<redacted>"
38
+ assert out["outer"]["text"] == "visible"
tests/messaging/test_handler.py CHANGED
@@ -14,10 +14,11 @@ def handler(mock_platform, mock_cli_manager, mock_session_store):
14
 
15
 
16
  @pytest.mark.asyncio
17
- async def test_handle_message_default_logs_text_len_not_content(
18
  mock_platform, mock_cli_manager, mock_session_store, incoming_message_factory
19
  ):
20
- secret = "user-secret-content-never-log-default"
 
21
  handler = ClaudeMessageHandler(
22
  mock_platform,
23
  mock_cli_manager,
@@ -27,33 +28,33 @@ async def test_handle_message_default_logs_text_len_not_content(
27
  incoming = incoming_message_factory(text=secret)
28
  with (
29
  patch.object(handler, "_handle_message_impl", new_callable=AsyncMock),
30
- patch("messaging.handler.logger.info") as log_info,
31
  ):
32
  await handler.handle_message(incoming)
33
- blob = " ".join(str(c) for c in log_info.call_args_list)
34
- assert secret not in blob
35
- assert "text_len=" in blob
36
 
37
 
38
  @pytest.mark.asyncio
39
- async def test_handle_message_raw_content_logging_includes_preview(
40
  mock_platform, mock_cli_manager, mock_session_store, incoming_message_factory
41
  ):
42
- secret = "visible-preview-xyz"
 
43
  handler = ClaudeMessageHandler(
44
  mock_platform,
45
  mock_cli_manager,
46
  mock_session_store,
47
  log_raw_messaging_content=True,
48
  )
49
- incoming = incoming_message_factory(text=secret)
50
  with (
51
  patch.object(handler, "_handle_message_impl", new_callable=AsyncMock),
52
- patch("messaging.handler.logger.info") as log_info,
53
  ):
54
  await handler.handle_message(incoming)
55
- blob = " ".join(str(c) for c in log_info.call_args_list)
56
- assert secret in blob
57
 
58
 
59
  def test_get_initial_status_new_conversation(handler):
 
14
 
15
 
16
  @pytest.mark.asyncio
17
+ async def test_handle_message_turn_trace_includes_full_message_text(
18
  mock_platform, mock_cli_manager, mock_session_store, incoming_message_factory
19
  ):
20
+ """turn.received always records the verbatim user message (local debugging)."""
21
+ secret = "user-message-content-visible-in-trace"
22
  handler = ClaudeMessageHandler(
23
  mock_platform,
24
  mock_cli_manager,
 
28
  incoming = incoming_message_factory(text=secret)
29
  with (
30
  patch.object(handler, "_handle_message_impl", new_callable=AsyncMock),
31
+ patch("messaging.handler.trace_event") as trace_mock,
32
  ):
33
  await handler.handle_message(incoming)
34
+ kwargs = trace_mock.call_args.kwargs
35
+ assert kwargs["event"] == "turn.received"
36
+ assert kwargs["message_text"] == secret
37
 
38
 
39
  @pytest.mark.asyncio
40
+ async def test_handle_message_log_raw_messaging_does_not_change_turn_received_shape(
41
  mock_platform, mock_cli_manager, mock_session_store, incoming_message_factory
42
  ):
43
+ """LOG_RAW_MESSAGING_CONTENT is adapter-only; ingress TRACE always includes text."""
44
+ text = "visible-either-way"
45
  handler = ClaudeMessageHandler(
46
  mock_platform,
47
  mock_cli_manager,
48
  mock_session_store,
49
  log_raw_messaging_content=True,
50
  )
51
+ incoming = incoming_message_factory(text=text)
52
  with (
53
  patch.object(handler, "_handle_message_impl", new_callable=AsyncMock),
54
+ patch("messaging.handler.trace_event") as trace_mock,
55
  ):
56
  await handler.handle_message(incoming)
57
+ assert trace_mock.call_args.kwargs["message_text"] == text
 
58
 
59
 
60
  def test_get_initial_status_new_conversation(handler):