feat: add bounded operational metrics
This commit is contained in:
@@ -134,6 +134,7 @@ func run(configPath string) error {
|
|||||||
controlService, err := control.NewService(control.Options{
|
controlService, err := control.NewService(control.Options{
|
||||||
Store: persistence, DefaultQueueTTL: configured.Queue.DefaultTTL, DefaultQueueTTLSet: true, TakeoverTTL: configured.Protocol.TakeoverTTL,
|
Store: persistence, DefaultQueueTTL: configured.Queue.DefaultTTL, DefaultQueueTTLSet: true, TakeoverTTL: configured.Protocol.TakeoverTTL,
|
||||||
WakeClient: registry.Wake,
|
WakeClient: registry.Wake,
|
||||||
|
Metrics: health,
|
||||||
Limits: agentproto.Limits{MaxEnvelopeBytes: configured.Protocol.MaxAgentEnvelopeBytes, MaxExecutionSpecBytes: configured.Protocol.MaxExecutionSpecBytes, MaxRawChunkBytes: configured.Protocol.MaxRawChunkBytes, MaxScriptBytes: configured.Protocol.MaxScriptBytes, MaxDetailBytes: configured.Storage.ProtocolDetailMaxBytes},
|
Limits: agentproto.Limits{MaxEnvelopeBytes: configured.Protocol.MaxAgentEnvelopeBytes, MaxExecutionSpecBytes: configured.Protocol.MaxExecutionSpecBytes, MaxRawChunkBytes: configured.Protocol.MaxRawChunkBytes, MaxScriptBytes: configured.Protocol.MaxScriptBytes, MaxDetailBytes: configured.Storage.ProtocolDetailMaxBytes},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -156,6 +157,7 @@ func run(configPath string) error {
|
|||||||
}
|
}
|
||||||
agent := &session.AgentServer{
|
agent := &session.AgentServer{
|
||||||
Store: persistence, Registry: registry, Path: configured.Server.AgentPath,
|
Store: persistence, Registry: registry, Path: configured.Server.AgentPath,
|
||||||
|
Metrics: health,
|
||||||
Limits: agentproto.Limits{
|
Limits: agentproto.Limits{
|
||||||
MaxEnvelopeBytes: configured.Protocol.MaxAgentEnvelopeBytes,
|
MaxEnvelopeBytes: configured.Protocol.MaxAgentEnvelopeBytes,
|
||||||
MaxExecutionSpecBytes: configured.Protocol.MaxExecutionSpecBytes,
|
MaxExecutionSpecBytes: configured.Protocol.MaxExecutionSpecBytes,
|
||||||
|
|||||||
+3
-2
@@ -190,7 +190,7 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("configure command supervisor: %w", err)
|
return fmt.Errorf("configure command supervisor: %w", err)
|
||||||
}
|
}
|
||||||
executor, err := agent.NewExecutor(agent.ExecutorOptions{Store: state, Supervisor: supervised, WorkDir: configured.Client.DaemonCWD, Notify: func(issue domain.UUID) {
|
executor, err := agent.NewExecutor(agent.ExecutorOptions{Store: state, Supervisor: supervised, WorkDir: configured.Client.DaemonCWD, Metrics: health, Notify: func(issue domain.UUID) {
|
||||||
select {
|
select {
|
||||||
case eventReady <- issue:
|
case eventReady <- issue:
|
||||||
default:
|
default:
|
||||||
@@ -216,7 +216,6 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
} else if len(recovered) > 0 && diagnostics != nil {
|
} else if len(recovered) > 0 && diagnostics != nil {
|
||||||
_, _ = fmt.Fprintf(diagnostics, "rvbox recovered %d uncertain launch(es)\n", len(recovered))
|
_, _ = fmt.Fprintf(diagnostics, "rvbox recovered %d uncertain launch(es)\n", len(recovered))
|
||||||
}
|
}
|
||||||
health.SetReady(true)
|
|
||||||
if runErr := agent.Run(ctx, agent.RunnerOptions{
|
if runErr := agent.Run(ctx, agent.RunnerOptions{
|
||||||
Store: state,
|
Store: state,
|
||||||
Dial: func(dialContext context.Context) (agent.Transport, error) {
|
Dial: func(dialContext context.Context) (agent.Transport, error) {
|
||||||
@@ -227,6 +226,8 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
|
|||||||
Jitter: agent.CryptoJitter, Now: func() time.Time { return time.Now().UTC() },
|
Jitter: agent.CryptoJitter, Now: func() time.Time { return time.Now().UTC() },
|
||||||
OnDispatch: executor.Dispatch, OnScriptReady: executor.ScriptReady, OnStdin: executor.Stdin,
|
OnDispatch: executor.Dispatch, OnScriptReady: executor.ScriptReady, OnStdin: executor.Stdin,
|
||||||
OnCloseStdin: executor.CloseStdin, OnSignal: executor.Signal, OnTerminate: executor.Terminate, EventReady: eventReady,
|
OnCloseStdin: executor.CloseStdin, OnSignal: executor.Signal, OnTerminate: executor.Terminate, EventReady: eventReady,
|
||||||
|
Metrics: health,
|
||||||
|
OnSessionActive: health.SetReady,
|
||||||
OnSessionError: func(sessionErr error) {
|
OnSessionError: func(sessionErr error) {
|
||||||
if diagnostics != nil {
|
if diagnostics != nil {
|
||||||
_, _ = fmt.Fprintf(diagnostics, "rvbox client session retry: %v\n", sessionErr)
|
_, _ = fmt.Fprintf(diagnostics, "rvbox client session retry: %v\n", sessionErr)
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ the process is up; `/readyz` becomes successful only after durable recovery.
|
|||||||
The public endpoint accepts only `wss://HOST/v1/agent`. Do not publish port
|
The public endpoint accepts only `wss://HOST/v1/agent`. Do not publish port
|
||||||
6900 or add a proxy route for JSON-RPC.
|
6900 or add a proxy route for JSON-RPC.
|
||||||
|
|
||||||
|
## Health, metrics, and logs
|
||||||
|
|
||||||
|
Keep the configured observability listener private to the host or monitoring
|
||||||
|
network. `/metrics` exposes only bounded, aggregate Prometheus samples: health
|
||||||
|
state; registration/takeover and protocol failures; session/reconnect and
|
||||||
|
heartbeat timing; dispatch/event/transition counts and latency; and client
|
||||||
|
output accounting. It never includes a command body, stdin, output, client ID,
|
||||||
|
or UUID label. Duration histograms use fixed buckets, so monitoring traffic
|
||||||
|
cannot create unbounded series.
|
||||||
|
|
||||||
|
For a Windows client, readiness is false while its durable spool is recovering
|
||||||
|
or it has no reconciled server session; it becomes true only while the active
|
||||||
|
WSS session can exchange command data. Server liveness starts before
|
||||||
|
asynchronous recovery, while server readiness remains false until that recovery
|
||||||
|
completes. The configured rotating JSON/text service logs are diagnostics, not
|
||||||
|
a command-output store; use `rvc` history and the audited storage for command
|
||||||
|
evidence.
|
||||||
|
|
||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
Stop dispatch before copying data: stop the server gracefully, confirm it is
|
Stop dispatch before copying data: stop the server gracefully, confirm it is
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/rvbox/rvbox/internal/client/spool"
|
"github.com/rvbox/rvbox/internal/client/spool"
|
||||||
"github.com/rvbox/rvbox/internal/client/supervisor"
|
"github.com/rvbox/rvbox/internal/client/supervisor"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,7 @@ type Executor struct {
|
|||||||
WorkDir string
|
WorkDir string
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
Notify func(domain.UUID)
|
Notify func(domain.UUID)
|
||||||
|
Metrics *observability.Health
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
active map[domain.UUID]supervisor.Process
|
active map[domain.UUID]supervisor.Process
|
||||||
@@ -38,6 +40,7 @@ type ExecutorOptions struct {
|
|||||||
WorkDir string
|
WorkDir string
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
Notify func(domain.UUID)
|
Notify func(domain.UUID)
|
||||||
|
Metrics *observability.Health
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExecutor(options ExecutorOptions) (*Executor, error) {
|
func NewExecutor(options ExecutorOptions) (*Executor, error) {
|
||||||
@@ -47,7 +50,7 @@ func NewExecutor(options ExecutorOptions) (*Executor, error) {
|
|||||||
if options.Now == nil {
|
if options.Now == nil {
|
||||||
options.Now = func() time.Time { return time.Now().UTC() }
|
options.Now = func() time.Time { return time.Now().UTC() }
|
||||||
}
|
}
|
||||||
return &Executor{Store: options.Store, Supervisor: options.Supervisor, WorkDir: options.WorkDir, Now: options.Now, Notify: options.Notify, active: make(map[domain.UUID]supervisor.Process), cancel: make(map[domain.UUID]context.CancelFunc), forced: make(map[domain.UUID]bool)}, nil
|
return &Executor{Store: options.Store, Supervisor: options.Supervisor, WorkDir: options.WorkDir, Now: options.Now, Notify: options.Notify, Metrics: options.Metrics, active: make(map[domain.UUID]supervisor.Process), cancel: make(map[domain.UUID]context.CancelFunc), forced: make(map[domain.UUID]bool)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch is safe to invoke after CommandAccepted has been sent. Script
|
// Dispatch is safe to invoke after CommandAccepted has been sent. Script
|
||||||
@@ -158,6 +161,7 @@ func (executor *Executor) launch(ctx context.Context, issue domain.UUID, revisio
|
|||||||
executor.remove(issue)
|
executor.remove(issue)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
executor.incMetric("command_running")
|
||||||
executor.notify(issue)
|
executor.notify(issue)
|
||||||
go executor.watch(runContext, issue, revision, process, cancel)
|
go executor.watch(runContext, issue, revision, process, cancel)
|
||||||
return nil
|
return nil
|
||||||
@@ -171,6 +175,7 @@ func (executor *Executor) watch(ctx context.Context, issue domain.UUID, revision
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
executor.incMetric("output_loss")
|
||||||
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
|
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -178,10 +183,13 @@ func (executor *Executor) watch(ctx context.Context, issue domain.UUID, revision
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := executor.Store.AppendOutput(context.Background(), issue, spool.OutputInput{Stream: chunk.Stream, Raw: chunk.Data, ObservedAt: executor.Now()}); err != nil {
|
if _, err := executor.Store.AppendOutput(context.Background(), issue, spool.OutputInput{Stream: chunk.Stream, Raw: chunk.Data, ObservedAt: executor.Now()}); err != nil {
|
||||||
|
executor.incMetric("output_loss")
|
||||||
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
|
_ = executor.appendIncomplete(context.Background(), issue, revision, err.Error())
|
||||||
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
|
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
executor.incMetric("output_chunk")
|
||||||
|
executor.incMetricBy("output_bytes", uint64(len(chunk.Data)))
|
||||||
executor.notify(issue)
|
executor.notify(issue)
|
||||||
}
|
}
|
||||||
status, waitErr := process.Wait(context.Background())
|
status, waitErr := process.Wait(context.Background())
|
||||||
@@ -210,9 +218,24 @@ func (executor *Executor) appendLifecycle(ctx context.Context, issue domain.UUID
|
|||||||
|
|
||||||
func (executor *Executor) appendLifecycleWithIdentity(ctx context.Context, issue domain.UUID, revision uint64, phase rvboxv1.CommandLifecycle, detail string, identity *rvboxv1.WindowsExecutionIdentity) error {
|
func (executor *Executor) appendLifecycleWithIdentity(ctx context.Context, issue domain.UUID, revision uint64, phase rvboxv1.CommandLifecycle, detail string, identity *rvboxv1.WindowsExecutionIdentity) error {
|
||||||
_, err := executor.Store.AppendLifecycleWithIdentity(ctx, issue, uint32(phase), revision, detail, executor.Now(), identity)
|
_, err := executor.Store.AppendLifecycleWithIdentity(ctx, issue, uint32(phase), revision, detail, executor.Now(), identity)
|
||||||
|
if err == nil {
|
||||||
|
executor.incMetric("command_transition")
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (executor *Executor) incMetric(name string) {
|
||||||
|
if executor != nil && executor.Metrics != nil {
|
||||||
|
executor.Metrics.Inc(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (executor *Executor) incMetricBy(name string, delta uint64) {
|
||||||
|
if executor != nil && executor.Metrics != nil {
|
||||||
|
executor.Metrics.IncBy(name, delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (executor *Executor) reject(ctx context.Context, issue domain.UUID, revision uint64, cause error) error {
|
func (executor *Executor) reject(ctx context.Context, issue domain.UUID, revision uint64, cause error) error {
|
||||||
return executor.rejectWithIdentity(ctx, issue, revision, cause, nil)
|
return executor.rejectWithIdentity(ctx, issue, revision, cause, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/client/spool"
|
"github.com/rvbox/rvbox/internal/client/spool"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -39,6 +40,12 @@ type RunnerOptions struct {
|
|||||||
// transport session before normal reconnect backoff. It must not block; the
|
// transport session before normal reconnect backoff. It must not block; the
|
||||||
// durable spool and retry policy remain owned by Run.
|
// durable spool and retry policy remain owned by Run.
|
||||||
OnSessionError func(error)
|
OnSessionError func(error)
|
||||||
|
// OnSessionActive reports whether a reconciled transport is currently able
|
||||||
|
// to exchange command data. It lets daemon readiness represent connection
|
||||||
|
// state without making reconnect behavior depend on the observer.
|
||||||
|
OnSessionActive func(bool)
|
||||||
|
// Metrics is optional. It only receives static, bounded metric names.
|
||||||
|
Metrics *observability.Health
|
||||||
// EventReady wakes the active session after a supervisor worker appends a
|
// EventReady wakes the active session after a supervisor worker appends a
|
||||||
// durable event. The network loop remains the sole writer; a reconnect can
|
// durable event. The network loop remains the sole writer; a reconnect can
|
||||||
// safely ignore a stale notification because replay reads the spool again.
|
// safely ignore a stale notification because replay reads the spool again.
|
||||||
@@ -98,6 +105,10 @@ func Run(ctx context.Context, options RunnerOptions) error {
|
|||||||
if err := waitUntil(ctx, delay); err != nil {
|
if err := waitUntil(ctx, delay); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if failures > 0 {
|
||||||
|
options.incMetric("reconnect_attempt")
|
||||||
|
}
|
||||||
|
options.incMetric("connection_attempt")
|
||||||
sessionStarted := options.Now()
|
sessionStarted := options.Now()
|
||||||
sessionErr := runOnce(ctx, options)
|
sessionErr := runOnce(ctx, options)
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -106,6 +117,9 @@ func Run(ctx context.Context, options RunnerOptions) error {
|
|||||||
if sessionErr != nil && options.OnSessionError != nil {
|
if sessionErr != nil && options.OnSessionError != nil {
|
||||||
options.OnSessionError(sessionErr)
|
options.OnSessionError(sessionErr)
|
||||||
}
|
}
|
||||||
|
if sessionErr != nil {
|
||||||
|
options.incMetric("connection_failure")
|
||||||
|
}
|
||||||
if options.Now().Sub(sessionStarted) >= options.Backoff.StableReset {
|
if options.Now().Sub(sessionStarted) >= options.Backoff.StableReset {
|
||||||
failures = 0
|
failures = 0
|
||||||
}
|
}
|
||||||
@@ -173,6 +187,7 @@ func runOnce(ctx context.Context, options RunnerOptions) (resultErr error) {
|
|||||||
}
|
}
|
||||||
session, err := Handshake(ctx, transport, options.Hello, limits)
|
session, err := Handshake(ctx, transport, options.Hello, limits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
options.incMetric("protocol_error")
|
||||||
return fmt.Errorf("agent handshake: %w", err)
|
return fmt.Errorf("agent handshake: %w", err)
|
||||||
}
|
}
|
||||||
snapshot, err := options.Store.ReconcileSnapshot(ctx)
|
snapshot, err := options.Store.ReconcileSnapshot(ctx)
|
||||||
@@ -201,6 +216,17 @@ func runOnce(ctx context.Context, options RunnerOptions) (resultErr error) {
|
|||||||
if err := sendCapacity(ctx, transport, session, options.Hello, snapshot, limits); err != nil {
|
if err := sendCapacity(ctx, transport, session, options.Hello, snapshot, limits); err != nil {
|
||||||
return fmt.Errorf("advertise client capacity: %w", err)
|
return fmt.Errorf("advertise client capacity: %w", err)
|
||||||
}
|
}
|
||||||
|
activeStarted := options.Now()
|
||||||
|
options.incMetric("session_established")
|
||||||
|
if options.OnSessionActive != nil {
|
||||||
|
options.OnSessionActive(true)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if options.OnSessionActive != nil {
|
||||||
|
options.OnSessionActive(false)
|
||||||
|
}
|
||||||
|
options.observeMetric("session_duration", options.Now().Sub(activeStarted))
|
||||||
|
}()
|
||||||
return serveActive(ctx, transport, options, session, sentEvents)
|
return serveActive(ctx, transport, options, session, sentEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,9 +369,11 @@ func serveActive(ctx context.Context, transport Transport, options RunnerOptions
|
|||||||
}
|
}
|
||||||
envelope, err := agentproto.DecodeEnvelope(encoded, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
|
envelope, err := agentproto.DecodeEnvelope(encoded, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
options.incMetric("protocol_error")
|
||||||
return fmt.Errorf("decode active agent frame: %w", err)
|
return fmt.Errorf("decode active agent frame: %w", err)
|
||||||
}
|
}
|
||||||
if envelope.GetSessionId() != session.ID || envelope.GetSessionGeneration() != session.Generation {
|
if envelope.GetSessionId() != session.ID || envelope.GetSessionGeneration() != session.Generation {
|
||||||
|
options.incMetric("stale_message")
|
||||||
return ErrProtocolHandshake
|
return ErrProtocolHandshake
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
@@ -452,6 +480,11 @@ func flushEvents(ctx context.Context, transport Transport, store *spool.Store, s
|
|||||||
func handleDispatch(ctx context.Context, transport Transport, options RunnerOptions, session Session, dispatch *rvboxv1.CommandDispatch, limits agentproto.Limits) error {
|
func handleDispatch(ctx context.Context, transport Transport, options RunnerOptions, session Session, dispatch *rvboxv1.CommandDispatch, limits agentproto.Limits) error {
|
||||||
acceptance, err := PersistDispatch(ctx, options.Store, session, dispatch, options.Now(), limits)
|
acceptance, err := PersistDispatch(ctx, options.Store, session, dispatch, options.Now(), limits)
|
||||||
accepted := err == nil
|
accepted := err == nil
|
||||||
|
if accepted {
|
||||||
|
options.incMetric("command_dispatch_accepted")
|
||||||
|
} else {
|
||||||
|
options.incMetric("command_dispatch_rejected")
|
||||||
|
}
|
||||||
ack := &rvboxv1.CommandAccepted{IssueUuid: dispatch.GetIssueUuid(), CommandRevision: dispatch.GetCommandRevision(), Accepted: accepted}
|
ack := &rvboxv1.CommandAccepted{IssueUuid: dispatch.GetIssueUuid(), CommandRevision: dispatch.GetCommandRevision(), Accepted: accepted}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ack.Rejection = rejectionForError(err, dispatch.GetIssueUuid())
|
ack.Rejection = rejectionForError(err, dispatch.GetIssueUuid())
|
||||||
@@ -474,6 +507,18 @@ func handleDispatch(ctx context.Context, transport Transport, options RunnerOpti
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (options RunnerOptions) incMetric(name string) {
|
||||||
|
if options.Metrics != nil {
|
||||||
|
options.Metrics.Inc(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (options RunnerOptions) observeMetric(name string, duration time.Duration) {
|
||||||
|
if options.Metrics != nil {
|
||||||
|
options.Metrics.ObserveDuration(name, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func rejectionForError(err error, issue string) *rvboxv1.ControlError {
|
func rejectionForError(err error, issue string) *rvboxv1.ControlError {
|
||||||
code := rvboxv1.ControlError_TRANSIENT
|
code := rvboxv1.ControlError_TRANSIENT
|
||||||
if errors.Is(err, spool.ErrCommandConflict) || errors.Is(err, spool.ErrAlreadyExecuted) {
|
if errors.Is(err, spool.ErrCommandConflict) || errors.Is(err, spool.ErrAlreadyExecuted) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/client/spool"
|
"github.com/rvbox/rvbox/internal/client/spool"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -46,7 +47,9 @@ func TestRunOnceReconcilesReplaysAndAdvertisesCapacity_HP_RUNTIME_01(t *testing.
|
|||||||
reconcileResult, _ := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileResult{ReconcileResult: &rvboxv1.ReconcileResult{}}})
|
reconcileResult, _ := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileResult{ReconcileResult: &rvboxv1.ReconcileResult{}}})
|
||||||
transport := &runnerTransport{reads: [][]byte{welcome, reconcileRequest, reconcileResult}, terminal: errors.New("transport closed")}
|
transport := &runnerTransport{reads: [][]byte{welcome, reconcileRequest, reconcileResult}, terminal: errors.New("transport closed")}
|
||||||
hello := &rvboxv1.ClientHello{ClientId: "runner-client", SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0}, DaemonVersion: "test", Platform: rvboxv1.Platform_PLATFORM_WINDOWS, Architecture: "amd64", DaemonCwd: `C:\`, SupportedShells: []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_POWERSHELL}, ClientInstanceId: store.ClientInstanceID().String(), MaxRunningCommands: 1, MaxQueuedCommands: 1, SentAt: timestamppb.Now()}
|
hello := &rvboxv1.ClientHello{ClientId: "runner-client", SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0}, DaemonVersion: "test", Platform: rvboxv1.Platform_PLATFORM_WINDOWS, Architecture: "amd64", DaemonCwd: `C:\`, SupportedShells: []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_POWERSHELL}, ClientInstanceId: store.ClientInstanceID().String(), MaxRunningCommands: 1, MaxQueuedCommands: 1, SentAt: timestamppb.Now()}
|
||||||
err = RunOnce(ctx, RunnerOptions{Store: store, Dial: func(context.Context) (Transport, error) { return transport, nil }, Hello: hello, Limits: agentproto.DefaultLimits(), Backoff: BackoffOptions{Initial: time.Millisecond, Maximum: time.Millisecond, StableReset: time.Second}, Jitter: func(value time.Duration) time.Duration { return 0 }, Now: func() time.Time { return time.Now().UTC() }})
|
metrics := observability.New()
|
||||||
|
var active []bool
|
||||||
|
err = RunOnce(ctx, RunnerOptions{Store: store, Dial: func(context.Context) (Transport, error) { return transport, nil }, Hello: hello, Limits: agentproto.DefaultLimits(), Backoff: BackoffOptions{Initial: time.Millisecond, Maximum: time.Millisecond, StableReset: time.Second}, Jitter: func(value time.Duration) time.Duration { return 0 }, Now: func() time.Time { return time.Now().UTC() }, Metrics: metrics, OnSessionActive: func(value bool) { active = append(active, value) }})
|
||||||
if !errors.Is(err, transport.terminal) {
|
if !errors.Is(err, transport.terminal) {
|
||||||
t.Fatalf("RunOnce error = %v, want transport close", err)
|
t.Fatalf("RunOnce error = %v, want transport close", err)
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,13 @@ func TestRunOnceReconcilesReplaysAndAdvertisesCapacity_HP_RUNTIME_01(t *testing.
|
|||||||
if err != nil || eventEnvelope.GetCommandEvent() == nil || eventEnvelope.GetCommandEvent().GetEventSeq() != 1 || eventEnvelope.GetCommandEvent().GetIssueUuid() != issue.String() {
|
if err != nil || eventEnvelope.GetCommandEvent() == nil || eventEnvelope.GetCommandEvent().GetEventSeq() != 1 || eventEnvelope.GetCommandEvent().GetIssueUuid() != issue.String() {
|
||||||
t.Fatalf("replayed event = %#v, %v", eventEnvelope, err)
|
t.Fatalf("replayed event = %#v, %v", eventEnvelope, err)
|
||||||
}
|
}
|
||||||
|
_, _, counters := metrics.Snapshot()
|
||||||
|
if counters["session_established"] != 1 {
|
||||||
|
t.Fatalf("session metrics = %#v", counters)
|
||||||
|
}
|
||||||
|
if len(active) != 2 || !active[0] || active[1] {
|
||||||
|
t.Fatalf("active transitions = %#v", active)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerOptionsRejectMissingJitter_HP_RUNTIME_02(t *testing.T) {
|
func TestRunnerOptionsRejectMissingJitter_HP_RUNTIME_02(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package observability
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -27,9 +28,28 @@ type Health struct {
|
|||||||
dirty atomic.Bool
|
dirty atomic.Bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
count map[string]uint64
|
count map[string]uint64
|
||||||
|
gauge map[string]float64
|
||||||
|
hist map[string]*durationHistogram
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Health { return &Health{count: make(map[string]uint64)} }
|
type durationHistogram struct {
|
||||||
|
count uint64
|
||||||
|
sum float64
|
||||||
|
buckets [len(durationBuckets)]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fixed buckets are intentionally shared by every duration metric. This
|
||||||
|
// keeps the Prometheus surface bounded and makes comparable operational
|
||||||
|
// latencies available without allowing callers to create arbitrary labels.
|
||||||
|
var durationBuckets = [...]float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 5, 15, 60}
|
||||||
|
|
||||||
|
func New() *Health {
|
||||||
|
return &Health{
|
||||||
|
count: make(map[string]uint64),
|
||||||
|
gauge: make(map[string]float64),
|
||||||
|
hist: make(map[string]*durationHistogram),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (health *Health) SetReady(value bool) {
|
func (health *Health) SetReady(value bool) {
|
||||||
if health != nil {
|
if health != nil {
|
||||||
@@ -44,11 +64,52 @@ func (health *Health) SetDirty(value bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (health *Health) Inc(name string) {
|
func (health *Health) Inc(name string) {
|
||||||
if health == nil || !validMetricName(name) {
|
health.IncBy(name, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncBy records a non-negative integral counter delta. Metric names are
|
||||||
|
// deliberately restricted to a small identifier grammar; callers cannot turn
|
||||||
|
// a client ID, request UUID, or other untrusted data into a metric name.
|
||||||
|
func (health *Health) IncBy(name string, delta uint64) {
|
||||||
|
if health == nil || delta == 0 || !validMetricName(name) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
health.mu.Lock()
|
health.mu.Lock()
|
||||||
health.count[name]++
|
health.count[name] += delta
|
||||||
|
health.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGauge records a bounded-cardinality instantaneous value. NaN and
|
||||||
|
// infinities are discarded because they are not safe Prometheus samples.
|
||||||
|
func (health *Health) SetGauge(name string, value float64) {
|
||||||
|
if health == nil || !validMetricName(name) || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
health.mu.Lock()
|
||||||
|
health.gauge[name] = value
|
||||||
|
health.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveDuration records a duration in a fixed-bucket histogram. Negative
|
||||||
|
// durations are invalid (normally a caller clock error) and are ignored.
|
||||||
|
func (health *Health) ObserveDuration(name string, duration time.Duration) {
|
||||||
|
if health == nil || !validMetricName(name) || duration < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seconds := duration.Seconds()
|
||||||
|
health.mu.Lock()
|
||||||
|
histogram := health.hist[name]
|
||||||
|
if histogram == nil {
|
||||||
|
histogram = &durationHistogram{}
|
||||||
|
health.hist[name] = histogram
|
||||||
|
}
|
||||||
|
histogram.count++
|
||||||
|
histogram.sum += seconds
|
||||||
|
for index, upperBound := range durationBuckets {
|
||||||
|
if seconds <= upperBound {
|
||||||
|
histogram.buckets[index]++
|
||||||
|
}
|
||||||
|
}
|
||||||
health.mu.Unlock()
|
health.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +126,46 @@ func (health *Health) Snapshot() (ready, dirty bool, counters map[string]uint64)
|
|||||||
return health.ready.Load(), health.dirty.Load(), copyCounters
|
return health.ready.Load(), health.dirty.Load(), copyCounters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MetricsSnapshot struct {
|
||||||
|
Ready bool
|
||||||
|
Dirty bool
|
||||||
|
Counters map[string]uint64
|
||||||
|
Gauges map[string]float64
|
||||||
|
Histograms map[string]DurationHistogramSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
type DurationHistogramSnapshot struct {
|
||||||
|
Count uint64
|
||||||
|
Sum float64
|
||||||
|
Buckets [len(durationBuckets)]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricsSnapshot returns a copy suitable for rendering or testing. It never
|
||||||
|
// includes application identifiers or payloads.
|
||||||
|
func (health *Health) MetricsSnapshot() MetricsSnapshot {
|
||||||
|
if health == nil {
|
||||||
|
return MetricsSnapshot{Dirty: true}
|
||||||
|
}
|
||||||
|
health.mu.Lock()
|
||||||
|
defer health.mu.Unlock()
|
||||||
|
result := MetricsSnapshot{
|
||||||
|
Ready: health.ready.Load(), Dirty: health.dirty.Load(),
|
||||||
|
Counters: make(map[string]uint64, len(health.count)),
|
||||||
|
Gauges: make(map[string]float64, len(health.gauge)),
|
||||||
|
Histograms: make(map[string]DurationHistogramSnapshot, len(health.hist)),
|
||||||
|
}
|
||||||
|
for name, value := range health.count {
|
||||||
|
result.Counters[name] = value
|
||||||
|
}
|
||||||
|
for name, value := range health.gauge {
|
||||||
|
result.Gauges[name] = value
|
||||||
|
}
|
||||||
|
for name, value := range health.hist {
|
||||||
|
result.Histograms[name] = DurationHistogramSnapshot{Count: value.count, Sum: value.sum, Buckets: value.buckets}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (health *Health) Handler(paths Paths) http.Handler {
|
func (health *Health) Handler(paths Paths) http.Handler {
|
||||||
if paths.Liveness == "" {
|
if paths.Liveness == "" {
|
||||||
paths.Liveness = "/livez"
|
paths.Liveness = "/livez"
|
||||||
@@ -92,17 +193,38 @@ func (health *Health) Handler(paths Paths) http.Handler {
|
|||||||
_, _ = fmt.Fprintf(response, "ready=%s dirty=%s\n", strconv.FormatBool(ready), strconv.FormatBool(dirty))
|
_, _ = fmt.Fprintf(response, "ready=%s dirty=%s\n", strconv.FormatBool(ready), strconv.FormatBool(dirty))
|
||||||
})
|
})
|
||||||
mux.HandleFunc(paths.Metrics, func(response http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc(paths.Metrics, func(response http.ResponseWriter, _ *http.Request) {
|
||||||
ready, dirty, counters := health.Snapshot()
|
snapshot := health.MetricsSnapshot()
|
||||||
response.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
response.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||||
response.WriteHeader(http.StatusOK)
|
response.WriteHeader(http.StatusOK)
|
||||||
_, _ = fmt.Fprintf(response, "rvbox_health_ready %d\nrvbox_health_dirty %d\n", boolMetric(ready), boolMetric(dirty))
|
_, _ = fmt.Fprintf(response, "rvbox_health_ready %d\nrvbox_health_dirty %d\n", boolMetric(snapshot.Ready), boolMetric(snapshot.Dirty))
|
||||||
keys := make([]string, 0, len(counters))
|
keys := make([]string, 0, len(snapshot.Counters))
|
||||||
for key := range counters {
|
for key := range snapshot.Counters {
|
||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
_, _ = fmt.Fprintf(response, "rvbox_%s_total %d\n", key, counters[key])
|
_, _ = fmt.Fprintf(response, "rvbox_%s_total %d\n", key, snapshot.Counters[key])
|
||||||
|
}
|
||||||
|
keys = keys[:0]
|
||||||
|
for key := range snapshot.Gauges {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
_, _ = fmt.Fprintf(response, "rvbox_%s %g\n", key, snapshot.Gauges[key])
|
||||||
|
}
|
||||||
|
keys = keys[:0]
|
||||||
|
for key := range snapshot.Histograms {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
histogram := snapshot.Histograms[key]
|
||||||
|
for index, upperBound := range durationBuckets {
|
||||||
|
_, _ = fmt.Fprintf(response, "rvbox_%s_seconds_bucket{le=\"%g\"} %d\n", key, upperBound, histogram.Buckets[index])
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(response, "rvbox_%s_seconds_bucket{le=\"+Inf\"} %d\n", key, histogram.Count)
|
||||||
|
_, _ = fmt.Fprintf(response, "rvbox_%s_seconds_sum %g\nrvbox_%s_seconds_count %d\n", key, histogram.Sum, key, histogram.Count)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return mux
|
return mux
|
||||||
@@ -142,7 +264,10 @@ func validMetricName(name string) bool {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, character := range name {
|
for index, character := range name {
|
||||||
|
if index == 0 && !(character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if !(character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9') {
|
if !(character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthHandlerReadinessAndMetrics_HP_OPS_01(t *testing.T) {
|
func TestHealthHandlerReadinessAndMetrics_HP_OPS_01(t *testing.T) {
|
||||||
@@ -35,8 +36,36 @@ func TestHealthMetricNamesAreBounded_BH_OPS_01(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
health := New()
|
health := New()
|
||||||
health.Inc("bad name")
|
health.Inc("bad name")
|
||||||
|
health.Inc("1bad_name")
|
||||||
_, _, counters := health.Snapshot()
|
_, _, counters := health.Snapshot()
|
||||||
if len(counters) != 0 {
|
if len(counters) != 0 {
|
||||||
t.Fatalf("invalid metric name recorded: %#v", counters)
|
t.Fatalf("invalid metric name recorded: %#v", counters)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHealthRendersBoundedGaugesAndDurationHistograms_HP_OPS_02(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
health := New()
|
||||||
|
health.SetGauge("client_spool_bytes", 4096)
|
||||||
|
health.SetGauge("bad gauge", 1)
|
||||||
|
health.ObserveDuration("dispatch_latency", 12*time.Millisecond)
|
||||||
|
health.ObserveDuration("bad latency", time.Second)
|
||||||
|
|
||||||
|
metrics := httptest.NewRecorder()
|
||||||
|
health.Handler(Paths{}).ServeHTTP(metrics, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
body := metrics.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"rvbox_client_spool_bytes 4096",
|
||||||
|
"rvbox_dispatch_latency_seconds_bucket{le=\"0.05\"} 1",
|
||||||
|
"rvbox_dispatch_latency_seconds_bucket{le=\"+Inf\"} 1",
|
||||||
|
"rvbox_dispatch_latency_seconds_sum 0.012",
|
||||||
|
"rvbox_dispatch_latency_seconds_count 1",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("metrics missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "bad gauge") || strings.Contains(body, "bad latency") {
|
||||||
|
t.Fatalf("unbounded metric name rendered: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"github.com/rvbox/rvbox/internal/server/store"
|
"github.com/rvbox/rvbox/internal/server/store"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -48,6 +49,8 @@ type Options struct {
|
|||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
CursorKey []byte
|
CursorKey []byte
|
||||||
WakeClient func(string) bool
|
WakeClient func(string) bool
|
||||||
|
// Metrics is optional and receives only static metric names.
|
||||||
|
Metrics *observability.Health
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -59,6 +62,7 @@ type Service struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
cursors *domain.CursorCodec
|
cursors *domain.CursorCodec
|
||||||
wakeClient func(string) bool
|
wakeClient func(string) bool
|
||||||
|
metrics *observability.Health
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(options Options) (*Service, error) {
|
func NewService(options Options) (*Service, error) {
|
||||||
@@ -94,7 +98,7 @@ func NewService(options Options) (*Service, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Service{store: options.Store, defaultQueueTTL: options.DefaultQueueTTL, takeoverTTL: options.TakeoverTTL, limits: options.Limits, now: options.Now, cursors: codec, wakeClient: options.WakeClient}, nil
|
return &Service{store: options.Store, defaultQueueTTL: options.DefaultQueueTTL, takeoverTTL: options.TakeoverTTL, limits: options.Limits, now: options.Now, cursors: codec, wakeClient: options.WakeClient, metrics: options.Metrics}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) ListClients(ctx context.Context, request *rvboxv1.ListClientsRequest) (*rvboxv1.ListClientsResponse, error) {
|
func (service *Service) ListClients(ctx context.Context, request *rvboxv1.ListClientsRequest) (*rvboxv1.ListClientsResponse, error) {
|
||||||
@@ -676,11 +680,19 @@ func (service *Service) AuthorizeClientTakeover(ctx context.Context, request *rv
|
|||||||
expires := service.now().UTC().Add(service.takeoverTTL)
|
expires := service.now().UTC().Add(service.takeoverTTL)
|
||||||
value, _, err := service.store.AuthorizeClientTakeover(ctx, store.TakeoverAuthorization{ClientID: request.GetClientId(), ClientInstanceID: [16]byte(instance), RequestID: [16]byte(requestID), ExpiresAt: expires})
|
value, _, err := service.store.AuthorizeClientTakeover(ctx, store.TakeoverAuthorization{ClientID: request.GetClientId(), ClientInstanceID: [16]byte(instance), RequestID: [16]byte(requestID), ExpiresAt: expires})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
service.incMetric("client_takeover_rejected")
|
||||||
return nil, mapStoreError(err)
|
return nil, mapStoreError(err)
|
||||||
}
|
}
|
||||||
|
service.incMetric("client_takeover_authorized")
|
||||||
return &rvboxv1.AuthorizeClientTakeoverResponse{ExpiresAt: timestamppb.New(value)}, nil
|
return &rvboxv1.AuthorizeClientTakeoverResponse{ExpiresAt: timestamppb.New(value)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) incMetric(name string) {
|
||||||
|
if service != nil && service.metrics != nil {
|
||||||
|
service.metrics.Inc(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func incidentRecord(view store.IncidentView) *rvboxv1.StorageIncident {
|
func incidentRecord(view store.IncidentView) *rvboxv1.StorageIncident {
|
||||||
result := &rvboxv1.StorageIncident{IncidentId: domain.UUID(view.IncidentUUID).String(), DetectedAt: timestamppb.New(view.DetectedAt), State: rvboxv1.StorageIncidentState(view.State), Scope: string(view.Scope), ClientId: view.ClientID, Summary: view.Summary, DataLoss: view.DataLoss, AutomaticallyRepairable: view.AutomaticallyRepairable}
|
result := &rvboxv1.StorageIncident{IncidentId: domain.UUID(view.IncidentUUID).String(), DetectedAt: timestamppb.New(view.DetectedAt), State: rvboxv1.StorageIncidentState(view.State), Scope: string(view.Scope), ClientId: view.ClientID, Summary: view.Summary, DataLoss: view.DataLoss, AutomaticallyRepairable: view.AutomaticallyRepairable}
|
||||||
if view.ResolvedAt != nil {
|
if view.ResolvedAt != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"github.com/rvbox/rvbox/internal/server/store"
|
"github.com/rvbox/rvbox/internal/server/store"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
@@ -45,6 +46,9 @@ type AgentServer struct {
|
|||||||
HeartbeatIdle time.Duration
|
HeartbeatIdle time.Duration
|
||||||
LivenessTimeout time.Duration
|
LivenessTimeout time.Duration
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
// Metrics is optional so transport tests and embeddings do not need an
|
||||||
|
// observability endpoint. Every emitted name is a static bounded value.
|
||||||
|
Metrics *observability.Health
|
||||||
}
|
}
|
||||||
|
|
||||||
func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||||
@@ -53,6 +57,7 @@ func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.Header.Get("Origin") != "" {
|
if request.Header.Get("Origin") != "" {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
http.Error(response, ErrUnexpectedOrigin.Error(), http.StatusForbidden)
|
http.Error(response, ErrUnexpectedOrigin.Error(), http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -83,6 +88,7 @@ func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
server.incMetric("agent_connection")
|
||||||
defer connection.CloseNow()
|
defer connection.CloseNow()
|
||||||
server.serveConnection(request.Context(), connection, heartbeat, started)
|
server.serveConnection(request.Context(), connection, heartbeat, started)
|
||||||
}
|
}
|
||||||
@@ -98,6 +104,7 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
}
|
}
|
||||||
helloEnvelope, err := agentproto.DecodeEnvelope(payload, server.limits(), rvboxv1.Platform_PLATFORM_UNSPECIFIED)
|
helloEnvelope, err := agentproto.DecodeEnvelope(payload, server.limits(), rvboxv1.Platform_PLATFORM_UNSPECIFIED)
|
||||||
if err != nil || helloEnvelope.GetClientHello() == nil {
|
if err != nil || helloEnvelope.GetClientHello() == nil {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "invalid ClientHello")
|
server.close(connection, websocket.StatusPolicyViolation, "invalid ClientHello")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,11 +115,13 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
reconcileBoundary := server.now()
|
reconcileBoundary := server.now()
|
||||||
instanceID, err := domain.ParseUUIDv7(hello.GetClientInstanceId())
|
instanceID, err := domain.ParseUUIDv7(hello.GetClientInstanceId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "invalid client instance ID")
|
server.close(connection, websocket.StatusPolicyViolation, "invalid client instance ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selected, err := domain.SelectProtocol(server.protocolRange(), hello.GetSupportedProtocol())
|
selected, err := domain.SelectProtocol(server.protocolRange(), hello.GetSupportedProtocol())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "unsupported protocol")
|
server.close(connection, websocket.StatusPolicyViolation, "unsupported protocol")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -126,15 +135,22 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
server.close(connection, websocket.StatusInternalError, "could not persist client capabilities")
|
server.close(connection, websocket.StatusInternalError, "could not persist client capabilities")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
registrationStarted := time.Now()
|
||||||
registration, err := server.Store.RegisterClientSession(parent, store.ClientRegistration{
|
registration, err := server.Store.RegisterClientSession(parent, store.ClientRegistration{
|
||||||
ClientID: hello.GetClientId(), Platform: uint32(hello.GetPlatform()), Architecture: hello.GetArchitecture(),
|
ClientID: hello.GetClientId(), Platform: uint32(hello.GetPlatform()), Architecture: hello.GetArchitecture(),
|
||||||
DaemonVersion: hello.GetDaemonVersion(), DaemonCWD: hello.GetDaemonCwd(), SupportedShells: shells,
|
DaemonVersion: hello.GetDaemonVersion(), DaemonCWD: hello.GetDaemonCwd(), SupportedShells: shells,
|
||||||
ClientInstanceID: [16]byte(instanceID), SessionID: sessionID, ConnectedAt: server.now(),
|
ClientInstanceID: [16]byte(instanceID), SessionID: sessionID, ConnectedAt: server.now(),
|
||||||
})
|
})
|
||||||
|
server.observeMetric("sqlite_write_latency", time.Since(registrationStarted))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
server.incMetric("client_registration_failure")
|
||||||
|
if errors.Is(err, store.ErrTakeoverRequired) {
|
||||||
|
server.incMetric("client_takeover_required")
|
||||||
|
}
|
||||||
server.close(connection, websocket.StatusPolicyViolation, sessionCloseReason(err))
|
server.close(connection, websocket.StatusPolicyViolation, sessionCloseReason(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
server.incMetric("client_registration")
|
||||||
registry := server.Registry
|
registry := server.Registry
|
||||||
handle, err := registry.Install(hello.GetClientId(), sessionID, registration.Generation)
|
handle, err := registry.Install(hello.GetClientId(), sessionID, registration.Generation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -227,16 +243,19 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
// to determine liveness.
|
// to determine liveness.
|
||||||
heartbeat.Observe(time.Since(started))
|
heartbeat.Observe(time.Since(started))
|
||||||
if messageType != websocket.MessageBinary {
|
if messageType != websocket.MessageBinary {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusUnsupportedData, ErrUnexpectedMessage.Error())
|
server.close(connection, websocket.StatusUnsupportedData, ErrUnexpectedMessage.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
envelope, err := agentproto.DecodeEnvelope(payload, server.limits(), hello.GetPlatform())
|
envelope, err := agentproto.DecodeEnvelope(payload, server.limits(), hello.GetPlatform())
|
||||||
if err != nil || envelope.GetClientHello() != nil || envelope.GetSessionId() != encodedSessionID || envelope.GetSessionGeneration() != registration.Generation {
|
if err != nil || envelope.GetClientHello() != nil || envelope.GetSessionId() != encodedSessionID || envelope.GetSessionGeneration() != registration.Generation {
|
||||||
|
server.incMetric("stale_message")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error())
|
server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clientID, err := server.Store.ValidateLiveSession(sessionContext, sessionID, registration.Generation)
|
clientID, err := server.Store.ValidateLiveSession(sessionContext, sessionID, registration.Generation)
|
||||||
if err != nil || clientID != hello.GetClientId() {
|
if err != nil || clientID != hello.GetClientId() {
|
||||||
|
server.incMetric("stale_message")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error())
|
server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -317,14 +336,20 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
|
|||||||
}
|
}
|
||||||
appendEvent, eventErr := eventAppendFromWire(event, hello.GetClientId(), registration.Generation, server.now())
|
appendEvent, eventErr := eventAppendFromWire(event, hello.GetClientId(), registration.Generation, server.now())
|
||||||
if eventErr != nil {
|
if eventErr != nil {
|
||||||
|
server.incMetric("protocol_error")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "invalid command event")
|
server.close(connection, websocket.StatusPolicyViolation, "invalid command event")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appended, eventErr := server.Store.AppendCommandEvent(sessionContext, appendEvent)
|
appended, eventErr := server.Store.AppendCommandEvent(sessionContext, appendEvent)
|
||||||
if eventErr != nil {
|
if eventErr != nil {
|
||||||
|
server.incMetric("command_event_rejected")
|
||||||
server.close(connection, websocket.StatusPolicyViolation, "command event was not accepted")
|
server.close(connection, websocket.StatusPolicyViolation, "command event was not accepted")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
server.incMetric("command_event")
|
||||||
|
if event.GetLifecycle() != nil {
|
||||||
|
server.incMetric("command_transition")
|
||||||
|
}
|
||||||
ack, eventErr := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: encodedSessionID, SessionGeneration: registration.Generation, Payload: &rvboxv1.AgentEnvelope_EventAck{EventAck: &rvboxv1.EventAck{IssueUuid: event.GetIssueUuid(), ThroughEventSeq: appended.ThroughEventSeq}}})
|
ack, eventErr := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: encodedSessionID, SessionGeneration: registration.Generation, Payload: &rvboxv1.AgentEnvelope_EventAck{EventAck: &rvboxv1.EventAck{IssueUuid: event.GetIssueUuid(), ThroughEventSeq: appended.ThroughEventSeq}}})
|
||||||
if eventErr != nil || queue.EnqueueControl(Frame{Kind: FrameControl, Payload: ack}) != nil {
|
if eventErr != nil || queue.EnqueueControl(Frame{Kind: FrameControl, Payload: ack}) != nil {
|
||||||
server.close(connection, websocket.StatusInternalError, "could not acknowledge command event")
|
server.close(connection, websocket.StatusInternalError, "could not acknowledge command event")
|
||||||
@@ -452,6 +477,8 @@ func (server *AgentServer) enqueueNextDispatch(ctx context.Context, queue *Write
|
|||||||
beforeEnqueue(candidate)
|
beforeEnqueue(candidate)
|
||||||
}
|
}
|
||||||
if !queue.EnqueueData(Frame{Kind: FrameData, Payload: encoded, OnWritten: func() {
|
if !queue.EnqueueData(Frame{Kind: FrameData, Payload: encoded, OnWritten: func() {
|
||||||
|
server.incMetric("command_dispatch")
|
||||||
|
server.observeMetric("dispatch_latency", server.now().Sub(candidate.IssueTime))
|
||||||
if onWritten != nil {
|
if onWritten != nil {
|
||||||
onWritten(candidate.IssueUUID)
|
onWritten(candidate.IssueUUID)
|
||||||
}
|
}
|
||||||
@@ -756,11 +783,24 @@ func (server *AgentServer) writeLoop(ctx context.Context, connection *websocket.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case HeartbeatClose:
|
case HeartbeatClose:
|
||||||
|
server.incMetric("heartbeat_timeout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (server *AgentServer) incMetric(name string) {
|
||||||
|
if server != nil && server.Metrics != nil {
|
||||||
|
server.Metrics.Inc(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *AgentServer) observeMetric(name string, duration time.Duration) {
|
||||||
|
if server != nil && server.Metrics != nil {
|
||||||
|
server.Metrics.ObserveDuration(name, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (server *AgentServer) close(connection *websocket.Conn, status websocket.StatusCode, reason string) {
|
func (server *AgentServer) close(connection *websocket.Conn, status websocket.StatusCode, reason string) {
|
||||||
_ = connection.Close(status, reason)
|
_ = connection.Close(status, reason)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
"github.com/rvbox/rvbox/internal/agentproto"
|
"github.com/rvbox/rvbox/internal/agentproto"
|
||||||
"github.com/rvbox/rvbox/internal/domain"
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"github.com/rvbox/rvbox/internal/observability"
|
||||||
"github.com/rvbox/rvbox/internal/server/store"
|
"github.com/rvbox/rvbox/internal/server/store"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
@@ -98,6 +99,19 @@ func TestAgentServerRegistrationAndReplacement_HP_SES_05(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentServerEmitsBoundedRegistrationMetrics_HP_OPS_05(t *testing.T) {
|
||||||
|
server, agent, cleanup := newTestAgentServerWithStore(t)
|
||||||
|
defer cleanup()
|
||||||
|
metrics := observability.New()
|
||||||
|
agent.Metrics = metrics
|
||||||
|
connection, _ := dialAndHello(t, server, "metric-client", "019c46f1-1d02-7000-8000-000000000073")
|
||||||
|
defer connection.CloseNow()
|
||||||
|
_, _, counters := metrics.Snapshot()
|
||||||
|
if counters["agent_connection"] != 1 || counters["client_registration"] != 1 {
|
||||||
|
t.Fatalf("registration metrics = %#v", counters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentServerDispatchesCommandAdmittedDuringReconcile_HP_SES_11(t *testing.T) {
|
func TestAgentServerDispatchesCommandAdmittedDuringReconcile_HP_SES_11(t *testing.T) {
|
||||||
server, agent, cleanup := newTestAgentServerWithStore(t)
|
server, agent, cleanup := newTestAgentServerWithStore(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -637,6 +637,15 @@ layer = "unit"
|
|||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["internal/observability/rotate_test.go:TestFormatLogWritesStructuredBoundedRecords_HP_OPS_04"]
|
tests = ["internal/observability/rotate_test.go:TestFormatLogWritesStructuredBoundedRecords_HP_OPS_04"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-OPS-05"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"internal/observability/health_test.go:TestHealthRendersBoundedGaugesAndDurationHistograms_HP_OPS_02",
|
||||||
|
"internal/server/session/agent_server_test.go:TestAgentServerEmitsBoundedRegistrationMetrics_HP_OPS_05",
|
||||||
|
]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-DISPATCH-10"
|
id = "HP-DISPATCH-10"
|
||||||
layer = "unit"
|
layer = "unit"
|
||||||
|
|||||||
Reference in New Issue
Block a user