Last active
January 24, 2026 12:01
-
-
Save suhail-sullad/35c762d7e1790c4034bab6a7222c74e5 to your computer and use it in GitHub Desktop.
MaxScale Log Tailer - Go program for parsing MaxScale logs and exposing Prometheus metrics
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # MaxScale Log Tailer Build Script | |
| # This script builds the Go-based log tailer for MaxScale | |
| # Usage: Run inside an Alpine container with Go installed | |
| set -e | |
| echo "Creating build directory..." | |
| mkdir -p /app | |
| cd /app | |
| echo "Creating go.mod..." | |
| cat > go.mod << 'MODEOF' | |
| module maxscale-log-tailer | |
| go 1.21 | |
| require github.com/prometheus/client_golang v1.19.0 | |
| require ( | |
| github.com/beorn7/perks v1.0.1 // indirect | |
| github.com/cespare/xxhash/v2 v2.2.0 // indirect | |
| github.com/prometheus/client_model v0.5.0 // indirect | |
| github.com/prometheus/common v0.48.0 // indirect | |
| github.com/prometheus/procfs v0.12.0 // indirect | |
| golang.org/x/sys v0.16.0 // indirect | |
| google.golang.org/protobuf v1.32.0 // indirect | |
| ) | |
| MODEOF | |
| echo "Downloading main.go from gist..." | |
| GIST_URL="${MAXSCALE_LOG_TAILER_GIST_URL:-https://gist.githubusercontent.com/suhail-sullad/35c762d7e1790c4034bab6a7222c74e5/raw/maxscale-log-tailer.go}" | |
| curl -sSL "$GIST_URL" -o main.go | |
| echo "Downloading dependencies..." | |
| go mod tidy | |
| echo "Building log tailer..." | |
| go build -o maxscale-log-tailer . | |
| echo "Build complete! Binary: /app/maxscale-log-tailer" | |
| # Usage | |
| echo "" | |
| echo "Usage:" | |
| echo " ./maxscale-log-tailer --log-file=/var/log/maxscale/maxscale.log --metrics-port=9196" | |
| echo "" | |
| echo "Arguments:" | |
| echo " --log-file Path to MaxScale log file (default: /var/log/maxscale/maxscale.log)" | |
| echo " --metrics-port Port for Prometheus metrics endpoint (default: 9196)" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "bufio" | |
| "flag" | |
| "fmt" | |
| "io" | |
| "log" | |
| "os" | |
| "regexp" | |
| "strings" | |
| "sync" | |
| "time" | |
| "net/http" | |
| "github.com/prometheus/client_golang/prometheus" | |
| "github.com/prometheus/client_golang/prometheus/promhttp" | |
| ) | |
| /* | |
| ======================== | |
| Metrics | |
| ======================== | |
| */ | |
| // Counters for routing | |
| var ( | |
| queriesRouted = prometheus.NewCounterVec( | |
| prometheus.CounterOpts{ | |
| Name: "maxscale_queries_routed_total", | |
| Help: "Queries routed by type and backend", | |
| }, | |
| []string{"category"}, // transaction_write, transaction_read, slave_read, master_read | |
| ) | |
| ccrForcedReads = prometheus.NewCounter(prometheus.CounterOpts{ | |
| Name: "maxscale_ccr_forced_master_reads_total", | |
| Help: "CCR forced master reads", | |
| }) | |
| trxStarted = prometheus.NewCounter(prometheus.CounterOpts{ | |
| Name: "maxscale_transactions_started_total", | |
| Help: "Transactions started", | |
| }) | |
| ) | |
| /* | |
| ======================== | |
| Parser State | |
| ======================== | |
| */ | |
| type SessionState struct { | |
| TrxOpen bool | |
| LastQuery string | |
| } | |
| type Parser struct { | |
| sessions map[string]*SessionState | |
| mu sync.RWMutex | |
| exclude []*regexp.Regexp | |
| } | |
| /* | |
| ======================== | |
| Regex Patterns | |
| ======================== | |
| */ | |
| var ( | |
| sessionRe = regexp.MustCompile(`\((\d+)\)`) | |
| trxRe = regexp.MustCompile(`trx is \[(open|not open)\]`) | |
| typeRe = regexp.MustCompile(`type:\s+([A-Z_|]+)`) | |
| routeRe = regexp.MustCompile(`Route query to (master|slave)`) | |
| ccrRe = regexp.MustCompile(`HINT_ROUTE_TO_MASTER`) | |
| tableRe = regexp.MustCompile("`?([A-Z0-9_]+)`?") // for QRTZ exclusion | |
| ) | |
| /* | |
| ======================== | |
| Helper Functions | |
| ======================== | |
| */ | |
| func (p *Parser) shouldExclude(stmt string) bool { | |
| for _, r := range p.exclude { | |
| if r.MatchString(stmt) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| /* | |
| ======================== | |
| Parsing Logic | |
| ======================== | |
| */ | |
| func (p *Parser) parseLine(line string) { | |
| // Extract session ID | |
| m := sessionRe.FindStringSubmatch(line) | |
| if len(m) < 2 { | |
| return | |
| } | |
| sid := m[1] | |
| p.mu.Lock() | |
| defer p.mu.Unlock() | |
| state, ok := p.sessions[sid] | |
| if !ok { | |
| state = &SessionState{} | |
| p.sessions[sid] = state | |
| } | |
| // Track transaction state | |
| if t := trxRe.FindStringSubmatch(line); len(t) == 2 { | |
| state.TrxOpen = (t[1] == "open") | |
| } | |
| // Track query type | |
| var queryType string | |
| if t := typeRe.FindStringSubmatch(line); len(t) == 2 { | |
| queryType = t[1] | |
| state.LastQuery = queryType | |
| if strings.Contains(queryType, "BEGIN_TRX") { | |
| trxStarted.Inc() | |
| state.TrxOpen = true | |
| } | |
| } | |
| // Only process lines with routing info | |
| r := routeRe.FindStringSubmatch(line) | |
| if len(r) < 2 { | |
| return | |
| } | |
| backend := r[1] | |
| // Ignore QRTZ tables | |
| if p.shouldExclude(line) { | |
| return | |
| } | |
| category := "" | |
| if state.TrxOpen { | |
| if strings.Contains(state.LastQuery, "QUERY_TYPE_WRITE") { | |
| category = "transaction_write" // rule 2 | |
| } else if strings.Contains(state.LastQuery, "QUERY_TYPE_READ") { | |
| category = "transaction_read" // rule 3 | |
| } | |
| } else { | |
| if strings.Contains(state.LastQuery, "QUERY_TYPE_READ") && backend == "slave" { | |
| category = "slave_read" // rule 4 | |
| } else if strings.Contains(state.LastQuery, "QUERY_TYPE_WRITE") && backend == "master" { | |
| category = "master_read" // rule 5 | |
| } | |
| } | |
| if category != "" { | |
| queriesRouted.WithLabelValues(category).Inc() | |
| } | |
| // CCR filter detection | |
| if ccrRe.MatchString(line) { | |
| ccrForcedReads.Inc() // rule 7 | |
| } | |
| } | |
| /* | |
| ======================== | |
| Main | |
| ======================== | |
| */ | |
| func main() { | |
| logFile := flag.String("log-file", "/var/log/maxscale/maxscale.log", "MaxScale log file") | |
| metricsPort := flag.Int("metrics-port", 9196, "Prometheus metrics port") | |
| flag.Parse() | |
| // Register Prometheus metrics | |
| prometheus.MustRegister( | |
| queriesRouted, | |
| ccrForcedReads, | |
| trxStarted, | |
| ) | |
| // Create parser | |
| parser := &Parser{ | |
| sessions: make(map[string]*SessionState), | |
| } | |
| // Exclude QRTZ tables | |
| parser.exclude = []*regexp.Regexp{ | |
| regexp.MustCompile(`QRTZ_`), | |
| } | |
| // Start metrics HTTP server | |
| go func() { | |
| http.Handle("/metrics", promhttp.Handler()) | |
| addr := fmt.Sprintf(":%d", *metricsPort) | |
| log.Printf("Starting metrics server on %s", addr) | |
| log.Fatal(http.ListenAndServe(addr, nil)) | |
| }() | |
| // Open log file and tail from the end | |
| file, err := os.Open(*logFile) | |
| if err != nil { | |
| log.Fatalf("Failed to open log file: %v", err) | |
| } | |
| defer file.Close() | |
| // Start at end of file | |
| file.Seek(0, io.SeekEnd) | |
| reader := bufio.NewReader(file) | |
| for { | |
| line, err := reader.ReadString('\n') | |
| if err != nil { | |
| if err == io.EOF { | |
| time.Sleep(100 * time.Millisecond) | |
| continue | |
| } else { | |
| log.Fatalf("Error reading log file: %v", err) | |
| } | |
| } | |
| parser.parseLine(strings.TrimSpace(line)) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment