feat(agent) multi-driver db monitoring, hot-plug config, release pipeline
This commit is contained in:
45
.gitea/workflows/build.yml
Normal file
45
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: build-and-release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
|
||||||
|
- name: Build binaries
|
||||||
|
run: make build-all
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
RESPONSE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${NODE_AUTH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases")
|
||||||
|
echo "id=$(echo "$RESPONSE" | jq -r .id)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload binaries
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for FILE in dist/bugswatch-*; do
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${NODE_AUTH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@${FILE}" \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ steps.release.outputs.id }}/assets?name=$(basename ${FILE})"
|
||||||
|
done
|
||||||
18
LICENSE
Normal file
18
LICENSE
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
MIT No Attribution
|
||||||
|
|
||||||
|
Copyright 2025 bugs.watch
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
19
Makefile
Normal file
19
Makefile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
LDFLAGS = -ldflags="-s -w -X main.version=$(VERSION)"
|
||||||
|
OUT = dist
|
||||||
|
|
||||||
|
.PHONY: build build-all clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
go mod tidy
|
||||||
|
CGO_ENABLED=0 go build $(LDFLAGS) -o $(OUT)/bugswatch ./cmd/agent
|
||||||
|
|
||||||
|
build-all:
|
||||||
|
go mod tidy
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(OUT)/bugswatch-linux-amd64 ./cmd/agent
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(OUT)/bugswatch-linux-arm64 ./cmd/agent
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(OUT)/bugswatch-darwin-amd64 ./cmd/agent
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(OUT)/bugswatch-darwin-arm64 ./cmd/agent
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(OUT)
|
||||||
@@ -2,24 +2,144 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"bugs.watch/agent/internal/agent"
|
"bugs.watch/agent/internal/agent"
|
||||||
|
"bugs.watch/agent/internal/config"
|
||||||
|
"bugs.watch/agent/internal/dbmanager"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Peek at the first arg before flag parsing to detect sub-commands.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "db" {
|
||||||
|
runDBCmd(os.Args[2:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("configuration error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flag.StringVar(&cfg.DBConfigPath, "db-config", cfg.DBConfigPath,
|
||||||
|
"path to databases.json (default /etc/bugswatch/databases.json, or $BUGSWATCH_DB_CONFIG)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigCh
|
<-sigCh
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
agent.Run(ctx)
|
agent.Run(ctx, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── db sub-commands ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func runDBCmd(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: bugswatch db <add|remove|list> [args]")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow --db-config as the first flag of a db sub-command.
|
||||||
|
cfgPath := config.DefaultDBConfigPath
|
||||||
|
if v := os.Getenv("BUGSWATCH_DB_CONFIG"); v != "" {
|
||||||
|
cfgPath = v
|
||||||
|
}
|
||||||
|
if len(args) >= 2 && args[0] == "--db-config" {
|
||||||
|
cfgPath = args[1]
|
||||||
|
args = args[2:]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "add":
|
||||||
|
if len(args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: bugswatch db add <driver> <url>")
|
||||||
|
fmt.Fprintln(os.Stderr, "drivers: mysql, mariadb, postgres, redis, mongodb, sqlite")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
dbAdd(cfgPath, args[1], args[2])
|
||||||
|
|
||||||
|
case "remove":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: bugswatch db remove <driver>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
dbRemove(cfgPath, args[1])
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
dbList(cfgPath)
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown db command %q\n", args[0])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbAdd(cfgPath, driver, url string) {
|
||||||
|
entries, err := dbmanager.ReadEntries(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Driver == driver {
|
||||||
|
entries[i].URL = url
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
entries = append(entries, dbmanager.Entry{Driver: driver, URL: url})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dbmanager.WriteEntries(cfgPath, entries); err != nil {
|
||||||
|
log.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("added %s → %s\n", driver, cfgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbRemove(cfgPath, driver string) {
|
||||||
|
entries, err := dbmanager.ReadEntries(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
next := entries[:0]
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Driver != driver {
|
||||||
|
next = append(next, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dbmanager.WriteEntries(cfgPath, next); err != nil {
|
||||||
|
log.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("removed %s from %s\n", driver, cfgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbList(cfgPath string) {
|
||||||
|
entries, err := dbmanager.ReadEntries(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("no database monitors configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
enc.Encode(entries)
|
||||||
}
|
}
|
||||||
7
go.mod
7
go.mod
@@ -1,3 +1,10 @@
|
|||||||
module bugs.watch/agent
|
module bugs.watch/agent
|
||||||
|
|
||||||
go 1.22.12
|
go 1.22.12
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/redis/go-redis/v9 v9.7.3
|
||||||
|
go.mongodb.org/mongo-driver v1.17.1
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"bugs.watch/agent/internal/collectors/disk"
|
"bugs.watch/agent/internal/collectors/disk"
|
||||||
"bugs.watch/agent/internal/collectors/memory"
|
"bugs.watch/agent/internal/collectors/memory"
|
||||||
"bugs.watch/agent/internal/collectors/uptime"
|
"bugs.watch/agent/internal/collectors/uptime"
|
||||||
|
"bugs.watch/agent/internal/config"
|
||||||
|
"bugs.watch/agent/internal/dbmanager"
|
||||||
"bugs.watch/agent/internal/sender"
|
"bugs.watch/agent/internal/sender"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,13 +21,12 @@ const (
|
|||||||
windowDuration = 60 * time.Second
|
windowDuration = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(ctx context.Context) {
|
func Run(ctx context.Context, cfg config.Config) {
|
||||||
log.Println("bugswatch agent starting")
|
log.Println("bugswatch agent starting")
|
||||||
|
log.Printf("db config: %s", cfg.DBConfigPath)
|
||||||
|
|
||||||
// Aggregation window
|
|
||||||
window := aggregate.NewWindow()
|
window := aggregate.NewWindow()
|
||||||
|
|
||||||
// Register collectors
|
|
||||||
cols := []collectors.Collector{
|
cols := []collectors.Collector{
|
||||||
uptime.New(),
|
uptime.New(),
|
||||||
cpu.New(),
|
cpu.New(),
|
||||||
@@ -33,8 +34,10 @@ func Run(ctx context.Context) {
|
|||||||
disk.New(),
|
disk.New(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sender (HTTP or logging)
|
mgr := dbmanager.New(cfg.DBConfigPath)
|
||||||
snd := sender.NewHTTPSender("http://172.17.0.1:5173/api/health")
|
go mgr.Watch(ctx)
|
||||||
|
|
||||||
|
snd := sender.NewHTTPSender(cfg)
|
||||||
|
|
||||||
ticker := time.NewTicker(sampleInterval)
|
ticker := time.NewTicker(sampleInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -43,21 +46,19 @@ func Run(ctx context.Context) {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Println("bugswatch agent shutting down")
|
log.Println("bugswatch agent shutting down")
|
||||||
|
|
||||||
// Best-effort final flush with bounded timeout
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
emitSnapshot(shutdownCtx, snd, window, mgr)
|
||||||
emitSnapshot(shutdownCtx, snd, window)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
for _, c := range cols {
|
for _, c := range cols {
|
||||||
c.Collect(window)
|
c.Collect(window)
|
||||||
}
|
}
|
||||||
|
mgr.Collect(window)
|
||||||
|
|
||||||
if window.Ready(windowDuration) {
|
if window.Ready(windowDuration) {
|
||||||
emitSnapshot(ctx, snd, window)
|
emitSnapshot(ctx, snd, window, mgr)
|
||||||
window = aggregate.NewWindow()
|
window = aggregate.NewWindow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ func emitSnapshot(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
snd sender.Sender,
|
snd sender.Sender,
|
||||||
window *aggregate.Window,
|
window *aggregate.Window,
|
||||||
|
mgr *dbmanager.Manager,
|
||||||
) {
|
) {
|
||||||
snap := window.Snapshot()
|
snap := window.Snapshot()
|
||||||
_ = snd.Send(ctx, snap)
|
_ = snd.Send(ctx, snap)
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ type Window struct {
|
|||||||
diskCount uint64
|
diskCount uint64
|
||||||
|
|
||||||
uptime uint64
|
uptime uint64
|
||||||
|
|
||||||
|
// DB metrics — populated only when a DB collector is registered.
|
||||||
|
hasDB bool
|
||||||
|
dbDriver uint8
|
||||||
|
dbConnPctSum float64
|
||||||
|
dbConnPctCount uint64
|
||||||
|
dbThreadsSum float64
|
||||||
|
dbThreadsCount uint64
|
||||||
|
dbSlowQueriesDelta uint64
|
||||||
|
dbSizeBytes uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
@@ -23,14 +33,18 @@ type Snapshot struct {
|
|||||||
CPUAvg float64
|
CPUAvg float64
|
||||||
MemAvg float64
|
MemAvg float64
|
||||||
DiskAvg float64
|
DiskAvg float64
|
||||||
|
|
||||||
Uptime uint64
|
Uptime uint64
|
||||||
|
|
||||||
|
// nil when DB monitoring is not enabled.
|
||||||
|
DBDriver *uint8
|
||||||
|
DBConnPct *float64
|
||||||
|
DBThreadsRunning *float64
|
||||||
|
DBSlowQueries *uint64
|
||||||
|
DBSizeBytes *uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWindow() *Window {
|
func NewWindow() *Window {
|
||||||
return &Window{
|
return &Window{start: time.Now()}
|
||||||
start: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Window) AddCPU(value float64) {
|
func (w *Window) AddCPU(value float64) {
|
||||||
@@ -43,10 +57,41 @@ func (w *Window) AddMemory(value float64) {
|
|||||||
w.memCount++
|
w.memCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *Window) AddDisk(value float64) {
|
||||||
|
w.diskSum += value
|
||||||
|
w.diskCount++
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Window) SetUptime(value uint64) {
|
func (w *Window) SetUptime(value uint64) {
|
||||||
w.uptime = value
|
w.uptime = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *Window) AddDBConnPct(v float64) {
|
||||||
|
w.dbConnPctSum += v
|
||||||
|
w.dbConnPctCount++
|
||||||
|
w.hasDB = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) AddDBThreadsRunning(v float64) {
|
||||||
|
w.dbThreadsSum += v
|
||||||
|
w.dbThreadsCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) AddDBSlowQueriesDelta(v uint64) {
|
||||||
|
w.dbSlowQueriesDelta += v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Window) SetDBSizeBytes(v uint64) {
|
||||||
|
w.dbSizeBytes = v
|
||||||
|
w.hasDB = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDBDriver records the numeric driver identifier (see dbmanager.Driver* constants).
|
||||||
|
// When multiple collectors are active this is called once per window by the manager.
|
||||||
|
func (w *Window) SetDBDriver(v uint8) {
|
||||||
|
w.dbDriver = v
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Window) Ready(windowDuration time.Duration) bool {
|
func (w *Window) Ready(windowDuration time.Duration) bool {
|
||||||
return time.Since(w.start) >= windowDuration
|
return time.Since(w.start) >= windowDuration
|
||||||
}
|
}
|
||||||
@@ -67,16 +112,36 @@ func (w *Window) Snapshot() Snapshot {
|
|||||||
diskAvg = w.diskSum / float64(w.diskCount)
|
diskAvg = w.diskSum / float64(w.diskCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Snapshot{
|
snap := Snapshot{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
CPUAvg: cpuAvg,
|
CPUAvg: cpuAvg,
|
||||||
MemAvg: memAvg,
|
MemAvg: memAvg,
|
||||||
DiskAvg: diskAvg,
|
DiskAvg: diskAvg,
|
||||||
Uptime: w.uptime,
|
Uptime: w.uptime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if w.hasDB {
|
||||||
|
driver := w.dbDriver
|
||||||
|
|
||||||
|
var connPct float64
|
||||||
|
if w.dbConnPctCount > 0 {
|
||||||
|
connPct = w.dbConnPctSum / float64(w.dbConnPctCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Window) AddDisk(value float64) {
|
var threads float64
|
||||||
w.diskSum += value
|
if w.dbThreadsCount > 0 {
|
||||||
w.diskCount++
|
threads = w.dbThreadsSum / float64(w.dbThreadsCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
slow := w.dbSlowQueriesDelta
|
||||||
|
sizeBytes := w.dbSizeBytes
|
||||||
|
|
||||||
|
snap.DBDriver = &driver
|
||||||
|
snap.DBConnPct = &connPct
|
||||||
|
snap.DBThreadsRunning = &threads
|
||||||
|
snap.DBSlowQueries = &slow
|
||||||
|
snap.DBSizeBytes = &sizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return snap
|
||||||
}
|
}
|
||||||
94
internal/collectors/mongodb/mongodb.go
Normal file
94
internal/collectors/mongodb/mongodb.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package mongodb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"bugs.watch/agent/internal/aggregate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collector queries MongoDB serverStatus and listDatabases each sample interval.
|
||||||
|
// All queries run under a 3-second timeout; any failure is silently skipped.
|
||||||
|
// Metrics mapping:
|
||||||
|
// db_conn_pct → current / (current + available) * 100
|
||||||
|
// db_threads_running → connections.current
|
||||||
|
// db_size_bytes → totalSize from listDatabases
|
||||||
|
type Collector struct {
|
||||||
|
client *mongo.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(uri string) (*Collector, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri).
|
||||||
|
SetConnectTimeout(3*time.Second).
|
||||||
|
SetServerSelectionTimeout(3*time.Second))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Collector{client: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Name() string { return "mongodb" }
|
||||||
|
|
||||||
|
func (c *Collector) Close() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c.client.Disconnect(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Collect(window *aggregate.Window) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
admin := c.client.Database("admin")
|
||||||
|
|
||||||
|
var status bson.M
|
||||||
|
if err := admin.RunCommand(ctx, bson.D{
|
||||||
|
{Key: "serverStatus", Value: 1},
|
||||||
|
{Key: "repl", Value: 0},
|
||||||
|
{Key: "metrics", Value: 0},
|
||||||
|
{Key: "locks", Value: 0},
|
||||||
|
}).Decode(&status); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if conns, ok := status["connections"].(bson.M); ok {
|
||||||
|
current := toFloat64(conns["current"])
|
||||||
|
available := toFloat64(conns["available"])
|
||||||
|
total := current + available
|
||||||
|
if total > 0 {
|
||||||
|
window.AddDBConnPct(current / total * 100)
|
||||||
|
}
|
||||||
|
if current > 0 {
|
||||||
|
window.AddDBThreadsRunning(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var listResult bson.M
|
||||||
|
if err := admin.RunCommand(ctx, bson.D{
|
||||||
|
{Key: "listDatabases", Value: 1},
|
||||||
|
{Key: "nameOnly", Value: false},
|
||||||
|
}).Decode(&listResult); err == nil {
|
||||||
|
if totalSize := toFloat64(listResult["totalSize"]); totalSize > 0 {
|
||||||
|
window.SetDBSizeBytes(uint64(totalSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat64(v interface{}) float64 {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int32:
|
||||||
|
return float64(x)
|
||||||
|
case int64:
|
||||||
|
return float64(x)
|
||||||
|
case float64:
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
92
internal/collectors/mysql/mysql.go
Normal file
92
internal/collectors/mysql/mysql.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"bugs.watch/agent/internal/aggregate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collector queries MySQL global status each sample interval.
|
||||||
|
// All queries run under a 3-second timeout; any failure is silently skipped.
|
||||||
|
type Collector struct {
|
||||||
|
db *sql.DB
|
||||||
|
prevSlowQueries uint64
|
||||||
|
hasPrev bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(dsn string) (*Collector, error) {
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(2)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
return &Collector{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Name() string { return "mysql" }
|
||||||
|
|
||||||
|
func (c *Collector) Close() { c.db.Close() }
|
||||||
|
|
||||||
|
func (c *Collector) Collect(window *aggregate.Window) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := c.db.QueryContext(ctx,
|
||||||
|
"SHOW GLOBAL STATUS WHERE Variable_name IN ('Threads_connected','Threads_running','Slow_queries')",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
vars := make(map[string]uint64, 3)
|
||||||
|
for rows.Next() {
|
||||||
|
var name, raw string
|
||||||
|
if err := rows.Scan(&name, &raw); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseUint(raw, 10, 64); err == nil {
|
||||||
|
vars[name] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxConn uint64
|
||||||
|
if err := c.db.QueryRowContext(ctx, "SELECT @@max_connections").Scan(&maxConn); err != nil || maxConn == 0 {
|
||||||
|
maxConn = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
connPct := float64(vars["Threads_connected"]) / float64(maxConn) * 100
|
||||||
|
window.AddDBConnPct(connPct)
|
||||||
|
window.AddDBThreadsRunning(float64(vars["Threads_running"]))
|
||||||
|
|
||||||
|
slow := vars["Slow_queries"]
|
||||||
|
if c.hasPrev {
|
||||||
|
var delta uint64
|
||||||
|
if slow >= c.prevSlowQueries {
|
||||||
|
delta = slow - c.prevSlowQueries
|
||||||
|
}
|
||||||
|
window.AddDBSlowQueriesDelta(delta)
|
||||||
|
}
|
||||||
|
c.prevSlowQueries = slow
|
||||||
|
c.hasPrev = true
|
||||||
|
|
||||||
|
var sizeBytes uint64
|
||||||
|
err = c.db.QueryRowContext(ctx,
|
||||||
|
"SELECT COALESCE(SUM(data_length + index_length), 0)"+
|
||||||
|
" FROM information_schema.tables"+
|
||||||
|
" WHERE table_schema NOT IN ('information_schema','performance_schema','mysql','sys')",
|
||||||
|
).Scan(&sizeBytes)
|
||||||
|
if err == nil {
|
||||||
|
window.SetDBSizeBytes(sizeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
internal/collectors/postgres/postgres.go
Normal file
63
internal/collectors/postgres/postgres.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
|
"bugs.watch/agent/internal/aggregate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collector queries PostgreSQL status each sample interval.
|
||||||
|
// All queries run under a 3-second timeout; any failure is silently skipped.
|
||||||
|
type Collector struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(dsn string) (*Collector, error) {
|
||||||
|
db, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(2)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
return &Collector{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Name() string { return "postgres" }
|
||||||
|
|
||||||
|
func (c *Collector) Close() { c.db.Close() }
|
||||||
|
|
||||||
|
func (c *Collector) Collect(window *aggregate.Window) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// pg_stat_database is readable by any user for their own database.
|
||||||
|
var numBackends, maxConn int
|
||||||
|
err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT d.numbackends, s.setting::int
|
||||||
|
FROM pg_stat_database d, pg_settings s
|
||||||
|
WHERE d.datname = current_database() AND s.name = 'max_connections'`,
|
||||||
|
).Scan(&numBackends, &maxConn)
|
||||||
|
if err == nil && maxConn > 0 {
|
||||||
|
window.AddDBConnPct(float64(numBackends) / float64(maxConn) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg_stat_activity requires pg_monitor role (PG 10+); silently skip on error.
|
||||||
|
var activeQueries int
|
||||||
|
if err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM pg_stat_activity WHERE state = 'active'`,
|
||||||
|
).Scan(&activeQueries); err == nil {
|
||||||
|
window.AddDBThreadsRunning(float64(activeQueries))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sizeBytes int64
|
||||||
|
if err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT pg_database_size(current_database())`,
|
||||||
|
).Scan(&sizeBytes); err == nil {
|
||||||
|
window.SetDBSizeBytes(uint64(sizeBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/collectors/redis/redis.go
Normal file
78
internal/collectors/redis/redis.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package redis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"bugs.watch/agent/internal/aggregate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collector queries Redis INFO each sample interval.
|
||||||
|
// All queries run under a 3-second timeout; any failure is silently skipped.
|
||||||
|
// Metrics mapping:
|
||||||
|
// db_conn_pct → used_memory / maxmemory * 100 (skipped when maxmemory=0)
|
||||||
|
// db_threads_running → connected_clients
|
||||||
|
// db_size_bytes → used_memory
|
||||||
|
type Collector struct {
|
||||||
|
client *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(addr string) (*Collector, error) {
|
||||||
|
opts, err := redis.ParseURL(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opts.DialTimeout = 3 * time.Second
|
||||||
|
opts.ReadTimeout = 3 * time.Second
|
||||||
|
opts.WriteTimeout = 3 * time.Second
|
||||||
|
return &Collector{client: redis.NewClient(opts)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Name() string { return "redis" }
|
||||||
|
|
||||||
|
func (c *Collector) Close() { c.client.Close() }
|
||||||
|
|
||||||
|
func (c *Collector) Collect(window *aggregate.Window) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
info, err := c.client.Info(ctx, "memory", "clients").Result()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := parseInfo(info)
|
||||||
|
|
||||||
|
usedMem, _ := strconv.ParseUint(fields["used_memory"], 10, 64)
|
||||||
|
maxMem, _ := strconv.ParseUint(fields["maxmemory"], 10, 64)
|
||||||
|
clients, _ := strconv.ParseUint(fields["connected_clients"], 10, 64)
|
||||||
|
|
||||||
|
if usedMem > 0 {
|
||||||
|
window.SetDBSizeBytes(usedMem)
|
||||||
|
}
|
||||||
|
if clients > 0 {
|
||||||
|
window.AddDBThreadsRunning(float64(clients))
|
||||||
|
}
|
||||||
|
if maxMem > 0 && usedMem > 0 {
|
||||||
|
window.AddDBConnPct(float64(usedMem) / float64(maxMem) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInfo(s string) map[string]string {
|
||||||
|
out := make(map[string]string)
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(line, ":")
|
||||||
|
if ok {
|
||||||
|
out[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
30
internal/collectors/sqlite/sqlite.go
Normal file
30
internal/collectors/sqlite/sqlite.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"bugs.watch/agent/internal/aggregate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collector reports the on-disk size of a SQLite database file.
|
||||||
|
// No driver or connection is needed; we simply stat the file.
|
||||||
|
// Failures are silently skipped.
|
||||||
|
type Collector struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) *Collector {
|
||||||
|
return &Collector{path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Name() string { return "sqlite" }
|
||||||
|
|
||||||
|
func (c *Collector) Close() {}
|
||||||
|
|
||||||
|
func (c *Collector) Collect(window *aggregate.Window) {
|
||||||
|
info, err := os.Stat(c.path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.SetDBSizeBytes(uint64(info.Size()))
|
||||||
|
}
|
||||||
45
internal/config/config.go
Normal file
45
internal/config/config.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultDBConfigPath = "/etc/bugswatch/databases.json"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Endpoint string
|
||||||
|
AgentID string
|
||||||
|
APIToken string
|
||||||
|
HostID string
|
||||||
|
Env string
|
||||||
|
DBConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
dbConfig := os.Getenv("BUGSWATCH_DB_CONFIG")
|
||||||
|
if dbConfig == "" {
|
||||||
|
dbConfig = DefaultDBConfigPath
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Endpoint: os.Getenv("BUGSWATCH_ENDPOINT"),
|
||||||
|
AgentID: os.Getenv("BUGSWATCH_AGENT_ID"),
|
||||||
|
APIToken: os.Getenv("BUGSWATCH_API_TOKEN"),
|
||||||
|
HostID: os.Getenv("BUGSWATCH_HOST_ID"),
|
||||||
|
Env: os.Getenv("BUGSWATCH_ENVIRONMENT"),
|
||||||
|
DBConfigPath: dbConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Endpoint == "" {
|
||||||
|
return Config{}, errors.New("BUGSWATCH_ENDPOINT not set")
|
||||||
|
}
|
||||||
|
if cfg.AgentID == "" {
|
||||||
|
return Config{}, errors.New("BUGSWATCH_AGENT_ID not set")
|
||||||
|
}
|
||||||
|
if cfg.APIToken == "" {
|
||||||
|
return Config{}, errors.New("BUGSWATCH_API_TOKEN not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
203
internal/dbmanager/manager.go
Normal file
203
internal/dbmanager/manager.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package dbmanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bugs.watch/agent/internal/aggregate"
|
||||||
|
"bugs.watch/agent/internal/collectors"
|
||||||
|
mongocol "bugs.watch/agent/internal/collectors/mongodb"
|
||||||
|
mysqlcol "bugs.watch/agent/internal/collectors/mysql"
|
||||||
|
pgcol "bugs.watch/agent/internal/collectors/postgres"
|
||||||
|
rediscol "bugs.watch/agent/internal/collectors/redis"
|
||||||
|
sqlitecol "bugs.watch/agent/internal/collectors/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver type identifiers — used as the db_driver field in the health payload
|
||||||
|
// so the dashboard can render the correct icon without parsing strings.
|
||||||
|
const (
|
||||||
|
DriverNone uint8 = 0
|
||||||
|
DriverMySQL uint8 = 1 // also MariaDB
|
||||||
|
DriverPostgres uint8 = 2
|
||||||
|
DriverRedis uint8 = 3
|
||||||
|
DriverMongoDB uint8 = 4
|
||||||
|
DriverSQLite uint8 = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one database config record in databases.json.
|
||||||
|
type Entry struct {
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dbCollector interface {
|
||||||
|
collectors.Collector
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager watches a config file and hot-swaps DB collectors without agent restart.
|
||||||
|
// Collect() is safe to call concurrently with Watch().
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
active []dbCollector
|
||||||
|
configPath string
|
||||||
|
lastHash [16]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(configPath string) *Manager {
|
||||||
|
return &Manager{configPath: configPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch polls the config file every 30 seconds and reloads on change.
|
||||||
|
// Blocks until ctx is cancelled.
|
||||||
|
func (m *Manager) Watch(ctx context.Context) {
|
||||||
|
m.reload()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
m.swap(nil)
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect runs all active DB collectors against the window.
|
||||||
|
// Holds RLock for the full duration so swaps never race with in-progress queries.
|
||||||
|
func (m *Manager) Collect(window *aggregate.Window) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if len(m.active) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.SetDBDriver(m.driverID(m.active[0].Name()))
|
||||||
|
for _, c := range m.active {
|
||||||
|
c.Collect(window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) driverID(name string) uint8 {
|
||||||
|
switch name {
|
||||||
|
case "mysql", "mariadb":
|
||||||
|
return DriverMySQL
|
||||||
|
case "postgres":
|
||||||
|
return DriverPostgres
|
||||||
|
case "redis":
|
||||||
|
return DriverRedis
|
||||||
|
case "mongodb":
|
||||||
|
return DriverMongoDB
|
||||||
|
case "sqlite":
|
||||||
|
return DriverSQLite
|
||||||
|
default:
|
||||||
|
return DriverNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) reload() {
|
||||||
|
data, err := os.ReadFile(m.configPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
m.swap(nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := md5.Sum(data)
|
||||||
|
m.mu.RLock()
|
||||||
|
unchanged := hash == m.lastHash
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if unchanged {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
log.Printf("dbmanager: invalid config: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var next []dbCollector
|
||||||
|
for _, e := range entries {
|
||||||
|
c, err := newCollector(e)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("dbmanager: %s: %v", e.Driver, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, c)
|
||||||
|
log.Printf("dbmanager: loaded %s", e.Driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.swap(next)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.lastHash = hash
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) swap(next []dbCollector) {
|
||||||
|
m.mu.Lock()
|
||||||
|
old := m.active
|
||||||
|
m.active = next
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, c := range old {
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCollector(e Entry) (dbCollector, error) {
|
||||||
|
switch e.Driver {
|
||||||
|
case "mysql", "mariadb":
|
||||||
|
return mysqlcol.New(e.URL)
|
||||||
|
case "postgres", "postgresql":
|
||||||
|
return pgcol.New(e.URL)
|
||||||
|
case "redis":
|
||||||
|
return rediscol.New(e.URL)
|
||||||
|
case "mongodb":
|
||||||
|
return mongocol.New(e.URL)
|
||||||
|
case "sqlite":
|
||||||
|
return sqlitecol.New(e.URL), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown driver %q (supported: mysql, mariadb, postgres, redis, mongodb, sqlite)", e.Driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadEntries reads the config file, returning an empty slice if it doesn't exist.
|
||||||
|
func ReadEntries(configPath string) ([]Entry, error) {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var entries []Entry
|
||||||
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteEntries writes entries to the config file, creating the directory if needed.
|
||||||
|
func WriteEntries(configPath string, entries []Entry) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(entries, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(configPath, data, 0644)
|
||||||
|
}
|
||||||
@@ -7,20 +7,27 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"bugs.watch/agent/internal/config"
|
||||||
"bugs.watch/agent/internal/aggregate"
|
"bugs.watch/agent/internal/aggregate"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTTPSender struct {
|
type HTTPSender struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
endpoint string
|
endpoint string
|
||||||
|
token string
|
||||||
|
hostID string
|
||||||
|
env string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPSender(endpoint string) *HTTPSender {
|
func NewHTTPSender(cfg config.Config) *HTTPSender {
|
||||||
return &HTTPSender{
|
return &HTTPSender{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
endpoint: endpoint,
|
endpoint: cfg.Endpoint + "/api/v1/agents/" + cfg.AgentID,
|
||||||
|
token: cfg.APIToken,
|
||||||
|
hostID: cfg.HostID,
|
||||||
|
env: cfg.Env,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +38,13 @@ func (s *HTTPSender) Send(ctx context.Context, snap aggregate.Snapshot) error {
|
|||||||
MemAvg: snap.MemAvg,
|
MemAvg: snap.MemAvg,
|
||||||
DiskAvg: snap.DiskAvg,
|
DiskAvg: snap.DiskAvg,
|
||||||
Uptime: snap.Uptime,
|
Uptime: snap.Uptime,
|
||||||
|
HostID: s.hostID,
|
||||||
|
Environment: s.env,
|
||||||
|
DBDriver: snap.DBDriver,
|
||||||
|
DBConnPct: snap.DBConnPct,
|
||||||
|
DBThreadsRunning: snap.DBThreadsRunning,
|
||||||
|
DBSlowQueries: snap.DBSlowQueries,
|
||||||
|
DBSizeBytes: snap.DBSizeBytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
@@ -40,7 +54,7 @@ func (s *HTTPSender) Send(ctx context.Context, snap aggregate.Snapshot) error {
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
req, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPut,
|
||||||
s.endpoint,
|
s.endpoint,
|
||||||
bytes.NewReader(body),
|
bytes.NewReader(body),
|
||||||
)
|
)
|
||||||
@@ -49,6 +63,7 @@ func (s *HTTPSender) Send(ctx context.Context, snap aggregate.Snapshot) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer " + s.token)
|
||||||
|
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ type SnapshotPayload struct {
|
|||||||
CPUAvg float64 `json:"cpu_avg"`
|
CPUAvg float64 `json:"cpu_avg"`
|
||||||
MemAvg float64 `json:"mem_avg"`
|
MemAvg float64 `json:"mem_avg"`
|
||||||
DiskAvg float64 `json:"disk_avg"`
|
DiskAvg float64 `json:"disk_avg"`
|
||||||
|
|
||||||
Uptime uint64 `json:"uptime"`
|
Uptime uint64 `json:"uptime"`
|
||||||
|
|
||||||
|
HostID string `json:"host_id,omitempty"`
|
||||||
|
Environment string `json:"environment,omitempty"`
|
||||||
|
|
||||||
|
// Optional — only present when DB monitoring is enabled.
|
||||||
|
// DBDriver is a numeric identifier: 1=mysql/mariadb 2=postgres 3=redis 4=mongodb 5=sqlite
|
||||||
|
DBDriver *uint8 `json:"db_driver,omitempty"`
|
||||||
|
DBConnPct *float64 `json:"db_conn_pct,omitempty"`
|
||||||
|
DBThreadsRunning *float64 `json:"db_threads_running,omitempty"`
|
||||||
|
DBSlowQueries *uint64 `json:"db_slow_queries,omitempty"`
|
||||||
|
DBSizeBytes *uint64 `json:"db_size_bytes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
9
package.json
Normal file
9
package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "bugswatch-agent",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"release": "bun version patch && git push && git push --tags",
|
||||||
|
"release:minor": "bun version minor && git push && git push --tags",
|
||||||
|
"release:major": "bun version major && git push && git push --tags"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user