-
Notifications
You must be signed in to change notification settings - Fork 4
Improved and more detailed logging to catch the intermittent issues with proxy stopping responding in certain scenarios. #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,71 @@ import Foundation | |
| import Logging | ||
| import NIOConcurrencyHelpers | ||
|
|
||
| private struct CompactDateLogHandler: LogHandler { | ||
| private static let cachedFormatter = NIOLockedValueBox<DateFormatter>({ | ||
| let formatter = DateFormatter() | ||
| formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| formatter.timeZone = .autoupdatingCurrent | ||
| formatter.dateFormat = "yy-MM-dd HH:mm:ss" | ||
| return formatter | ||
| }()) | ||
|
|
||
| let label: String | ||
| var logLevel: Logger.Level | ||
| var metadata: Logger.Metadata = [:] | ||
|
|
||
| init(label: String, logLevel: Logger.Level) { | ||
| self.label = label | ||
| self.logLevel = logLevel | ||
| } | ||
|
|
||
| subscript(metadataKey key: String) -> Logger.Metadata.Value? { | ||
| get { metadata[key] } | ||
| set { metadata[key] = newValue } | ||
| } | ||
|
|
||
| func log( | ||
| level: Logger.Level, | ||
| message: Logger.Message, | ||
| metadata explicitMetadata: Logger.Metadata?, | ||
| source: String, | ||
| file: String, | ||
| function: String, | ||
| line: UInt | ||
| ) { | ||
| guard level >= logLevel else { return } | ||
|
|
||
| let mergedMetadata = metadata.merging(explicitMetadata ?? [:], uniquingKeysWith: { _, new in new }) | ||
| let timestamp = Self.formatTimestamp(Date()) | ||
|
|
||
| var output = "\(timestamp) \(level) \(label):" | ||
| if !mergedMetadata.isEmpty { | ||
| output += " \(Self.formatMetadata(mergedMetadata))" | ||
| } | ||
| output += " \(message)\n" | ||
|
|
||
| guard let data = output.data(using: .utf8) else { return } | ||
| FileHandle.standardOutput.write(data) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Writing bootstrap logger output to Useful? React with 👍 / 👎. |
||
| } | ||
|
Comment on lines
+42
to
+50
|
||
|
|
||
| private static func formatTimestamp(_ date: Date) -> String { | ||
| cachedFormatter.withLockedValue { formatter in | ||
| formatter.string(from: date) | ||
| } | ||
| } | ||
|
|
||
| private static func formatMetadata(_ metadata: Logger.Metadata) -> String { | ||
| metadata | ||
| .keys | ||
| .sorted() | ||
| .compactMap { key in | ||
| guard let value = metadata[key] else { return nil } | ||
| return "\(key)=\(value)" | ||
| } | ||
| .joined(separator: " ") | ||
| } | ||
| } | ||
|
|
||
| public enum ProxyLogging { | ||
| private static let bootstrapState = NIOLockedValueBox(false) | ||
| private static let labelPrefix = "XcodeMCPProxy" | ||
|
|
@@ -21,9 +86,7 @@ public enum ProxyLogging { | |
| let level = LogLevelParser.resolve(from: environment) | ||
|
|
||
| LoggingSystem.bootstrap { label in | ||
| var handler = StreamLogHandler.standardError(label: label) | ||
| handler.logLevel = level | ||
| return handler | ||
| CompactDateLogHandler(label: label, logLevel: level) | ||
| } | ||
|
Comment on lines
86
to
90
|
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,19 @@ package final class HTTPHandler: ChannelInboundHandler, Sendable { | |
| package let method: String | ||
| package let path: String | ||
| package let remoteAddress: String? | ||
| package let mcpInvocation: String | ||
| package let requestParamsJSON: String | ||
|
|
||
| package func withMCPDetails(invocation: String, paramsJSON: String) -> RequestLogContext { | ||
| RequestLogContext( | ||
| id: id, | ||
| method: method, | ||
| path: path, | ||
| remoteAddress: remoteAddress, | ||
| mcpInvocation: invocation, | ||
| requestParamsJSON: paramsJSON | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| package struct State: Sendable { | ||
|
|
@@ -150,7 +163,9 @@ package final class HTTPHandler: ChannelInboundHandler, Sendable { | |
| id: UUID().uuidString, | ||
| method: head.method.rawValue, | ||
| path: path, | ||
| remoteAddress: remoteAddressString(for: context.channel) | ||
| remoteAddress: remoteAddressString(for: context.channel), | ||
| mcpInvocation: "unknown", | ||
| requestParamsJSON: "null" | ||
| ) | ||
| logRequest(requestLog) | ||
|
|
||
|
|
@@ -399,6 +414,12 @@ package final class HTTPHandler: ChannelInboundHandler, Sendable { | |
| return | ||
| } | ||
|
|
||
| let details = Self.mcpLogDetails(from: bodyData) | ||
| let requestLog = requestLog.withMCPDetails( | ||
| invocation: details.invocation, | ||
| paramsJSON: details.paramsJSON | ||
| ) | ||
|
Comment on lines
+417
to
+421
|
||
|
|
||
| let headerSessionID = HTTPRequestValidator.sessionID(from: head.headers) | ||
| let headerSessionExists = headerSessionID.map { controlService.hasSession(id: $0) } ?? false | ||
| let keepAlive = head.isKeepAlive | ||
|
|
@@ -460,4 +481,52 @@ package final class HTTPHandler: ChannelInboundHandler, Sendable { | |
| } | ||
| } | ||
|
|
||
| package static func mcpLogDetails(from bodyData: Data) -> (invocation: String, paramsJSON: String) { | ||
| guard let requestJSON = try? JSONSerialization.jsonObject(with: bodyData, options: []) else { | ||
| return ("unknown", "null") | ||
| } | ||
|
|
||
| if let object = requestJSON as? [String: Any] { | ||
| return ( | ||
| mcpInvocation(from: object), | ||
| jsonString((object["params"] as Any?) ?? NSNull()) | ||
| ) | ||
| } | ||
|
|
||
| if let array = requestJSON as? [Any] { | ||
| let paramsArray = array.map { item -> Any in | ||
| guard let object = item as? [String: Any] else { return NSNull() } | ||
| return object["params"] ?? NSNull() | ||
| } | ||
| return ( | ||
| "batch[\(array.count)]", | ||
| jsonString(paramsArray) | ||
| ) | ||
| } | ||
|
|
||
| return ("unknown", "null") | ||
| } | ||
|
|
||
| private static func mcpInvocation(from object: [String: Any]) -> String { | ||
| let method = (object["method"] as? String) ?? "unknown" | ||
| guard method == "tools/call", | ||
| let params = object["params"] as? [String: Any], | ||
| let toolName = params["name"] as? String, | ||
| toolName.isEmpty == false | ||
| else { | ||
| return method | ||
| } | ||
| return "\(method):\(toolName)" | ||
| } | ||
|
|
||
| private static func jsonString(_ value: Any) -> String { | ||
| guard JSONSerialization.isValidJSONObject(value), | ||
| let data = try? JSONSerialization.data(withJSONObject: value, options: []), | ||
| let string = String(data: data, encoding: .utf8) | ||
| else { | ||
| return "null" | ||
| } | ||
| return string | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -199,29 +199,57 @@ package struct HTTPResponseWriter: Sendable { | |||||||||||||
| if let remote = request.remoteAddress { | ||||||||||||||
| metadata["remote"] = .string(remote) | ||||||||||||||
| } | ||||||||||||||
| logger.info("HTTP request", metadata: metadata) | ||||||||||||||
| logger.debug("HTTP request received", metadata: metadata) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| package func logResponse( | ||||||||||||||
| _ request: HTTPHandler.RequestLogContext, | ||||||||||||||
| status: HTTPResponseStatus, | ||||||||||||||
| sessionID: String? | ||||||||||||||
| ) { | ||||||||||||||
| var metadata: Logger.Metadata = [ | ||||||||||||||
| "id": .string(request.id), | ||||||||||||||
| "method": .string(request.method), | ||||||||||||||
| "path": .string(request.path), | ||||||||||||||
| "status": .string("\(status.code)"), | ||||||||||||||
| ] | ||||||||||||||
| if let remote = request.remoteAddress { | ||||||||||||||
| metadata["remote"] = .string(remote) | ||||||||||||||
| let resolvedSessionID = sessionID ?? "no-session" | ||||||||||||||
| let message = Self.makeHTTPLogBlock( | ||||||||||||||
| request: request, | ||||||||||||||
| statusCode: status.code, | ||||||||||||||
| sessionID: resolvedSessionID | ||||||||||||||
| ) | ||||||||||||||
| // Emit the human-readable block directly so the visible timestamp format | ||||||||||||||
| // is not wrapped by the default swift-log ISO8601 prefix. | ||||||||||||||
| if let data = (message + "\n").data(using: .utf8) { | ||||||||||||||
| FileHandle.standardOutput.write(data) | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+216
to
220
|
||||||||||||||
| // Emit the human-readable block directly so the visible timestamp format | |
| // is not wrapped by the default swift-log ISO8601 prefix. | |
| if let data = (message + "\n").data(using: .utf8) { | |
| FileHandle.standardOutput.write(data) | |
| } | |
| logger.info("\(message)") |
Copilot
AI
Apr 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Directly writing a multi-line block to stdout from potentially concurrent request handlers risks interleaving log blocks under load (writes aren’t guaranteed to be atomic once the payload exceeds PIPE_BUF). If this format must be preserved, consider serializing output through a lock/queue (or a dedicated LogHandler) to ensure each block is emitted atomically.
Copilot
AI
Apr 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makeHTTPLogBlock hardcodes the literal log level string "info" in the header. This can become misleading for non-2xx responses and also diverges from the configured log level. Consider taking a Logger.Level (or deriving one from statusCode) and formatting it consistently instead of always emitting info.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import Foundation | ||
| import Testing | ||
|
|
||
| @testable import ProxyHTTPTransport | ||
|
|
||
| struct HTTPLoggingFormatTests { | ||
| @Test func mcpLogDetailsExtractsToolsCallNameAndParams() throws { | ||
| let payload = """ | ||
| { | ||
| "jsonrpc": "2.0", | ||
| "id": 1, | ||
| "method": "tools/call", | ||
| "params": { | ||
| "name": "XcodeListWindows", | ||
| "arguments": { | ||
| "includeMinimized": true | ||
| } | ||
| } | ||
| } | ||
| """.data(using: .utf8)! | ||
|
|
||
| let details = HTTPHandler.mcpLogDetails(from: payload) | ||
|
|
||
| #expect(details.invocation == "tools/call:XcodeListWindows") | ||
| let paramsJSON = try #require(details.paramsJSON.data(using: .utf8)) | ||
| let paramsObject = try JSONSerialization.jsonObject(with: paramsJSON, options: []) as? [String: Any] | ||
| let name = paramsObject?["name"] as? String | ||
| let arguments = paramsObject?["arguments"] as? [String: Any] | ||
| #expect(name == "XcodeListWindows") | ||
| #expect(arguments?["includeMinimized"] as? Bool == true) | ||
| } | ||
|
|
||
| @Test func mcpLogDetailsHandlesBatchRequests() throws { | ||
| let payload = """ | ||
| [ | ||
| {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"cursor":"next"}}, | ||
| {"jsonrpc":"2.0","id":2,"method":"ping"} | ||
| ] | ||
| """.data(using: .utf8)! | ||
|
|
||
| let details = HTTPHandler.mcpLogDetails(from: payload) | ||
|
|
||
| #expect(details.invocation == "batch[2]") | ||
| let paramsJSON = try #require(details.paramsJSON.data(using: .utf8)) | ||
| let paramsArray = try JSONSerialization.jsonObject(with: paramsJSON, options: []) as? [Any] | ||
| #expect(paramsArray?.count == 2) | ||
| let first = paramsArray?[0] as? [String: Any] | ||
| #expect(first?["cursor"] as? String == "next") | ||
| #expect(paramsArray?[1] is NSNull) | ||
| } | ||
|
|
||
| @Test func makeHTTPLogBlockUsesRequiredFourLineFormat() throws { | ||
| let request = HTTPHandler.RequestLogContext( | ||
| id: "request-1", | ||
| method: "POST", | ||
| path: "/mcp", | ||
| remoteAddress: nil, | ||
| mcpInvocation: "tools/list", | ||
| requestParamsJSON: "{\"cursor\":\"next\"}" | ||
| ) | ||
|
|
||
| let logBlock = HTTPResponseWriter.makeHTTPLogBlock( | ||
| request: request, | ||
| statusCode: 200, | ||
| sessionID: "session-123", | ||
| date: Date(timeIntervalSince1970: 1_714_469_200) | ||
| ) | ||
| let lines = logBlock.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) | ||
|
|
||
| #expect(lines.count == 4) | ||
| #expect(lines[0].contains(" info session-123 200")) | ||
| #expect(lines[0].contains("+") == false) | ||
| #expect(lines[0].range(of: #"^\d{2}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} info session-123 200$"#, options: .regularExpression) != nil) | ||
| #expect(lines[1] == "POST /mcp") | ||
| #expect(lines[2] == "tools/list") | ||
| #expect(lines[3] == "{\"cursor\":\"next\"}") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CompactDateLogHandlerstoresmetadataas an unprotected mutable dictionary and writes toFileHandle.standardOutputwithout synchronization. If aLoggerinstance is shared across threads, metadata mutation and concurrent writes can race/interleave, producing corrupted log lines. Consider protecting handler state/output with a lock (similar toStreamLogHandler), or delegating to an existing thread-safe handler implementation.