Skip to content

feat(logging): rotate diagnostic logs by size and time#738

Open
nk-tedo-001 wants to merge 1 commit into
stonerl:developmentfrom
nk-tedo-001:feat/log-rotation
Open

feat(logging): rotate diagnostic logs by size and time#738
nk-tedo-001 wants to merge 1 commit into
stonerl:developmentfrom
nk-tedo-001:feat/log-rotation

Conversation

@nk-tedo-001

@nk-tedo-001 nk-tedo-001 commented Jun 22, 2026

Copy link
Copy Markdown

Summary

Add automatic rotation of the ~/Library/Logs/Thaw diagnostic logs so a single file stays under GitHub's 25 MB upload limit. Rotation triggers on file size (default 10 MB) and optionally on a time interval (off/hourly/ daily).

Retention switches from a fixed file count to an age-based policy (default 2 days) plus a count cap.

The main app owns rotation; on each rotation it re-points the XPC service to the new segment over the existing configureLogging channel, whose path is now optional (nil disables logging, which also fixes toggling logging after launch).

All file-descriptor operations are serialized on the write queue, the new file is opened before the old one is closed, and log files are created with 0o600 permissions and O_NOFOLLOW.

New settings (max file size, retention days, rotation interval) are exposed in Advanced → Diagnostics and carried in profiles and settings reset.

Closes #732

  • Size rotation (default 10 MB): hot-path check (throttled fstat) plus a
    periodic owner-side poll that also catches growth driven solely by the XPC
    service.
  • Time rotation (optional: off / hourly / daily).
  • Retention: replaces the fixed "keep 5 files" with an age-based policy
    (default 2 days) plus a file-count cap; never deletes the current or the
    just-rotated segment.
  • XPC: configureLogging(filePath:) is now optional — the main app
    (sole rotation owner) re-points the service to each new segment, and nil
    disables logging (also fixes toggling logging after launch).
  • Robustness/security: all fd operations serialized on the write queue,
    new file opened before the old is closed, log files created 0o600 with
    O_NOFOLLOW.
  • New settings in Advanced → Diagnostics; carried in profiles and reset.
  • Unit tests for the retention/filename cores.

PR Type

  • Bugfix
  • CI/CD
  • Documentation
  • Feature
  • Performance improvement
  • Refactor
  • Test addition or update
  • Other (please describe)

Does this PR introduce a breaking change?

  • Yes - if yes, please describe the impact and migration path
  • No

What is the new behavior?

Add automatic rotation of the ~/Library/Logs/Thaw diagnostic logs so a single file stays under GitHub's 25 MB upload limit.

PR Checklist

  • I've built and run the app locally and verified that it works as expected.
  • I've run swiftformat . to keep the code style consistent.
  • I've added tests for new behavior (if applicable)
  • I've updated documentation as needed

Other information

Screenshots, notes, or anything useful for reviewers.

Summary by CodeRabbit

  • New Features

    • Diagnostic logging now supports configurable settings: maximum log file size, retention period, and time-based rotation interval
    • Advanced Settings includes new UI controls for diagnostic logging configuration and behavior customization
  • Tests

    • Added tests for log retention policies and log file collision detection

Add automatic rotation of the ~/Library/Logs/Thaw diagnostic logs so a
single file stays under GitHub's 25 MB upload limit. Rotation triggers on
file size (default 10 MB) and optionally on a time interval (off/hourly/
daily). Retention switches from a fixed file count to an age-based policy
(default 2 days) plus a count cap.

The main app owns rotation; on each rotation it re-points the XPC service to
the new segment over the existing configureLogging channel, whose path is now
optional (nil disables logging, which also fixes toggling logging after
launch). All file-descriptor operations are serialized on the write queue,
the new file is opened before the old one is closed, and log files are
created with 0o600 permissions and O_NOFOLLOW.

New settings (max file size, retention days, rotation interval) are exposed
in Advanced → Diagnostics and carried in profiles and settings reset.

Closes stonerl#732
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a size-based and time-based log rotation system to DiagnosticLogger with a configurable RotationPolicy. When the main app rotates to a new log segment, the new file path is forwarded to the XPC menu bar service via a retry-aware connection state machine. New settings (max size, retention days, rotation interval) are exposed in the advanced settings UI and persisted in UserDefaults and profile snapshots.

Changes

Diagnostic Log Rotation

