├── docker-entrypoint.sh ├── Dockerfile ├── Makefile ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql.yml ├── config.go ├── go.mod ├── LICENSE ├── .gitignore ├── grok.go ├── config_test.go ├── process_test.go ├── grok_test.go ├── main.go ├── README.md ├── go.sum └── process.go /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # For compatibility with older entrypoints 4 | if [ "${1}" == "sentlog" ]; then 5 | shift 6 | elif [ "${1}" == "sh" ] || [ "${1}" == "/bin/sh" ]; then 7 | exec "$@" 8 | fi 9 | 10 | exec /bin/sentlog "$@" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOVERSION=latest 2 | FROM golang:$GOVERSION AS builder 3 | 4 | WORKDIR /src 5 | COPY . . 6 | 7 | RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o . -ldflags="-s -w" . 8 | 9 | FROM alpine:3 10 | 11 | RUN apk add --no-cache ca-certificates tzdata 12 | COPY ./docker-entrypoint.sh / 13 | COPY --from=builder /src/sentlog /bin/sentlog 14 | ENTRYPOINT ["/docker-entrypoint.sh"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OUTDIR := out 2 | 3 | build: 4 | go build 5 | 6 | prepare-dir: 7 | @mkdir -p ${OUTDIR} 8 | 9 | build-static-mac: prepare-dir 10 | GOOS=darwin GOARCH=amd64 go build -a -o ${OUTDIR}/sentlog-Darwin-x86_64 11 | 12 | build-static-linux: prepare-dir 13 | GOOS=linux GOARCH=amd64 go build -a -o ${OUTDIR}/sentlog-Linux-x86_64 14 | 15 | build-static-all: build-static-mac build-static-linux 16 | 17 | run: 18 | go build && ./$(notdir $(CURDIR)) 19 | 20 | clean: 21 | rm -rf ./sentlog 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | # security updates only 8 | open-pull-requests-limit: 0 9 | - package-ecosystem: "docker" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | # security updates only 14 | open-pull-requests-limit: 0 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "daily" 19 | # security updates only 20 | open-pull-requests-limit: 0 -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | type FileInputConfig struct { 10 | File string 11 | Follow *bool 12 | FromLineNumber *int `yaml:"from_line_number"` 13 | Patterns []string 14 | Tags map[string]string 15 | } 16 | 17 | type Config struct { 18 | SentryDsn string `yaml:"sentry_dsn"` 19 | PatternFiles []string `yaml:"pattern_files"` 20 | Inputs []FileInputConfig 21 | } 22 | 23 | func ReadConfigFromFile(filename string) (*Config, error) { 24 | file, err := os.Open(filename) 25 | if err != nil { 26 | return nil, err 27 | } 28 | defer file.Close() 29 | 30 | config := Config{} 31 | decoder := yaml.NewDecoder(file) 32 | decoder.KnownFields(true) 33 | err = decoder.Decode(&config) 34 | if err != nil { 35 | return &config, err 36 | } 37 | 38 | return &config, nil 39 | } 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module sentlog 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/alecthomas/kingpin/v2 v2.3.2 7 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 8 | github.com/getsentry/sentry-go v0.20.0 9 | github.com/hpcloud/tail v1.0.1-0.20180514194441-a1dbeea552b7 10 | github.com/sirupsen/logrus v1.9.0 11 | github.com/stretchr/testify v1.8.2 12 | github.com/vjeantet/grok v1.0.1 13 | gopkg.in/yaml.v3 v3.0.1 14 | ) 15 | 16 | require ( 17 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/fsnotify/fsnotify v1.6.0 // indirect 20 | github.com/pmezard/go-difflib v1.0.0 // indirect 21 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 22 | golang.org/x/sys v0.6.0 // indirect 23 | golang.org/x/text v0.8.0 // indirect 24 | gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect 25 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: continuous-integration 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - main 10 | - master # backward compatibility 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 14 | cancel-in-progress: true 15 | 16 | env: 17 | SHELL: /bin/bash 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | jobs: 24 | test: 25 | 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | with: 30 | fetch-depth: 0 31 | - uses: actions/setup-go@v3 32 | with: 33 | go-version: 'stable' 34 | - run: go test -v -cover ./... 35 | - name: TruffleHog OSS 36 | uses: trufflesecurity/trufflehog@v3.16.4 37 | with: 38 | path: ./ 39 | base: ${{ github.event.repository.default_branch }} 40 | head: HEAD 41 | extra_args: --debug --only-verified -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sentry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/go,linux,macos 2 | # Edit at https://www.gitignore.io/?templates=go,linux,macos 3 | 4 | ### Go ### 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | ### Go Patch ### 19 | /vendor/ 20 | /Godeps/ 21 | 22 | ### Linux ### 23 | *~ 24 | 25 | # temporary files which can be created if a process still has a handle open of a deleted file 26 | .fuse_hidden* 27 | 28 | # KDE directory preferences 29 | .directory 30 | 31 | # Linux trash folder which might appear on any partition or disk 32 | .Trash-* 33 | 34 | # .nfs files are created when an open file is removed but is still being accessed 35 | .nfs* 36 | 37 | ### macOS ### 38 | # General 39 | .DS_Store 40 | .AppleDouble 41 | .LSOverride 42 | 43 | # Icon must end with two \r 44 | Icon 45 | 46 | # Thumbnails 47 | ._* 48 | 49 | # Files that might appear in the root of a volume 50 | .DocumentRevisions-V100 51 | .fseventsd 52 | .Spotlight-V100 53 | .TemporaryItems 54 | .Trashes 55 | .VolumeIcon.icns 56 | .com.apple.timemachine.donotpresent 57 | 58 | # Directories potentially created on remote AFP share 59 | .AppleDB 60 | .AppleDesktop 61 | Network Trash Folder 62 | Temporary Items 63 | .apdisk 64 | 65 | # End of https://www.gitignore.io/api/go,linux,macos 66 | 67 | .env 68 | .vscode 69 | out/ 70 | sentlog 71 | -------------------------------------------------------------------------------- /grok.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/vjeantet/grok" 10 | ) 11 | 12 | func AddDefaultPatterns(g *grok.Grok) (err error) { 13 | // Nginx 14 | err = g.AddPattern("NGINX_ERROR_DATESTAMP", `\d{4}/\d{2}/\d{2}[- ]%{TIME}`) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | err = g.AddPattern("NGINX_ERROR_LOG", `%{NGINX_ERROR_DATESTAMP:timestamp} \[%{DATA:err_severity}\] (%{NUMBER:pid:int}#%{NUMBER}: \*%{NUMBER}|\*%{NUMBER}) %{DATA:message}(?:, client: "?%{IPORHOST:client}"?)(?:, server: %{IPORHOST:server})(?:, request: "%{WORD:verb} %{URIPATHPARAM:request} HTTP/%{NUMBER:httpversion}")?(?:, upstream: "%{DATA:upstream}")?(?:, host: "%{URIHOST:host}")?(?:, referrer: "%{URI:referrer}")?`) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | return nil 25 | } 26 | 27 | func ReadPatternsFromFile(g *grok.Grok, filename string) error { 28 | file, err := os.Open(filename) 29 | if err != nil { 30 | return err 31 | } 32 | defer file.Close() 33 | 34 | log.Printf("Adding grok patterns from \"%s\"", filename) 35 | scanner := bufio.NewScanner(file) 36 | for scanner.Scan() { 37 | line := strings.TrimSpace(scanner.Text()) 38 | 39 | // Skip comments and empty lines 40 | if strings.HasPrefix(line, "#") || line == "" { 41 | continue 42 | } 43 | 44 | parts := strings.SplitN(line, " ", 2) 45 | if len(parts) != 2 { 46 | return fmt.Errorf("Cannot parse patterns in \"%s\"", filename) 47 | } 48 | 49 | patternName, pattern := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) 50 | if patternName == "" || pattern == "" { 51 | return fmt.Errorf("Empty pattern definition in \"%s\"", filename) 52 | } 53 | 54 | err := g.AddPattern(patternName, pattern) 55 | if err != nil { 56 | return err 57 | } 58 | } 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestReadConfigFromFile(t *testing.T) { 12 | temporaryDirectory, err := os.MkdirTemp(os.TempDir(), "sentlog-*") 13 | if err != nil { 14 | t.Fatalf("creating temporary directory: %v", err) 15 | } 16 | 17 | t.Cleanup(func() { 18 | err := os.RemoveAll(temporaryDirectory) 19 | if err != nil { 20 | t.Log(err) 21 | } 22 | }) 23 | 24 | // Write temporary file 25 | payload := []byte(`--- 26 | # Sentry DSN (also can be configured via environment) 27 | sentry_dsn: https://XXX@sentry.io/YYY 28 | # Additional Grok pattern files 29 | pattern_files: 30 | - ./patterns1.txt 31 | - ../patterns2.txt 32 | 33 | # List of files that we want to watch 34 | inputs: 35 | - file: /var/log/nginx/error.log 36 | # Patterns to find and report 37 | patterns: 38 | - "%{NGINX_ERROR_LOG}" 39 | # Additional tags that will be added to the Sentry event 40 | tags: 41 | pattern: nginx_error 42 | custom: tag`) 43 | fileName := path.Join(temporaryDirectory, "config.yml") 44 | err = os.WriteFile(fileName, payload, 0644) 45 | if err != nil { 46 | t.Fatalf("writing temporary file: %v", err) 47 | } 48 | 49 | config, err := ReadConfigFromFile(fileName) 50 | if err != nil { 51 | t.Error(err) 52 | } 53 | 54 | assert.Equal(t, &Config{ 55 | SentryDsn: "https://XXX@sentry.io/YYY", 56 | PatternFiles: []string{"./patterns1.txt", "../patterns2.txt"}, 57 | Inputs: []FileInputConfig{ 58 | { 59 | File: "/var/log/nginx/error.log", 60 | Follow: nil, 61 | FromLineNumber: nil, 62 | Patterns: []string{"%{NGINX_ERROR_LOG}"}, 63 | Tags: map[string]string{ 64 | "pattern": "nginx_error", 65 | "custom": "tag", 66 | }, 67 | }, 68 | }, 69 | }, config) 70 | } 71 | -------------------------------------------------------------------------------- /process_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | 8 | "github.com/getsentry/sentry-go" 9 | ) 10 | 11 | func TestProcessLine(t *testing.T) { 12 | _verbose = true 13 | transportMock := &TransportMock{} 14 | err := sentry.Init(sentry.ClientOptions{Debug: true, Transport: transportMock}) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | g := initGrokProcessor() 20 | 21 | processLine( 22 | "", 23 | []string{"%{COMMONAPACHELOG}"}, 24 | g, 25 | sentry.CurrentHub().Clone(), 26 | ) 27 | // We expect it to not send anything to Sentry 28 | if transportMock.lastEvent != nil { 29 | t.Errorf("expecting nil, got %v", transportMock.lastEvent) 30 | } 31 | 32 | processLine( 33 | `127.0.0.1 - - [23/Apr/2014:22:58:32 +0200] "GET /index.php HTTP/1.1" 404 207`, 34 | []string{"%{COMMONAPACHELOG}"}, 35 | g, 36 | sentry.CurrentHub().Clone(), 37 | ) 38 | // We expect it to send something to Sentry 39 | expectMessage := "127.0.0.1 - - [23/Apr/2014:22:58:32 +0200] \"GET /index.php HTTP/1.1\" 404 207" 40 | if transportMock.lastEvent.Message != expectMessage { 41 | t.Errorf("expecting lastEvent.Message to be %q, instead got %q", expectMessage, transportMock.lastEvent.Message) 42 | } 43 | 44 | if transportMock.lastEvent.Level != sentry.LevelError { 45 | t.Errorf("expecting lastEvent.Level to be %q, instead got %q", sentry.LevelError, transportMock.lastEvent.Level) 46 | } 47 | 48 | if value, ok := transportMock.lastEvent.Extra["log_entry"]; ok && value != expectMessage { 49 | t.Errorf("expecting transportMock.lastEvent.Extra[\"log_entry\"] to be %q, instead got %q", expectMessage, value) 50 | } 51 | 52 | if value, ok := transportMock.lastEvent.Extra["pattern"]; ok && value != "%{COMMONAPACHELOG}" { 53 | t.Errorf("expecting transportMock.lastEvent.Extra[\"log_entry\"] to be %q, instead got %q", "%{COMMONAPACHELOG}", value) 54 | } 55 | } 56 | 57 | type TransportMock struct { 58 | mu sync.Mutex 59 | events []*sentry.Event 60 | lastEvent *sentry.Event 61 | } 62 | 63 | func (t *TransportMock) Configure(options sentry.ClientOptions) {} 64 | func (t *TransportMock) SendEvent(event *sentry.Event) { 65 | t.mu.Lock() 66 | defer t.mu.Unlock() 67 | t.events = append(t.events, event) 68 | t.lastEvent = event 69 | } 70 | func (t *TransportMock) Flush(timeout time.Duration) bool { 71 | return true 72 | } 73 | func (t *TransportMock) Events() []*sentry.Event { 74 | t.mu.Lock() 75 | defer t.mu.Unlock() 76 | return t.events 77 | } 78 | -------------------------------------------------------------------------------- /grok_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path" 8 | "testing" 9 | 10 | "github.com/vjeantet/grok" 11 | ) 12 | 13 | func TestAddDefaultPatterns(t *testing.T) { 14 | g, err := grok.NewWithConfig(&grok.Config{NamedCapturesOnly: true}) 15 | if err != nil { 16 | t.Fatalf("Grok engine initialization failed: %v\n", err) 17 | } 18 | 19 | err = AddDefaultPatterns(g) 20 | if err != nil { 21 | t.Error(err) 22 | } 23 | } 24 | 25 | func TestReadPatternsFromFile(t *testing.T) { 26 | temporaryDirectory, err := os.MkdirTemp(os.TempDir(), "sentlog-*") 27 | if err != nil { 28 | t.Fatalf("creating temporary directory: %v", err) 29 | } 30 | 31 | t.Cleanup(func() { 32 | err := os.RemoveAll(temporaryDirectory) 33 | if err != nil { 34 | t.Log(err) 35 | } 36 | }) 37 | 38 | var tests = []struct { 39 | testName string 40 | fileName string 41 | payload []byte 42 | expectedError error 43 | }{ 44 | { 45 | testName: "Normal case", 46 | fileName: path.Join(temporaryDirectory, "normal.txt"), 47 | payload: []byte(`postgres ^%{DATESTAMP:timestamp}.*FATAL:.*host" 48 | 49 | # This is a comment, should be skipped`), 50 | expectedError: nil, 51 | }, 52 | { 53 | testName: "Invalid length", 54 | fileName: path.Join(temporaryDirectory, "invalid-length.txt"), 55 | payload: []byte(`HelloWorld`), 56 | expectedError: fmt.Errorf("Cannot parse patterns in \"%s\"", path.Join(temporaryDirectory, "invalid-length.txt")), 57 | }, 58 | { 59 | testName: "Invalid pattern", 60 | fileName: path.Join(temporaryDirectory, "invalid-pattern.txt"), 61 | payload: []byte(`hello %{HELLO-WORLD}`), 62 | expectedError: errors.New("no pattern found for %{HELLO-WORLD}"), 63 | }, 64 | { 65 | testName: "Empty file", 66 | fileName: path.Join(temporaryDirectory, "empty.txt"), 67 | payload: nil, 68 | expectedError: nil, 69 | }, 70 | } 71 | for _, test := range tests { 72 | t.Run(test.testName, func(t *testing.T) { 73 | g, err := grok.NewWithConfig(&grok.Config{NamedCapturesOnly: true}) 74 | if err != nil { 75 | t.Fatalf("Grok engine initialization failed: %v\n", err) 76 | } 77 | 78 | err = os.WriteFile(test.fileName, test.payload, 0644) 79 | if err != nil { 80 | t.Fatalf("writing temporary file: %v", err) 81 | } 82 | 83 | err = ReadPatternsFromFile(g, test.fileName) 84 | if test.expectedError == nil && err != nil { 85 | t.Error(err) 86 | } else if test.expectedError != nil { 87 | if err != nil { 88 | if test.expectedError.Error() != err.Error() { 89 | t.Errorf("expecting %s, got %s", test.expectedError.Error(), err.Error()) 90 | } 91 | } else { 92 | // err is nil 93 | t.Errorf("expecting %s, got nil", test.expectedError.Error()) 94 | } 95 | } 96 | }) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '32 16 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v2 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v2 75 | with: 76 | category: "/language:${{matrix.language}}" -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "strings" 7 | "syscall" 8 | "time" 9 | 10 | "github.com/alecthomas/kingpin/v2" 11 | "github.com/getsentry/sentry-go" 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type CmdArgs struct { 16 | file *string 17 | pattern *string 18 | dryRun *bool 19 | noFollow *bool 20 | verbose *bool 21 | fromLineNumber *int 22 | config *string 23 | } 24 | 25 | var ( 26 | _isDryRun bool 27 | _verbose bool 28 | ) 29 | 30 | var log = logrus.New() 31 | 32 | func isDryRun() bool { 33 | return _isDryRun 34 | } 35 | 36 | func isVerbose() bool { 37 | return _verbose 38 | } 39 | 40 | func initSentry(config *Config) { 41 | if isDryRun() { 42 | log.Println("Dry-run mode enabled, not initializing Sentry client") 43 | return 44 | } 45 | 46 | dsn := "" 47 | 48 | if config.SentryDsn != "" { 49 | dsn = config.SentryDsn 50 | } else { 51 | dsn = os.Getenv("SENTLOG_SENTRY_DSN") 52 | } 53 | 54 | if dsn == "" { 55 | log.Fatal("No DSN found\n") 56 | } 57 | 58 | err := sentry.Init(sentry.ClientOptions{Dsn: dsn}) 59 | if err != nil { 60 | log.Fatalf("Sentry initialization failed: %v\n", err) 61 | } 62 | } 63 | 64 | // Catches Ctrl-C 65 | func catchInterrupt() { 66 | c := make(chan os.Signal, 1) 67 | signal.Notify(c, os.Interrupt, syscall.SIGINT) 68 | go func() { 69 | <-c 70 | log.Println("Cleaning up...") 71 | sentry.Flush(5 * time.Second) 72 | os.Exit(1) 73 | }() 74 | } 75 | 76 | func initLogging() { 77 | log.SetFormatter(&logrus.TextFormatter{ 78 | FullTimestamp: true, 79 | }) 80 | 81 | var logLevel = logrus.InfoLevel 82 | 83 | logLevelEnv := strings.ToLower(os.Getenv("SENTLOG_LOG_LEVEL")) 84 | switch logLevelEnv { 85 | case "debug": 86 | logLevel = logrus.DebugLevel 87 | case "info": 88 | logLevel = logrus.InfoLevel 89 | } 90 | log.SetLevel(logLevel) 91 | } 92 | 93 | func showGreeting() { 94 | 95 | } 96 | 97 | func main() { 98 | initLogging() 99 | 100 | args := CmdArgs{ 101 | file: kingpin.Arg("file", "File to parse").String(), 102 | pattern: kingpin.Flag("pattern", "Pattern to look for").Short('p').String(), 103 | dryRun: kingpin.Flag("dry-run", "Dry-run mode").Default("false").Bool(), 104 | noFollow: kingpin.Flag("no-follow", "Do not wait for the new data").Bool(), 105 | fromLineNumber: kingpin.Flag("from-line", "Start reading from this line number").Default("-1").Int(), 106 | config: kingpin.Flag("config", "Path to the configuration").Short('c').String(), 107 | verbose: kingpin.Flag("verbose", "Print every match").Short('v').Default("false").Bool(), 108 | } 109 | kingpin.Parse() 110 | 111 | showGreeting() 112 | 113 | _isDryRun = *args.dryRun 114 | _verbose = *args.verbose 115 | 116 | var config *Config 117 | 118 | if *args.config == "" { 119 | if *args.pattern == "" || *args.file == "" { 120 | log.Fatalln("Both file and pattern have to be specified, aborting") 121 | } 122 | 123 | log.Println("Using parameters from the command line") 124 | follow := !*args.noFollow 125 | config = &Config{ 126 | SentryDsn: "", 127 | Inputs: []FileInputConfig{ 128 | FileInputConfig{ 129 | File: *args.file, 130 | Follow: &follow, 131 | FromLineNumber: args.fromLineNumber, 132 | Patterns: []string{*args.pattern}, 133 | }, 134 | }, 135 | } 136 | } else { 137 | log.Println("Using parameters from the configuration file") 138 | if *args.pattern != "" || *args.file != "" { 139 | log.Fatalln("No pattern/file allowed when configuration file is provided, aborting") 140 | } 141 | 142 | configPath := *args.config 143 | parsedConfig, err := ReadConfigFromFile(configPath) 144 | if err != nil { 145 | log.Fatal(err) 146 | } 147 | config = parsedConfig 148 | log.Printf("Configuration file loaded: \"%s\"\n", configPath) 149 | } 150 | 151 | initSentry(config) 152 | catchInterrupt() 153 | runWithConfig(config) 154 | } 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sentlog 2 | 3 | ## This is a Sentry Hackweek project! Development may stop anytime. You've been warned. 4 | 5 | `sentlog` is a command-line tool that can read arbitrary text files (e.g., webserver or database logs), search for specific user-defined patterns, and report the findings to Sentry. 6 | 7 | ## Table of Contents 8 | - [Introduction](#Introduction) 9 | - [Downloads](#Downloads) 10 | - [Command Line Arguments](#Command-Line-Arguments) 11 | - [Example](#Example) 12 | - [Configuration File](#Configuration-File) 13 | - [About Patterns](#About-Patterns) 14 | 15 | ## Introduction 16 | 17 | Sentry provides SDKs for a lot of different [platforms and frameworks](https://docs.sentry.io/). However, you might also want to use Sentry for parts of your infrastructure that were not developed by you, or don't have an integration with Sentry (yet): databases, web servers, and even operating system kernels. What do these tools have in common? They normally have some sort of output (i.e. logs), where both regular events and errors are usually logged. So why not parsing those logs and look for entries that look like errors? We can do that. And what platform do we usually use for error management? Sentry, of course! 18 | 19 | And this is when `sentlog` steps in. 20 | 21 | ## Downloads 22 | 23 | `sentlog` binaries can be downloaded from [GitHub releases](https://github.com/getsentry/sentlog/releases). 24 | 25 | ## Command Line Arguments 26 | 27 | ```sh 28 | usage: sentlog [] [] 29 | 30 | Flags: 31 | --help Show context-sensitive help (also try --help-long and --help-man). 32 | -p, --pattern=PATTERN Pattern to look for 33 | --dry-run Dry-run mode 34 | --no-follow Do not wait for the new data 35 | --from-line=-1 Start reading from this line number 36 | -c, --config=CONFIG Path to the configuration 37 | -v, --verbose Print every match 38 | 39 | Args: 40 | [] File to parse 41 | ``` 42 | 43 | `sentlog` can operate in two modes: 44 | 45 | 1. Basic: filename and pattern are specified on the command line 46 | 2. Advanced: using the configuration file provided by `--config` argument 47 | 48 | ## Example 49 | 50 | The following example shows how you can run `sentlog` in Basic mode. 51 | 52 | ```sh 53 | export SENTLOG_SENTRY_DSN="https://XXX@sentry.io/YYY" # Your Sentry DSN 54 | sentlog /var/log/postgresql/postgresql-9.6.log \ 55 | -p '^%{DATESTAMP:timestamp}.*FATAL:.*host "%{IP:host}", user "%{USERNAME:user}", database "%{WORD:database}"' 56 | ``` 57 | 58 | ...will watch the PostgreSQL log (`/var/log/postgresql/postgresql-9.6.log`) for events that look like this: 59 | 60 | ``` 61 | 2019-05-21 08:51:09 GMT [11212]: FATAL: no pg_hba.conf entry for host "123.123.123.123", user "postgres", database "testdb" 62 | ``` 63 | 64 | `sentlog` will extract the timestamp, IP address, username, and database from the entry, and will add them as tags to the Sentry event. 65 | 66 | ## Configuration File 67 | 68 | ```yaml 69 | --- 70 | # Sentry DSN (also can be configured via environment) 71 | sentry_dsn: https://XXX@sentry.io/YYY 72 | # Additional Grok pattern files 73 | pattern_files: 74 | - ./patterns1.txt 75 | - ../patterns2.txt 76 | 77 | # List of files that we want to watch 78 | inputs: 79 | - file: /var/log/nginx/error.log 80 | # Patterns to find and report 81 | patterns: 82 | - "%{NGINX_ERROR_LOG}" 83 | # Additional tags that will be added to the Sentry event 84 | tags: 85 | pattern: nginx_error 86 | custom: tag 87 | ``` 88 | 89 | ## About Patterns 90 | 91 | `sentlog` uses [Grok patterns](https://www.elastic.co/guide/en/logstash/current/plugins-filters-grok.html#_grok_basics) to match your data. A cool thing about Grok expressions is that they can be nested, which lets you to define complex matching expressions based on smaller building blocks ([example](https://github.com/vjeantet/grok/blob/5a86c829f3c347ec23dbd632af2db0d3508c11ce/patterns/grok-patterns)). 92 | 93 | This Grok debugger can be quite helpful when preparing your Grok expressions: https://grokdebug.herokuapp.com/ 94 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin/v2 v2.3.2 h1:H0aULhgmSzN8xQ3nX1uxtdlTHYoPLu5AhHxWrKI6ocU= 2 | github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 3 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 4 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 5 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= 6 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 11 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 12 | github.com/getsentry/sentry-go v0.20.0 h1:bwXW98iMRIWxn+4FgPW7vMrjmbym6HblXALmhjHmQaQ= 13 | github.com/getsentry/sentry-go v0.20.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= 14 | github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= 15 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 16 | github.com/hpcloud/tail v1.0.1-0.20180514194441-a1dbeea552b7 h1:Ysi1UhrSyBltF8f+3RAt4UaqHc+53JJ0jyl0pY0sfck= 17 | github.com/hpcloud/tail v1.0.1-0.20180514194441-a1dbeea552b7/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 18 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 19 | github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= 20 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 24 | github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= 25 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 26 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 27 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 28 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 29 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 30 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 31 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 32 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 33 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 34 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 35 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 36 | github.com/vjeantet/grok v1.0.1 h1:2rhIR7J4gThTgcZ1m2JY4TrJZNgjn985U28kT2wQrJ4= 37 | github.com/vjeantet/grok v1.0.1/go.mod h1:ax1aAchzC6/QMXMcyzHQGZWaW1l195+uMYIkCWPCNIo= 38 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 39 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 40 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 43 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= 45 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 46 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= 49 | gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= 50 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 51 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 52 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 53 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | -------------------------------------------------------------------------------- /process.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "strconv" 11 | "sync" 12 | "time" 13 | 14 | "github.com/araddon/dateparse" 15 | "github.com/getsentry/sentry-go" 16 | "github.com/hpcloud/tail" 17 | "github.com/vjeantet/grok" 18 | ) 19 | 20 | const MessageField = "message" 21 | const TimeStampField = "timestamp" 22 | 23 | // Used to coordinate per-file goroutines we spawn 24 | var wg sync.WaitGroup 25 | 26 | // Mutex for logging 27 | var printMutex sync.Mutex 28 | 29 | func printMap(m map[string]string) { 30 | keys := make([]string, 0, len(m)) 31 | for k := range m { 32 | keys = append(keys, k) 33 | } 34 | sort.Strings(keys) 35 | 36 | for _, k := range keys { 37 | fmt.Printf("%+15s: %s\n", k, m[k]) 38 | } 39 | fmt.Println() 40 | } 41 | 42 | func captureEvent(line string, values map[string]string, hub *sentry.Hub) { 43 | if isDryRun() { 44 | return 45 | } 46 | 47 | message := values[MessageField] 48 | if message == "" { 49 | message = line 50 | } 51 | 52 | // Attempt to parse the timestamp 53 | timestamp := parseTimestamp(values[TimeStampField]) 54 | 55 | scope := hub.Scope() 56 | 57 | for key, value := range values { 58 | if value == "" { 59 | continue 60 | } 61 | scope.SetTag(key, value) 62 | } 63 | 64 | if timestamp != 0 { 65 | scope.SetTag("parsed_timestamp", strconv.FormatInt(timestamp, 10)) 66 | } 67 | 68 | scope.SetLevel(sentry.LevelError) 69 | 70 | scope.SetExtra("log_entry", line) 71 | 72 | hub.CaptureMessage(message) 73 | } 74 | 75 | func parseTimestamp(str string) int64 { 76 | fallback := int64(0) 77 | if str == "" { 78 | return fallback 79 | } 80 | 81 | time, err := dateparse.ParseLocal(str) 82 | if err != nil { 83 | return fallback 84 | } 85 | 86 | return time.Unix() 87 | } 88 | 89 | func processLine(line string, patterns []string, g *grok.Grok, hub *sentry.Hub) { 90 | var parsedValues map[string]string 91 | 92 | // Try all patterns 93 | for _, pattern := range patterns { 94 | values, err := g.Parse(pattern, line) 95 | if err != nil { 96 | log.Fatalf("grok parsing failed: %v\n", err) 97 | } 98 | 99 | if len(values) != 0 { 100 | parsedValues = values 101 | hub.Scope().SetExtra("pattern", pattern) 102 | break 103 | } 104 | } 105 | 106 | if len(parsedValues) == 0 { 107 | return 108 | } 109 | 110 | captureEvent(line, parsedValues, hub) 111 | 112 | if isVerbose() { 113 | printMutex.Lock() 114 | log.Println("Entry found:") 115 | printMap(parsedValues) 116 | printMutex.Unlock() 117 | } 118 | } 119 | 120 | func initGrokProcessor() *grok.Grok { 121 | g, err := grok.NewWithConfig(&grok.Config{NamedCapturesOnly: true}) 122 | if err != nil { 123 | log.Fatalf("Grok engine initialization failed: %v\n", err) 124 | } 125 | 126 | if err := AddDefaultPatterns(g); err != nil { 127 | log.Fatalf("Processing default patterns: %v\n", err) 128 | } 129 | return g 130 | } 131 | 132 | func getSeekInfo(file *os.File, fromLineNumber int) tail.SeekInfo { 133 | if fromLineNumber < 0 { 134 | // By default: from the end 135 | return tail.SeekInfo{ 136 | Offset: 0, 137 | Whence: io.SeekEnd, 138 | } 139 | } else { 140 | // Seek to the line number 141 | scanner := bufio.NewScanner(file) 142 | pos := int64(0) 143 | scanLines := func(data []byte, atEOF bool) (advance int, token []byte, err error) { 144 | advance, token, err = bufio.ScanLines(data, atEOF) 145 | pos += int64(advance) 146 | return 147 | } 148 | scanner.Split(scanLines) 149 | for i := 0; i < fromLineNumber; i++ { 150 | dataAvailable := scanner.Scan() 151 | if !dataAvailable { 152 | break 153 | } 154 | } 155 | return tail.SeekInfo{ 156 | Offset: pos, 157 | Whence: io.SeekStart, 158 | } 159 | } 160 | } 161 | 162 | func processFile(fileInput *FileInputConfig, g *grok.Grok) { 163 | defer wg.Done() 164 | 165 | absFilePath, err := filepath.Abs(fileInput.File) 166 | if err != nil { 167 | log.Fatal(err) 168 | } 169 | file, err := os.Open(absFilePath) 170 | if err != nil { 171 | log.Fatal(err) 172 | } 173 | defer file.Close() 174 | 175 | info, err := file.Stat() 176 | if err != nil { 177 | log.Fatal(err) 178 | } 179 | if info.IsDir() { 180 | log.Fatal("Directory paths are not allowed, exiting") 181 | } 182 | 183 | log.Printf("Reading input from file \"%s\"", absFilePath) 184 | 185 | // One hub per file/goroutine 186 | hub := sentry.CurrentHub().Clone() 187 | scope := hub.PushScope() 188 | scope.SetTag("file_input_path", absFilePath) 189 | scope.SetTags(fileInput.Tags) 190 | 191 | fromLineNumber := -1 192 | if fileInput.FromLineNumber != nil { 193 | fromLineNumber = *fileInput.FromLineNumber 194 | } 195 | 196 | seekInfo := getSeekInfo(file, fromLineNumber) 197 | 198 | follow := true 199 | if fileInput.Follow != nil { 200 | follow = *fileInput.Follow 201 | } 202 | 203 | tailFile, err := tail.TailFile( 204 | absFilePath, 205 | tail.Config{ 206 | Follow: follow, 207 | Location: &seekInfo, 208 | ReOpen: follow, 209 | Logger: log, 210 | }) 211 | 212 | for line := range tailFile.Lines { 213 | hub.WithScope(func(_ *sentry.Scope) { 214 | processLine(line.Text, fileInput.Patterns, g, hub) 215 | }) 216 | 217 | if !isDryRun() { 218 | hub.AddBreadcrumb(&sentry.Breadcrumb{ 219 | Message: line.Text, 220 | Level: sentry.LevelInfo, 221 | }, nil) 222 | } 223 | } 224 | 225 | log.Printf("Finished reading from \"%s\", flushing events...\n", absFilePath) 226 | hub.Flush(10 * time.Second) 227 | } 228 | 229 | func runWithConfig(config *Config) { 230 | if isVerbose() { 231 | log.Println("Verbose mode enabled, printing every match") 232 | } 233 | 234 | g := initGrokProcessor() 235 | 236 | // Load patterns 237 | for _, filename := range config.PatternFiles { 238 | err := ReadPatternsFromFile(g, filename) 239 | if err != nil { 240 | log.Fatal(err) 241 | } 242 | log.Printf("Loaded additional patterns from \"%s\"\n", filename) 243 | } 244 | 245 | if len(config.Inputs) == 0 { 246 | log.Fatalln("No file inputs specified, aborting") 247 | } 248 | 249 | // Process file inputs 250 | for _, fileInput := range config.Inputs { 251 | wg.Add(1) 252 | go processFile(&fileInput, g) 253 | } 254 | 255 | wg.Wait() 256 | } 257 | --------------------------------------------------------------------------------