Skip to content

Commit 6fb4c3e

Browse files
authored
Cleanup network logs after 7 days (#56)
* Add log file cleanup * Cleanup
1 parent 4965efb commit 6fb4c3e

5 files changed

Lines changed: 289 additions & 3 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,14 @@ Network requests are displayed in the Scyther UI under **Network Logs**. Each re
327327
- Status code and timing
328328
- cURL command for reproduction
329329

330+
#### Log Retention
331+
332+
Network logs are automatically cleaned up to prevent disk bloat:
333+
334+
- **7-day retention**: Log files older than 7 days are automatically deleted on app startup
335+
- **Manual cleanup**: Clearing logs via the UI also deletes all associated files from disk
336+
- **Files managed**: `SessionLog.log`, request body files, and response body files
337+
330338
---
331339

332340
### Console Logging

Sources/Scyther/Core/Scyther.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,9 @@ public enum Scyther {
178178

179179
_started = true
180180

181+
// Clean up old network logs (files older than 7 days)
182+
NetworkLogCleaner.shared.cleanupOldLogs()
183+
181184
Console.shared.startCapturing()
182185
Network.shared.startIntercepting()
183186
Interface.shared.setup()
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//
2+
// NetworkLogCleaner.swift
3+
//
4+
//
5+
// Created by Brandon Stillitano on 01/01/26.
6+
//
7+
8+
import Foundation
9+
10+
/// Manages cleanup of network log files stored on disk.
11+
///
12+
/// `NetworkLogCleaner` provides mechanisms to remove old or all network log files
13+
/// from the app's Documents directory. It handles both time-based retention policies
14+
/// and manual cleanup operations.
15+
///
16+
/// ## Features
17+
/// - Automatic cleanup of files older than the retention period
18+
/// - Manual deletion of all log files
19+
/// - Thread-safe operations via `@MainActor`
20+
///
21+
/// ## File Types Managed
22+
/// - `SessionLog.log` - Main session log file
23+
/// - `logger_request_body_*` - Individual request body files
24+
/// - `logger_response_body_*` - Individual response body files
25+
///
26+
/// ## Usage
27+
/// ```swift
28+
/// // Clean up old logs (called automatically on Scyther.start())
29+
/// NetworkLogCleaner.shared.cleanupOldLogs()
30+
///
31+
/// // Delete all log files
32+
/// NetworkLogCleaner.shared.deleteAllLogFiles()
33+
/// ```
34+
@MainActor
35+
final class NetworkLogCleaner {
36+
/// Shared instance of the network log cleaner.
37+
static let shared = NetworkLogCleaner()
38+
39+
/// The number of days to retain log files before deletion.
40+
///
41+
/// Files older than this threshold will be automatically deleted
42+
/// when `cleanupOldLogs()` is called.
43+
static let retentionDays: Int = 7
44+
45+
/// Private initializer to enforce singleton pattern.
46+
private init() {}
47+
48+
/// Deletes network log files older than the retention period.
49+
///
50+
/// This method scans the Documents directory for network log files
51+
/// (request bodies, response bodies, and session log) and deletes
52+
/// any that were created more than `retentionDays` ago.
53+
///
54+
/// The cleanup is performed synchronously but is designed to be
55+
/// lightweight and fast, making it suitable for app startup.
56+
func cleanupOldLogs() {
57+
let fileManager = FileManager.default
58+
guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
59+
return
60+
}
61+
62+
let cutoffDate = Calendar.current.date(byAdding: .day, value: -Self.retentionDays, to: Date()) ?? Date()
63+
64+
do {
65+
let files = try fileManager.contentsOfDirectory(
66+
at: documentsPath,
67+
includingPropertiesForKeys: [.creationDateKey],
68+
options: .skipsHiddenFiles
69+
)
70+
71+
for fileURL in files {
72+
let fileName = fileURL.lastPathComponent
73+
74+
// Check if this is a network log file
75+
guard isNetworkLogFile(fileName) else {
76+
continue
77+
}
78+
79+
// Get file creation date
80+
guard let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
81+
let creationDate = attributes[.creationDate] as? Date else {
82+
continue
83+
}
84+
85+
// Delete if older than retention period
86+
if creationDate < cutoffDate {
87+
try? fileManager.removeItem(at: fileURL)
88+
}
89+
}
90+
} catch {
91+
// Silently handle errors - cleanup is best-effort
92+
}
93+
}
94+
95+
/// Deletes all network log files from disk.
96+
///
97+
/// This method removes all network log files regardless of age,
98+
/// including:
99+
/// - All request body files (`logger_request_body_*`)
100+
/// - All response body files (`logger_response_body_*`)
101+
/// - The session log file (`SessionLog.log`)
102+
///
103+
/// This is called when the user manually clears the network logs.
104+
func deleteAllLogFiles() {
105+
let fileManager = FileManager.default
106+
guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
107+
return
108+
}
109+
110+
do {
111+
let files = try fileManager.contentsOfDirectory(
112+
at: documentsPath,
113+
includingPropertiesForKeys: nil,
114+
options: .skipsHiddenFiles
115+
)
116+
117+
for fileURL in files {
118+
let fileName = fileURL.lastPathComponent
119+
120+
if isNetworkLogFile(fileName) {
121+
try? fileManager.removeItem(at: fileURL)
122+
}
123+
}
124+
} catch {
125+
// Silently handle errors - cleanup is best-effort
126+
}
127+
}
128+
129+
/// Determines whether a file is a network log file based on its name.
130+
///
131+
/// - Parameter fileName: The name of the file to check.
132+
/// - Returns: `true` if the file is a network log file, `false` otherwise.
133+
private func isNetworkLogFile(_ fileName: String) -> Bool {
134+
return fileName.hasPrefix("logger_request_body_") ||
135+
fileName.hasPrefix("logger_response_body_") ||
136+
fileName == "SessionLog.log"
137+
}
138+
}

