mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
169 lines
7.8 KiB
Go
169 lines
7.8 KiB
Go
package metrics
|
|
|
|
import (
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
|
|
"github.com/multica-ai/multica/server/internal/analytics"
|
|
"github.com/multica-ai/multica/server/pkg/taskfailure"
|
|
)
|
|
|
|
func TestBusinessMetricsLifecycleCountersAndGauge(t *testing.T) {
|
|
m := NewBusinessMetrics()
|
|
|
|
m.RecordTaskEnqueued("issue", "local")
|
|
for i := 0; i < 100; i++ {
|
|
m.RecordTaskDispatched("task-"+strconv.Itoa(i), "issue", "local", 2.5)
|
|
}
|
|
m.RecordTaskStarted("issue", "local", "codex")
|
|
m.RecordTaskTerminal("task-0", "issue", "local", "completed", 10, 20, 1)
|
|
|
|
if got := testutil.ToFloat64(m.taskEnqueued.WithLabelValues("issue", "local")); got != 1 {
|
|
t.Fatalf("enqueued counter = %v, want 1", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.taskDispatched.WithLabelValues("issue", "local")); got != 100 {
|
|
t.Fatalf("dispatched counter = %v, want 100", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.taskStarted.WithLabelValues("issue", "local", "codex")); got != 1 {
|
|
t.Fatalf("started counter = %v, want 1", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.taskTerminal.WithLabelValues("issue", "local", "completed")); got != 1 {
|
|
t.Fatalf("terminal counter = %v, want 1", got)
|
|
}
|
|
if got := testutil.CollectAndCount(m.taskInProgress); got != 1 {
|
|
t.Fatalf("in_progress series count = %d, want 1 despite 100 task ids", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.taskInProgress.WithLabelValues("issue", "local")); got != 99 {
|
|
t.Fatalf("in_progress gauge = %v, want 99", got)
|
|
}
|
|
if got := testutil.CollectAndCount(m.taskQueueWait); got != 1 {
|
|
t.Fatalf("queue wait series count = %d, want 1", got)
|
|
}
|
|
if got := testutil.CollectAndCount(m.taskRunSeconds); got != 1 {
|
|
t.Fatalf("run seconds series count = %d, want 1", got)
|
|
}
|
|
if got := testutil.CollectAndCount(m.taskTotalSeconds); got != 1 {
|
|
t.Fatalf("total seconds series count = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestBusinessMetricsFailureReasonUsesCanonicalClassifier(t *testing.T) {
|
|
m := NewBusinessMetrics()
|
|
|
|
rawError := `API Error: 429 {"error":"overloaded"}`
|
|
m.RecordTaskFailed("issue", "local", rawError)
|
|
|
|
wantReason := taskfailure.ReasonAgentProviderCapacityOrRateLimit.String()
|
|
if got := testutil.ToFloat64(m.taskFailed.WithLabelValues("issue", "local", wantReason)); got != 1 {
|
|
t.Fatalf("classified failure counter = %v, want 1", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.taskFailed.WithLabelValues("issue", "local", taskfailure.ReasonAgentUnknown.String())); got != 0 {
|
|
t.Fatalf("unknown failure counter = %v, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestBusinessMetricsLLMPricingAndUnpricedTokens(t *testing.T) {
|
|
m := NewBusinessMetrics()
|
|
|
|
m.RecordLLMUsage("chat", "cloud", "codex", "gpt-5.4", 1_000_000, 2_000_000, 3_000_000, 4_000_000)
|
|
|
|
if got := testutil.ToFloat64(m.llmTokens.WithLabelValues("openai", "gpt-5.4", "input", "cloud", "chat")); got != 1_000_000 {
|
|
t.Fatalf("priced input tokens = %v, want 1000000", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.llmTokens.WithLabelValues("openai", "gpt-5.4", "output", "cloud", "chat")); got != 2_000_000 {
|
|
t.Fatalf("priced output tokens = %v, want 2000000", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.llmCostUSD.WithLabelValues("openai", "gpt-5.4", "input", "cloud", "chat")); got != 2.5 {
|
|
t.Fatalf("priced input cost = %v, want 2.5", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.llmCostUSD.WithLabelValues("openai", "gpt-5.4", "output", "cloud", "chat")); got != 30 {
|
|
t.Fatalf("priced output cost = %v, want 30", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.llmRequests.WithLabelValues("openai", "gpt-5.4", "cloud")); got != 1 {
|
|
t.Fatalf("priced request counter = %v, want 1", got)
|
|
}
|
|
|
|
m.RecordLLMUsage("issue", "local", "custom-provider", "Free Model!!", 7, 0, 0, 0)
|
|
if got := testutil.ToFloat64(m.llmUnpricedTokens.WithLabelValues("other", "free_model_", "input")); got != 7 {
|
|
t.Fatalf("unpriced input tokens = %v, want 7", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.llmRequests.WithLabelValues("other", "unknown", "local")); got != 1 {
|
|
t.Fatalf("unpriced request counter = %v, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestBusinessMetricsRegistryExposesAllFamilies(t *testing.T) {
|
|
registry := prometheus.NewRegistry()
|
|
m := NewBusinessMetrics()
|
|
registry.MustRegister(m.Collectors()...)
|
|
|
|
m.RecordTaskEnqueued("issue", "local")
|
|
m.RecordTaskDispatched("task-1", "issue", "local", 1)
|
|
m.RecordTaskStarted("issue", "local", "codex")
|
|
m.RecordTaskTerminal("task-1", "issue", "local", "completed", 2, 3, 1)
|
|
m.RecordTaskFailed("issue", "local", taskfailure.ReasonTimeout.String())
|
|
m.RecordTaskQueuedExpired("issue", "local")
|
|
m.RecordTaskLeaseExpired("issue")
|
|
m.RecordLLMUsage("issue", "local", "codex", "gpt-5.4", 1, 1, 1, 1)
|
|
m.RecordLLMUsage("issue", "local", "custom-provider", "custom-model", 1, 0, 0, 0)
|
|
|
|
// PR3 funnel / community / commercial events. Drive every counter
|
|
// with one synthetic value so the gather loop below sees the family.
|
|
exerciseEvent(m, analytics.EventSignup, map[string]any{"signup_source": "test"})
|
|
exerciseEvent(m, analytics.EventWorkspaceCreated, map[string]any{"source": "manual"})
|
|
exerciseEvent(m, analytics.EventTeamInviteSent, nil)
|
|
exerciseEvent(m, analytics.EventTeamInviteAccepted, nil)
|
|
exerciseEvent(m, analytics.EventOnboardingStarted, map[string]any{"platform": "web"})
|
|
exerciseEvent(m, analytics.EventOnboardingQuestionnaireSubmit, nil)
|
|
exerciseEvent(m, analytics.EventOnboardingCompleted, map[string]any{"completion_path": "full"})
|
|
exerciseEvent(m, analytics.EventCloudWaitlistJoined, nil)
|
|
exerciseEvent(m, analytics.EventIssueCreated, map[string]any{"source": "manual", "platform": "web"})
|
|
exerciseEvent(m, analytics.EventChatMessageSent, map[string]any{"platform": "web"})
|
|
exerciseEvent(m, analytics.EventAgentCreated, map[string]any{"runtime_mode": "local", "source": "manual"})
|
|
exerciseEvent(m, analytics.EventSquadCreated, nil)
|
|
exerciseEvent(m, analytics.EventAutopilotCreated, map[string]any{"cadence": "manual"})
|
|
exerciseEvent(m, analytics.EventIssueExecuted, map[string]any{"source": "manual"})
|
|
exerciseEvent(m, analytics.EventRuntimeRegistered, map[string]any{"runtime_mode": "local", "provider": "claude"})
|
|
exerciseEvent(m, analytics.EventRuntimeReady, map[string]any{"runtime_mode": "local", "provider": "claude", "ready_duration_ms": int64(1000)})
|
|
exerciseEvent(m, analytics.EventRuntimeFailed, map[string]any{"runtime_mode": "local", "provider": "claude", "failure_reason": "timeout", "recoverable": true})
|
|
exerciseEvent(m, analytics.EventRuntimeOffline, map[string]any{"runtime_mode": "local", "provider": "claude"})
|
|
exerciseEvent(m, analytics.EventAutopilotRunStarted, map[string]any{"cadence": "manual", "trigger_kind": "manual"})
|
|
exerciseEvent(m, analytics.EventAutopilotRunCompleted, map[string]any{"cadence": "manual", "trigger_kind": "manual"})
|
|
exerciseEvent(m, analytics.EventAutopilotRunFailed, map[string]any{"cadence": "manual", "trigger_kind": "manual"})
|
|
exerciseEvent(m, analytics.EventFeedbackSubmitted, map[string]any{"kind": "general", "platform": "web"})
|
|
exerciseEvent(m, analytics.EventContactSalesSubmitted, map[string]any{"form_source": "page"})
|
|
|
|
// Direct Record* helpers (no PostHog event source).
|
|
m.RecordAutopilotRunSkipped("manual", "throttled")
|
|
m.RecordWebhookDelivery("github", "dispatched")
|
|
m.RecordGithubEventReceived("pull_request", "opened")
|
|
m.RecordGithubPRReview("approved")
|
|
m.ObserveGithubPRMergeSeconds(120)
|
|
m.RecordCloudRuntimeRequest("provision", "ok", 0.5)
|
|
m.RecordDaemonWSMessageReceived("heartbeat")
|
|
|
|
families, err := registry.Gather()
|
|
if err != nil {
|
|
t.Fatalf("gather: %v", err)
|
|
}
|
|
seen := make(map[string]bool, len(families))
|
|
for _, family := range families {
|
|
seen[family.GetName()] = true
|
|
}
|
|
for metric := range businessMetricLabels {
|
|
if !seen[metric] {
|
|
t.Fatalf("registry did not expose metric family %s", metric)
|
|
}
|
|
}
|
|
}
|
|
|
|
func exerciseEvent(m *BusinessMetrics, name string, props map[string]any) {
|
|
if props == nil {
|
|
props = map[string]any{}
|
|
}
|
|
m.IncForEvent(analytics.Event{Name: name, Properties: props})
|
|
}
|