Skip to content

Commit 441554b

Browse files
Add Filestream harvester ingestion progress metrics (#51077) (#51838)
Track active harvesters by ingestion percentage so users can see which files are fully caught up, nearly caught up, or lagging. Use scanner-observed file sizes and atomic harvester offsets to avoid extra syscalls and keep the harvester read path free of shared mutex contention. GZIP files and files ignored for any reason are excluded because their progress cannot be represented accurately by plain-file offset/size comparisons. I ran the Filestream benchmark (BenchmarkFilestream) against the latest main (69c5d2b), here are the results of 10 iterations: goos: linux goarch: amd64 pkg: github.com/elastic/beats/v7/filebeat/input/filestream cpu: Intel(R) Core(TM) Ultra 9 285H │ before.txt │ after.txt │ │ sec/op │ sec/op vs base │ Filestream/1_file/inode-16 18.98m ± 3% 19.32m ± 4% ~ (p=0.684 n=10) Filestream/1_file/fingerprint-16 18.31m ± 7% 18.12m ± 4% ~ (p=0.853 n=10) Filestream/100_files/inode-16 79.06m ± 9% 82.39m ± 16% ~ (p=0.190 n=10) Filestream/100_files/fingerprint-16 79.14m ± 8% 77.90m ± 8% ~ (p=0.796 n=10) Filestream/1000_files/fingerprint-16 44.76m ± 12% 44.62m ± 8% ~ (p=0.739 n=10) Filestream/10000_files/fingerprint-16 459.6m ± 22% 448.5m ± 13% ~ (p=0.796 n=10) Filestream/line_filter/none-16 15.20m ± 23% 16.52m ± 13% ~ (p=0.436 n=10) Filestream/line_filter/include-16 16.28m ± 5% 15.39m ± 9% ~ (p=0.247 n=10) Filestream/line_filter/exclude-16 17.03m ± 10% 15.93m ± 10% -6.46% (p=0.043 n=10) Filestream/line_filter/include_and_exclude-16 15.91m ± 9% 15.37m ± 12% ~ (p=0.684 n=10) Filestream/line_filter/drop_all-16 14.16m ± 16% 13.24m ± 8% ~ (p=0.684 n=10) geomean 32.48m 32.08m -1.24% GenAI-Assisted: Yes Human-Reviewed: Yes Tool: Cursor, Model: GPT-5.5 Agent Mode Assisted-By: Cursor (cherry picked from commit 768b58b) Co-authored-by: Tiago Queiroz <tiago.queiroz@elastic.co>
1 parent 1bd08ec commit 441554b

11 files changed

Lines changed: 685 additions & 25 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
kind: enhancement
2+
summary: Add Filestream harvester metrics to monitoring logs
3+
description: |
4+
The following Filestream metrics are added to the logs and the `/stats` HTTP endpoint:
5+
- files_ingested_percent_100
6+
- files_ingested_percent_95_99
7+
- files_ingested_percent_lt_95
8+
9+
These gauges are aggregated across all running Filestream inputs and
10+
count active plain-file harvesters only.
11+
12+
component: filebeat
13+
issue: https://github.com/elastic/beats/issues/36653

docs/reference/filebeat/filebeat-input-filestream.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -441,14 +441,14 @@ file_identity.fingerprint: ~
441441

442442
`growing` {applies_to}`stack: ga 9.5.0+`
443443
: When `true` (default), files smaller than the fingerprint size (`offset` + `length`) are tracked using the bytes available so far, instead of being skipped until they grow large enough. Once a file reaches the fingerprint size, it's automatically migrated to the regular SHA-256 fingerprint, with no data duplication. Refer to [Enhanced fingerprint](/reference/filebeat/file-identity.md#file-identity-fingerprint-growing) for details.
444-
444+
445445
Set to `false` to restore the pre-9.5 behavior.
446446

447447
```yaml
448448
file_identity.fingerprint:
449449
growing: true
450450
```
451-
451+
452452

453453
**`native`**
454454
: Differentiates between files using their inodes and device IDs. This is the default file identity in Filebeat versions prior to 9.0.0.
@@ -1371,22 +1371,28 @@ Note: Each metric listed has a corresponding gzip_* counterpart (e.g.,
13711371
the same data but exclusively for GZIP compressed files. The original metrics
13721372
provide the total count, including both plain and GZIP files.
13731373

1374-
### Harvester metrics [_harvester_metrics]
1374+
### Scanner and harvester metrics [_harvester_metrics]
13751375

13761376
```{applies_to}
13771377
stack: ga 9.5+
13781378
```
13791379

1380-
The `filestream` input also exposes scanner metrics under
1380+
The `filestream` input also exposes scanner and harvester progress metrics under
13811381
`.monitoring.metrics.filebeat.filestream` in monitoring logs and under
13821382
`filebeat.filestream` in the `/stats` HTTP endpoint output. These metrics are
13831383
aggregate gauges across all running `filestream` inputs. They are updated after
1384-
each scanner pass and reset when inputs stop.
1384+
each scanner pass and reset when inputs stop. Harvester progress metrics measure
1385+
how much data active plain-file harvesters have read from their files. They do
1386+
not measure output publishing or acknowledgment progress. GZIP files and files
1387+
ignored by `filestream` settings or state are excluded.
13851388

13861389
| Metric | Description |
13871390
| --- | --- |
13881391
| `files_empty` | Number of matched files that are empty. |
13891392
| `files_ignored` | Number of matched files ignored by `filestream` settings or state, such as `prospector.scanner.exclude_files`, `ignore_older`, or `ignore_inactive`. |
1393+
| `files_ingested_percent_100` | Number of active plain-file harvesters whose read offset is at or beyond the scanner-observed file size. |
1394+
| `files_ingested_percent_95_99` | Number of active plain-file harvesters whose read offset is at least 95% and less than 100% of the scanner-observed file size. |
1395+
| `files_ingested_percent_lt_95` | Number of active plain-file harvesters whose read offset is less than 95% of the scanner-observed file size. |
13901396
| `files_matched` | Number of filesystem path matches returned by the configured `paths` globs before duplicate, ignore, and ingestibility filtering. |
13911397
| `files_no_ingest_target` | Number of matched non-empty files that did not produce an ingest target, such as duplicate matches, files that are too small to fingerprint or symlinks to already known files. |
13921398
| `files_unique` | Number of unique files that produced ingest targets after scanner filtering and de-duplication. |

filebeat/input/filestream/fswatch.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ func (w *fileWatcher) Run(
159159
ignoreInactiveSince time.Time,
160160
) {
161161
defer close(w.events)
162-
defer metrics.CleanupFileScanMetrics()
162+
defer metrics.Cleanup()
163163

164164
// run initial scan before starting regular
165165
w.watch(ctx, metrics, ignoreOlder, ignoreInactiveSince)
@@ -222,6 +222,7 @@ func (w *fileWatcher) watch(
222222

223223
newFilesByName := make(map[string]*loginp.FileDescriptor)
224224
newFilesByID := make(map[string]*loginp.FileDescriptor)
225+
harvesterFiles := make([]loginp.HarvesterFile, 0, len(paths))
225226

226227
for path, fd := range paths {
227228
// srcID is the file identity, it is the same value used to identify
@@ -305,6 +306,11 @@ func (w *fileWatcher) watch(
305306
}
306307
}
307308

309+
// We want progress metrics for all files except truncated ones
310+
if e.Op != loginp.OpTruncate {
311+
harvesterFiles = appendHarvesterFile(harvesterFiles, fd, srcID, now, ignoreOlder, ignoreInactiveSince)
312+
}
313+
308314
// delete from previous state to mark that we've seen the existing file again
309315
delete(w.prev, path)
310316
// Delete used state from closedHarvesters
@@ -431,12 +437,18 @@ func (w *fileWatcher) watch(
431437
// Unmatched-leftover creates: new files left over after both rename
432438
// passes are genuinely new.
433439
for path, fd := range newFilesByName {
440+
srcID := w.getFileIdentity(*fd)
441+
434442
select {
435443
case <-ctx.Done():
436444
return
437-
case w.events <- createEvent(path, *fd, w.getFileIdentity(*fd)):
445+
case w.events <- createEvent(path, *fd, srcID):
438446
createdCount++
439447
}
448+
449+
// The previous for loop has an early continue for new files,
450+
// so we need to collect their metrics here.
451+
harvesterFiles = appendHarvesterFile(harvesterFiles, *fd, srcID, now, ignoreOlder, ignoreInactiveSince)
440452
}
441453

442454
w.log.Debugw("File scan complete",
@@ -474,9 +486,34 @@ func (w *fileWatcher) watch(
474486
}
475487
}
476488

489+
metrics.UpdateHarvesterBuckets(harvesterFiles)
490+
477491
w.prev = paths
478492
}
479493

494+
func appendHarvesterFile(
495+
files []loginp.HarvesterFile,
496+
fd loginp.FileDescriptor,
497+
srcID string,
498+
now time.Time,
499+
ignoreOlder time.Duration,
500+
ignoreInactiveSince time.Time,
501+
) []loginp.HarvesterFile {
502+
opts := loginp.FileScanOptions{
503+
CurrentTime: now,
504+
IgnoreOlder: ignoreOlder,
505+
IgnoreInactiveSince: ignoreInactiveSince,
506+
}
507+
if fd.GZIP || fd.Info.Size() <= 0 || isFileIgnored(fd, opts) {
508+
return files
509+
}
510+
511+
return append(files, loginp.HarvesterFile{
512+
ID: srcID,
513+
Size: fd.Info.Size(),
514+
})
515+
}
516+
480517
// isFileIgnored returns true when a file is ignored, no matter the reason.
481518
func isFileIgnored(
482519
fd loginp.FileDescriptor,

filebeat/input/filestream/fswatch_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1398,6 +1398,147 @@ scanner:
13981398
assert.Equal(t, baseline.FilesEmpty, metrics.FilesEmpty.Get(), "files_empty")
13991399
}
14001400

1401+
func TestFileWatcherHarvesterMetrics(t *testing.T) {
1402+
identifier, err := newFingerprintIdentifier(nil, logp.NewNopLogger())
1403+
require.NoError(t, err, "failed to create fingerprint identifier")
1404+
fw := &fileWatcher{
1405+
fileIdentifier: identifier,
1406+
sourceIdentifier: mustSourceIdentifier("foo-id"),
1407+
log: logp.NewNopLogger(),
1408+
events: make(chan loginp.FSEvent, 10),
1409+
}
1410+
1411+
now := time.Now()
1412+
oldModTime := now.Add(-2 * time.Hour)
1413+
descriptor := func(name string, size int64, modTime time.Time, gzip bool) loginp.FileDescriptor {
1414+
return loginp.FileDescriptor{
1415+
Filename: name,
1416+
Fingerprint: loginp.FingerprintID{Sum: name},
1417+
GZIP: gzip,
1418+
Info: file.ExtendFileInfo(&testFileInfo{name: name, size: size, time: modTime}),
1419+
}
1420+
}
1421+
paths := map[string]loginp.FileDescriptor{
1422+
"complete": descriptor("complete", 100, now, false),
1423+
"near": descriptor("near", 100, now, false),
1424+
"lagging": descriptor("lagging", 100, now, false),
1425+
"no-active": descriptor("no-active", 100, now, false),
1426+
"gzip": descriptor("gzip", 100, now, true),
1427+
"ignored": descriptor("ignored", 100, oldModTime, false),
1428+
}
1429+
fw.prev = map[string]loginp.FileDescriptor{
1430+
"complete": descriptor("complete", 100, now, false),
1431+
"near": descriptor("near", 100, now, false),
1432+
"lagging": descriptor("lagging", 100, now, false),
1433+
"no-active": descriptor("no-active", 100, now, false),
1434+
"gzip": descriptor("gzip", 100, now, true),
1435+
"ignored": descriptor("ignored", 100, oldModTime, false),
1436+
}
1437+
fw.scanner = &testFileScanner{files: paths}
1438+
1439+
metrics := loginp.NewMetrics(monitoring.NewRegistry(), logp.NewNopLogger())
1440+
// Register some files/offsets, like a harvester would do.
1441+
completeOffset, cleanupCompleteOffset := metrics.RegisterHarvesterOffset(fw.getFileIdentity(paths["complete"]), 10)
1442+
nearOffset, _ := metrics.RegisterHarvesterOffset(fw.getFileIdentity(paths["near"]), 5)
1443+
laggingOffset, _ := metrics.RegisterHarvesterOffset(fw.getFileIdentity(paths["lagging"]), 4)
1444+
gzipOffset, _ := metrics.RegisterHarvesterOffset(fw.getFileIdentity(paths["gzip"]), 10)
1445+
ignoredOffset, _ := metrics.RegisterHarvesterOffset(fw.getFileIdentity(paths["ignored"]), 10)
1446+
1447+
// Make sure the test uses the same atomic update path as harvesters.
1448+
// Update to the actually expected values, like a harvester would do.
1449+
completeOffset.Store(100)
1450+
nearOffset.Store(95)
1451+
laggingOffset.Store(94)
1452+
gzipOffset.Store(100)
1453+
ignoredOffset.Store(100)
1454+
1455+
fw.watch(t.Context(), metrics, time.Hour, time.Time{})
1456+
1457+
assert.EqualValues(t, 1, metrics.FilesIngestedPercent100.Get(), "files_ingested_percent_100")
1458+
assert.EqualValues(t, 1, metrics.FilesIngestedPercent95To99.Get(), "files_ingested_percent_95_99")
1459+
assert.EqualValues(t, 1, metrics.FilesIngestedPercentLt95.Get(), "files_ingested_percent_lt_95")
1460+
1461+
// Copy paths and 'truncate' one file
1462+
truncatedPaths := map[string]loginp.FileDescriptor{}
1463+
for path, fd := range paths {
1464+
truncatedPaths[path] = fd
1465+
}
1466+
truncatedPaths["complete"] = descriptor("complete", 50, now, false)
1467+
fw.scanner = &testFileScanner{files: truncatedPaths}
1468+
fw.watch(t.Context(), metrics, time.Hour, time.Time{})
1469+
1470+
assert.EqualValues(t, 0, metrics.FilesIngestedPercent100.Get(), "files_ingested_percent_100 after truncation")
1471+
assert.EqualValues(t, 1, metrics.FilesIngestedPercent95To99.Get(), "files_ingested_percent_95_99 after truncation")
1472+
assert.EqualValues(t, 1, metrics.FilesIngestedPercentLt95.Get(), "files_ingested_percent_lt_95 after truncation")
1473+
1474+
// Simulate the harvester restart caused by truncation.
1475+
cleanupCompleteOffset()
1476+
_, _ = metrics.RegisterHarvesterOffset(fw.getFileIdentity(truncatedPaths["complete"]), 0)
1477+
1478+
// Copy truncatedPaths and make one file older
1479+
ignoredPaths := map[string]loginp.FileDescriptor{}
1480+
for path, fd := range truncatedPaths {
1481+
ignoredPaths[path] = fd
1482+
}
1483+
ignoredPaths["near"] = descriptor("near", 100, oldModTime, false)
1484+
fw.scanner = &testFileScanner{files: ignoredPaths}
1485+
fw.watch(t.Context(), metrics, time.Hour, time.Time{})
1486+
1487+
// The truncated file from the previous step is now a 'normal file at 50%'
1488+
// The 'near' file (95% ingested) is ignored because of ignore_older
1489+
assert.EqualValues(t, 0, metrics.FilesIngestedPercent100.Get(), "files_ingested_percent_100 after ignored")
1490+
assert.EqualValues(t, 0, metrics.FilesIngestedPercent95To99.Get(), "files_ingested_percent_95_99 after ignored")
1491+
assert.EqualValues(t, 2, metrics.FilesIngestedPercentLt95.Get(), "files_ingested_percent_lt_95 after ignored")
1492+
1493+
// An update with no paths slice effectively removes all files from the
1494+
// last update from the metrics
1495+
fw.scanner = &testFileScanner{}
1496+
fw.watch(t.Context(), metrics, time.Hour, time.Time{})
1497+
1498+
assert.EqualValues(t, 0, metrics.FilesIngestedPercent100.Get(), "files_ingested_percent_100 after reset")
1499+
assert.EqualValues(t, 0, metrics.FilesIngestedPercent95To99.Get(), "files_ingested_percent_95_99 after reset")
1500+
assert.EqualValues(t, 0, metrics.FilesIngestedPercentLt95.Get(), "files_ingested_percent_lt_95 after reset")
1501+
}
1502+
1503+
func TestFileWatcherRunCleansHarvesterMetricsOnShutdown(t *testing.T) {
1504+
identifier, err := newFingerprintIdentifier(nil, logp.NewNopLogger())
1505+
require.NoError(t, err, "failed to create fingerprint identifier")
1506+
1507+
now := time.Now()
1508+
fd := loginp.FileDescriptor{
1509+
Filename: "complete",
1510+
Fingerprint: loginp.FingerprintID{Sum: "complete"},
1511+
Info: file.ExtendFileInfo(&testFileInfo{name: "complete", size: 100, time: now}),
1512+
}
1513+
paths := map[string]loginp.FileDescriptor{
1514+
"complete": fd,
1515+
}
1516+
1517+
fw := &fileWatcher{
1518+
cfg: fileWatcherConfig{Interval: time.Hour},
1519+
prev: map[string]loginp.FileDescriptor{"complete": fd},
1520+
scanner: &testFileScanner{files: paths},
1521+
log: logp.NewNopLogger(),
1522+
events: make(chan loginp.FSEvent, 1),
1523+
notifyChan: make(chan loginp.HarvesterStatus, 1),
1524+
closedHarvesters: map[string]int64{},
1525+
fileIdentifier: identifier,
1526+
sourceIdentifier: mustSourceIdentifier("foo-id"),
1527+
}
1528+
1529+
metrics := loginp.NewMetrics(monitoring.NewRegistry(), logp.NewNopLogger())
1530+
sourceID := fw.getFileIdentity(fd)
1531+
_, _ = metrics.RegisterHarvesterOffset(sourceID, 100)
1532+
1533+
ctx, cancel := context.WithCancel(t.Context())
1534+
cancel()
1535+
fw.Run(ctx, metrics, time.Hour, time.Time{})
1536+
1537+
assert.EqualValues(t, 0, metrics.FilesIngestedPercent100.Get(), "files_ingested_percent_100 after watcher shutdown")
1538+
assert.EqualValues(t, 0, metrics.FilesIngestedPercent95To99.Get(), "files_ingested_percent_95_99 after watcher shutdown")
1539+
assert.EqualValues(t, 0, metrics.FilesIngestedPercentLt95.Get(), "files_ingested_percent_lt_95 after watcher shutdown")
1540+
}
1541+
14011542
func mustSourceIdentifier(inputID string) *loginp.SourceIdentifier {
14021543
si, err := loginp.NewSourceIdentifier("filestream", inputID)
14031544
if err != nil {
@@ -1408,6 +1549,15 @@ func mustSourceIdentifier(inputID string) *loginp.SourceIdentifier {
14081549
return si
14091550
}
14101551

1552+
type testFileScanner struct {
1553+
files map[string]loginp.FileDescriptor
1554+
}
1555+
1556+
// GetFiles returns s.files and empty metrics.
1557+
func (s *testFileScanner) GetFiles(loginp.FileScanOptions) (map[string]loginp.FileDescriptor, loginp.FileScanMetrics) {
1558+
return s.files, loginp.FileScanMetrics{}
1559+
}
1560+
14111561
const benchmarkFileCount = 1000
14121562

14131563
func BenchmarkGetFiles(b *testing.B) {

filebeat/input/filestream/input.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"io"
2525
"os"
2626
"slices"
27+
"sync/atomic"
2728
"time"
2829

2930
"go.uber.org/zap"
@@ -235,6 +236,7 @@ func (inp *filestream) Test(src loginp.Source, ctx input.TestContext) error {
235236
func (inp *filestream) Run(
236237
ctx input.Context,
237238
src loginp.Source,
239+
sourceID string,
238240
cursor loginp.Cursor,
239241
publisher loginp.Publisher,
240242
metrics *loginp.Metrics) error {
@@ -266,6 +268,13 @@ func (inp *filestream) Run(
266268
state.Offset = 0
267269
}
268270

271+
var metricsOffset *atomic.Int64
272+
if !fs.desc.GZIP {
273+
var cleanupActiveOffset func()
274+
metricsOffset, cleanupActiveOffset = metrics.RegisterHarvesterOffset(sourceID, state.Offset)
275+
defer cleanupActiveOffset()
276+
}
277+
269278
metrics.FilesActive.Inc()
270279
metrics.HarvesterRunning.Inc()
271280
defer metrics.FilesActive.Dec()
@@ -287,7 +296,7 @@ func (inp *filestream) Run(
287296
// The caller of Run already reports the error and filters out errors that
288297
// must not be reported, like 'context cancelled'.
289298
err = inp.readFromSource(
290-
ctx, log, r, fs.newPath, state, publisher, fs.desc.GZIP, metrics,
299+
ctx, log, r, fs.newPath, state, publisher, fs.desc.GZIP, metricsOffset, metrics,
291300
startReadUntilEOF)
292301
if err != nil {
293302
// First handle actual errors
@@ -729,6 +738,7 @@ func (inp *filestream) readFromSource(
729738
s state,
730739
p loginp.Publisher,
731740
isGZIP bool,
741+
metricsOffset *atomic.Int64,
732742
metrics *loginp.Metrics,
733743
startReadUntilEOF func(ctxtool.CancelContext)) error {
734744

@@ -750,7 +760,7 @@ func (inp *filestream) readFromSource(
750760

751761
var err error
752762
for ctx.Cancelation.Err() == nil {
753-
err = inp.readLineFromSource(r, log, metrics, isGZIP, &s, p)
763+
err = inp.readLineFromSource(r, log, metrics, isGZIP, &s, metricsOffset, p)
754764
err, shouldContinue := inp.handleReadError(ctx, err, log, path, metrics, isGZIP)
755765
if !shouldContinue {
756766
return err
@@ -770,7 +780,7 @@ func (inp *filestream) readFromSource(
770780
inp.readUntilEOF.Timeout)
771781
LOOP:
772782
for eofCancelCtx.Err() == nil {
773-
err = inp.readLineFromSource(r, log, metrics, isGZIP, &s, p)
783+
err = inp.readLineFromSource(r, log, metrics, isGZIP, &s, metricsOffset, p)
774784
err, shouldContinue := inp.handleReadError(ctx, err, log, path, metrics, isGZIP)
775785
if errors.Is(err, io.EOF) {
776786
log.Debug("read_until_eof enabled, EOF reached. closing input")
@@ -788,7 +798,7 @@ func (inp *filestream) readFromSource(
788798
return nil
789799
}
790800

791-
func (inp *filestream) readLineFromSource(r reader.Reader, log *logp.Logger, metrics *loginp.Metrics, isGZIP bool, s *state, p loginp.Publisher) error {
801+
func (inp *filestream) readLineFromSource(r reader.Reader, log *logp.Logger, metrics *loginp.Metrics, isGZIP bool, s *state, metricsOffset *atomic.Int64, p loginp.Publisher) error {
792802
message, err := r.Next()
793803
if err != nil {
794804
return err
@@ -797,6 +807,9 @@ func (inp *filestream) readLineFromSource(r reader.Reader, log *logp.Logger, met
797807
// state offset increase. Mutated through *s so subsequent reads in
798808
// readFromSource see the accumulated offset
799809
s.Offset += int64(message.Bytes) + int64(message.Offset)
810+
if metricsOffset != nil {
811+
metricsOffset.Store(s.Offset)
812+
}
800813

801814
flags, err := message.Fields.GetValue("log.flags")
802815
if err == nil {

0 commit comments

Comments
 (0)