Layer / File(s) Summary
XPC configureLogging nil support
Shared/Services/MenuBarItemService.swift, MenuBarItemService/Listener.swift
configureLogging case changes to filePath: String?; the XPC listener branches on nil to attach or disable diagnostic logging.
DiagnosticLogger rotation engine
Shared/Utilities/DiagnosticLogger.swift, ThawTests/DiagnosticLoggerTests.swift
Adds RotationPolicy (size, time interval, retention days, max count); rewrites attachToFile/openLogFile for idempotency and concurrency safety with O_NOFOLLOW POSIX open; introduces rotateLogFile (owner-only, onRotate callback, retention pruning), a reschedulable maintenance timer, throttled fstat, and afterWriteLocked for size-triggered rotation. Adds filesToPrune and uniqueLogFileURL helpers with unit tests.
XPC connection retry state machine
Thaw/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift
Adds LoggingState (desired path, dirty flag, generation counter) under OSAllocatedUnfairLock; rewrites start() to replay desired state; adds configureLogging(to:), sendConfigureLogging, resendLoggingIfDirty; sourcePIDs calls resendLoggingIfDirty opportunistically.
AppState onRotate wiring
Thaw/Main/AppState.swift
Registers DiagnosticLogger.shared.onRotate to forward the new log URL to MenuBarItemService.Connection.shared.configureLogging(to:) via async Task.
Rotation settings model, defaults, and Combine wiring
Thaw/Settings/Models/AdvancedSettings.swift, Thaw/Utilities/Defaults.swift, Thaw/Settings/Models/SettingsResetter.swift
Adds LogRotationInterval enum; adds diagnosticLogMaxSizeMB, diagnosticLogRetentionDays, diagnosticLogRotationInterval to AdvancedSettings with load/persist logic; configureCancellables pushes XPC updates on toggle and applies RotationPolicy via CombineLatest3; adds Defaults keys/values and resetAdvanced reset.
Profile snapshot encoding
Thaw/Settings/Models/Profile.swift
AdvancedSettingsSnapshot gains three new diagnostic logging fields with capture/apply, CodingKeys, memberwise initializer, Codable decoder with DefaultValue fallbacks, and Profile.init(from:) fallback path all updated.
Advanced settings UI controls
Thaw/Settings/SettingsPanes/AdvancedSettingsPane.swift
Conditionally renders steppers for max log file size and retention days plus an IcePicker for rotation interval when diagnostic logging is enabled.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(173, 216, 230, 0.5)
    note over AdvancedSettings,DiagnosticLogger: Settings change triggers rotation policy update
    AdvancedSettings->>DiagnosticLogger: setRotationPolicy(RotationPolicy)
    DiagnosticLogger->>DiagnosticLogger: rescheduleMaintenanceTimer()
  end

  rect rgba(144, 238, 144, 0.5)
    note over DiagnosticLogger,MenuBarItemService: Rotation event propagates across XPC boundary
    DiagnosticLogger->>AppState: onRotate(newURL)
    AppState->>MenuBarItemServiceConnection: configureLogging(to: newURL.path)
    MenuBarItemServiceConnection->>MenuBarItemServiceConnection: update LoggingState (generation++)
    MenuBarItemServiceConnection->>MenuBarItemService XPC: configureLogging(filePath: newPath)
    MenuBarItemService XPC-->>MenuBarItemServiceConnection: response
    MenuBarItemServiceConnection->>MenuBarItemServiceConnection: clear dirty if generation matches
  end

  rect rgba(255, 215, 0, 0.5)
    note over MenuBarItemServiceConnection,DiagnosticLogger: Retry on next sourcePIDs call
    MenuBarItemServiceConnection->>MenuBarItemServiceConnection: resendLoggingIfDirty()
    MenuBarItemServiceConnection->>MenuBarItemService XPC: configureLogging(filePath: desiredPath)
    MenuBarItemService XPC->>DiagnosticLogger: attachToFile(at: URL)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • stonerl/Thaw#615: The configureLogging(filePath:) XPC request and Listener calling DiagnosticLogger.attachToFile(at:) introduced there are directly extended by this PR's optional-path handling and retry state machine.
  • stonerl/Thaw#604: Both PRs modify DiagnosticLogger.openLogFile() startup semantics and file-handle opening, making the earlier refactor directly overlapping with the expanded rotation work here.
  • stonerl/Thaw#399: The enableDiagnosticLogging toggle path in AdvancedSettings.configureCancellables() modified there is extended by this PR to also push updated XPC logging configuration.

