Skip to content

Commit 14cb07a

Browse files
author
Jon Petersson
committed
Change log rotation to a quota based system
1 parent 71b9e7f commit 14cb07a

File tree

10 files changed

+210
-43
lines changed

10 files changed

+210
-43
lines changed

ios/MullvadLogging/Date+LogFormat.swift

+7
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,11 @@ extension Date {
1515

1616
return formatter.string(from: self)
1717
}
18+
19+
public func logFormatFilename() -> String {
20+
let formatter = DateFormatter()
21+
formatter.dateFormat = "dd-MM-yyyy'T'HH:mm:ss"
22+
23+
return formatter.string(from: self)
24+
}
1825
}

ios/MullvadLogging/LogRotation.swift

+71-23
Original file line numberDiff line numberDiff line change
@@ -10,48 +10,96 @@ import Foundation
1010
import MullvadTypes
1111

1212
public enum LogRotation {
13+
private struct LogData {
14+
var path: URL
15+
var size: UInt64
16+
var creationDate: Date
17+
}
18+
19+
public struct Options {
20+
let storageSizeLimit: Int
21+
let oldestAllowedDate: Date
22+
23+
/// Options for log rotation, defining how logs should be retained.
24+
///
25+
/// - Parameter storageSizeLimit: Storage size limit in bytes.
26+
/// - Parameter oldestAllowedDate: Oldest allowed date.
27+
public init(storageSizeLimit: Int, oldestAllowedDate: Date) {
28+
self.storageSizeLimit = storageSizeLimit
29+
self.oldestAllowedDate = oldestAllowedDate
30+
}
31+
}
32+
1333
public enum Error: LocalizedError, WrappingError {
14-
case noSourceLogFile
15-
case moveSourceLogFile(Swift.Error)
34+
case rotateLogFiles(Swift.Error)
1635

1736
public var errorDescription: String? {
1837
switch self {
19-
case .noSourceLogFile:
20-
return "Source log file does not exist."
21-
case .moveSourceLogFile:
22-
return "Failure to move the source log file to backup."
38+
case .rotateLogFiles:
39+
return "Failure to rotate the source log file to backup."
2340
}
2441
}
2542

2643
public var underlyingError: Swift.Error? {
2744
switch self {
28-
case .noSourceLogFile:
29-
return nil
30-
case let .moveSourceLogFile(error):
45+
case let .rotateLogFiles(error):
3146
return error
3247
}
3348
}
3449
}
3550

36-
public static func rotateLog(logsDirectory: URL, logFileName: String) throws {
37-
let source = logsDirectory.appendingPathComponent(logFileName)
38-
let backup = source.deletingPathExtension().appendingPathExtension("old.log")
51+
public static func rotateLogs(logDirectory: URL, options: Options) throws {
52+
let fileManager = FileManager.default
3953

4054
do {
41-
_ = try FileManager.default.replaceItemAt(backup, withItemAt: source)
42-
} catch {
43-
// FileManager returns a very obscure error chain so we need to traverse it to find
44-
// the root cause of the error.
45-
for case let fileError as CocoaError in error.underlyingErrorChain {
46-
// .fileNoSuchFile is returned when both backup and source log files do not exist
47-
// .fileReadNoSuchFile is returned when backup exists but source log file does not
48-
if fileError.code == .fileNoSuchFile || fileError.code == .fileReadNoSuchFile,
49-
fileError.url == source {
50-
throw Error.noSourceLogFile
55+
// Filter out all log files in directory.
56+
let logPaths: [URL] = (try fileManager.contentsOfDirectory(
57+
atPath: logDirectory.relativePath
58+
)).compactMap { file in
59+
if file.split(separator: ".").last == "log" {
60+
logDirectory.appendingPathComponent(file)
61+
} else {
62+
nil
5163
}
5264
}
5365

54-
throw Error.moveSourceLogFile(error)
66+
// Convert logs into objects with necessary meta data.
67+
let logs = try logPaths.map { logPath in
68+
let attributes = try fileManager.attributesOfItem(atPath: logPath.relativePath)
69+
let size = (attributes[.size] as? UInt64) ?? 0
70+
let creationDate = (attributes[.creationDate] as? Date) ?? Date.distantPast
71+
72+
return LogData(path: logPath, size: size, creationDate: creationDate)
73+
}.sorted { log1, log2 in
74+
log1.creationDate > log2.creationDate
75+
}
76+
77+
try deleteLogsOlderThan(options.oldestAllowedDate, in: logs)
78+
try deleteLogsWithCombinedSizeLargerThan(options.storageSizeLimit, in: logs)
79+
} catch {
80+
throw Error.rotateLogFiles(error)
81+
}
82+
}
83+
84+
private static func deleteLogsOlderThan(_ dateThreshold: Date, in logs: [LogData]) throws {
85+
let fileManager = FileManager.default
86+
87+
for log in logs where log.creationDate < dateThreshold {
88+
try fileManager.removeItem(at: log.path)
89+
}
90+
}
91+
92+
private static func deleteLogsWithCombinedSizeLargerThan(_ sizeThreshold: Int, in logs: [LogData]) throws {
93+
let fileManager = FileManager.default
94+
95+
// Delete all logs outside maximum capacity (ordered newest to oldest).
96+
var fileSizes = UInt64.zero
97+
for log in logs {
98+
fileSizes += log.size
99+
100+
if fileSizes > sizeThreshold {
101+
try fileManager.removeItem(at: log.path)
102+
}
55103
}
56104
}
57105
}

ios/MullvadLogging/Logging.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import Foundation
1010
@_exported import Logging
11+
import MullvadTypes
1112

1213
private enum LoggerOutput {
1314
case fileOutput(_ fileOutput: LogFileOutputStream)
@@ -24,7 +25,6 @@ public struct LoggerBuilder {
2425
public init() {}
2526

2627
public mutating func addFileOutput(fileURL: URL) {
27-
let logFileName = fileURL.lastPathComponent
2828
let logsDirectoryURL = fileURL.deletingLastPathComponent()
2929

3030
try? FileManager.default.createDirectory(
@@ -34,7 +34,10 @@ public struct LoggerBuilder {
3434
)
3535

3636
do {
37-
try LogRotation.rotateLog(logsDirectory: logsDirectoryURL, logFileName: logFileName)
37+
try LogRotation.rotateLogs(logDirectory: logsDirectoryURL, options: LogRotation.Options(
38+
storageSizeLimit: 5_242_880, // 5 MB
39+
oldestAllowedDate: Date(timeIntervalSinceNow: Duration.days(7).timeInterval)
40+
))
3841
} catch {
3942
logRotationErrors.append(error)
4043
}

ios/MullvadVPN.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,7 @@
581581
7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */; };
582582
7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1412A2E3306000B728D /* CheckboxView.swift */; };
583583
7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */; };
584+
7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */; };
584585
7AB2B6702BA1EB8C00B03E3B /* ListCustomListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */; };
585586
7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */; };
586587
7AB4CCB92B69097E006037F5 /* IPOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */; };
@@ -1839,6 +1840,7 @@
18391840
7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelCoordinator.swift; sourceTree = "<group>"; };
18401841
7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = "<group>"; };
18411842
7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = "<group>"; };
1843+
7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRotationTests.swift; sourceTree = "<group>"; };
18421844
7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListViewController.swift; sourceTree = "<group>"; };
18431845
7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListCoordinator.swift; sourceTree = "<group>"; };
18441846
7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideTests.swift; sourceTree = "<group>"; };
@@ -2956,6 +2958,7 @@
29562958
7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */,
29572959
7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */,
29582960
7A516C3B2B712F0B00BBD33D /* IPOverrideWrapperTests.swift */,
2961+
7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */,
29592962
A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */,
29602963
58C3FA652A38549D006A450A /* MockFileCache.swift */,
29612964
F09D04B42AE93CB6003D4F89 /* OutgoingConnectionProxy+Stub.swift */,
@@ -4970,6 +4973,7 @@
49704973
A9A5FA2D2ACB05160083449F /* DurationTests.swift in Sources */,
49714974
A9A5FA2E2ACB05160083449F /* FileCacheTests.swift in Sources */,
49724975
A9A5FA2F2ACB05160083449F /* FixedWidthIntegerArithmeticsTests.swift in Sources */,
4976+
7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */,
49734977
F04413622BA45CE30018A6EE /* CustomListLocationNodeBuilder.swift in Sources */,
49744978
A9A5FA302ACB05160083449F /* InputTextFormatterTests.swift in Sources */,
49754979
F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */,

