prometheus

This commit is contained in:
JurajKubrican
2025-08-15 14:55:59 +02:00
parent fecbe91f5d
commit 45aa362789
6 changed files with 189 additions and 866 deletions

View File

@@ -8,9 +8,12 @@ import (
"strings"
"time"
"github.com/labstack/echo-contrib/prometheus"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/labstack/gommon/log"
promclient "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"knet.sk/src/boxes"
"knet.sk/src/draw"
"knet.sk/src/util"
@@ -46,26 +49,66 @@ func newPage(boxes []boxes.Box) Page {
var (
e = echo.New()
// Custom Prometheus metrics
boxClicks = promclient.NewCounterVec(
promclient.CounterOpts{
Name: "knet_box_clicks_total",
Help: "Total number of box clicks by position",
},
[]string{"position"},
)
activeConnections = promclient.NewGauge(
promclient.GaugeOpts{
Name: "knet_websocket_connections",
Help: "Current number of active WebSocket connections",
},
)
pageViews = promclient.NewCounterVec(
promclient.CounterOpts{
Name: "knet_page_views_total",
Help: "Total page views by route",
},
[]string{"route"},
)
)
func init() {
// Register custom metrics
promclient.MustRegister(boxClicks)
promclient.MustRegister(activeConnections)
promclient.MustRegister(pageViews)
}
func main() {
e.Renderer = NewTemplates()
// Initialize tracking service
trackingService, err := util.NewTrackingService("data/tracking.db")
if err != nil {
e.Logger.Fatal("Failed to initialize tracking service:", err)
}
defer trackingService.Close()
// Setup Prometheus metrics
p := prometheus.NewPrometheus("knet", nil)
p.Use(e)
e.Logger.SetLevel(log.DEBUG)
e.Use(middleware.Logger())
e.Use(trackingService.TrackingMiddleware())
if util.IsProd() {
e.Use(middleware.Gzip())
e.Use(middleware.HTTPSRedirect())
}
// Prometheus metrics endpoint (standard /metrics)
metricsGroup := e.Group("/metrics")
if util.IsProd() {
metricsGroup.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
expectedToken := os.Getenv("API_TOKEN")
if expectedToken == "" {
return false, nil
}
return username == "api" && password == expectedToken, nil
}))
}
metricsGroup.GET("", echo.WrapHandler(promhttp.Handler()))
e.GET("/health", func(c echo.Context) error {
return c.Render(200, "health", Page{
BuildNumber: util.GetBuildNumber(),
@@ -88,62 +131,19 @@ func main() {
e.Static("/images", "images")
e.GET("/", func(c echo.Context) error {
// Track page view
pageViews.WithLabelValues("/").Inc()
return c.Render(200, "index", newPage(boxes.GetBoxes()))
})
// Tracking API endpoints (protected by basic auth if needed)
api := e.Group("/api")
if util.IsProd() {
api.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
// Get expected credentials from environment
expectedToken := os.Getenv("API_TOKEN")
if expectedToken == "" {
return false, nil // No token configured
}
// Check credentials
return username == "api" && password == expectedToken, nil
}))
}
// Grafana-compatible endpoints
api.GET("/metrics", trackingService.GetMetrics)
api.GET("/tracking/timeseries", trackingService.GetTimeSeriesData)
api.POST("/tracking/query", trackingService.GetGrafanaQuery)
// Prometheus-compatible endpoints for Grafana
prometheusAPI := e.Group("/api/v1")
if util.IsProd() {
prometheusAPI.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
expectedToken := os.Getenv("API_TOKEN")
if expectedToken == "" {
return false, nil
}
return username == "api" && password == expectedToken, nil
}))
}
// Prometheus API endpoints that Grafana expects
prometheusAPI.GET("/status/buildinfo", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"data": map[string]interface{}{
"version": "knet-tracking-1.0",
"revision": "main",
"branch": "main",
"buildUser": "knet",
"buildDate": "2025-08-15",
"goVersion": "go1.24.0",
},
})
})
prometheusAPI.POST("/query", trackingService.GetPrometheusQuery)
prometheusAPI.POST("/query_range", trackingService.GetPrometheusQueryRange)
e.GET("/boxes/ws", boxes.HandleBoxesWs)
e.GET("/draw", draw.Page)
e.GET("/draw", func(c echo.Context) error {
// Track page view
pageViews.WithLabelValues("/draw").Inc()
return draw.Page(c)
})
e.GET("/draw/ws", draw.InitWs)
e.GET("/draw/ws", draw.InitWs)
go boxes.RegisterTicker()

