Skip to content

Commit df2f8ec

Browse files
committed
multiple logfile config
1 parent 02d30cb commit df2f8ec

File tree

8 files changed

+203
-44
lines changed

8 files changed

+203
-44
lines changed

config/v2/configV2.go

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package v2
1616

1717
import (
1818
"fmt"
19+
"github.com/fstab/grok_exporter/tailer/glob"
1920
"github.com/fstab/grok_exporter/template"
2021
"gopkg.in/yaml.v2"
2122
"strconv"
@@ -57,8 +58,8 @@ type GlobalConfig struct {
5758
}
5859

5960
type InputConfig struct {
60-
Type string `yaml:",omitempty"`
61-
Path string `yaml:",omitempty"`
61+
Type string `yaml:",omitempty"`
62+
PathsAndGlobs `yaml:",inline"`
6263
FailOnMissingLogfileString string `yaml:"fail_on_missing_logfile,omitempty"` // cannot use bool directly, because yaml.v2 doesn't support true as default value.
6364
FailOnMissingLogfile bool `yaml:"-"`
6465
Readall bool `yaml:",omitempty"`
@@ -76,10 +77,17 @@ type GrokConfig struct {
7677
AdditionalPatterns []string `yaml:"additional_patterns,omitempty"`
7778
}
7879

80+
type PathsAndGlobs struct {
81+
Path string `yaml:",omitempty"`
82+
Paths []string `yaml:",omitempty"`
83+
Globs []glob.Glob `yaml:"-"`
84+
}
85+
7986
type MetricConfig struct {
80-
Type string `yaml:",omitempty"`
81-
Name string `yaml:",omitempty"`
82-
Help string `yaml:",omitempty"`
87+
Type string `yaml:",omitempty"`
88+
Name string `yaml:",omitempty"`
89+
Help string `yaml:",omitempty"`
90+
PathsAndGlobs `yaml:",inline"`
8391
Match string `yaml:",omitempty"`
8492
Retention time.Duration `yaml:",omitempty"` // implicitly parsed with time.ParseDuration()
8593
Value string `yaml:",omitempty"`
@@ -184,22 +192,53 @@ func (cfg *Config) validate() error {
184192
return nil
185193
}
186194

195+
func validateGlobs(p *PathsAndGlobs, optional bool, prefix string) error {
196+
if !optional && len(p.Path) == 0 && len(p.Paths) == 0 {
197+
return fmt.Errorf("%v: one of 'path' or 'paths' is required", prefix)
198+
}
199+
if len(p.Path) > 0 && len(p.Paths) > 0 {
200+
return fmt.Errorf("%v: use either 'path' or 'paths' but not both", prefix)
201+
}
202+
if len(p.Path) > 0 {
203+
parsedGlob, err := glob.Parse(p.Path)
204+
if err != nil {
205+
return fmt.Errorf("%v: %v", prefix, err)
206+
}
207+
p.Globs = []glob.Glob{parsedGlob}
208+
}
209+
if len(p.Paths) > 0 {
210+
p.Globs = make([]glob.Glob, 0, len(p.Paths))
211+
for _, path := range p.Paths {
212+
parsedGlob, err := glob.Parse(path)
213+
if err != nil {
214+
return fmt.Errorf("%v: %v", prefix, err)
215+
}
216+
p.Globs = append(p.Globs, parsedGlob)
217+
}
218+
}
219+
return nil
220+
}
221+
187222
func (c *InputConfig) validate() error {
188223
var err error
189224
switch {
190225
case c.Type == inputTypeStdin:
191-
if c.Path != "" {
226+
if len(c.Path) > 0 {
192227
return fmt.Errorf("invalid input configuration: cannot use 'input.path' when 'input.type' is stdin")
193228
}
229+
if len(c.Paths) > 0 {
230+
return fmt.Errorf("invalid input configuration: cannot use 'input.paths' when 'input.type' is stdin")
231+
}
194232
if c.Readall {
195233
return fmt.Errorf("invalid input configuration: cannot use 'input.readall' when 'input.type' is stdin")
196234
}
197235
if c.PollIntervalSeconds != "" {
198236
return fmt.Errorf("invalid input configuration: cannot use 'input.poll_interval_seconds' when 'input.type' is stdin")
199237
}
200238
case c.Type == inputTypeFile:
201-
if c.Path == "" {
202-
return fmt.Errorf("invalid input configuration: 'input.path' is required for input type \"file\"")
239+
err = validateGlobs(&c.PathsAndGlobs, false, "invalid input configuration")
240+
if err != nil {
241+
return err
203242
}
204243
if len(c.PollIntervalSeconds) > 0 { // TODO: Use duration directly, as with other durations in the config file
205244
nSeconds, err := strconv.Atoi(c.PollIntervalSeconds)
@@ -215,6 +254,18 @@ func (c *InputConfig) validate() error {
215254
}
216255
}
217256
case c.Type == inputTypeWebhook:
257+
if c.Path != "" {
258+
return fmt.Errorf("invalid input configuration: cannot use 'input.path' when 'input.type' is %v", inputTypeWebhook)
259+
}
260+
if len(c.Paths) > 0 {
261+
return fmt.Errorf("invalid input configuration: cannot use 'input.paths' when 'input.type' is %v", inputTypeWebhook)
262+
}
263+
if c.Readall {
264+
return fmt.Errorf("invalid input configuration: cannot use 'input.readall' when 'input.type' is %v", inputTypeWebhook)
265+
}
266+
if c.PollIntervalSeconds != "" {
267+
return fmt.Errorf("invalid input configuration: cannot use 'input.poll_interval_seconds' when 'input.type' is %v", inputTypeWebhook)
268+
}
218269
if c.WebhookPath == "" {
219270
return fmt.Errorf("invalid input configuration: 'input.webhook_path' is required for input type \"webhook\"")
220271
} else if c.WebhookPath[0] != '/' {
@@ -249,7 +300,8 @@ func (c *MetricsConfig) validate() error {
249300
return fmt.Errorf("Invalid metrics configuration: 'metrics' must not be empty.")
250301
}
251302
metricNames := make(map[string]bool)
252-
for _, metric := range *c {
303+
for i, _ := range *c {
304+
metric := &(*c)[i] // validate modifies the metric, therefore we must use it by reference here.
253305
err := metric.validate()
254306
if err != nil {
255307
return err
@@ -259,6 +311,14 @@ func (c *MetricsConfig) validate() error {
259311
return fmt.Errorf("Invalid metric configuration: metric '%v' defined twice.", metric.Name)
260312
}
261313
metricNames[metric.Name] = true
314+
315+
if len(metric.Path) > 0 && len(metric.Paths) > 0 {
316+
return fmt.Errorf("invalid metric configuration: metric %v defines both path and paths, you should use either one or the other", metric.Name)
317+
}
318+
if len(metric.Path) > 0 {
319+
metric.Paths = []string{metric.Path}
320+
metric.Path = ""
321+
}
262322
}
263323
return nil
264324
}
@@ -274,6 +334,10 @@ func (c *MetricConfig) validate() error {
274334
case c.Match == "":
275335
return fmt.Errorf("Invalid metric configuration: 'metrics.match' must not be empty.")
276336
}
337+
err := validateGlobs(&c.PathsAndGlobs, true, fmt.Sprintf("invalid metric configuration: %v", c.Name))
338+
if err != nil {
339+
return err
340+
}
277341
var hasValue, cumulativeAllowed, bucketsAllowed, quantilesAllowed bool
278342
switch c.Type {
279343
case "counter":
@@ -411,6 +475,16 @@ func (cfg *Config) String() string {
411475
if stripped.Server.Path == "/metrics" {
412476
stripped.Server.Path = ""
413477
}
478+
if len(stripped.Input.Paths) == 1 {
479+
stripped.Input.Path = stripped.Input.Paths[0]
480+
stripped.Input.Paths = nil
481+
}
482+
for i := range stripped.Metrics {
483+
if len(stripped.Metrics[i].Paths) == 1 {
484+
stripped.Metrics[i].Path = stripped.Metrics[i].Paths[i]
485+
stripped.Metrics[i].Paths = nil
486+
}
487+
}
414488
return stripped.marshalToString()
415489
}
416490

config/v2/configV2_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,34 @@ server:
145145
port: 9144
146146
`
147147

148+
const multiple_paths_config = `
149+
global:
150+
config_version: 2
151+
input:
152+
type: file
153+
paths:
154+
- /tmp/dir1/*.log
155+
- /tmp/dir2/*.log
156+
fail_on_missing_logfile: false
157+
readall: true
158+
grok:
159+
patterns_dir: b/c
160+
metrics:
161+
- type: counter
162+
name: test_count_total
163+
help: Dummy help message.
164+
paths:
165+
- /tmp/dir1/*.log
166+
- /tmp/dir2/*.log
167+
match: Some text here, then a %{DATE}.
168+
labels:
169+
label_a: '{{.some_grok_field_a}}'
170+
label_b: '{{.some_grok_field_b}}'
171+
server:
172+
protocol: https
173+
port: 1111
174+
`
175+
148176
func TestCounterValidConfig(t *testing.T) {
149177
loadOrFail(t, counter_config)
150178
}
@@ -255,6 +283,51 @@ func TestRetentionInvalidConfig(t *testing.T) {
255283
}
256284
}
257285

286+
func TestPathsValidConfig(t *testing.T) {
287+
loadOrFail(t, multiple_paths_config)
288+
}
289+
290+
func TestDuplicateInputPaths(t *testing.T) {
291+
var s = `type: file
292+
path: /some/path/file.log`
293+
invalidCfg := strings.Replace(multiple_paths_config, "type: file", s, 1)
294+
_, err := Unmarshal([]byte(invalidCfg))
295+
if err == nil {
296+
t.Fatal("Expected error, but unmarshalling was successful.")
297+
}
298+
// Make sure it's the right error and not an error accidentally caused by incorrect indentation of the injected 'path' field.
299+
if !strings.Contains(err.Error(), "use either 'path' or 'paths' but not both") {
300+
t.Fatalf("Expected error message about path and paths being mutually exclusive, but got %v", err)
301+
}
302+
}
303+
304+
func TestDuplicateMetricPaths(t *testing.T) {
305+
var s = `help: Dummy help message.
306+
path: /some/path/file.log`
307+
invalidCfg := strings.Replace(multiple_paths_config, "help: Dummy help message.", s, 1)
308+
_, err := Unmarshal([]byte(invalidCfg))
309+
if err == nil {
310+
t.Fatal("Expected error, but unmarshalling was successful.")
311+
}
312+
// Make sure it's the right error and not an error accidentally caused by incorrect indentation of the injected 'path' field.
313+
if !strings.Contains(err.Error(), "use either 'path' or 'paths' but not both") {
314+
t.Fatalf("Expected error message about path and paths being mutually exclusive, but got %v", err)
315+
}
316+
}
317+
318+
func TestGlobsAreGenerated(t *testing.T) {
319+
cfg, err := Unmarshal([]byte(multiple_paths_config))
320+
if err != nil {
321+
t.Fatalf("unexpected error: %v", err)
322+
}
323+
if len(cfg.Input.Globs) != 2 {
324+
t.Fatalf("expected 2 Globs in input config, but found %v", len(cfg.Input.Globs))
325+
}
326+
if len(cfg.Metrics[0].Globs) != 2 {
327+
t.Fatalf("expected 2 Globs in metric config, but found %v", len(cfg.Metrics[0].Globs))
328+
}
329+
}
330+
258331
func loadOrFail(t *testing.T, cfgString string) *Config {
259332
cfg, err := Unmarshal([]byte(cfgString))
260333
if err != nil {

exporter/grok.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ package exporter
1616

1717
import (
1818
"fmt"
19-
"github.com/fstab/grok_exporter/config/v2"
19+
configuration "github.com/fstab/grok_exporter/config/v2"
2020
"github.com/fstab/grok_exporter/oniguruma"
2121
"github.com/fstab/grok_exporter/template"
2222
"regexp"
@@ -36,7 +36,7 @@ func Compile(pattern string, patterns *Patterns) (*oniguruma.Regex, error) {
3636
return result, nil
3737
}
3838

39-
func VerifyFieldNames(m *v2.MetricConfig, regex, deleteRegex *oniguruma.Regex, additionalFieldDefinitions map[string]string) error {
39+
func VerifyFieldNames(m *configuration.MetricConfig, regex, deleteRegex *oniguruma.Regex, additionalFieldDefinitions map[string]string) error {
4040
for _, template := range m.LabelTemplates {
4141
err := verifyFieldName(m.Name, template, regex, additionalFieldDefinitions)
4242
if err != nil {

exporter/metrics.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"fmt"
1919
configuration "github.com/fstab/grok_exporter/config/v2"
2020
"github.com/fstab/grok_exporter/oniguruma"
21+
"github.com/fstab/grok_exporter/tailer/glob"
2122
"github.com/fstab/grok_exporter/template"
2223
"github.com/prometheus/client_golang/prometheus"
2324
"strconv"
@@ -33,6 +34,7 @@ type Metric interface {
3334
Name() string
3435
Collector() prometheus.Collector
3536

37+
PathMatches(logfilePath string) bool
3638
// Returns the match if the line matched, and nil if the line didn't match.
3739
ProcessMatch(line string, additionalFields map[string]string) (*Match, error)
3840
// Returns the match if the delete pattern matched, nil otherwise.
@@ -44,6 +46,7 @@ type Metric interface {
4446
// Common values for incMetric and observeMetric
4547
type metric struct {
4648
name string
49+
globs []glob.Glob
4750
regex *oniguruma.Regex
4851
deleteRegex *oniguruma.Regex
4952
retention time.Duration
@@ -116,6 +119,18 @@ func (m *metric) Name() string {
116119
return m.name
117120
}
118121

122+
func (m *metric) PathMatches(logfilePath string) bool {
123+
if len(m.globs) == 0 {
124+
return true
125+
}
126+
for _, g := range m.globs {
127+
if g.Match(logfilePath) {
128+
return true
129+
}
130+
}
131+
return false
132+
}
133+
119134
func (m *counterMetric) Collector() prometheus.Collector {
120135
return m.counter
121136
}
@@ -375,6 +390,7 @@ func (m *summaryVecMetric) ProcessRetention() error {
375390
func newMetric(cfg *configuration.MetricConfig, regex, deleteRegex *oniguruma.Regex) metric {
376391
return metric{
377392
name: cfg.Name,
393+
globs: cfg.Globs,
378394
regex: regex,
379395
deleteRegex: deleteRegex,
380396
retention: cfg.Retention,

grok_exporter.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"github.com/fstab/grok_exporter/oniguruma"
2424
"github.com/fstab/grok_exporter/tailer"
2525
"github.com/fstab/grok_exporter/tailer/fswatcher"
26-
"github.com/fstab/grok_exporter/tailer/glob"
2726
"github.com/prometheus/client_golang/prometheus"
2827
"github.com/sirupsen/logrus"
2928
"os"
@@ -100,7 +99,7 @@ func main() {
10099
case err := <-serverErrors:
101100
exitOnError(fmt.Errorf("server error: %v", err.Error()))
102101
case err := <-tail.Errors():
103-
if os.IsNotExist(err.Cause()) {
102+
if err.Type() == fswatcher.FileNotFound || os.IsNotExist(err.Cause()) {
104103
exitOnError(fmt.Errorf("error reading log lines: %v: use 'fail_on_missing_logfile: false' in the input configuration if you want grok_exporter to start even though the logfile is missing", err))
105104
} else {
106105
exitOnError(fmt.Errorf("error reading log lines: %v", err.Error()))
@@ -109,6 +108,9 @@ func main() {
109108
matched := false
110109
for _, metric := range metrics {
111110
start := time.Now()
111+
if !metric.PathMatches(line.File) {
112+
continue
113+
}
112114
match, err := metric.ProcessMatch(line.Line, makeAdditionalFields(line))
113115
if err != nil {
114116
fmt.Fprintf(os.Stderr, "WARNING: skipping log line: %v\n", err.Error())
@@ -306,19 +308,24 @@ func startServer(cfg v2.ServerConfig, httpHandlers []exporter.HttpServerPathHand
306308
}
307309

308310
func startTailer(cfg *v2.Config) (fswatcher.FileTailer, error) {
311+
var (
312+
tail fswatcher.FileTailer
313+
err error
314+
)
309315
logger := logrus.New()
310316
logger.Level = logrus.WarnLevel
311-
var tail fswatcher.FileTailer
312-
g, err := glob.FromPath(cfg.Input.Path)
313-
if err != nil {
314-
return nil, err
315-
}
316317
switch {
317318
case cfg.Input.Type == "file":
318319
if cfg.Input.PollInterval == 0 {
319-
tail, err = fswatcher.RunFileTailer([]glob.Glob{g}, cfg.Input.Readall, cfg.Input.FailOnMissingLogfile, logger)
320+
tail, err = fswatcher.RunFileTailer(cfg.Input.Globs, cfg.Input.Readall, cfg.Input.FailOnMissingLogfile, logger)
321+
if err != nil {
322+
return nil, err
323+
}
320324
} else {
321-
tail, err = fswatcher.RunPollingFileTailer([]glob.Glob{g}, cfg.Input.Readall, cfg.Input.FailOnMissingLogfile, cfg.Input.PollInterval, logger)
325+
tail, err = fswatcher.RunPollingFileTailer(cfg.Input.Globs, cfg.Input.Readall, cfg.Input.FailOnMissingLogfile, cfg.Input.PollInterval, logger)
326+
if err != nil {
327+
return nil, err
328+
}
322329
}
323330
case cfg.Input.Type == "stdin":
324331
tail = tailer.RunStdinTailer()

0 commit comments

Comments
 (0)