Skip to content

Commit 6a76273

Browse files
committed
feat: implement independent scraper loops for multiple exporters with configurable intervals
1 parent 7803337 commit 6a76273

5 files changed

Lines changed: 358 additions & 84 deletions

File tree

cmd/start.go

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"os/exec"
88
"os/signal"
9+
"sync"
910
"syscall"
1011
"time"
1112

@@ -149,67 +150,101 @@ func runAgent(cmd *cobra.Command, args []string) error {
149150
cancel()
150151
}()
151152

152-
// Main scraping loop
153-
// Use ticker for interval, but align collection timestamps to interval boundaries
154-
ticker := time.NewTicker(cfg.Agent.Interval)
155-
defer ticker.Stop()
153+
// Launch independent scraper goroutine for each exporter (Phase 2)
154+
var wg sync.WaitGroup
156155

157156
logger.Info("Agent started",
158157
logger.String("server_id", cfg.Agent.ServerID),
159-
logger.Duration("interval", cfg.Agent.Interval),
160158
logger.Int("exporters", len(activeExporters)),
161159
logger.String("server_endpoint", cfg.Server.Endpoint))
162160

161+
for i, exp := range activeExporters {
162+
exporterCfg := cfg.Exporters[i]
163+
interval := exporterCfg.ParsedInterval
164+
timeout := exporterCfg.Timeout
165+
166+
wg.Add(1)
167+
go func(exporter exporters.Exporter, scrapeInterval time.Duration, scrapeTimeout time.Duration) {
168+
defer wg.Done()
169+
runScraperLoop(ctx, exporter, sender, cfg.Agent.ServerID, scrapeInterval, scrapeTimeout)
170+
}(exp, interval, timeout)
171+
172+
logger.Info("Started scraper loop",
173+
logger.String("exporter", exp.Name()),
174+
logger.Duration("interval", interval),
175+
logger.Duration("timeout", timeout))
176+
}
177+
178+
// Wait for shutdown signal
179+
<-ctx.Done()
180+
181+
// Wait for all scraper goroutines to finish
182+
logger.Info("Waiting for all scrapers to stop...")
183+
wg.Wait()
184+
185+
logger.Info("All scrapers stopped, agent shutdown complete")
186+
return nil
187+
}
188+
189+
// runScraperLoop runs an independent scrape loop for a single exporter
190+
// Each exporter has its own ticker and runs at its configured interval
191+
func runScraperLoop(ctx context.Context, exporter exporters.Exporter,
192+
sender *report.Sender, serverID string, interval time.Duration, timeout time.Duration) {
193+
194+
ticker := time.NewTicker(interval)
195+
defer ticker.Stop()
196+
163197
// Scrape immediately on start with aligned timestamp (UTC)
164-
collectionTime := time.Now().UTC().Truncate(cfg.Agent.Interval)
165-
scrapeAllExporters(ctx, activeExporters, sender, cfg.Agent.ServerID, collectionTime)
198+
collectionTime := time.Now().UTC().Truncate(interval)
199+
scrapeAndBuffer(ctx, exporter, sender, serverID, collectionTime, timeout)
166200

167201
// Continue with ticker
168202
for {
169203
select {
170204
case <-ctx.Done():
171-
return nil
205+
logger.Info("Scraper loop stopped", logger.String("exporter", exporter.Name()))
206+
return
207+
172208
case tickTime := <-ticker.C:
173209
// Align collection time to interval boundary (UTC)
174-
collectionTime := tickTime.UTC().Truncate(cfg.Agent.Interval)
175-
scrapeAllExporters(ctx, activeExporters, sender, cfg.Agent.ServerID, collectionTime)
210+
collectionTime := tickTime.UTC().Truncate(interval)
211+
scrapeAndBuffer(ctx, exporter, sender, serverID, collectionTime, timeout)
176212
}
177213
}
178214
}
179215