ios/MullvadVPN/AppDelegate.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
353353

354354
private func configureLogging() {
355355
var loggerBuilder = LoggerBuilder()
356-
loggerBuilder.addFileOutput(fileURL: ApplicationConfiguration.logFileURL(for: .mainApp))
356+
loggerBuilder.addFileOutput(fileURL: ApplicationConfiguration.newLogFileURL(for: .mainApp))
357357
#if DEBUG
358358
loggerBuilder.addOSLogOutput(subsystem: ApplicationTarget.mainApp.bundleIdentifier)
359359
#endif

ios/MullvadVPN/Classes/ConsolidatedApplicationLog.swift

+2-10
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,9 @@ class ConsolidatedApplicationLog: TextOutputStreamable {
4747
}
4848
}
4949

50-
func addLogFile(fileURL: URL, includeLogBackup: Bool) {
51-
addSingleLogFile(fileURL)
52-
if includeLogBackup {
53-
let oldLogFileURL = fileURL.deletingPathExtension().appendingPathExtension("old.log")
54-
addSingleLogFile(oldLogFileURL)
55-
}
56-
}
57-
58-
func addLogFiles(fileURLs: [URL], includeLogBackups: Bool) {
50+
func addLogFiles(fileURLs: [URL]) {
5951
for fileURL in fileURLs {
60-
addLogFile(fileURL: fileURL, includeLogBackup: includeLogBackups)
52+
addSingleLogFile(fileURL)
6153
}
6254
}
6355

ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift

+2-3
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ final class ProblemReportInteractor {
2424
redactContainerPathsForSecurityGroupIdentifiers: [securityGroupIdentifier]
2525
)
2626

27-
let logFileURLs = ApplicationTarget.allCases.map { ApplicationConfiguration.logFileURL(for: $0) }
28-
29-
report.addLogFiles(fileURLs: logFileURLs, includeLogBackups: true)
27+
let logFileURLs = ApplicationTarget.allCases.flatMap { ApplicationConfiguration.logFileURLs(for: $0) }
28+
report.addLogFiles(fileURLs: logFileURLs)
3029

3130
return report
3231
}()
+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//
2+
// LogRotationTests.swift
3+
// MullvadVPNTests
4+
//
5+
// Created by Jon Petersson on 2024-04-12.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import MullvadLogging
10+
import XCTest
11+
12+
final class LogRotationTests: XCTestCase {
13+
let fileManager = FileManager.default
14+
let directoryPath = FileManager.default.temporaryDirectory.appendingPathComponent("LogRotationTests")
15+
16+
override func setUpWithError() throws {
17+
try? fileManager.createDirectory(
18+
at: directoryPath,
19+
withIntermediateDirectories: false
20+
)
21+
}
22+
23+
override func tearDownWithError() throws {
24+
try fileManager.removeItem(atPath: directoryPath.relativePath)
25+
}
26+
27+
func testRotateLogsByStorageSizeLimit() throws {
28+
let logPaths = [
29+
directoryPath.appendingPathComponent("test1.log"),
30+
directoryPath.appendingPathComponent("test2.log"),
31+
directoryPath.appendingPathComponent("test3.log"),
32+
directoryPath.appendingPathComponent("test4.log"),
33+
directoryPath.appendingPathComponent("test5.log"),
34+
]
35+
36+
try logPaths.forEach { logPath in
37+
try writeDataToDisk(path: logPath, fileSize: 1000)
38+
}
39+
40+
try LogRotation.rotateLogs(logDirectory: directoryPath, options: LogRotation.Options(
41+
storageSizeLimit: 5000,
42+
oldestAllowedDate: .distantPast)
43+
)
44+
var logFileCount = try fileManager.contentsOfDirectory(atPath: directoryPath.relativePath).count
45+
XCTAssertEqual(logFileCount, 5)
46+
47+
try LogRotation.rotateLogs(logDirectory: directoryPath, options: LogRotation.Options(
48+
storageSizeLimit: 3999,
49+
oldestAllowedDate: .distantPast)
50+
)
51+
logFileCount = try fileManager.contentsOfDirectory(atPath: directoryPath.relativePath).count
52+
XCTAssertEqual(logFileCount, 3)
53+
}
54+
55+
func testRotateLogsByOldestAllowedDate() throws {
56+
let firstBatchOflogPaths = [
57+
directoryPath.appendingPathComponent("test1.log"),
58+
directoryPath.appendingPathComponent("test2.log"),
59+
directoryPath.appendingPathComponent("test3.log"),
60+
]
61+
62+
let secondBatchOflogPaths = [
63+
directoryPath.appendingPathComponent("test4.log"),
64+
directoryPath.appendingPathComponent("test5.log"),
65+
]
66+
67+
let oldestDateAllowedForFirstBatch = Date()
68+
try firstBatchOflogPaths.forEach { logPath in
69+
try writeDataToDisk(path: logPath, fileSize: 1000)
70+
}
71+
72+
let oldestDateAllowedForSecondBatch = Date()
73+
try secondBatchOflogPaths.forEach { logPath in
74+
try writeDataToDisk(path: logPath, fileSize: 1000)
75+
}
76+
77+
try LogRotation.rotateLogs(
78+
logDirectory: directoryPath,
79+
options: LogRotation.Options(storageSizeLimit: .max, oldestAllowedDate: oldestDateAllowedForFirstBatch)
80+
)
81+
var logFileCount = try fileManager.contentsOfDirectory(atPath: directoryPath.relativePath).count
82+
XCTAssertEqual(logFileCount, 5)
83+
84+
try LogRotation.rotateLogs(
85+
logDirectory: directoryPath,
86+
options: LogRotation.Options(storageSizeLimit: .max, oldestAllowedDate: oldestDateAllowedForSecondBatch)
87+
)
88+
logFileCount = try fileManager.contentsOfDirectory(atPath: directoryPath.relativePath).count
89+
XCTAssertEqual(logFileCount, 2)
90+
}
91+
}
92+
93+
extension LogRotationTests {
94+
private func writeDataToDisk(path: URL, fileSize: Int) throws {
95+
let data = Data((0 ..< fileSize).map { UInt8($0 & 0xff) })
96+
try data.write(to: path)
97+
}
98+
}

ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ extension PacketTunnelProvider {
163163
var loggerBuilder = LoggerBuilder()
164164
let pid = ProcessInfo.processInfo.processIdentifier
165165
loggerBuilder.metadata["pid"] = .string("\(pid)")
166-
loggerBuilder.addFileOutput(fileURL: ApplicationConfiguration.logFileURL(for: .packetTunnel))
166+
loggerBuilder.addFileOutput(fileURL: ApplicationConfiguration.newLogFileURL(for: .packetTunnel))
167167
#if DEBUG
168168
loggerBuilder.addOSLogOutput(subsystem: ApplicationTarget.packetTunnel.bundleIdentifier)
169169
#endif

ios/Shared/ApplicationConfiguration.swift

+19-3
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,25 @@ enum ApplicationConfiguration {
2121
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: securityGroupIdentifier)!
2222
}
2323

24-
/// Returns URL for log file associated with application target and located within shared container.
25-
static func logFileURL(for target: ApplicationTarget) -> URL {
26-
containerURL.appendingPathComponent("\(target.bundleIdentifier).log", isDirectory: false)
24+
/// Returns URL for new log file associated with application target and located within shared container.
25+
static func newLogFileURL(for target: ApplicationTarget) -> URL {
26+
containerURL.appendingPathComponent(
27+
"\(target.bundleIdentifier)_\(Date().logFormatFilename()).log",
28+
isDirectory: false
29+
)
30+
}
31+
32+
/// Returns URLs for log files associated with application target and located within shared container.
33+
static func logFileURLs(for target: ApplicationTarget) -> [URL] {
34+
let containerUrl = containerURL
35+
36+
return (try? FileManager.default.contentsOfDirectory(atPath: containerURL.relativePath))?.compactMap { file in
37+
if file.split(separator: ".").last == "log" {
38+
containerUrl.appendingPathComponent(file)
39+
} else {
40+
nil
41+
}
42+
}.sorted { $0.relativePath > $1.relativePath } ?? []
2743
}
2844

2945
/// Privacy policy URL.

0 commit comments

Comments
 (0)