20
src/metrics.go Normal file
View File

@@ -0,0 +1,20 @@
package main
import (
"github.com/prometheus/client_golang/prometheus"
)
// GetActiveConnections returns the current number of active WebSocket connections
// This function will be called by Prometheus to get the current gauge value
func GetActiveConnections() prometheus.GaugeFunc {
return prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "knet_websocket_connections_active",
Help: "Current number of active WebSocket connections",
},
func() float64 {
// This will be implemented to call boxes.GetConnectionCount()
return 0 // Placeholder for now
},
)
}

View File

@@ -1,633 +0,0 @@
package util
import (
"crypto/md5"
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
_ "modernc.org/sqlite"
)
type TrackingService struct {
db *sql.DB
}
type UserVisit struct {
ID int `json:"id" db:"id"`
UserHash string `json:"user_hash" db:"user_hash"`
Endpoint string `json:"endpoint" db:"endpoint"`
Method string `json:"method" db:"method"`
IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"`
Referer string `json:"referer" db:"referer"`
Timestamp time.Time `json:"timestamp" db:"timestamp"`
}
func NewTrackingService(dbPath string) (*TrackingService, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open tracking database: %w", err)
}
service := &TrackingService{db: db}
if err := service.initDB(); err != nil {
return nil, fmt.Errorf("failed to initialize tracking database: %w", err)
}
return service, nil
}
func (ts *TrackingService) initDB() error {
query := `
CREATE TABLE IF NOT EXISTS user_visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_hash TEXT NOT NULL,
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
referer TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_hash ON user_visits(user_hash);
CREATE INDEX IF NOT EXISTS idx_endpoint_method ON user_visits(endpoint, method);
CREATE INDEX IF NOT EXISTS idx_timestamp ON user_visits(timestamp);
CREATE INDEX IF NOT EXISTS idx_endpoint_timestamp ON user_visits(endpoint, timestamp);
`
_, err := ts.db.Exec(query)
return err
}
func (ts *TrackingService) generateUserHash(c echo.Context) string {
// Create a hash from IP + User-Agent for basic user identification
ip := c.RealIP()
userAgent := c.Request().UserAgent()
// Combine IP and User-Agent for a basic fingerprint
fingerprint := fmt.Sprintf("%s|%s", ip, userAgent)
// MD5 hash for privacy (not cryptographically secure but fine for tracking)
hash := md5.Sum([]byte(fingerprint))
return fmt.Sprintf("%x", hash)
}
func (ts *TrackingService) shouldTrack(path string) bool {
// Skip tracking for certain endpoints
skipPrefixes := []string{
"/css/",
"/js/",
"/images/",
"/swagger/",
"/favicon",
}
for _, prefix := range skipPrefixes {
if strings.HasPrefix(path, prefix) {
return false
}
}
return true
}
func (ts *TrackingService) TrackingMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Execute the request first
err := next(c)
// Only track successful requests and trackable paths
if c.Response().Status < 400 && ts.shouldTrack(c.Request().URL.Path) {
go ts.trackRequest(c)
}
return err
}
}
}
func (ts *TrackingService) trackRequest(c echo.Context) {
endpoint := c.Request().URL.Path
method := c.Request().Method
userHash := ts.generateUserHash(c)
// Record the visit - this is all we need now
ts.recordVisit(userHash, endpoint, method, c)
}
func (ts *TrackingService) recordVisit(userHash, endpoint, method string, c echo.Context) error {
query := `
INSERT INTO user_visits (user_hash, endpoint, method, ip_address, user_agent, referer)
VALUES (?, ?, ?, ?, ?, ?)
`
_, err := ts.db.Exec(query,
userHash,
endpoint,
method,
c.RealIP(),
c.Request().UserAgent(),
c.Request().Referer(),
)
return err
}
func (ts *TrackingService) Close() error {
return ts.db.Close()
}
// Prometheus-compatible endpoints for Grafana
// GetPrometheusQuery handles Prometheus /api/v1/query endpoint
func (ts *TrackingService) GetPrometheusQuery(c echo.Context) error {
query := c.FormValue("query")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]interface{}{
"status": "error",
"error": "query parameter is required",
})
}
// Handle different metric queries
var value float64
var metricName string
switch query {
case "http_requests_total":
metricName = "http_requests_total"
var count int
ts.db.QueryRow("SELECT COUNT(*) FROM user_visits WHERE timestamp >= datetime('now', '-1 day')").Scan(&count)
value = float64(count)
case "http_unique_users_total":
metricName = "http_unique_users_total"
var count int
ts.db.QueryRow("SELECT COUNT(DISTINCT user_hash) FROM user_visits WHERE timestamp >= datetime('now', '-1 day')").Scan(&count)
value = float64(count)
case "http_unique_endpoints_total":
metricName = "http_unique_endpoints_total"
var count int
ts.db.QueryRow("SELECT COUNT(DISTINCT endpoint) FROM user_visits WHERE timestamp >= datetime('now', '-1 day')").Scan(&count)
value = float64(count)
default:
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"data": map[string]interface{}{
"resultType": "vector",
"result": []interface{}{},
},
})
}
// Return Prometheus-style response
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"data": map[string]interface{}{
"resultType": "vector",
"result": []map[string]interface{}{
{
"metric": map[string]string{
"__name__": metricName,
},
"value": []interface{}{
time.Now().Unix(),
fmt.Sprintf("%.0f", value),
},
},
},
},
})
}
// GetPrometheusQueryRange handles Prometheus /api/v1/query_range endpoint
func (ts *TrackingService) GetPrometheusQueryRange(c echo.Context) error {
query := c.FormValue("query")
start := c.FormValue("start") // Unix timestamp
end := c.FormValue("end") // Unix timestamp
step := c.FormValue("step") // Duration like "15s", "1m", "5m"
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]interface{}{
"status": "error",
"error": "query parameter is required",
})
}
// Parse step duration (default to 1 minute)
stepSeconds := 60 // Default 1 minute
if step != "" {
if strings.HasSuffix(step, "s") {
if s, err := fmt.Sscanf(step, "%ds", &stepSeconds); s != 1 || err != nil {
stepSeconds = 60
}
} else if strings.HasSuffix(step, "m") {
var minutes int
if s, err := fmt.Sscanf(step, "%dm", &minutes); s == 1 && err == nil {
stepSeconds = minutes * 60
}
}
}
// Build time condition
var timeCondition string
if start != "" && end != "" {
timeCondition = fmt.Sprintf("WHERE timestamp BETWEEN datetime(%s, 'unixepoch') AND datetime(%s, 'unixepoch')", start, end)
} else {
timeCondition = "WHERE timestamp >= datetime('now', '-24 hours')"
}
// Determine what metric to query
var sqlQuery string
var metricName string
switch query {
case "http_requests_total":
metricName = "http_requests_total"
sqlQuery = fmt.Sprintf(`
SELECT
strftime('%%s', strftime('%%Y-%%m-%%d %%H:%%M:00', timestamp)) as ts,
COUNT(*) as value
FROM user_visits
%s
GROUP BY strftime('%%Y-%%m-%%d %%H:%%M:00', timestamp)
ORDER BY ts
`, timeCondition)
case "http_unique_users_total":
metricName = "http_unique_users_total"
sqlQuery = fmt.Sprintf(`
SELECT
strftime('%%s', strftime('%%Y-%%m-%%d %%H:%%M:00', timestamp)) as ts,
COUNT(DISTINCT user_hash) as value
FROM user_visits
%s
GROUP BY strftime('%%Y-%%m-%%d %%H:%%M:00', timestamp)
ORDER BY ts
`, timeCondition)
default:
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"data": map[string]interface{}{
"resultType": "matrix",
"result": []interface{}{},
},
})
}
rows, err := ts.db.Query(sqlQuery)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"status": "error",
"error": "Failed to execute query",
})
}
defer rows.Close()
var values [][]interface{}
for rows.Next() {
var timestamp int64
var value int
err := rows.Scan(&timestamp, &value)
if err != nil {
continue
}
values = append(values, []interface{}{timestamp, fmt.Sprintf("%d", value)})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "success",
"data": map[string]interface{}{
"resultType": "matrix",
"result": []map[string]interface{}{
{
"metric": map[string]string{
"__name__": metricName,
},
"values": values,
},
},
},
})
}
// Grafana-compatible endpoints below
// GetMetrics returns Prometheus-style metrics for Grafana
func (ts *TrackingService) GetMetrics(c echo.Context) error {
period := c.QueryParam("period")
if period == "" {
period = "24h"
}
var timeCondition string
switch period {
case "1h":
timeCondition = "WHERE timestamp >= datetime('now', '-1 hour')"
case "24h":
timeCondition = "WHERE timestamp >= datetime('now', '-1 day')"
case "7d":
timeCondition = "WHERE timestamp >= datetime('now', '-7 days')"
case "30d":
timeCondition = "WHERE timestamp >= datetime('now', '-30 days')"
default:
timeCondition = "WHERE timestamp >= datetime('now', '-1 day')"
}
// Get basic metrics
var totalHits, uniqueUsers, uniqueEndpoints int
query := fmt.Sprintf("SELECT COUNT(*) FROM user_visits %s", timeCondition)
ts.db.QueryRow(query).Scan(&totalHits)
query = fmt.Sprintf("SELECT COUNT(DISTINCT user_hash) FROM user_visits %s", timeCondition)
ts.db.QueryRow(query).Scan(&uniqueUsers)
query = fmt.Sprintf("SELECT COUNT(DISTINCT endpoint) FROM user_visits %s", timeCondition)
ts.db.QueryRow(query).Scan(&uniqueEndpoints)
// Return Prometheus-style metrics
response := fmt.Sprintf(`# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total %d
# HELP http_unique_users_total Unique users
# TYPE http_unique_users_total gauge
http_unique_users_total %d
# HELP http_unique_endpoints_total Unique endpoints accessed
# TYPE http_unique_endpoints_total gauge
http_unique_endpoints_total %d
`, totalHits, uniqueUsers, uniqueEndpoints)
return c.String(http.StatusOK, response)
}
// GetTimeSeriesData returns time-series data for Grafana charts
func (ts *TrackingService) GetTimeSeriesData(c echo.Context) error {
target := c.QueryParam("target") // What to measure: hits, users, endpoints
from := c.QueryParam("from") // Start time (unix timestamp or relative)
to := c.QueryParam("to") // End time
interval := c.QueryParam("interval") // Time interval: 1m, 5m, 1h, 1d
if target == "" {
target = "hits"
}
if interval == "" {
interval = "1h"
}
// Convert interval to SQLite format
var timeFormat string
switch interval {
case "1m":
timeFormat = "strftime('%Y-%m-%d %H:%M', timestamp)"
case "5m":
timeFormat = "strftime('%Y-%m-%d %H:%M', datetime(timestamp, 'unixepoch', 'start of minute', '+' || (strftime('%M', timestamp) / 5) * 5 || ' minutes'))"
case "1h":
timeFormat = "strftime('%Y-%m-%d %H:00', timestamp)"
case "1d":
timeFormat = "strftime('%Y-%m-%d', timestamp)"
default:
timeFormat = "strftime('%Y-%m-%d %H:00', timestamp)"
}
// Build time condition
var timeCondition string
if from != "" && to != "" {
timeCondition = fmt.Sprintf("WHERE timestamp BETWEEN datetime('%s') AND datetime('%s')", from, to)
} else {
timeCondition = "WHERE timestamp >= datetime('now', '-24 hours')"
}
// Build query based on target
var selectClause string
switch target {
case "hits":
selectClause = "COUNT(*) as value"
case "users":
selectClause = "COUNT(DISTINCT user_hash) as value"
case "endpoints":
selectClause = "COUNT(DISTINCT endpoint) as value"
default:
selectClause = "COUNT(*) as value"
}
query := fmt.Sprintf(`
SELECT
%s as time_bucket,
%s,
strftime('%%s', %s) * 1000 as timestamp_ms
FROM user_visits
%s
GROUP BY time_bucket
ORDER BY timestamp_ms ASC
`, timeFormat, selectClause, timeFormat, timeCondition)
rows, err := ts.db.Query(query)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "Failed to retrieve time series data",
})
}
defer rows.Close()
type DataPoint struct {
Target string `json:"target"`
DataPoints [][2]interface{} `json:"datapoints"` // [value, timestamp_ms]
}
var dataPoints [][2]interface{}
for rows.Next() {
var timeBucket string
var value int
var timestampMs int64
err := rows.Scan(&timeBucket, &value, &timestampMs)
if err != nil {
continue
}
dataPoints = append(dataPoints, [2]interface{}{value, timestampMs})
}
response := []DataPoint{
{
Target: target,
DataPoints: dataPoints,
},
}
return c.JSON(http.StatusOK, response)
}
// GetGrafanaQuery handles Grafana's query format
func (ts *TrackingService) GetGrafanaQuery(c echo.Context) error {
// This endpoint mimics Grafana's SimpleJSON datasource format
var request struct {
PanelId int `json:"panelId"`
Range struct {
From string `json:"from"`
To string `json:"to"`
} `json:"range"`
RangeRaw struct {
From string `json:"from"`
To string `json:"to"`
} `json:"rangeRaw"`
Interval string `json:"interval"`
IntervalMs int `json:"intervalMs"`
Targets []struct {
Target string `json:"target"`
RefId string `json:"refId"`
Type string `json:"type"`
} `json:"targets"`
Format string `json:"format"`
MaxDataPoints int `json:"maxDataPoints"`
}
if err := c.Bind(&request); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid request format",
})
}
var results []interface{}
for _, target := range request.Targets {
// Parse the target to determine what to query
// Format: "metric_name.endpoint_filter.method_filter"
parts := strings.Split(target.Target, ".")
metricName := parts[0]
var timeCondition string
if request.Range.From != "" && request.Range.To != "" {
timeCondition = fmt.Sprintf("WHERE timestamp BETWEEN '%s' AND '%s'",
request.Range.From, request.Range.To)
} else {
timeCondition = "WHERE timestamp >= datetime('now', '-24 hours')"
}
switch metricName {
case "http_requests":
results = append(results, ts.getRequestsTimeSeries(timeCondition, target.RefId))
case "unique_users":
results = append(results, ts.getUniqueUsersTimeSeries(timeCondition, target.RefId))
case "top_endpoints":
results = append(results, ts.getTopEndpoints(timeCondition, target.RefId))
}
}
return c.JSON(http.StatusOK, results)
}
// Helper functions for Grafana queries
func (ts *TrackingService) getRequestsTimeSeries(timeCondition, refId string) map[string]interface{} {
query := fmt.Sprintf(`
SELECT
strftime('%%Y-%%m-%%d %%H:00', timestamp) as hour,
COUNT(*) as requests,
strftime('%%s', strftime('%%Y-%%m-%%d %%H:00', timestamp)) * 1000 as timestamp_ms
FROM user_visits
%s
GROUP BY hour
ORDER BY timestamp_ms ASC
`, timeCondition)
rows, _ := ts.db.Query(query)
defer rows.Close()
var dataPoints [][2]interface{}
for rows.Next() {
var hour string
var requests int
var timestampMs int64
rows.Scan(&hour, &requests, &timestampMs)
dataPoints = append(dataPoints, [2]interface{}{requests, timestampMs})
}
return map[string]interface{}{
"target": "HTTP Requests",
"refId": refId,
"datapoints": dataPoints,
}
}
func (ts *TrackingService) getUniqueUsersTimeSeries(timeCondition, refId string) map[string]interface{} {
query := fmt.Sprintf(`
SELECT
strftime('%%Y-%%m-%%d %%H:00', timestamp) as hour,
COUNT(DISTINCT user_hash) as users,
strftime('%%s', strftime('%%Y-%%m-%%d %%H:00', timestamp)) * 1000 as timestamp_ms
FROM user_visits
%s
GROUP BY hour
ORDER BY timestamp_ms ASC
`, timeCondition)
rows, _ := ts.db.Query(query)
defer rows.Close()
var dataPoints [][2]interface{}
for rows.Next() {
var hour string
var users int
var timestampMs int64
rows.Scan(&hour, &users, &timestampMs)
dataPoints = append(dataPoints, [2]interface{}{users, timestampMs})
}
return map[string]interface{}{
"target": "Unique Users",
"refId": refId,
"datapoints": dataPoints,
}
}
func (ts *TrackingService) getTopEndpoints(timeCondition, refId string) map[string]interface{} {
query := fmt.Sprintf(`
SELECT
endpoint,
COUNT(*) as hits
FROM user_visits
%s
GROUP BY endpoint
ORDER BY hits DESC
LIMIT 10
`, timeCondition)
rows, _ := ts.db.Query(query)
defer rows.Close()
type EndpointData struct {
Endpoint string `json:"endpoint"`
Hits int `json:"hits"`
}
var endpoints []EndpointData
for rows.Next() {
var endpoint string
var hits int
rows.Scan(&endpoint, &hits)
endpoints = append(endpoints, EndpointData{Endpoint: endpoint, Hits: hits})
}
return map[string]interface{}{
"target": "Top Endpoints",
"refId": refId,
"type": "table",
"columns": []map[string]string{
{"text": "Endpoint", "type": "string"},
{"text": "Hits", "type": "number"},
},
"rows": endpoints,
}
}