Sources/Scyther/Features/NetworkLogger/NetworkLogger.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,18 @@ actor NetworkLogger {
7676
continuation?.yield(items)
7777
}
7878

79-
/// Removes all HTTP requests from the log.
79+
/// Removes all HTTP requests from the log and deletes associated files from disk.
8080
///
81-
/// Clears the entire log and broadcasts an update with an empty array
82-
/// to all stream subscribers.
81+
/// Clears the entire in-memory log, broadcasts an update with an empty array
82+
/// to all stream subscribers, and deletes all network log files from disk
83+
/// including request bodies, response bodies, and the session log.
8384
func clear() {
8485
items.removeAll()
8586
continuation?.yield(items)
87+
88+
// Delete all log files from disk
89+
Task { @MainActor in
90+
NetworkLogCleaner.shared.deleteAllLogFiles()
91+
}
8692
}
8793
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
//
2+
// NetworkLogCleanerTests.swift
3+
// ScytherTests
4+
//
5+
// Created by Brandon Stillitano on 01/01/2026.
6+
//
7+
8+
#if !os(macOS)
9+
@testable import Scyther
10+
import XCTest
11+
12+
@MainActor
13+
final class NetworkLogCleanerTests: XCTestCase {
14+
15+
private var testDirectory: URL!
16+
private var fileManager: FileManager!
17+
18+
override func setUp() async throws {
19+
try await super.setUp()
20+
fileManager = FileManager.default
21+
22+
// Create a temporary test directory
23+
testDirectory = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString)
24+
try fileManager.createDirectory(at: testDirectory, withIntermediateDirectories: true)
25+
}
26+
27+
override func tearDown() async throws {
28+
// Clean up test directory
29+
if let testDirectory = testDirectory {
30+
try? fileManager.removeItem(at: testDirectory)
31+
}
32+
try await super.tearDown()
33+
}
34+
35+
// MARK: - Singleton Tests
36+
37+
func testSingletonInstance() {
38+
let instance1 = NetworkLogCleaner.shared
39+
let instance2 = NetworkLogCleaner.shared
40+
XCTAssertTrue(instance1 === instance2)
41+
}
42+
43+
// MARK: - Retention Period Tests
44+
45+
func testRetentionDaysIsSevenDays() {
46+
XCTAssertEqual(NetworkLogCleaner.retentionDays, 7)
47+
}
48+
49+
// MARK: - File Pattern Matching Tests
50+
51+
func testCleanupDoesNotDeleteNonLogFiles() {
52+
// Create a non-log file
53+
let nonLogFile = testDirectory.appendingPathComponent("user_data.json")
54+
fileManager.createFile(atPath: nonLogFile.path, contents: Data())
55+
56+
// Verify file exists
57+
XCTAssertTrue(fileManager.fileExists(atPath: nonLogFile.path))
58+
59+
// cleanupOldLogs should not affect non-log files (testing the pattern matching logic)
60+
// Since we can't inject the directory, we verify the file pattern matching works correctly
61+
let fileName = "user_data.json"
62+
XCTAssertFalse(isNetworkLogFile(fileName))
63+
}
64+
65+
func testRequestBodyFilePatternRecognized() {
66+
XCTAssertTrue(isNetworkLogFile("logger_request_body_10:30:45.123_ABC123"))
67+
XCTAssertTrue(isNetworkLogFile("logger_request_body_unknown_XYZ789"))
68+
}
69+
70+
func testResponseBodyFilePatternRecognized() {
71+
XCTAssertTrue(isNetworkLogFile("logger_response_body_10:30:45.123_ABC123"))
72+
XCTAssertTrue(isNetworkLogFile("logger_response_body_unknown_XYZ789"))
73+
}
74+
75+
func testSessionLogFilePatternRecognized() {
76+
XCTAssertTrue(isNetworkLogFile("SessionLog.log"))
77+
}
78+
79+
func testNonLogFilePatternsNotRecognized() {
80+
XCTAssertFalse(isNetworkLogFile("user_preferences.json"))
81+
XCTAssertFalse(isNetworkLogFile("database.sqlite"))
82+
XCTAssertFalse(isNetworkLogFile("console.log"))
83+
XCTAssertFalse(isNetworkLogFile("logger_crash_body_123"))
84+
XCTAssertFalse(isNetworkLogFile("SessionLog.txt"))
85+
}
86+
87+
// MARK: - Helper to test file pattern matching
88+
89+
/// Mirrors the logic in NetworkLogCleaner.isNetworkLogFile
90+
private func isNetworkLogFile(_ fileName: String) -> Bool {
91+
return fileName.hasPrefix("logger_request_body_") ||
92+
fileName.hasPrefix("logger_response_body_") ||
93+
fileName == "SessionLog.log"
94+
}
95+
96+
// MARK: - Integration Tests
97+
98+
func testCleanupOldLogsDoesNotCrashOnEmptyDirectory() {
99+
// Should handle empty directory gracefully without crashing
100+
NetworkLogCleaner.shared.cleanupOldLogs()
101+
// If we get here without crashing, the test passes
102+
}
103+
104+
func testDeleteAllLogFilesDoesNotCrashOnEmptyDirectory() {
105+
// Should handle empty directory gracefully without crashing
106+
NetworkLogCleaner.shared.deleteAllLogFiles()
107+
// If we get here without crashing, the test passes
108+
}
109+
110+
func testCleanupPreservesFilePattern() {
111+
// Test that the file pattern matching logic correctly identifies log files
112+
let testCases: [(fileName: String, isLogFile: Bool)] = [
113+
("logger_request_body_12:34:56.789_UUID123", true),
114+
("logger_response_body_12:34:56.789_UUID456", true),
115+
("SessionLog.log", true),
116+
("other_file.txt", false),
117+
("logger_request_body", false), // Missing timestamp and UUID
118+
("request_body_12:34:56.789_UUID", false), // Missing logger_ prefix
119+
("SessionLog", false), // Missing .log extension
120+
]
121+
122+
for testCase in testCases {
123+
XCTAssertEqual(
124+
isNetworkLogFile(testCase.fileName),
125+
testCase.isLogFile,
126+
"Pattern matching failed for: \(testCase.fileName)"
127+
)
128+
}
129+
}
130+
}
131+
#endif

0 commit comments

Comments
 (0)