Skip to content

Commit b1589ef

Browse files
committed
macos: notable-events collector health stats
1 parent de626e0 commit b1589ef

9 files changed

Lines changed: 654 additions & 18 deletions

File tree

cmd/system-probe/modules/notable_events_darwin.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type notableEventsCollector interface {
3434
Close() error
3535
Pending() []notableevents.Event
3636
Ack([]string) error
37+
Stats() notableevents.CollectorStats
3738
}
3839

3940
// newNotableEventsCollector provides the platform collector constructor and a test seam.
@@ -157,9 +158,34 @@ func writeNotableEventsJSON(req *http.Request, w http.ResponseWriter, status int
157158
utils.WriteAsJSON(req, w, body, utils.CompactOutput)
158159
}
159160

160-
// GetStats returns the module's currently empty runtime statistics.
161+
// GetStats returns aggregate collector health for the system-probe stats
162+
// endpoint, which feeds `agent status` and flares. Every value is a scalar:
163+
// report directories are user-specific, so no path or per-directory breakdown
164+
// may appear here.
161165
func (m *notableEventsModule) GetStats() map[string]interface{} {
162-
return map[string]interface{}{}
166+
stats := m.collector.Stats()
167+
return map[string]interface{}{
168+
"pending_events": stats.PendingEvents,
169+
"pending_events_max": stats.PendingEventsMax,
170+
"tracked_files": stats.TrackedFiles,
171+
"tracked_files_max": stats.TrackedFilesMax,
172+
"tracked_directories": stats.TrackedDirectories,
173+
"tracked_directories_max": stats.TrackedDirectoriesMax,
174+
"saturated_directories": stats.SaturatedDirectories,
175+
"retry_directories": stats.RetryDirectories,
176+
"acknowledged_identities": stats.AcknowledgedIdentities,
177+
"acknowledged_identities_max": stats.AcknowledgedIdentitiesMax,
178+
"bookmark_unsaved": stats.BookmarkUnsaved,
179+
"bookmark_stage_pending": stats.BookmarkStagePending,
180+
"watcher_active": stats.WatcherActive,
181+
"persistence_errors": stats.PersistenceErrors,
182+
"capacity_deferrals": stats.CapacityDeferrals,
183+
"baseline_suppressed_first_run": stats.BaselineSuppressedFirstRun,
184+
"baseline_suppressed_after_saturation": stats.BaselineSuppressedAfterSaturation,
185+
"fsevents_drops": stats.FSEventsDrops,
186+
"watcher_errors": stats.WatcherErrors,
187+
"watcher_restarts": stats.WatcherRestarts,
188+
}
163189
}
164190

165191
// Close releases the collector and its filesystem monitoring resources.

cmd/system-probe/modules/notable_events_darwin_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package modules
99

