Coverage for src / agent / trace_logger.py: 100%
86 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
1# Copyright 2025-2026 Microsoft Corporation
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Trace-level logging for LLM request/response capture.
17Provides structured JSON logging of LLM interactions with token usage,
18timing, and optional message content for offline analysis and optimization.
19"""
21import json
22import logging
23from datetime import datetime
24from pathlib import Path
25from typing import Any
27logger = logging.getLogger(__name__)
30class TraceLogger:
31 """Logger for capturing detailed LLM request/response traces."""
33 def __init__(self, trace_file: Path, include_messages: bool = False):
34 """Initialize trace logger.
36 Args:
37 trace_file: Path to trace log file
38 include_messages: Whether to include full message content in traces
39 """
40 self.trace_file = trace_file
41 self.include_messages = include_messages
42 self._ensure_trace_file()
44 def _ensure_trace_file(self) -> None:
45 """Ensure trace log file and directory exist."""
46 self.trace_file.parent.mkdir(parents=True, exist_ok=True)
47 if not self.trace_file.exists():
48 self.trace_file.touch()
49 logger.debug(f"Created trace log file: {self.trace_file}")
51 def log_interaction(
52 self,
53 *,
54 request_id: str,
55 messages: list[dict[str, Any]] | None = None,
56 response_content: str | None = None,
57 model: str | None = None,
58 input_tokens: int | None = None,
59 output_tokens: int | None = None,
60 total_tokens: int | None = None,
61 latency_ms: float | None = None,
62 provider: str | None = None,
63 error: str | None = None,
64 ) -> None:
65 """Log a complete LLM interaction with request and response data.
67 Args:
68 request_id: Unique identifier for this request
69 messages: Request messages (logged only if include_messages=True)
70 response_content: LLM response text
71 model: Model name used
72 input_tokens: Number of input tokens
73 output_tokens: Number of output tokens
74 total_tokens: Total tokens used
75 latency_ms: Response latency in milliseconds
76 provider: LLM provider name
77 error: Error message if request failed
78 """
79 trace_entry: dict[str, Any] = {
80 "timestamp": datetime.now().isoformat(),
81 "request_id": request_id,
82 "model": model,
83 "provider": provider,
84 }
86 # Add message data if enabled
87 if self.include_messages and messages:
88 trace_entry["message_count"] = len(messages)
89 trace_entry["messages"] = messages
90 elif messages:
91 # Include count but not content
92 trace_entry["message_count"] = len(messages)
94 # Add response data
95 if response_content is not None:
96 if self.include_messages:
97 trace_entry["response"] = response_content
98 else:
99 # Include length but not content
100 trace_entry["response_length"] = len(response_content)
102 # Add token usage
103 if input_tokens is not None or output_tokens is not None or total_tokens is not None:
104 trace_entry["tokens"] = {}
105 if input_tokens is not None:
106 trace_entry["tokens"]["input"] = input_tokens
107 if output_tokens is not None:
108 trace_entry["tokens"]["output"] = output_tokens
109 if total_tokens is not None:
110 trace_entry["tokens"]["total"] = total_tokens
112 # Add timing
113 if latency_ms is not None:
114 trace_entry["latency_ms"] = round(latency_ms, 2)
116 # Add error if present
117 if error:
118 trace_entry["error"] = error
120 # Write to trace log file (append mode)
121 try:
122 with open(self.trace_file, "a") as f:
123 json.dump(trace_entry, f)
124 f.write("\n") # Write each entry on its own line
125 except Exception as e:
126 logger.error(f"Failed to write trace log: {e}")
128 def log_request(
129 self,
130 *,
131 request_id: str,
132 messages: list[dict[str, Any]],
133 model: str | None = None,
134 provider: str | None = None,
135 system_instructions: str | None = None,
136 tools_summary: dict[str, Any] | None = None,
137 ) -> None:
138 """Log LLM request data.
140 Args:
141 request_id: Unique identifier for this request
142 messages: Request messages
143 model: Model name
144 provider: Provider name
145 system_instructions: System prompt sent to LLM (if include_messages=True)
146 tools_summary: Tool definitions summary (if include_messages=True)
147 """
148 trace_entry: dict[str, Any] = {
149 "timestamp": datetime.now().isoformat(),
150 "request_id": request_id,
151 "type": "request",
152 "model": model,
153 "provider": provider,
154 }
156 if self.include_messages:
157 trace_entry["message_count"] = len(messages)
158 trace_entry["messages"] = messages
160 # Add system instructions if provided
161 if system_instructions:
162 trace_entry["system_instructions"] = system_instructions
163 trace_entry["system_instructions_length"] = len(system_instructions)
164 trace_entry["system_instructions_tokens_est"] = len(system_instructions) // 4
166 # Add tools summary if provided
167 if tools_summary:
168 trace_entry["tools"] = tools_summary
169 else:
170 trace_entry["message_count"] = len(messages)
172 try:
173 with open(self.trace_file, "a") as f:
174 json.dump(trace_entry, f, default=str, ensure_ascii=False)
175 f.write("\n")
176 except Exception as e:
177 logger.error(f"Failed to write trace log: {e}")
179 def log_response(
180 self,
181 *,
182 request_id: str,
183 response_content: str,
184 model: str | None = None,
185 input_tokens: int | None = None,
186 output_tokens: int | None = None,
187 total_tokens: int | None = None,
188 latency_ms: float | None = None,
189 error: str | None = None,
190 ) -> None:
191 """Log LLM response data with token usage.
193 Args:
194 request_id: Unique identifier matching the request
195 response_content: LLM response text
196 model: Model name
197 input_tokens: Number of input tokens
198 output_tokens: Number of output tokens
199 total_tokens: Total tokens used
200 latency_ms: Response latency in milliseconds
201 error: Error message if request failed
202 """
203 trace_entry: dict[str, Any] = {
204 "timestamp": datetime.now().isoformat(),
205 "request_id": request_id,
206 "type": "response",
207 "model": model,
208 }
210 if self.include_messages:
211 trace_entry["response"] = response_content
212 else:
213 trace_entry["response_length"] = len(response_content)
215 if input_tokens is not None or output_tokens is not None or total_tokens is not None:
216 trace_entry["tokens"] = {}
217 if input_tokens is not None:
218 trace_entry["tokens"]["input"] = input_tokens
219 if output_tokens is not None:
220 trace_entry["tokens"]["output"] = output_tokens
221 if total_tokens is not None:
222 trace_entry["tokens"]["total"] = total_tokens
224 if latency_ms is not None:
225 trace_entry["latency_ms"] = round(latency_ms, 2)
227 if error:
228 trace_entry["error"] = error
230 try:
231 with open(self.trace_file, "a") as f:
232 json.dump(trace_entry, f, default=str, ensure_ascii=False)
233 f.write("\n")
234 except Exception as e:
235 logger.error(f"Failed to write trace log: {e}")