diff --git a/app/services/metrics/metrics.go b/app/services/metrics/metrics.go index 116ed4f..d281be0 100644 --- a/app/services/metrics/metrics.go +++ b/app/services/metrics/metrics.go @@ -341,7 +341,7 @@ func (mp *metricspusher) Push(cred credential.Credential) error { } } - // ── Traefik metrics (aggregate per entrypoint) ────────────────────────────── + // ── Traefik metrics (HTTP requests / response time / error rate per entrypoint) ── traefikSets, err := mp.traefikcollector.Collect(ctx) if err != nil { diff --git a/domain/metrics/metrics.go b/domain/metrics/metrics.go index 4dfe69b..7d0f4f2 100644 --- a/domain/metrics/metrics.go +++ b/domain/metrics/metrics.go @@ -104,16 +104,19 @@ type StorageAttributes struct { } type MySQLDatabaseMetrics struct { - Up bool `json:"up"` - ThreadsConnected int `json:"threads_connected"` - ThreadsRunning int `json:"threads_running"` - MaxConnections int `json:"max_connections"` - MaxUsedConnections int64 `json:"max_used_connections"` - QueriesPerSecond float64 `json:"queries_per_second"` - SlowQueries int64 `json:"slow_queries"` - InnoDBCacheHitRatio float64 `json:"innodb_cache_hit_ratio"` - ReplicationLagSeconds *int `json:"replication_lag_seconds,omitempty"` - ReplicationConnected *bool `json:"replication_connected,omitempty"` + Up bool `json:"up"` + ConnectionsTotal int `json:"connections_total"` + ConnectionsAborted int64 `json:"connections_aborted"` + MaxConnections int `json:"max_connections"` + ThreadsRunning int `json:"threads_running"` + QueriesPerSecond float64 `json:"queries_per_second"` + SlowQueriesPerSecond float64 `json:"slow_queries_per_second"` + InnoDBBufferPoolHitRatio float64 `json:"innodb_buffer_pool_hit_ratio"` + InnoDBRowLockWaitsPerSecond float64 `json:"innodb_row_lock_waits_per_second"` + TmpDiskTablesPerSecond float64 `json:"tmp_disk_tables_per_second"` + SelectFullScansPerSecond float64 `json:"select_full_scans_per_second"` + ReplicationLagSeconds *int `json:"replication_lag_seconds,omitempty"` + ReplicationConnected *bool `json:"replication_connected,omitempty"` } type MongoDBMetrics struct { @@ -204,9 +207,9 @@ type TraefikRouterMetrics struct { } type TraefikRouterAttributes struct { - RouterName string `json:"router_name"` + RouterName string `json:"router_name"` EntrypointName string `json:"entrypoint_name"` - Service string `json:"service,omitempty"` + Service string `json:"service,omitempty"` } type ContainerAttributes struct { diff --git a/internal/mysqlmetrics/collector.go b/internal/mysqlmetrics/collector.go index 8ba9de0..b7348a9 100644 --- a/internal/mysqlmetrics/collector.go +++ b/internal/mysqlmetrics/collector.go @@ -20,9 +20,13 @@ type Collector interface { } type mysqlCollector struct { - queryTimeout time.Duration - lastQueries *int64 - lastTime time.Time + queryTimeout time.Duration + lastQueries *int64 + lastSlowQueries *int64 + lastRowLockWaits *int64 + lastTmpDiskTables *int64 + lastSelectFullJoins *int64 + lastTime time.Time } func New() Collector { @@ -73,9 +77,10 @@ func (mc *mysqlCollector) Collect(cred credential.Credential) (metrics.MySQLData func (mc *mysqlCollector) collectGlobalStatus(ctx context.Context, db *sql.DB, m *metrics.MySQLDatabaseMetrics) error { rows, err := db.QueryContext(ctx, ` SHOW GLOBAL STATUS WHERE Variable_name IN ( - 'Threads_connected','Threads_running','Max_used_connections', - 'Queries','Slow_queries', - 'Innodb_buffer_pool_read_requests','Innodb_buffer_pool_reads' + 'Threads_connected','Threads_running', + 'Queries','Slow_queries','Aborted_connects', + 'Innodb_buffer_pool_read_requests','Innodb_buffer_pool_reads', + 'Innodb_row_lock_waits','Created_tmp_disk_tables','Select_full_join' ) `) if err != nil { @@ -95,27 +100,38 @@ func (mc *mysqlCollector) collectGlobalStatus(ctx context.Context, db *sql.DB, m return err } - m.ThreadsConnected = parseInt(status["Threads_connected"]) + m.ConnectionsTotal = parseInt(status["Threads_connected"]) m.ThreadsRunning = parseInt(status["Threads_running"]) - m.MaxUsedConnections = parseInt64(status["Max_used_connections"]) - m.SlowQueries = parseInt64(status["Slow_queries"]) + m.ConnectionsAborted = parseInt64(status["Aborted_connects"]) readReqs := parseFloat64(status["Innodb_buffer_pool_read_requests"]) diskReads := parseFloat64(status["Innodb_buffer_pool_reads"]) if readReqs > 0 { - m.InnoDBCacheHitRatio = (readReqs - diskReads) / readReqs * 100 + m.InnoDBBufferPoolHitRatio = (readReqs - diskReads) / readReqs * 100 } - // Delta-based QPS using cumulative Queries counter + // Delta-based rates using cumulative counters currentQueries := parseInt64(status["Queries"]) + currentSlowQueries := parseInt64(status["Slow_queries"]) + currentRowLockWaits := parseInt64(status["Innodb_row_lock_waits"]) + currentTmpDiskTables := parseInt64(status["Created_tmp_disk_tables"]) + currentSelectFullJoins := parseInt64(status["Select_full_join"]) now := time.Now() if mc.lastQueries != nil { elapsed := now.Sub(mc.lastTime).Seconds() if elapsed > 0 { m.QueriesPerSecond = float64(currentQueries-*mc.lastQueries) / elapsed + m.SlowQueriesPerSecond = float64(currentSlowQueries-*mc.lastSlowQueries) / elapsed + m.InnoDBRowLockWaitsPerSecond = float64(currentRowLockWaits-*mc.lastRowLockWaits) / elapsed + m.TmpDiskTablesPerSecond = float64(currentTmpDiskTables-*mc.lastTmpDiskTables) / elapsed + m.SelectFullScansPerSecond = float64(currentSelectFullJoins-*mc.lastSelectFullJoins) / elapsed } } mc.lastQueries = ¤tQueries + mc.lastSlowQueries = ¤tSlowQueries + mc.lastRowLockWaits = ¤tRowLockWaits + mc.lastTmpDiskTables = ¤tTmpDiskTables + mc.lastSelectFullJoins = ¤tSelectFullJoins mc.lastTime = now return nil diff --git a/internal/mysqlmetrics/collector_test.go b/internal/mysqlmetrics/collector_test.go new file mode 100644 index 0000000..1889c49 --- /dev/null +++ b/internal/mysqlmetrics/collector_test.go @@ -0,0 +1,89 @@ +package mysqlmetrics + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDeltaRates_FirstCollectionReturnsZero(t *testing.T) { + mc := &mysqlCollector{queryTimeout: 10 * time.Second} + + assert.Nil(t, mc.lastQueries, "lastQueries should be nil before first collection") + assert.Nil(t, mc.lastSlowQueries, "lastSlowQueries should be nil before first collection") +} + +func TestDeltaRates_SecondCollectionReturnsRates(t *testing.T) { + baseTime := time.Now() + initialQueries := int64(1000) + initialSlowQueries := int64(10) + + mc := &mysqlCollector{ + queryTimeout: 10 * time.Second, + lastQueries: &initialQueries, + lastSlowQueries: &initialSlowQueries, + lastTime: baseTime, + } + + // Simulate 10 seconds elapsed, 500 new queries, 5 new slow queries + elapsed := 10 * time.Second + currentQueries := int64(1500) + currentSlowQueries := int64(15) + now := baseTime.Add(elapsed) + + elapsedSec := now.Sub(mc.lastTime).Seconds() + qps := float64(currentQueries-*mc.lastQueries) / elapsedSec + sqps := float64(currentSlowQueries-*mc.lastSlowQueries) / elapsedSec + + assert.Equal(t, 50.0, qps, "QPS should be 500 queries / 10s = 50") + assert.Equal(t, 0.5, sqps, "slow QPS should be 5 queries / 10s = 0.5") +} + +func TestDeltaRates_ZeroElapsedReturnsZero(t *testing.T) { + baseTime := time.Now() + initialQueries := int64(1000) + initialSlowQueries := int64(10) + + mc := &mysqlCollector{ + queryTimeout: 10 * time.Second, + lastQueries: &initialQueries, + lastSlowQueries: &initialSlowQueries, + lastTime: baseTime, + } + + elapsed := mc.lastTime.Sub(baseTime).Seconds() + assert.Equal(t, 0.0, elapsed, "elapsed should be 0 when time hasn't advanced") +} + +func TestInnoDBBufferPoolHitRatio_Calculation(t *testing.T) { + readReqs := 10000.0 + diskReads := 100.0 + + ratio := (readReqs - diskReads) / readReqs * 100 + assert.Equal(t, 99.0, ratio, "hit ratio should be 99% when 100 of 10000 reads hit disk") +} + +func TestInnoDBBufferPoolHitRatio_ZeroRequests(t *testing.T) { + readReqs := 0.0 + diskReads := 0.0 + + var ratio float64 + if readReqs > 0 { + ratio = (readReqs - diskReads) / readReqs * 100 + } + assert.Equal(t, 0.0, ratio, "hit ratio should be 0 when there are no read requests") +} + +func TestParseInt_ValidValues(t *testing.T) { + assert.Equal(t, 42, parseInt("42")) + assert.Equal(t, 0, parseInt("0")) + assert.Equal(t, 0, parseInt("")) + assert.Equal(t, 0, parseInt("invalid")) +} + +func TestParseInt64_ValidValues(t *testing.T) { + assert.Equal(t, int64(1234567890), parseInt64("1234567890")) + assert.Equal(t, int64(0), parseInt64("")) + assert.Equal(t, int64(0), parseInt64("invalid")) +}