Files
galaxy-game/lobby/internal/telemetry/runtime_test.go
T
2026-04-25 23:20:55 +02:00

265 lines
9.0 KiB
Go

package telemetry
import (
"context"
"errors"
"sort"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
)
func TestProcessConfigValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg ProcessConfig
wantErr string
}{
{name: "defaults", cfg: ProcessConfig{TracesExporter: "none", MetricsExporter: "none"}},
{name: "otlp", cfg: ProcessConfig{TracesExporter: "otlp", MetricsExporter: "otlp", TracesProtocol: "grpc", MetricsProtocol: "http/protobuf"}},
{name: "bad traces exporter", cfg: ProcessConfig{TracesExporter: "weird", MetricsExporter: "none"}, wantErr: "unsupported traces exporter"},
{name: "bad metrics exporter", cfg: ProcessConfig{TracesExporter: "none", MetricsExporter: "weird"}, wantErr: "unsupported metrics exporter"},
{name: "bad traces protocol", cfg: ProcessConfig{TracesExporter: "none", MetricsExporter: "none", TracesProtocol: "xyz"}, wantErr: "OTLP traces protocol"},
{name: "bad metrics protocol", cfg: ProcessConfig{TracesExporter: "none", MetricsExporter: "none", MetricsProtocol: "xyz"}, wantErr: "OTLP metrics protocol"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.cfg.Validate()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestNewProcessNoExporters(t *testing.T) {
runtime, err := NewProcess(context.Background(), ProcessConfig{
ServiceName: "galaxy-lobby-test",
TracesExporter: "none",
MetricsExporter: "none",
}, nil)
require.NoError(t, err)
require.NotNil(t, runtime)
assert.NotNil(t, runtime.TracerProvider())
assert.NotNil(t, runtime.MeterProvider())
require.NoError(t, runtime.Shutdown(context.Background()))
require.NoError(t, runtime.Shutdown(context.Background())) // idempotent
}
func TestNewProcessNilContext(t *testing.T) {
t.Parallel()
_, err := NewProcess(nil, ProcessConfig{TracesExporter: "none", MetricsExporter: "none"}, nil) //nolint:staticcheck // test exercises the nil-context guard.
require.Error(t, err)
require.Contains(t, err.Error(), "nil context")
}
func TestNewProcessInvalidConfig(t *testing.T) {
t.Parallel()
_, err := NewProcess(context.Background(), ProcessConfig{TracesExporter: "weird", MetricsExporter: "none"}, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported traces exporter")
}
func TestRuntimeNilSafeRecorders(t *testing.T) {
t.Parallel()
var runtime *Runtime
runtime.RecordPublicHTTPRequest(context.Background(), nil, 0)
runtime.RecordInternalHTTPRequest(context.Background(), nil, 0)
runtime.RecordGameTransition(context.Background(), "draft", "enrollment_open", "command")
runtime.RecordApplicationOutcome(context.Background(), "submitted")
runtime.RecordInviteOutcome(context.Background(), "created")
runtime.RecordMembershipChange(context.Background(), "activated")
runtime.RecordStartFlowOutcome(context.Background(), "running")
runtime.RecordNotificationPublish(context.Background(), "lobby.application.submitted", "ok")
runtime.RecordEnrollmentAutomationCheck(context.Background(), "no_op")
runtime.RecordRaceNameOutcome(context.Background(), "reserved")
runtime.RecordPendingRegistrationExpiration(context.Background(), "tick")
runtime.RecordUserLifecycleCascadeRelease(context.Background(), "permanent_blocked")
runtime.RecordCapabilityEvaluation(context.Background(), "capable")
require.Error(t, runtime.RegisterGauges(GaugeDependencies{}))
require.NoError(t, runtime.Shutdown(context.Background()))
}
func TestRuntimeLobbyCountersRegistered(t *testing.T) {
t.Parallel()
reader := sdkmetric.NewManualReader()
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
runtime, err := NewWithProviders(provider, nil)
require.NoError(t, err)
ctx := context.Background()
runtime.RecordGameTransition(ctx, "draft", "enrollment_open", "command")
runtime.RecordApplicationOutcome(ctx, "submitted")
runtime.RecordInviteOutcome(ctx, "created")
runtime.RecordMembershipChange(ctx, "activated")
runtime.RecordStartFlowOutcome(ctx, "running")
runtime.RecordNotificationPublish(ctx, "lobby.application.submitted", "ok")
runtime.RecordEnrollmentAutomationCheck(ctx, "no_op")
runtime.RecordRaceNameOutcome(ctx, "reserved")
runtime.RecordPendingRegistrationExpiration(ctx, "tick")
runtime.RecordUserLifecycleCascadeRelease(ctx, "permanent_blocked")
runtime.RecordCapabilityEvaluation(ctx, "capable")
var rm metricdata.ResourceMetrics
require.NoError(t, reader.Collect(ctx, &rm))
got := collectedMetricNames(rm)
want := []string{
"lobby.application.outcomes",
"lobby.capability_evaluations",
"lobby.enrollment_automation.checks",
"lobby.game.transitions",
"lobby.invite.outcomes",
"lobby.membership.changes",
"lobby.notification.publish_attempts",
"lobby.pending_registration.expirations",
"lobby.race_name.outcomes",
"lobby.start_flow.outcomes",
"lobby.user_lifecycle.cascade_releases",
}
for _, name := range want {
assert.Contains(t, got, name)
}
}
type stubActiveGames map[string]int
func (s stubActiveGames) CountByStatus(_ context.Context) (map[string]int, error) {
return s, nil
}
type stubLag struct {
age time.Duration
ok bool
err error
}
func (s stubLag) OldestUnprocessedAge(_ context.Context, _ string, _ string) (time.Duration, bool, error) {
return s.age, s.ok, s.err
}
type stubOffsets struct{}
func (stubOffsets) Load(_ context.Context, _ string) (string, bool, error) {
return "0-0", true, nil
}
func TestRuntimeGaugesObserveCounts(t *testing.T) {
t.Parallel()
reader := sdkmetric.NewManualReader()
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
runtime, err := NewWithProviders(provider, nil)
require.NoError(t, err)
require.NoError(t, runtime.RegisterGauges(GaugeDependencies{
ActiveGames: stubActiveGames{"running": 3, "paused": 1},
StreamLag: stubLag{age: 750 * time.Millisecond, ok: true},
Offsets: stubOffsets{},
GMEvents: StreamGaugeBinding{OffsetLabel: "gm_lobby_events", StreamName: "gm:lobby_events"},
RuntimeResults: StreamGaugeBinding{OffsetLabel: "runtime_results", StreamName: "runtime:job_results"},
UserLifecycle: StreamGaugeBinding{OffsetLabel: "user_lifecycle", StreamName: "user:lifecycle_events"},
}))
ctx := context.Background()
var rm metricdata.ResourceMetrics
require.NoError(t, reader.Collect(ctx, &rm))
gauges := collectedGauges(rm)
require.Contains(t, gauges, "lobby.active_games")
require.Contains(t, gauges, "lobby.gm_events.oldest_unprocessed_age_ms")
require.Contains(t, gauges, "lobby.runtime_results.oldest_unprocessed_age_ms")
require.Contains(t, gauges, "lobby.user_lifecycle.oldest_unprocessed_age_ms")
}
func TestRuntimeRegisterGaugesValidates(t *testing.T) {
t.Parallel()
runtime, err := NewWithProviders(sdkmetric.NewMeterProvider(), nil)
require.NoError(t, err)
cases := []struct {
name string
deps GaugeDependencies
}{
{"missing active games", GaugeDependencies{}},
{"missing stream lag", GaugeDependencies{ActiveGames: stubActiveGames{}}},
{"missing offsets", GaugeDependencies{ActiveGames: stubActiveGames{}, StreamLag: stubLag{}}},
{"missing gm events stream", GaugeDependencies{ActiveGames: stubActiveGames{}, StreamLag: stubLag{}, Offsets: stubOffsets{}}},
}
for _, tc := range cases {
err := runtime.RegisterGauges(tc.deps)
require.Error(t, err, tc.name)
}
}
func TestRuntimeGaugeProbeErrorsAreLogged(t *testing.T) {
t.Parallel()
reader := sdkmetric.NewManualReader()
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
runtime, err := NewWithProviders(provider, nil)
require.NoError(t, err)
require.NoError(t, runtime.RegisterGauges(GaugeDependencies{
ActiveGames: stubActiveGames{"running": 0},
StreamLag: stubLag{err: errors.New("boom")},
Offsets: stubOffsets{},
GMEvents: StreamGaugeBinding{OffsetLabel: "gm_lobby_events", StreamName: "gm:lobby_events"},
RuntimeResults: StreamGaugeBinding{OffsetLabel: "runtime_results", StreamName: "runtime:job_results"},
UserLifecycle: StreamGaugeBinding{OffsetLabel: "user_lifecycle", StreamName: "user:lifecycle_events"},
}))
var rm metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &rm))
}
func collectedMetricNames(rm metricdata.ResourceMetrics) []string {
var names []string
for _, scope := range rm.ScopeMetrics {
for _, m := range scope.Metrics {
names = append(names, m.Name)
}
}
sort.Strings(names)
return names
}
func collectedGauges(rm metricdata.ResourceMetrics) map[string]struct{} {
gauges := map[string]struct{}{}
for _, scope := range rm.ScopeMetrics {
for _, m := range scope.Metrics {
if _, ok := m.Data.(metricdata.Gauge[int64]); ok {
gauges[m.Name] = struct{}{}
}
}
}
return gauges
}