347 lines
9.6 KiB
Go
347 lines
9.6 KiB
Go
package relay
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"orly.dev/pkg/database"
|
|
"orly.dev/pkg/utils/log"
|
|
)
|
|
|
|
// MetricsCollector tracks subscription system metrics
|
|
type MetricsCollector struct {
|
|
mu sync.RWMutex
|
|
db *database.D
|
|
|
|
// Subscription metrics
|
|
totalTrialSubscriptions int64
|
|
totalPaidSubscriptions int64
|
|
|
|
// Payment metrics
|
|
paymentSuccessCount int64
|
|
paymentFailureCount int64
|
|
|
|
// Conversion metrics
|
|
trialToPaidConversions int64
|
|
totalTrialsStarted int64
|
|
|
|
// Duration metrics
|
|
subscriptionDurations []time.Duration
|
|
maxDurationSamples int
|
|
|
|
// Health status
|
|
lastHealthCheck time.Time
|
|
isHealthy bool
|
|
healthCheckErrors []string
|
|
}
|
|
|
|
// NewMetricsCollector creates a new metrics collector
|
|
func NewMetricsCollector(db *database.D) *MetricsCollector {
|
|
return &MetricsCollector{
|
|
db: db,
|
|
maxDurationSamples: 1000,
|
|
isHealthy: true,
|
|
lastHealthCheck: time.Now(),
|
|
}
|
|
}
|
|
|
|
// RecordTrialStarted increments trial subscription counter
|
|
func (mc *MetricsCollector) RecordTrialStarted() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
mc.totalTrialsStarted++
|
|
mc.totalTrialSubscriptions++
|
|
}
|
|
|
|
// RecordPaidSubscription increments paid subscription counter
|
|
func (mc *MetricsCollector) RecordPaidSubscription() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
mc.totalPaidSubscriptions++
|
|
}
|
|
|
|
// RecordTrialExpired decrements trial subscription counter
|
|
func (mc *MetricsCollector) RecordTrialExpired() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
if mc.totalTrialSubscriptions > 0 {
|
|
mc.totalTrialSubscriptions--
|
|
}
|
|
}
|
|
|
|
// RecordPaidExpired decrements paid subscription counter
|
|
func (mc *MetricsCollector) RecordPaidExpired() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
if mc.totalPaidSubscriptions > 0 {
|
|
mc.totalPaidSubscriptions--
|
|
}
|
|
}
|
|
|
|
// RecordPaymentSuccess increments successful payment counter
|
|
func (mc *MetricsCollector) RecordPaymentSuccess() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
mc.paymentSuccessCount++
|
|
}
|
|
|
|
// RecordPaymentFailure increments failed payment counter
|
|
func (mc *MetricsCollector) RecordPaymentFailure() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
mc.paymentFailureCount++
|
|
}
|
|
|
|
// RecordTrialToPaidConversion records when a trial user becomes paid
|
|
func (mc *MetricsCollector) RecordTrialToPaidConversion() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
mc.trialToPaidConversions++
|
|
// Move from trial to paid
|
|
if mc.totalTrialSubscriptions > 0 {
|
|
mc.totalTrialSubscriptions--
|
|
}
|
|
mc.totalPaidSubscriptions++
|
|
}
|
|
|
|
// RecordSubscriptionDuration adds a subscription duration sample
|
|
func (mc *MetricsCollector) RecordSubscriptionDuration(duration time.Duration) {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
|
|
// Keep only the most recent samples to prevent memory growth
|
|
mc.subscriptionDurations = append(mc.subscriptionDurations, duration)
|
|
if len(mc.subscriptionDurations) > mc.maxDurationSamples {
|
|
mc.subscriptionDurations = mc.subscriptionDurations[1:]
|
|
}
|
|
}
|
|
|
|
// GetMetrics returns current metrics snapshot
|
|
func (mc *MetricsCollector) GetMetrics() map[string]interface{} {
|
|
mc.mu.RLock()
|
|
defer mc.mu.RUnlock()
|
|
|
|
totalPayments := mc.paymentSuccessCount + mc.paymentFailureCount
|
|
var paymentSuccessRate float64
|
|
if totalPayments > 0 {
|
|
paymentSuccessRate = float64(mc.paymentSuccessCount) / float64(totalPayments)
|
|
}
|
|
|
|
var conversionRate float64
|
|
if mc.totalTrialsStarted > 0 {
|
|
conversionRate = float64(mc.trialToPaidConversions) / float64(mc.totalTrialsStarted)
|
|
}
|
|
|
|
var avgDuration time.Duration
|
|
if len(mc.subscriptionDurations) > 0 {
|
|
var total time.Duration
|
|
for _, d := range mc.subscriptionDurations {
|
|
total += d
|
|
}
|
|
avgDuration = total / time.Duration(len(mc.subscriptionDurations))
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"total_trial_subscriptions": mc.totalTrialSubscriptions,
|
|
"total_paid_subscriptions": mc.totalPaidSubscriptions,
|
|
"total_active_subscriptions": mc.totalTrialSubscriptions + mc.totalPaidSubscriptions,
|
|
"payment_success_count": mc.paymentSuccessCount,
|
|
"payment_failure_count": mc.paymentFailureCount,
|
|
"payment_success_rate": paymentSuccessRate,
|
|
"trial_to_paid_conversions": mc.trialToPaidConversions,
|
|
"total_trials_started": mc.totalTrialsStarted,
|
|
"conversion_rate": conversionRate,
|
|
"average_subscription_duration_seconds": avgDuration.Seconds(),
|
|
"last_health_check": mc.lastHealthCheck.Unix(),
|
|
"is_healthy": mc.isHealthy,
|
|
}
|
|
}
|
|
|
|
// GetPrometheusMetrics returns metrics in Prometheus format
|
|
func (mc *MetricsCollector) GetPrometheusMetrics() string {
|
|
metrics := mc.GetMetrics()
|
|
|
|
promMetrics := `# HELP orly_trial_subscriptions_total Total number of active trial subscriptions
|
|
# TYPE orly_trial_subscriptions_total gauge
|
|
orly_trial_subscriptions_total %d
|
|
|
|
# HELP orly_paid_subscriptions_total Total number of active paid subscriptions
|
|
# TYPE orly_paid_subscriptions_total gauge
|
|
orly_paid_subscriptions_total %d
|
|
|
|
# HELP orly_active_subscriptions_total Total number of active subscriptions (trial + paid)
|
|
# TYPE orly_active_subscriptions_total gauge
|
|
orly_active_subscriptions_total %d
|
|
|
|
# HELP orly_payment_success_total Total number of successful payments
|
|
# TYPE orly_payment_success_total counter
|
|
orly_payment_success_total %d
|
|
|
|
# HELP orly_payment_failure_total Total number of failed payments
|
|
# TYPE orly_payment_failure_total counter
|
|
orly_payment_failure_total %d
|
|
|
|
# HELP orly_payment_success_rate Payment success rate (0.0 to 1.0)
|
|
# TYPE orly_payment_success_rate gauge
|
|
orly_payment_success_rate %.6f
|
|
|
|
# HELP orly_trial_to_paid_conversions_total Total number of trial to paid conversions
|
|
# TYPE orly_trial_to_paid_conversions_total counter
|
|
orly_trial_to_paid_conversions_total %d
|
|
|
|
# HELP orly_trials_started_total Total number of trials started
|
|
# TYPE orly_trials_started_total counter
|
|
orly_trials_started_total %d
|
|
|
|
# HELP orly_conversion_rate Trial to paid conversion rate (0.0 to 1.0)
|
|
# TYPE orly_conversion_rate gauge
|
|
orly_conversion_rate %.6f
|
|
|
|
# HELP orly_avg_subscription_duration_seconds Average subscription duration in seconds
|
|
# TYPE orly_avg_subscription_duration_seconds gauge
|
|
orly_avg_subscription_duration_seconds %.2f
|
|
|
|
# HELP orly_last_health_check_timestamp Last health check timestamp
|
|
# TYPE orly_last_health_check_timestamp gauge
|
|
orly_last_health_check_timestamp %d
|
|
|
|
# HELP orly_health_status Health status (1 = healthy, 0 = unhealthy)
|
|
# TYPE orly_health_status gauge
|
|
orly_health_status %d
|
|
`
|
|
|
|
healthStatus := 0
|
|
if metrics["is_healthy"].(bool) {
|
|
healthStatus = 1
|
|
}
|
|
|
|
return fmt.Sprintf(promMetrics,
|
|
metrics["total_trial_subscriptions"],
|
|
metrics["total_paid_subscriptions"],
|
|
metrics["total_active_subscriptions"],
|
|
metrics["payment_success_count"],
|
|
metrics["payment_failure_count"],
|
|
metrics["payment_success_rate"],
|
|
metrics["trial_to_paid_conversions"],
|
|
metrics["total_trials_started"],
|
|
metrics["conversion_rate"],
|
|
metrics["average_subscription_duration_seconds"],
|
|
metrics["last_health_check"],
|
|
healthStatus,
|
|
)
|
|
}
|
|
|
|
// PerformHealthCheck checks system health
|
|
func (mc *MetricsCollector) PerformHealthCheck() {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
|
|
mc.lastHealthCheck = time.Now()
|
|
mc.healthCheckErrors = []string{}
|
|
mc.isHealthy = true
|
|
|
|
if mc.db != nil {
|
|
testPubkey := make([]byte, 32)
|
|
_, err := mc.db.GetSubscription(testPubkey)
|
|
if err != nil {
|
|
mc.isHealthy = false
|
|
mc.healthCheckErrors = append(mc.healthCheckErrors, fmt.Sprintf("database error: %v", err))
|
|
}
|
|
} else {
|
|
mc.isHealthy = false
|
|
mc.healthCheckErrors = append(mc.healthCheckErrors, "database not initialized")
|
|
}
|
|
|
|
if mc.isHealthy {
|
|
log.D.Ln("health check passed")
|
|
} else {
|
|
log.W.F("health check failed: %v", mc.healthCheckErrors)
|
|
}
|
|
}
|
|
|
|
// GetHealthStatus returns current health status
|
|
func (mc *MetricsCollector) GetHealthStatus() map[string]interface{} {
|
|
mc.mu.RLock()
|
|
defer mc.mu.RUnlock()
|
|
|
|
return map[string]interface{}{
|
|
"healthy": mc.isHealthy,
|
|
"last_check": mc.lastHealthCheck.Format(time.RFC3339),
|
|
"errors": mc.healthCheckErrors,
|
|
"uptime_seconds": time.Since(mc.lastHealthCheck).Seconds(),
|
|
}
|
|
}
|
|
|
|
// StartPeriodicHealthChecks runs health checks periodically
|
|
func (mc *MetricsCollector) StartPeriodicHealthChecks(interval time.Duration, stopCh <-chan struct{}) {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
// Perform initial health check
|
|
mc.PerformHealthCheck()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
mc.PerformHealthCheck()
|
|
case <-stopCh:
|
|
log.D.Ln("stopping periodic health checks")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// MetricsHandler handles HTTP requests for metrics endpoint
|
|
func (mc *MetricsCollector) MetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
metrics := mc.GetPrometheusMetrics()
|
|
w.Write([]byte(metrics))
|
|
}
|
|
|
|
// HealthHandler handles HTTP requests for health check endpoint
|
|
func (mc *MetricsCollector) HealthHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Perform real-time health check
|
|
mc.PerformHealthCheck()
|
|
|
|
status := mc.GetHealthStatus()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if status["healthy"].(bool) {
|
|
w.WriteHeader(http.StatusOK)
|
|
} else {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}
|
|
|
|
// Simple JSON formatting without external dependencies
|
|
healthy := "true"
|
|
if !status["healthy"].(bool) {
|
|
healthy = "false"
|
|
}
|
|
|
|
errorsJson := "[]"
|
|
if errors, ok := status["errors"].([]string); ok && len(errors) > 0 {
|
|
errorsJson = `["`
|
|
for i, err := range errors {
|
|
if i > 0 {
|
|
errorsJson += `", "`
|
|
}
|
|
errorsJson += err
|
|
}
|
|
errorsJson += `"]`
|
|
}
|
|
|
|
response := fmt.Sprintf(`{
|
|
"healthy": %s,
|
|
"last_check": "%s",
|
|
"errors": %s,
|
|
"uptime_seconds": %.2f
|
|
}`, healthy, status["last_check"], errorsJson, status["uptime_seconds"])
|
|
|
|
w.Write([]byte(response))
|
|
}
|