Unverified Commit 8ac5e64d authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(metrics): observe felis_start_duration_seconds across the start lifecycle

Wire the fourth mandated §23 metric to a real producer. The histogram
spans two reconcile passes, so anchor and observation must persist in
status:

- Add status.startRequestedAt, set once on the first Starting reconcile
  of a start attempt and cleared on Stopped so the next start re-anchors.
- Observe felis_start_duration_seconds exactly when readiness is first
  reached (ReadySignalAt - StartRequestedAt), guarded so a server that
  reaches ready without a Starting pass records nothing.
- Mirror the field into the deepcopy and the structural CRD schema so the
  apiserver does not prune it on patchStatus round-trips.
- Promote prometheus/client_golang and client_model to direct deps now
  that the operator and its tests import them.

Tests drive a step clock through Starting -> Running asserting the exact
observed duration, and through Running -> Stopped asserting the metric is
observed once and the anchor clears.
parent 79eae7f6
Loading
Loading
Loading
Loading
+10 −0
Changes for deploy/crd/felis.lolicon.best_minecraftservers.yaml: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -383,6 +383,16 @@ spec:
                description: ReadySignalAt is when the first RCON probe succeeded.
                format: date-time
                type: string
              startRequestedAt:
                description: |-
                  StartRequestedAt is when the current start attempt was first observed
                  (the first Starting reconcile after desiredState=Running). It anchors the
                  felis_start_duration_seconds histogram (spec §23): the operator observes
                  ReadySignalAt-StartRequestedAt the moment readiness is first reached, then
                  clears this on stop so the next start re-anchors. Persisted in status
                  because the two endpoints fall in different reconcile passes.
                format: date-time
                type: string
            type: object
        type: object
    served: true
+2 −2
Changes for go.mod: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -10,6 +10,8 @@ require (
	github.com/charmbracelet/lipgloss v1.1.0
	github.com/golang-jwt/jwt/v5 v5.2.1
	github.com/jackc/pgx/v5 v5.7.1
	github.com/prometheus/client_golang v1.19.1
	github.com/prometheus/client_model v0.6.1
	golang.org/x/crypto v0.27.0
	k8s.io/api v0.31.3
	k8s.io/apimachinery v0.31.3
@@ -68,8 +70,6 @@ require (
	github.com/muesli/termenv v0.16.0 // indirect
	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
	github.com/pkg/errors v0.9.1 // indirect
	github.com/prometheus/client_golang v1.19.1 // indirect
	github.com/prometheus/client_model v0.6.1 // indirect
	github.com/prometheus/common v0.55.0 // indirect
	github.com/prometheus/procfs v0.15.1 // indirect
	github.com/rivo/uniseg v0.4.7 // indirect
+7 −0
Changes for internal/apis/felis/v1alpha1/minecraftserver_types.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -234,6 +234,13 @@ type MinecraftServerStatus struct {
	LiveMotd string `json:"liveMotd,omitempty"`
	// ReadySignalAt is when the first RCON probe succeeded.
	ReadySignalAt *metav1.Time `json:"readySignalAt,omitempty"`
	// StartRequestedAt is when the current start attempt was first observed
	// (the first Starting reconcile after desiredState=Running). It anchors the
	// felis_start_duration_seconds histogram (spec §23): the operator observes
	// ReadySignalAt-StartRequestedAt the moment readiness is first reached, then
	// clears this on stop so the next start re-anchors. Persisted in status
	// because the two endpoints fall in different reconcile passes.
	StartRequestedAt *metav1.Time `json:"startRequestedAt,omitempty"`
	// ObservedGeneration is the spec generation this status reflects.
	ObservedGeneration int64 `json:"observedGeneration,omitempty"`
	// Conditions are the standard metav1 conditions (Ready, RconReached, ...).
+3 −0
Changes for internal/apis/felis/v1alpha1/zz_generated.deepcopy.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -115,6 +115,9 @@ func (in *MinecraftServerStatus) DeepCopyInto(out *MinecraftServerStatus) {
	if in.ReadySignalAt != nil {
		out.ReadySignalAt = in.ReadySignalAt.DeepCopy()
	}
	if in.StartRequestedAt != nil {
		out.StartRequestedAt = in.StartRequestedAt.DeepCopy()
	}
	if in.Conditions != nil {
		l := make([]metav1.Condition, len(in.Conditions))
		for i := range in.Conditions {
+21 −0
Changes for internal/operator/reconciler.go: 21 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ import (
	"time"

	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/metrics"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -235,6 +236,16 @@ func (r *Reconciler) markStarting(server *v1alpha1.MinecraftServer, reason, msg
	server.Status.Phase = v1alpha1.PhaseStarting
	server.Status.Ready = false
	server.Status.ObservedGeneration = server.Generation
	// Anchor felis_start_duration_seconds (spec §23) at the first Starting pass of
	// this start attempt. Set-once (cleared on stop) so re-entrant Starting
	// reconciles preserve the original anchor and the observed duration spans the
	// whole start, not just the last requeue. A server that reaches readiness
	// without ever passing through Starting leaves this nil, and markRunningReady
	// skips the observation rather than recording a bogus one.
	if server.Status.StartRequestedAt == nil {
		t := r.now()
		server.Status.StartRequestedAt = &t
	}
	server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer}
	server.Status.LiveMotd = server.Spec.Motd.Starting
	r.setCondition(server, v1alpha1.ConditionReady, metav1.ConditionFalse, reason, msg)
@@ -248,6 +259,13 @@ func (r *Reconciler) markRunningReady(server *v1alpha1.MinecraftServer) {
	if server.Status.ReadySignalAt == nil {
		t := r.now()
		server.Status.ReadySignalAt = &t
		// Observe felis_start_duration_seconds (spec §23) exactly once, when
		// readiness is first reached. StartRequestedAt was persisted by an earlier
		// Starting reconcile; if it is nil the server became ready without a
		// Starting pass and there is no meaningful start interval to record.
		if server.Status.StartRequestedAt != nil {
			metrics.StartDurationSeconds.Observe(t.Sub(server.Status.StartRequestedAt.Time).Seconds())
		}
	}
	server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointDirect, Address: gameAddress(server)}
	server.Status.LiveMotd = server.Spec.Motd.Running
@@ -269,6 +287,9 @@ func (r *Reconciler) markStopped(server *v1alpha1.MinecraftServer) {
	server.Status.Ready = false
	server.Status.ObservedGeneration = server.Generation
	server.Status.ReadySignalAt = nil
	// Clear the start anchor so the next Running transition re-anchors and
	// felis_start_duration_seconds measures the new start, not since the last one.
	server.Status.StartRequestedAt = nil
	server.Status.Players = v1alpha1.PlayersStatus{}
	server.Status.Endpoint = v1alpha1.EndpointStatus{Mode: v1alpha1.EndpointFallback, Address: server.Spec.FallbackServer}
	server.Status.LiveMotd = server.Spec.Motd.Stopped
Loading