Suggested labels

feature, enhancement, test

Suggested reviewers

  • diazdesandi

Poem

🐰 Hop, hop, the logs won't grow too fat,
Each ten megabytes — snip, rotate that!
The XPC bunny retries when it fails,
No stale path shall slip through the trails.
With steppers and pickers all neatly aligned,
The rabbit keeps logs tidy, sized, and defined! 🪵

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(logging): rotate diagnostic logs by size and time' clearly describes the main feature added—automatic log rotation based on size and time intervals.
Description check ✅ Passed The PR description covers all required template sections: summary, PR type (Feature/Refactor/Test), breaking changes (No), new behavior, and checklist items fully checked.
Linked Issues check ✅ Passed The PR fully implements the feature request #732: log rotation by size (10 MB default) and time (off/hourly/daily), retention policy (2 days default), and prevents GitHub upload failures.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing diagnostic log rotation as specified in #732; no unrelated modifications or scope creep detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added feature New capability that did not exist before refactor Code restructuring without behavior change test Test additions or updates labels Jun 22, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Shared/Utilities/DiagnosticLogger.swift`:
- Around line 28-39: The current implementation has a race condition where
concurrent assignments to isEnabled can lose the latest toggle intent. The
snapshot read at the beginning of the setter happens before openLogFile()
completes its work, so if isEnabled=true is in-flight and isEnabled=false
arrives concurrently, the disable path may return early based on the stale
snapshot while the enable operation publishes its state afterwards. Serialize
both the enable and disable paths (the branches that call openLogFile() and
closeLogFile()) onto a single serialized queue such as writeQueue to ensure that
the last assignment always wins and the final state reflects the most recent
intent.

In `@Thaw/Settings/Models/AdvancedSettings.swift`:
- Around line 245-248: In the sink block where maxFileSizeBytes is calculated,
clamp the maxSizeMB value to a safe maximum before performing the multiplication
by 1024 * 1024. Instead of converting and multiplying the raw maxSizeMB value
directly, first constrain it to a reasonable upper limit (such as 100 to match
UI constraints) using the max function, then cast to UInt64 and perform the
multiplication. This prevents integer overflow when persisted or tampered
profile data contains out-of-range values that bypass UI validation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 56c338cf-59ff-42cb-8da5-426656aff964

📥 Commits

Reviewing files that changed from the base of the PR and between 5345c03 and b1ef17c.

📒 Files selected for processing (11)
  • MenuBarItemService/Listener.swift
  • Shared/Services/MenuBarItemService.swift
  • Shared/Utilities/DiagnosticLogger.swift
  • Thaw/Main/AppState.swift
  • Thaw/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift
  • Thaw/Settings/Models/AdvancedSettings.swift
  • Thaw/Settings/Models/Profile.swift
  • Thaw/Settings/Models/SettingsResetter.swift
  • Thaw/Settings/SettingsPanes/AdvancedSettingsPane.swift
  • Thaw/Utilities/Defaults.swift
  • ThawTests/DiagnosticLoggerTests.swift

