Добавлены метрики Prometheus и дашборды Grafana (этап 2.5)
Все три Go-сервиса (ingest, device-control, rule-engine) отдают /metrics в формате Prometheus: счётчики MQTT/RabbitMQ/ClickHouse операций, гистограммы длительности батч-флашей и диспатча правил, переходы устройств online/offline. Добавлены сервисы prometheus и grafana в docker-compose с провижининг конфигом (datasource + два готовых дашборда: "Ingest & Telemetry" и "Automation & Devices").
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// Package metrics defines device-control-service's Prometheus metrics.
|
||||
// Registered automatically (via promauto) into the default registry on
|
||||
// import; served alongside the command HTTP API's /metrics route.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
CommandsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "device_control_commands_total",
|
||||
Help: "Total number of TurnOn/TurnOff/SetLevel commands dispatched, by action and outcome.",
|
||||
}, []string{"action", "outcome"})
|
||||
|
||||
CommandDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "device_control_command_duration_seconds",
|
||||
Help: "Duration of a command dispatch (Redis desired_state patch + MQTT publish), by action.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"action"})
|
||||
|
||||
MQTTEventsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "device_control_mqtt_events_total",
|
||||
Help: "Total number of telemetry/ack messages received, by event type and outcome.",
|
||||
}, []string{"event_type", "outcome"})
|
||||
|
||||
HealthTransitionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "device_control_health_transitions_total",
|
||||
Help: "Total number of device online/offline transitions detected.",
|
||||
}, []string{"direction"})
|
||||
)
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/metrics"
|
||||
)
|
||||
|
||||
// ShadowPatcher is the subset of shadow.Store the server needs.
|
||||
@@ -36,21 +38,21 @@ func New(shadow ShadowPatcher, mqtt CommandPublisher, logger *slog.Logger) *Serv
|
||||
}
|
||||
|
||||
func (s *Server) TurnOn(ctx context.Context, req *devicecontrol.TurnOnRequest) (*devicecontrol.CommandResult, error) {
|
||||
return s.dispatch(ctx, req.GetDeviceId(),
|
||||
return s.dispatch(ctx, "turn_on", req.GetDeviceId(),
|
||||
map[string]any{"power": "on"},
|
||||
map[string]any{"action": "turn_on"},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) TurnOff(ctx context.Context, req *devicecontrol.TurnOffRequest) (*devicecontrol.CommandResult, error) {
|
||||
return s.dispatch(ctx, req.GetDeviceId(),
|
||||
return s.dispatch(ctx, "turn_off", req.GetDeviceId(),
|
||||
map[string]any{"power": "off"},
|
||||
map[string]any{"action": "turn_off"},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) SetLevel(ctx context.Context, req *devicecontrol.SetLevelRequest) (*devicecontrol.CommandResult, error) {
|
||||
return s.dispatch(ctx, req.GetDeviceId(),
|
||||
return s.dispatch(ctx, "set_level", req.GetDeviceId(),
|
||||
map[string]any{"level": req.GetLevel()},
|
||||
map[string]any{"action": "set_level", "level": req.GetLevel()},
|
||||
)
|
||||
@@ -60,27 +62,41 @@ func (s *Server) SetLevel(ctx context.Context, req *devicecontrol.SetLevelReques
|
||||
// gets an instant, optimistic view even if the device is offline), then hand
|
||||
// the command to MQTT. Business failures come back as CommandResult.Error,
|
||||
// not a gRPC error — the wire contract is success/error in one message.
|
||||
func (s *Server) dispatch(ctx context.Context, deviceID string, desiredPatch, commandPayload map[string]any) (*devicecontrol.CommandResult, error) {
|
||||
func (s *Server) dispatch(ctx context.Context, action, deviceID string, desiredPatch, commandPayload map[string]any) (*devicecontrol.CommandResult, error) {
|
||||
start := time.Now()
|
||||
result := s.doDispatch(ctx, action, deviceID, desiredPatch, commandPayload)
|
||||
|
||||
metrics.CommandDuration.WithLabelValues(action).Observe(time.Since(start).Seconds())
|
||||
outcome := "success"
|
||||
if !result.Success {
|
||||
outcome = "error"
|
||||
}
|
||||
metrics.CommandsTotal.WithLabelValues(action, outcome).Inc()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Server) doDispatch(ctx context.Context, action, deviceID string, desiredPatch, commandPayload map[string]any) *devicecontrol.CommandResult {
|
||||
if deviceID == "" {
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "device_id is required"}, nil
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "device_id is required"}
|
||||
}
|
||||
|
||||
if err := s.shadow.PatchDesiredState(ctx, deviceID, desiredPatch); err != nil {
|
||||
s.logger.Error("patch desired state failed", "device_id", deviceID, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to update desired state"}, nil
|
||||
s.logger.Error("patch desired state failed", "action", action, "device_id", deviceID, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to update desired state"}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(commandPayload)
|
||||
if err != nil {
|
||||
s.logger.Error("encode command failed", "device_id", deviceID, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to encode command"}, nil
|
||||
s.logger.Error("encode command failed", "action", action, "device_id", deviceID, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to encode command"}
|
||||
}
|
||||
|
||||
topic := fmt.Sprintf("devices/%s/commands", deviceID)
|
||||
if err := s.mqtt.Publish(topic, 1, false, body); err != nil {
|
||||
s.logger.Error("publish command failed", "device_id", deviceID, "topic", topic, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to dispatch command"}, nil
|
||||
s.logger.Error("publish command failed", "action", action, "device_id", deviceID, "topic", topic, "error", err)
|
||||
return &devicecontrol.CommandResult{Success: false, Error: "failed to dispatch command"}
|
||||
}
|
||||
|
||||
return &devicecontrol.CommandResult{Success: true}, nil
|
||||
return &devicecontrol.CommandResult{Success: true}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/metrics"
|
||||
)
|
||||
|
||||
type fakeShadow struct {
|
||||
@@ -122,3 +125,27 @@ func TestDispatch_PublishFailure(t *testing.T) {
|
||||
t.Fatal("expected failure when mqtt publish errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_RecordsMetrics(t *testing.T) {
|
||||
successCounter := metrics.CommandsTotal.WithLabelValues("turn_on", "success")
|
||||
errorCounter := metrics.CommandsTotal.WithLabelValues("turn_on", "error")
|
||||
before := testutil.ToFloat64(successCounter)
|
||||
|
||||
s := New(&fakeShadow{}, &fakeMQTT{}, discardLogger())
|
||||
if _, err := s.TurnOn(context.Background(), &devicecontrol.TurnOnRequest{DeviceId: "d1"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := testutil.ToFloat64(successCounter); got != before+1 {
|
||||
t.Fatalf("got turn_on/success counter %v, want %v", got, before+1)
|
||||
}
|
||||
|
||||
beforeErr := testutil.ToFloat64(errorCounter)
|
||||
s2 := New(&fakeShadow{err: errors.New("redis down")}, &fakeMQTT{}, discardLogger())
|
||||
if _, err := s2.TurnOn(context.Background(), &devicecontrol.TurnOnRequest{DeviceId: "d1"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := testutil.ToFloat64(errorCounter); got != beforeErr+1 {
|
||||
t.Fatalf("got turn_on/error counter %v, want %v", got, beforeErr+1)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user