import Foundation
|
|
enum DevelopmentDiagnostics {
|
static let logPath: String = {
|
let baseURL = FileManager.default.homeDirectoryForCurrentUser
|
.appendingPathComponent("Library", isDirectory: true)
|
.appendingPathComponent("Logs", isDirectory: true)
|
.appendingPathComponent("Aligner", isDirectory: true)
|
return baseURL.appendingPathComponent("aligner-dev.log").path
|
}()
|
|
static var logURLs: [URL] {
|
let currentURL = URL(fileURLWithPath: logPath)
|
let previousURL = currentURL
|
.deletingLastPathComponent()
|
.appendingPathComponent("aligner-dev.previous.log")
|
return [currentURL, previousURL]
|
}
|
|
private static let queue = DispatchQueue(label: "com.ar.Aligner.development-diagnostics")
|
private static let maxLogBytes: UInt64 = 10 * 1024 * 1024
|
private static let runID = UUID().uuidString
|
static func log(_ event: String, _ fields: [String: CustomStringConvertible?] = [:]) {
|
guard ProcessInfo.processInfo.environment["ALIGNER_DEV_LOG_DISABLED"] != "1" else {
|
return
|
}
|
|
let normalizedFields = fields.reduce(into: [String: String]()) { result, item in
|
guard let value = item.value else { return }
|
result[item.key] = String(describing: value)
|
}
|
|
queue.async {
|
write(event: event, fields: normalizedFields)
|
}
|
}
|
|
static func logSync(_ event: String, _ fields: [String: CustomStringConvertible?] = [:]) {
|
guard ProcessInfo.processInfo.environment["ALIGNER_DEV_LOG_DISABLED"] != "1" else {
|
return
|
}
|
|
let normalizedFields = fields.reduce(into: [String: String]()) { result, item in
|
guard let value = item.value else { return }
|
result[item.key] = String(describing: value)
|
}
|
|
queue.sync {
|
write(event: event, fields: normalizedFields)
|
}
|
}
|
|
static func argumentSummary(_ arguments: [String]) -> String {
|
arguments
|
.dropFirst()
|
.map { argument in
|
if argument.hasPrefix("--") {
|
return argument.split(separator: "=", maxSplits: 1).first.map(String.init) ?? "--unknown"
|
}
|
|
return "<argument>"
|
}
|
.joined(separator: ",")
|
}
|
|
static func stableFingerprint(_ value: String?) -> String {
|
let text = value ?? ""
|
var hash: UInt64 = 0xcbf29ce484222325
|
for byte in text.utf8 {
|
hash ^= UInt64(byte)
|
hash = hash &* 0x100000001b3
|
}
|
return String(format: "%016llx", hash)
|
}
|
|
static func pathSummary(_ path: String?) -> [String: String] {
|
guard let path, !path.isEmpty else {
|
return [
|
"present": "false",
|
"locationKind": "missing",
|
"basename": "",
|
"fingerprint": stableFingerprint("")
|
]
|
}
|
|
return [
|
"present": "true",
|
"locationKind": locationKind(for: path),
|
"basename": URL(fileURLWithPath: path).lastPathComponent,
|
"fingerprint": stableFingerprint(path)
|
]
|
}
|
|
static var includesSensitiveFields: Bool {
|
let value = ProcessInfo.processInfo.environment["ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE"]?
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.lowercased()
|
return value == "1" || value == "true" || value == "yes"
|
}
|
|
static func errorSummaryFields(_ error: Error, prefix: String = "error") -> [String: String] {
|
let nsError = error as NSError
|
var result: [String: String] = [
|
"\(prefix)Domain": nsError.domain,
|
"\(prefix)Code": String(nsError.code),
|
"\(prefix)DescriptionHash": stableFingerprint(nsError.localizedDescription),
|
"\(prefix)DescriptionLength": String(nsError.localizedDescription.count)
|
]
|
|
if let filePath = nsError.userInfo[NSFilePathErrorKey] as? String {
|
let summary = pathSummary(filePath)
|
result["\(prefix)FilePathKind"] = summary["locationKind"]
|
result["\(prefix)FilePathBasename"] = summary["basename"]
|
result["\(prefix)FilePathHash"] = summary["fingerprint"]
|
}
|
|
if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError {
|
result["\(prefix)UnderlyingDomain"] = underlyingError.domain
|
result["\(prefix)UnderlyingCode"] = String(underlyingError.code)
|
}
|
|
return result
|
}
|
|
static func errorSummaryString(_ error: Error) -> String {
|
let fields = errorSummaryFields(error)
|
let domain = fields["errorDomain"] ?? "unknown"
|
let code = fields["errorCode"] ?? "unknown"
|
let descriptionHash = fields["errorDescriptionHash"] ?? stableFingerprint("")
|
return "domain=\(domain) code=\(code) descriptionHash=\(descriptionHash)"
|
}
|
|
private static func locationKind(for path: String) -> String {
|
let homePath = FileManager.default.homeDirectoryForCurrentUser.path
|
let userApplicationsPath = homePath + "/Applications/"
|
|
if path.hasPrefix(userApplicationsPath) {
|
return "userApplications"
|
}
|
if path.hasPrefix("/Applications/") {
|
return "systemApplications"
|
}
|
if path.hasPrefix("/Users/") && path.contains("/Projects/Aligner/03-O/C2.builds/") {
|
return "alignerBuilds"
|
}
|
if path.hasPrefix("/Users/") && path.contains("/Projects/Aligner/03-O/C1.source/") {
|
return "alignerSource"
|
}
|
if path.hasPrefix("/tmp/") || path.hasPrefix("/private/tmp/") {
|
return "temporary"
|
}
|
if path.hasPrefix(homePath + "/") {
|
return "userHome"
|
}
|
return "other"
|
}
|
|
private static func write(event: String, fields: [String: String]) {
|
let fileURL = URL(fileURLWithPath: logPath)
|
let directoryURL = fileURL.deletingLastPathComponent()
|
|
do {
|
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
rotateIfNeeded(fileURL: fileURL)
|
|
var payload = fields
|
payload["ts"] = timestampString()
|
payload["event"] = event
|
payload["pid"] = String(ProcessInfo.processInfo.processIdentifier)
|
payload["runID"] = runID
|
|
let line = payload
|
.sorted { $0.key < $1.key }
|
.map { "\($0.key)=\(escape($0.value))" }
|
.joined(separator: " ")
|
+ "\n"
|
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
let handle = try FileHandle(forWritingTo: fileURL)
|
try handle.seekToEnd()
|
if let data = line.data(using: .utf8) {
|
try handle.write(contentsOf: data)
|
}
|
try handle.close()
|
} else {
|
try line.write(to: fileURL, atomically: true, encoding: .utf8)
|
}
|
} catch {
|
fputs("Aligner development diagnostics failed: \(errorSummaryString(error))\n", stderr)
|
}
|
}
|
|
private static func timestampString() -> String {
|
let formatter = ISO8601DateFormatter()
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
return formatter.string(from: Date())
|
}
|
|
private static func rotateIfNeeded(fileURL: URL) {
|
guard
|
let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
|
let size = attributes[.size] as? UInt64,
|
size > maxLogBytes
|
else {
|
return
|
}
|
|
let archiveURL = fileURL
|
.deletingLastPathComponent()
|
.appendingPathComponent("aligner-dev.previous.log")
|
try? FileManager.default.removeItem(at: archiveURL)
|
try? FileManager.default.moveItem(at: fileURL, to: archiveURL)
|
}
|
|
private static func escape(_ value: String) -> String {
|
if value.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
|
!value.contains("=") {
|
return value
|
}
|
|
return "\""
|
+ value
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
.replacingOccurrences(of: "\n", with: "\\n")
|
+ "\""
|
}
|
}
|