Comment on lines +28 to 39
let wasEnabled = isEnabledLock.withLock { $0 }
guard newValue != wasEnabled else { return }
if newValue {
// `openLogFile()` flips `isEnabled` to true only after the file
// handle is installed, so a concurrent `log()` never sees
// enabled with no handle.
openLogFile()
} else if !newValue, oldValue {
} else {
// Stop new writes before closing so no line lands after the footer.
isEnabledLock.withLock { $0 = false }
closeLogFile()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Serialize isEnabled transitions so the latest toggle cannot be lost.

At Line 29, the equality short-circuit uses a snapshot read before openLogFile() publishes enabled state. If isEnabled = true is in-flight and a concurrent isEnabled = false arrives, the disable path can return early (still seeing false) and logging ends up enabled after the open completes. Reconcile enable/disable on a single serialized path (e.g., writeQueue) so last-write intent wins.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Shared/Utilities/DiagnosticLogger.swift` around lines 28 - 39, The current
implementation has a race condition where concurrent assignments to isEnabled
can lose the latest toggle intent. The snapshot read at the beginning of the
setter happens before openLogFile() completes its work, so if isEnabled=true is
in-flight and isEnabled=false arrives concurrently, the disable path may return
early based on the stale snapshot while the enable operation publishes its state
afterwards. Serialize both the enable and disable paths (the branches that call
openLogFile() and closeLogFile()) onto a single serialized queue such as
writeQueue to ensure that the last assignment always wins and the final state
reflects the most recent intent.

Comment on lines +245 to +248
.sink { maxSizeMB, retentionDays, interval in
var policy = DiagnosticLogger.RotationPolicy()
policy.maxFileSizeBytes = UInt64(max(0, maxSizeMB)) * 1024 * 1024
policy.retentionDays = max(1, retentionDays)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify current bounds/sanitization path for diagnosticLogMaxSizeMB.
rg -n 'diagnosticLogMaxSizeMB|maxFileSizeBytes|CombineLatest3' Thaw/Settings/Models/AdvancedSettings.swift -C3
rg -n 'diagnosticLogMaxSizeMB' Thaw/Settings/Models/Profile.swift Thaw/Settings/SettingsPanes/AdvancedSettingsPane.swift -C2

Repository: stonerl/Thaw

Length of output: 7302


🏁 Script executed:

# Find DiagnosticLogger and check RotationPolicy definition
fd -t f DiagnosticLogger -e swift | head -5
rg -n "class RotationPolicy|struct RotationPolicy" -A 10

Repository: stonerl/Thaw

Length of output: 1176


🏁 Script executed:

# Check if there's any validation in the Defaults system for diagnosticLogMaxSizeMB
rg -n "DefaultValue.diagnosticLogMaxSizeMB|.diagnosticLogMaxSizeMB.*case" -B 2 -A 2

Repository: stonerl/Thaw

Length of output: 2953


Clamp before multiplication to prevent overflow in maxFileSizeBytes derivation.

At line 247, UInt64(max(0, maxSizeMB)) * 1024 * 1024 can overflow when maxSizeMB contains out-of-range values from persisted or tampered profiles, potentially crashing during policy recomputation. While the UI constrains input to 1–100 MB, the persisted Int value bypasses this constraint and reaches the sink without validation.

Proposed fix
         .sink { maxSizeMB, retentionDays, interval in
             var policy = DiagnosticLogger.RotationPolicy()
-            policy.maxFileSizeBytes = UInt64(max(0, maxSizeMB)) * 1024 * 1024
+            let bytesPerMB: UInt64 = 1_048_576
+            let normalizedMB = max(1, maxSizeMB)
+            let clampedMB = min(UInt64(normalizedMB), UInt64.max / bytesPerMB)
+            policy.maxFileSizeBytes = clampedMB * bytesPerMB
             policy.retentionDays = max(1, retentionDays)
             policy.rotationInterval = interval.seconds
             DiagnosticLogger.shared.setRotationPolicy(policy)
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.sink { maxSizeMB, retentionDays, interval in
var policy = DiagnosticLogger.RotationPolicy()
policy.maxFileSizeBytes = UInt64(max(0, maxSizeMB)) * 1024 * 1024
policy.retentionDays = max(1, retentionDays)
.sink { maxSizeMB, retentionDays, interval in
var policy = DiagnosticLogger.RotationPolicy()
let bytesPerMB: UInt64 = 1_048_576
let normalizedMB = max(1, maxSizeMB)
let clampedMB = min(UInt64(normalizedMB), UInt64.max / bytesPerMB)
policy.maxFileSizeBytes = clampedMB * bytesPerMB
policy.retentionDays = max(1, retentionDays)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Thaw/Settings/Models/AdvancedSettings.swift` around lines 245 - 248, In the
sink block where maxFileSizeBytes is calculated, clamp the maxSizeMB value to a
safe maximum before performing the multiplication by 1024 * 1024. Instead of
converting and multiplying the raw maxSizeMB value directly, first constrain it
to a reasonable upper limit (such as 100 to match UI constraints) using the max
function, then cast to UInt64 and perform the multiplication. This prevents
integer overflow when persisted or tampered profile data contains out-of-range
values that bypass UI validation.

@nk-tedo-001

nk-tedo-001 commented Jun 23, 2026

Copy link
Copy Markdown
Author

I'm happy to fix the AI review if the code owners approve it.

@diazdesandi

Copy link
Copy Markdown
Collaborator

Please do @nk-tedo-001

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New capability that did not exist before refactor Code restructuring without behavior change test Test additions or updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Split logs by size/time

2 participants