180-
// scrapeAllExporters scrapes all exporters sequentially
181-
func scrapeAllExporters(ctx context.Context, exportersList []exporters.Exporter,
182-
sender *report.Sender, serverID string, collectionTime time.Time) {
216+
// scrapeAndBuffer performs a single scrape operation for an exporter
217+
func scrapeAndBuffer(ctx context.Context, exporter exporters.Exporter,
218+
sender *report.Sender, serverID string, collectionTime time.Time, timeout time.Duration) {
183219

184-
for _, exp := range exportersList {
185-
// Scrape with timeout
186-
scrapeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
187-
data, err := exp.Scrape(scrapeCtx)
188-
cancel()
189-
190-
if err != nil {
191-
logger.Warn("Failed to scrape exporter",
192-
logger.String("exporter", exp.Name()),
193-
logger.Err(err))
194-
continue
195-
}
220+
// Create timeout context for scrape
221+
scrapeCtx, cancel := context.WithTimeout(ctx, timeout)
222+
defer cancel()
196223

197-
// Add explicit timestamps to metrics (aligned to collection time)
198-
dataWithTimestamp := prometheus.AddTimestamps(data, collectionTime)
224+
// Scrape metrics
225+
data, err := exporter.Scrape(scrapeCtx)
226+
if err != nil {
227+
logger.Warn("Failed to scrape exporter",
228+
logger.String("exporter", exporter.Name()),
229+
logger.Err(err))
230+
return
231+
}
199232

200-
// Save raw Prometheus text to buffer (WAL pattern)
201-
if err := sender.BufferPrometheus(dataWithTimestamp, serverID, exp.Name()); err != nil {
202-
logger.Error("Failed to buffer metrics",
203-
logger.String("exporter", exp.Name()),
204-
logger.Err(err))
205-
continue
206-
}
233+
// Add explicit timestamps to metrics (aligned to collection time)
234+
dataWithTimestamp := prometheus.AddTimestamps(data, collectionTime)
207235

208-
logger.Debug("Exporter scraped and buffered",
209-
logger.String("exporter", exp.Name()),
210-
logger.Int("bytes", len(dataWithTimestamp)),
211-
logger.String("collection_time", collectionTime.Format(time.RFC3339)))
236+
// Save raw Prometheus text to buffer (WAL pattern)
237+
if err := sender.BufferPrometheus(dataWithTimestamp, serverID, exporter.Name()); err != nil {
238+
logger.Error("Failed to buffer metrics",
239+
logger.String("exporter", exporter.Name()),
240+
logger.Err(err))
241+
return
212242
}
243+
244+
logger.Debug("Exporter scraped and buffered",
245+
logger.String("exporter", exporter.Name()),
246+
logger.Int("bytes", len(dataWithTimestamp)),
247+
logger.String("collection_time", collectionTime.Format(time.RFC3339)))
213248
}
214249

215250
func runInBackground() error {

docs/multi-exporter-architecture.md

Lines changed: 142 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Multi-Exporter Architecture Design
22

3-
**Document Status:** Phase 1 Complete ✅
3+
**Document Status:** Phase 2 Complete ✅
44
**Created:** 2025-10-30
5-
**Implemented:** 2025-10-30
6-
**Target Version:** v2.1.0+
5+
**Phase 1 Implemented:** 2025-10-30
6+
**Phase 2 Implemented:** 2025-10-30
7+
**Target Version:** v2.2.0+
78

89
## Executive Summary
910

@@ -38,12 +39,136 @@ Each exporter is a top-level key with an array of metric snapshots. This allows:
3839
- Easy extensibility (add new exporters without schema changes)
3940

4041
**Phased Approach:**
41-
- **Phase 1**: Plugin architecture with exporter registry ✅ **COMPLETE**
42-
- **Phase 2**: Independent scrape loops + smart batching (true scalability, ~5-7 days)
42+
- **Phase 1**: Plugin architecture with exporter registry ✅ **COMPLETE** (2025-10-30)
43+
- **Phase 2**: Independent scrape loops + smart batching **COMPLETE** (2025-10-30)
4344
- **Phase 3**: Auto-discovery and advanced features (optional, ~3-5 days)
4445

4546
---
4647

48+
## Implementation Status - Phase 2 Complete ✅
49+
50+
**Completed:** 2025-10-30
51+
52+
### What Was Implemented in Phase 2
53+
54+
#### 1. Per-Exporter Intervals
55+
- ✅ Updated `ExporterConfig` to support optional `interval` field (string)
56+
- ✅ Added `ParsedInterval` computed field to store parsed time.Duration
57+
-`agent.interval` now serves as default for exporters without explicit interval
58+
- ✅ Validation supports: 15s, 30s, 1m, 5m intervals
59+
60+
#### 2. Independent Scraper Goroutines
61+
- ✅ Each exporter runs in its own goroutine with independent ticker
62+
- ✅ Implemented `runScraperLoop()` - per-exporter scrape loop
63+
- ✅ Implemented `scrapeAndBuffer()` - single scrape operation
64+
- ✅ Parallel scraping - no blocking between exporters
65+
- ✅ Graceful shutdown with `sync.WaitGroup`
66+
67+
#### 3. Smart Batching by Time Windows
68+
- ✅ Implemented `groupFilesByTimeWindow()` - groups files into 5s buckets
69+
- ✅ Implemented `parseTimestampFromFilename()` - extracts timestamp from filename
70+
- ✅ Drain loop processes oldest time window first
71+
- ✅ Multiple exporters scraped at similar times batched into single HTTP request
72+
73+
#### 4. Configuration Changes
74+
**agent.interval:**
75+
- Still required (serves as default for exporters)
76+
- Falls back when exporter doesn't specify interval
77+
78+
**exporters[].interval:**
79+
- Optional per-exporter interval
80+
- Validates against allowed values: 15s, 30s, 1m, 5m
81+
- Falls back to `agent.interval` if not specified
82+
83+
### Example Phase 2 Configuration
84+
85+
```yaml
86+
server:
87+
endpoint: "https://dashboard.nodepulse.io/metrics/prometheus"
88+
timeout: 5s
89+
90+
agent:
91+
server_id: "550e8400-e29b-41d4-a716-446655440000"
92+
interval: 15s # Default interval for exporters without explicit interval
93+
94+
exporters:
95+
- name: node_exporter
96+
enabled: true
97+
endpoint: "http://localhost:9100/metrics"
98+
interval: 15s # Fast scraping for system metrics
99+
timeout: 3s
100+
101+
- name: postgres_exporter
102+
enabled: true
103+
endpoint: "http://localhost:9187/metrics"
104+
interval: 30s # Slower scraping for database metrics
105+
timeout: 5s
106+
107+
- name: backup_monitor
108+
enabled: true
109+
endpoint: "http://localhost:8080/metrics"
110+
interval: 5m # Very slow scraping for backup status
111+
timeout: 10s
112+
113+
buffer:
114+
path: "/var/lib/nodepulse/buffer"
115+
retention_hours: 48
116+
batch_size: 10 # Increased for Phase 2 efficiency
117+
```
118+
119+
### Key Features Delivered
120+
121+
✅ **True Parallelism** - Each exporter runs independently
122+
✅ **Per-Exporter Intervals** - Different scrape rates (15s, 30s, 1m, 5m)
123+
✅ **No Blocking** - Slow exporters don't delay fast ones
124+
✅ **Smart Batching** - Time-window grouping reduces HTTP requests
125+
✅ **Graceful Shutdown** - WaitGroup ensures clean exit
126+
✅ **Backward Compatible** - Phase 1 configs work (all use default interval)
127+
128+
### Behavioral Changes from Phase 1
129+
130+
**Phase 1 (Sequential):**
131+
```
132+
14:00:00 - Scrape all exporters sequentially (blocking)
133+
14:00:15 - Scrape all exporters sequentially
134+
14:00:30 - Scrape all exporters sequentially
135+
```
136+
137+
**Phase 2 (Independent):**
138+
```
139+
14:00:00 - node_exporter scrapes
140+
14:00:00 - postgres_exporter scrapes
141+
14:00:00 - backup_monitor scrapes
142+
14:00:15 - node_exporter scrapes
143+
14:00:30 - node_exporter scrapes
144+
14:00:30 - postgres_exporter scrapes (30s interval)
145+
14:05:00 - backup_monitor scrapes (5m interval)
146+
```
147+
148+
### Files Modified in Phase 2
149+
150+
1. `internal/config/config.go` - Added per-exporter interval support
151+
2. `cmd/start.go` - Replaced single loop with independent goroutines
152+
3. `internal/report/sender.go` - Added time-window batching
153+
154+
### Performance Improvements
155+
156+
**Without Phase 2 (3 exporters at 15s interval):**
157+
- node_exporter: 0.5s scrape
158+
- postgres_exporter: 3s scrape (slow database query)
159+
- backup_monitor: 2s scrape
160+
- **Total time per cycle: 5.5s** (sequential blocking)
161+
162+
**With Phase 2 (independent intervals):**
163+
- node_exporter: 15s interval, 0.5s scrape (parallel)
164+
- postgres_exporter: 30s interval, 3s scrape (parallel)
165+
- backup_monitor: 5m interval, 2s scrape (parallel)
166+
- **Max scrape time: 3s** (longest individual scrape, no blocking)
167+
- **50% fewer postgres scrapes** (30s vs 15s)
168+
- **95% fewer backup scrapes** (5m vs 15s)
169+
170+
---
171+
47172
## Implementation Status - Phase 1 Complete ✅
48173

49174
**Completed:** 2025-10-30
@@ -1682,18 +1807,18 @@ Expected output:
16821807
- ⏭️ Write unit tests for exporter interface (TODO)
16831808
- ⏭️ Write integration tests for multi-exporter buffering (TODO)
16841809

1685-
### Phase 2: Independent Scrape Loops
1686-
1687-
- [ ] Update `cmd/start.go` (launch goroutines per exporter)
1688-
- [ ] Implement `runScraperLoop()` function
1689-
- [ ] Implement `groupFilesByTimeWindow()` in sender
1690-
- [ ] Implement `sendBatchedMetrics()` for batch payloads
1691-
- [ ] Update dashboard API to accept batched metrics
1692-
- [ ] Add per-exporter interval validation
1693-
- [ ] Add WaitGroup for graceful shutdown
1694-
- [ ] Write tests for concurrent scraping
1695-
- [ ] Write tests for time-window batching
1696-
- [ ] Performance testing with 10+ exporters
1810+
### Phase 2: Independent Scrape Loops ✅ COMPLETE
1811+
1812+
- Update `cmd/start.go` (launch goroutines per exporter)
1813+
- Implement `runScraperLoop()` function
1814+
- Implement `groupFilesByTimeWindow()` in sender
1815+
- Implement `parseTimestampFromFilename()` helper
1816+
- ✅ Dashboard already supports batched metrics (Phase 1 payload format)
1817+
- Add per-exporter interval validation
1818+
- Add WaitGroup for graceful shutdown
1819+
- ⏭️ Write tests for concurrent scraping (TODO)
1820+
- ⏭️ Write tests for time-window batching (TODO)
1821+
- ⏭️ Performance testing with 10+ exporters (TODO)
16971822

16981823
### Phase 3: Advanced Features (Optional)
16991824

0 commit comments

Comments
 (0)