feat: NWC Subscription System

This commit is contained in:
2025-08-19 11:13:19 -04:00
parent 499dab72b9
commit 9176a013d1
19 changed files with 2251 additions and 108 deletions

346
pkg/app/relay/metrics.go Normal file
View 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))
}