Добавлены метрики 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:
@@ -49,6 +49,13 @@
|
||||
Laravel→device-control. gRPC-контракт между Go-сервисами (rule-engine)
|
||||
не тронут.
|
||||
|
||||
## Метрики
|
||||
|
||||
`GET /metrics` на том же порту, что и команды (`DEVICE_CONTROL_HTTP_PORT`)
|
||||
— не открывали отдельный порт ради этого. Счётчики/latency команд
|
||||
(TurnOn/TurnOff/SetLevel по action+outcome), MQTT-событий (telemetry/ack),
|
||||
переходов online/offline health-check.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
|
||||
@@ -16,12 +16,14 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/config"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/healthcheck"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/httpapi"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/metrics"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/mqttclient"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/rabbitmq"
|
||||
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/server"
|
||||
@@ -74,6 +76,8 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
|
||||
checker := healthcheck.New(shadowStore, cfg.HealthCheckTimeout, cfg.HealthCheckInterval, func(deviceID string) {
|
||||
metrics.HealthTransitionsTotal.WithLabelValues("offline").Inc()
|
||||
|
||||
pubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := publisher.PublishStatusChanged(pubCtx, deviceID, shadow.StatusOffline); err != nil {
|
||||
@@ -93,10 +97,14 @@ func run(logger *slog.Logger) error {
|
||||
devicecontrol.RegisterDeviceControlServer(grpcServer, srv)
|
||||
|
||||
// Same server instance, second transport — for callers where a full
|
||||
// gRPC client is impractical (Laravel/PHP; see README).
|
||||
// gRPC client is impractical (Laravel/PHP; see README). /metrics rides
|
||||
// along on this same port rather than opening a third one.
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", httpapi.NewRouter(srv, logger))
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.HTTPPort),
|
||||
Handler: httpapi.NewRouter(srv, logger),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -145,8 +153,10 @@ func telemetryHandler(store *shadow.Store, publisher *rabbitmq.Publisher, logger
|
||||
deviceID, ok := deviceIDFromTopic(topic)
|
||||
if !ok {
|
||||
logger.Warn("dropping telemetry from unparseable topic", "topic", topic)
|
||||
metrics.MQTTEventsTotal.WithLabelValues("telemetry", "invalid").Inc()
|
||||
return
|
||||
}
|
||||
metrics.MQTTEventsTotal.WithLabelValues("telemetry", "ok").Inc()
|
||||
touch(context.Background(), store, publisher, deviceID, logger)
|
||||
}
|
||||
}
|
||||
@@ -160,14 +170,17 @@ func ackHandler(store *shadow.Store, publisher *rabbitmq.Publisher, logger *slog
|
||||
deviceID, ok := deviceIDFromTopic(topic)
|
||||
if !ok {
|
||||
logger.Warn("dropping ack from unparseable topic", "topic", topic)
|
||||
metrics.MQTTEventsTotal.WithLabelValues("ack", "invalid").Inc()
|
||||
return
|
||||
}
|
||||
|
||||
var ack ackPayload
|
||||
if err := json.Unmarshal(payload, &ack); err != nil {
|
||||
logger.Warn("dropping invalid ack payload", "device_id", deviceID, "error", err)
|
||||
metrics.MQTTEventsTotal.WithLabelValues("ack", "invalid").Inc()
|
||||
return
|
||||
}
|
||||
metrics.MQTTEventsTotal.WithLabelValues("ack", "ok").Inc()
|
||||
|
||||
ctx := context.Background()
|
||||
if len(ack.State) > 0 {
|
||||
@@ -186,6 +199,8 @@ func touch(ctx context.Context, store *shadow.Store, publisher *rabbitmq.Publish
|
||||
return
|
||||
}
|
||||
if becameOnline {
|
||||
metrics.HealthTransitionsTotal.WithLabelValues("online").Inc()
|
||||
|
||||
pubCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := publisher.PublishStatusChanged(pubCtx, deviceID, shadow.StatusOnline); err != nil {
|
||||
|
||||
@@ -6,20 +6,27 @@ require (
|
||||
git.cactoz.su/cacto/home_automatization/proto v0.0.0
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/rabbitmq/amqp091-go v1.13.0
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -22,16 +24,30 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0 h1:L8NA1WtF76C6KA3LAoufjfLgbist/If1UQYcsOjtxXA=
|
||||
github.com/rabbitmq/amqp091-go v1.13.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
||||
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
@@ -52,14 +68,16 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
@@ -68,3 +86,5 @@ google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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