Skip to content

Commit 248824b

Browse files
author
Jon Petersson
committed
Change log rotation to a quota based system
1 parent e859f00 commit 248824b

File tree

11 files changed

+219
-43
lines changed

11 files changed

+219
-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

+77-23
Original file line numberDiff line numberDiff line change
@@ -9,49 +9,103 @@
99
import Foundation
1010
import MullvadTypes
1111

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

1737
public var errorDescription: String? {
1838
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."
39+
case .rotateLogFiles:
40+
return "Failure to rotate the source log file to backup."
2341
}
2442
}
2543

2644
public var underlyingError: Swift.Error? {
2745
switch self {
28-
case .noSourceLogFile:
29-
return nil
30-
case let .moveSourceLogFile(error):
46+
case let .rotateLogFiles(error):
3147
return error
3248
}
3349
}
3450
}
3551

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

4055
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
56+
// Filter out all log files in directory.
57+
let logPaths: [URL] = (try fileManager.contentsOfDirectory(
58+
atPath: logDirectory.relativePath
59+
)).compactMap { file in
60+
if file.split(separator: ".").last == "log" {
61+
logDirectory.appendingPathComponent(file)
62+
} else {
63+
nil
5164
}
5265
}
5366

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

ios/MullvadLogging/Logging.swift

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

99
import Foundation
10+
import MullvadTypes
1011
@_exported import Logging
1112

1213
private enum LoggerOutput {
@@ -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
@@ -580,6 +580,7 @@
580580
7A9CCCC42A96302800DD6A34 /* TunnelCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */; };
581581
7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1412A2E3306000B728D /* CheckboxView.swift */; };
582582
7A9FA1442A2E3FE5000B728D /* CheckableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */; };
583+
7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */; };
583584
7AB2B6702BA1EB8C00B03E3B /* ListCustomListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */; };
584585
7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */; };
585586
7AB4CCB92B69097E006037F5 /* IPOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */; };
@@ -1838,6 +1839,7 @@
18381839
7A9CCCB22A96302800DD6A34 /* TunnelCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelCoordinator.swift; sourceTree = "<group>"; };
18391840
7A9FA1412A2E3306000B728D /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = "<group>"; };
18401841
7A9FA1432A2E3FE5000B728D /* CheckableSettingsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckableSettingsCell.swift; sourceTree = "<group>"; };
1842+
7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRotationTests.swift; sourceTree = "<group>"; };
18411843
7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListViewController.swift; sourceTree = "<group>"; };
18421844
7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListCoordinator.swift; sourceTree = "<group>"; };
18431845
7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPOverrideTests.swift; sourceTree = "<group>"; };
@@ -2957,6 +2959,7 @@
29572959
7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */,
29582960
7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */,
29592961
7A516C3B2B712F0B00BBD33D /* IPOverrideWrapperTests.swift */,
2962+
7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */,
29602963
A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */,
29612964
58C3FA652A38549D006A450A /* MockFileCache.swift */,
29622965
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/Supporting Files/PrivacyInfo.xcprivacy

+9
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88
<false/>
99
<key>NSPrivacyAccessedAPITypes</key>
1010
<array>
11+
<dict>
12+
<key>NSPrivacyAccessedAPITypeReasons</key>
13+
<array>
14+
<string>C617.1</string>
15+
<string>3B52.1</string>
16+
</array>
17+
<key>NSPrivacyAccessedAPIType</key>
18+
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
19+
</dict>
1120
<dict>
1221
<key>NSPrivacyAccessedAPITypeReasons</key>
1322
<array>

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
}()
+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 setUp() async throws {
17+
try? fileManager.createDirectory(
18+
at: directoryPath,
19+
withIntermediateDirectories: false
20+
)
21+
}
22+
23+
override func tearDown() async 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, byteSize: 1000)
38+
}
39+
40+
try LogRotation.rotateLogs(logDirectory: directoryPath, options: LogRotation.Options(storageSizeLimit: 5000))
41+
var logFileCount = try fileManager.contentsOfDirectory(atPath: directoryPath.relativePath).count
42+
XCTAssertEqual(logFileCount, 5)
43+
44+
try LogRotation.rotateLogs(logDirectory: directoryPath, options: LogRotation.Options(storageSizeLimit: 3999))
45+
logFileCount = try fileManager.contentsOfDirectory(atPath: directoryPath.relativePath).count
46+
XCTAssertEqual(logFileCount, 3)
47+
}
48+
49+
func testRotateLogsByOldestAllowedDate() throws {
50+
let firstBatchOflogPaths = [
51+
directoryPath.appendingPathComponent("test1.log"),
52+
directoryPath.appendingPathComponent("test2.log"),
53+
directoryPath.appendingPathComponent("test3.log")
54+
]
55+
56+
let secondBatchOflogPaths = [
57+
directoryPath.appendingPathComponent("test4.log"),
58+
directoryPath.appendingPathComponent("test5.log")
59+
]
60+
61+
let oldestDateAllowedForFirstBatch = Date()
62+
try firstBatchOflogPaths.forEach { logPath in
63+
try writeDataToDisk(path: logPath, byteSize: 1000)
64+
}
65+
66+
let oldestDateAllowedForSecondBatch = Date()
67+
try secondBatchOflogPaths.forEach { logPath in
68+
try writeDataToDisk(path: logPath, byteSize: 1000)
69+
}
70+
71+
try LogRotation.rotateLogs(
72+
logDirectory: directoryPath,
73+
options: LogRotation.Options(oldestAllowedDate: oldestDateAllowedForFirstBatch)
74+
)
75+
var logFileCount = try fileManager.contentsOfDirectory(atPath: directoryPath.relativePath).count
76+
XCTAssertEqual(logFileCount, 5)
77+
78+
try LogRotation.rotateLogs(
79+
logDirectory: directoryPath,
80+
options: LogRotation.Options(oldestAllowedDate: oldestDateAllowedForSecondBatch)
81+
)
82+
logFileCount = try fileManager.contentsOfDirectory(atPath: directoryPath.relativePath).count
83+
XCTAssertEqual(logFileCount, 2)
84+
}
85+
}
86+
87+
extension LogRotationTests {
88+
private func writeDataToDisk(path: URL, byteSize: Int) throws {
89+
let data = Data((0 ..< 1000).map { UInt8($0 & 0xff) })
90+
try data.write(to: path)
91+
}
92+
}

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)