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 }