1010
import (
1111
"bytes"
12+
"encoding/json"
1213
"errors"
1314
"net/http"
1415
"net/http/httptest"
@@ -26,6 +27,7 @@ import (
2627
type fakeNotableEventsCollector struct {
2728
events []notableevents.Event
2829
acked []string
30+
stats notableevents.CollectorStats
2931
startErr error
3032
closeErr error
3133
ackErr error
@@ -58,6 +60,11 @@ func (f *fakeNotableEventsCollector) Ack(ids []string) error {
5860
return f.ackErr
5961
}
6062

63+
// Stats returns the configured health snapshot.
64+
func (f *fakeNotableEventsCollector) Stats() notableevents.CollectorStats {
65+
return f.stats
66+
}
67+
6168
// notableEventsTestHandler constructs the module routes around a supplied fake collector.
6269
func notableEventsTestHandler(t *testing.T, collector notableEventsCollector) http.Handler {
6370
t.Helper()
@@ -202,6 +209,60 @@ func TestNotableEventsAckFailure(t *testing.T) {
202209
assert.JSONEq(t, `{"error":"failed to acknowledge notable events"}`, recorder.Body.String())
203210
}
204211

212+
// TestNotableEventsGetStatsExposesCollectorHealth verifies every collector
213+
// health field reaches the stats endpoint under a stable key, and that the
214+
// payload stays flat and JSON-serializable for `agent status` and flares.
215+
func TestNotableEventsGetStatsExposesCollectorHealth(t *testing.T) {
216+
collector := &fakeNotableEventsCollector{stats: notableevents.CollectorStats{
217+
PendingEvents: 3,
218+
PendingEventsMax: 128,
219+
TrackedFiles: 7,
220+
TrackedFilesMax: 2048,
221+
TrackedDirectories: 2,
222+
TrackedDirectoriesMax: 256,
223+
SaturatedDirectories: 1,
224+
RetryDirectories: 4,
225+
AcknowledgedIdentities: 9,
226+
AcknowledgedIdentitiesMax: 4096,
227+
BookmarkUnsaved: true,
228+
BookmarkStagePending: true,
229+
WatcherActive: true,
230+
PersistenceErrors: 5,
231+
CapacityDeferrals: 6,
232+
BaselineSuppressedFirstRun: 11,
233+
BaselineSuppressedAfterSaturation: 12,
234+
FSEventsDrops: 13,
235+
WatcherErrors: 14,
236+
WatcherRestarts: 15,
237+
}}
238+
239+
encoded, err := json.Marshal((&notableEventsModule{collector: collector}).GetStats())
240+
241+
require.NoError(t, err)
242+
assert.JSONEq(t, `{
243+
"pending_events":3,
244+
"pending_events_max":128,
245+
"tracked_files":7,
246+
"tracked_files_max":2048,
247+
"tracked_directories":2,
248+
"tracked_directories_max":256,
249+
"saturated_directories":1,
250+
"retry_directories":4,
251+
"acknowledged_identities":9,
252+
"acknowledged_identities_max":4096,
253+
"bookmark_unsaved":true,
254+
"bookmark_stage_pending":true,
255+
"watcher_active":true,
256+
"persistence_errors":5,
257+
"capacity_deferrals":6,
258+
"baseline_suppressed_first_run":11,
259+
"baseline_suppressed_after_saturation":12,
260+
"fsevents_drops":13,
261+
"watcher_errors":14,
262+
"watcher_restarts":15
263+
}`, string(encoded))
264+
}
265+
205266
// TestNotableEventsRoutesAreMethodSpecific verifies endpoints reject unsupported HTTP methods.
206267
func TestNotableEventsRoutesAreMethodSpecific(t *testing.T) {
207268
handler := notableEventsTestHandler(t, &fakeNotableEventsCollector{})

pkg/notableevents/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ go_library(
77
"collector_darwin.go",
88
"format_darwin.go",
99
"reader_darwin.go",
10+
"stats_darwin.go",
1011
"store_darwin.go",
1112
"watcher_darwin.go",
1213
"watcher_fsevents_darwin.c",

pkg/notableevents/collector_darwin.go

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,12 @@ type Collector struct {
122122
store darwinBookmarkStore
123123
createWatcher darwinReportWatcherFactory
124124
watcher darwinReportWatcher
125-
knownDirs map[string]reportDirectory
126-
retryDirs map[string]reportDirectory
127-
stagedRuntime *stagedDarwinScanRuntime
125+
// watcherEverAttached distinguishes the initial attach from later ones so
126+
// only the latter count as restarts.
127+
watcherEverAttached bool
128+
knownDirs map[string]reportDirectory
129+
retryDirs map[string]reportDirectory
130+
stagedRuntime *stagedDarwinScanRuntime
128131

129132
state *darwinBookmarkState
130133
stagedScan *stagedDarwinScan
@@ -137,6 +140,8 @@ type Collector struct {
137140
identityRetention time.Duration
138141
maxAcknowledged int
139142

143+
counters collectorCounters
144+
140145
started bool
141146
closed bool
142147
cancel context.CancelFunc
@@ -286,6 +291,45 @@ func (c *Collector) Pending() []Event {
286291
return events
287292
}
288293

294+
// Stats returns an aggregate snapshot of collector health. It never acquires
295+
// scanMu, which a scan holds across report I/O, so it stays responsive while
296+
// scanning. Gauges owned by scanMu are read from their atomic mirrors.
297+
func (c *Collector) Stats() CollectorStats {
298+
stats := CollectorStats{
299+
PendingEventsMax: maxDarwinPendingEvents,
300+
TrackedFilesMax: maxDarwinTotalFiles,
301+
TrackedDirectoriesMax: maxDarwinDirectories,
302+
AcknowledgedIdentitiesMax: c.maxAcknowledged,
303+
RetryDirectories: int(c.counters.retryDirectories.Load()),
304+
WatcherActive: c.counters.watcherActive.Load(),
305+
PersistenceErrors: c.counters.persistenceErrors.Load(),
306+
CapacityDeferrals: c.counters.capacityDeferrals.Load(),
307+
BaselineSuppressedFirstRun: c.counters.baselineSuppressedFirstRun.Load(),
308+
BaselineSuppressedAfterSaturation: c.counters.baselineSuppressedAfterSaturation.Load(),
309+
FSEventsDrops: c.counters.fseventsDrops.Load(),
310+
WatcherErrors: c.counters.watcherErrors.Load(),
311+
WatcherRestarts: c.counters.watcherRestarts.Load(),
312+
}
313+
314+
c.stateMu.Lock()
315+
defer c.stateMu.Unlock()
316+
stats.PendingEvents = len(c.state.Pending)
317+
stats.AcknowledgedIdentities = len(c.state.Acknowledged)
318+
stats.TrackedDirectories = len(c.state.Directories)
319+
stats.BookmarkUnsaved = c.unsaved
320+
stats.BookmarkStagePending = c.stagedScan != nil
321+
for _, dirState := range c.state.Directories {
322+
if dirState == nil {
323+
continue
324+
}
325+
stats.TrackedFiles += len(dirState.Files)
326+
if dirState.Saturated {
327+
stats.SaturatedDirectories++
328+
}
329+
}
330+
return stats
331+
}
332+
289333
// Ack atomically moves pending IDs to retained acknowledgement state. Unknown
290334
// and already acknowledged IDs are no-ops. If persistence fails, no in-memory
291335
// event is removed and callers can safely retry.
@@ -337,6 +381,7 @@ func (c *Collector) Ack(ids []string) error {
337381
c.stateMu.Unlock()
338382

339383
if err := c.store.Save(next); err != nil {
384+
c.counters.persistenceErrors.Add(1)
340385
c.stateMu.Lock()
341386
c.releaseCommitLocked(reservation)
342387
c.stateMu.Unlock()
@@ -412,11 +457,23 @@ func (c *Collector) run(ctx context.Context) {
412457
c.scanMu.Unlock()
413458
continue
414459
}
460+
c.recordWatcherError(err)
415461
log.Warnf("macOS DiagnosticReports watcher error: %v", err)
416462
}
417463
}
418464
}
419465

466+
// recordWatcherError separates dropped-event notifications, which imply lost
467+
// change notifications, from other asynchronous watcher failures.
468+
func (c *Collector) recordWatcherError(err error) {
469+
var dropped *fseventsDroppedError
470+
if errors.As(err, &dropped) {
471+
c.counters.fseventsDrops.Add(1)
472+
return
473+
}
474+
c.counters.watcherErrors.Add(1)
475+
}
476+
420477
// watcherChannelsLocked returns nil-safe watcher channels while scanMu is held.
421478
func (c *Collector) watcherChannelsLocked() (<-chan string, <-chan error) {
422479
if c.watcher == nil {
@@ -429,6 +486,7 @@ func (c *Collector) watcherChannelsLocked() (<-chan string, <-chan error) {
429486
func (c *Collector) closeWatcherLocked() error {
430487
watcher := c.watcher
431488
c.watcher = nil
489+
c.counters.watcherActive.Store(false)
432490
if watcher == nil {
433491
return nil
434492
}
@@ -447,6 +505,11 @@ type directoryScanResult struct {
447505
BaselineIncidentIDs map[string]struct{}
448506
BaselineCompletions []darwinBaselineReportCompletion
449507
Deliverables map[string]darwinScanDeliverable
508+
// BaselineSuppressedFirstRun and BaselineSuppressedAfterSaturation count
509+
// reports recorded without delivery, split by why the directory was being
510+
// baselined.
511+
BaselineSuppressedFirstRun int
512+
BaselineSuppressedAfterSaturation int
450513
}
451514

452515
type darwinBaselineReportCompletion struct {
@@ -469,6 +532,9 @@ type darwinReportSource struct {
469532
type darwinScanIncidents struct {
470533
baselineIDs map[string]struct{}
471534
deliverables map[string]darwinScanDeliverable
535+
// capacityDeferrals counts events reconcile could not queue because
536+
// pending delivery was full.
537+
capacityDeferrals int
472538
}
473539

474540
// scanOnce refreshes report directories and reconciles their current contents.
@@ -503,6 +569,11 @@ func (c *Collector) ensureWatcherLocked() {
503569
return
504570
}
505571
c.watcher = watcher
572+
c.counters.watcherActive.Store(true)
573+
if c.watcherEverAttached {
574+
c.counters.watcherRestarts.Add(1)
575+
}
576+
c.watcherEverAttached = true
506577
}
507578

508579
// processWatcherEvent coalesces queued changes and scans only affected known directories.
@@ -588,9 +659,10 @@ func (c *Collector) retryDirectoriesLocked() []reportDirectory {
588659
// restoreWatcherLocked recreates watcher coverage after an asynchronous watcher failure.
589660
func (c *Collector) restoreWatcherLocked() {
590661
c.ensureWatcherLocked()
591-
if c.watcher != nil {
592-
c.refreshWatchedDirectoriesLocked(c.knownDirectoriesLocked())
662+
if c.watcher == nil {
663+
return
593664
}
665+
c.refreshWatchedDirectoriesLocked(c.knownDirectoriesLocked())
594666
}
595667

596668
// knownDirectoriesLocked returns a stable snapshot of currently discovered report directories.
@@ -637,6 +709,8 @@ func (c *Collector) scanDirectoriesLocked(ctx context.Context, dirs []reportDire
637709

638710
result, err := c.scanDirectory(ctx, dir, candidate)
639711
dirty = dirty || result.StateChanged
712+
addUint64(&c.counters.baselineSuppressedFirstRun, result.BaselineSuppressedFirstRun)
713+
addUint64(&c.counters.baselineSuppressedAfterSaturation, result.BaselineSuppressedAfterSaturation)
640714
retryResults[directoryRuntimeKey(dir.path)] = result.ShouldRetry
641715
results = append(results, result)
642716
incidents.addDirectoryResult(result)
@@ -652,6 +726,7 @@ func (c *Collector) scanDirectoriesLocked(ctx context.Context, dirs []reportDire
652726
if incidents.reconcile(candidate, retryResults, c.currentTime()) {
653727
dirty = true
654728
}
729+
addUint64(&c.counters.capacityDeferrals, incidents.capacityDeferrals)
655730
for index := range results {
656731
result := &results[index]
657732
dirState := candidate.Directories[result.DirectoryKey]
@@ -737,6 +812,7 @@ func (i *darwinScanIncidents) reconcile(state *darwinBookmarkState, retryResults
737812
continue
738813
}
739814
if len(state.Pending) >= maxDarwinPendingEvents {
815+
i.capacityDeferrals++
740816
for key := range deliverable.ContributingDirKeys {
741817
retryResults[key] = true
742818
}
@@ -814,6 +890,7 @@ func (c *Collector) persistCandidateLocked(
814890
c.stateMu.Unlock()
815891

816892
if err := c.store.Save(cloneDarwinBookmarkState(staged.candidate)); err != nil {
893+
c.counters.persistenceErrors.Add(1)
817894
c.stagedRuntime = runtime
818895
c.stateMu.Lock()
819896
c.stagedScan = staged
@@ -851,6 +928,7 @@ func (c *Collector) persistStagedScanLocked() ([]reportDirectory, bool) {
851928
c.stateMu.Unlock()
852929

853930
if err := c.store.Save(cloneDarwinBookmarkState(staged.candidate)); err != nil {
931+
c.counters.persistenceErrors.Add(1)
854932
c.stateMu.Lock()
855933
c.releaseCommitLocked(reservation)
856934
c.stateMu.Unlock()
@@ -966,6 +1044,7 @@ func (c *Collector) refreshKnownDirectoriesLocked(dirs []reportDirectory) {
9661044
delete(c.retryDirs, key)
9671045
}
9681046
}
1047+
c.counters.retryDirectories.Store(int64(len(c.retryDirs)))
9691048
}
9701049

9711050
// refreshWatchedDirectoriesLocked synchronizes FSEvents coverage with watchable directories.
@@ -994,6 +1073,7 @@ func (c *Collector) setDirectoryRetryLocked(dir reportDirectory, shouldRetry boo
9941073
} else {
9951074
delete(c.retryDirs, key)
9961075
}
1076+
c.counters.retryDirectories.Store(int64(len(c.retryDirs)))
9971077
}
9981078

9991079
// scanDirectoryInternal securely reads reports and updates only the private
@@ -1060,6 +1140,10 @@ func (c *Collector) scanDirectoryInternal(ctx context.Context, dir reportDirecto
10601140
}
10611141

10621142
baselineScan := !dirState.Initialized || dirState.Saturated
1143+
// A directory that never completed a baseline has never delivered anything,
1144+
// so attribute its suppressions to first run even when it is also
1145+
// saturated. Saturation only costs real events once delivery was working.
1146+
saturationBaseline := dirState.Initialized && dirState.Saturated
10631147
presentFiles := make(map[string]struct{})
10641148

10651149
for _, entry := range entries {
@@ -1126,6 +1210,15 @@ func (c *Collector) scanDirectoryInternal(ctx context.Context, dir reportDirecto
11261210
}
11271211

11281212
if baselineScan || suppressOnSuccess {
1213+
// Only a baseline scan counts. The suppressOnSuccess-only path
1214+
// completes a report that an earlier baseline already counted.
1215+
if baselineScan {
1216+
if saturationBaseline {
1217+
result.BaselineSuppressedAfterSaturation++
1218+
} else {
1219+
result.BaselineSuppressedFirstRun++
1220+
}
1221+
}
11291222
if suppressOnSuccess && !baselineScan {
11301223
result.BaselineCompletions = append(result.BaselineCompletions, darwinBaselineReportCompletion{
11311224
Name: name,

0 commit comments

Comments
 (0)