feat: NWC Subscription System
This commit is contained in:
346
pkg/app/relay/metrics.go
Normal file
346
pkg/app/relay/metrics.go
Normal file
@@ -0,0 +1,346 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user