Pulse/pkg/licensing/conversion_quality.go
2026-03-18 16:06:30 +00:00

202 lines
4.8 KiB
Go

package licensing
import (
"strings"
"sync"
"time"
)
const defaultPipelineStaleThreshold = 5 * time.Minute
var pipelineCoverageEventTypes = []string{
EventPaywallViewed,
EventTrialStarted,
EventUpgradeClicked,
EventCheckoutCompleted,
}
// HealthStatus describes conversion pipeline health and recent activity.
type HealthStatus struct {
Status string `json:"status"`
LastEventAgeSeconds float64 `json:"last_event_age_seconds"`
EventsTotal int64 `json:"events_total"`
EventsByType map[string]int64 `json:"events_by_type"`
StartedAt int64 `json:"started_at"`
}
// PipelineHealthOption configures pipeline health behavior.
type PipelineHealthOption func(*pipelineHealthOptions)
type pipelineHealthOptions struct {
now func() time.Time
staleThreshold time.Duration
}
// WithPipelineHealthNow sets the clock source used by PipelineHealth.
func WithPipelineHealthNow(now func() time.Time) PipelineHealthOption {
return func(options *pipelineHealthOptions) {
options.now = now
}
}
// WithPipelineHealthStaleThreshold sets the stale threshold used by PipelineHealth.
func WithPipelineHealthStaleThreshold(threshold time.Duration) PipelineHealthOption {
return func(options *pipelineHealthOptions) {
options.staleThreshold = threshold
}
}
// PipelineHealth tracks conversion pipeline activity for health checks.
type PipelineHealth struct {
mu sync.RWMutex
now func() time.Time
staleThreshold time.Duration
startedAt time.Time
lastEventTime time.Time
eventsTotal int64
eventsByType map[string]int64
}
// NewPipelineHealth creates a health tracker initialized at current time.
func NewPipelineHealth(opts ...PipelineHealthOption) *PipelineHealth {
options := pipelineHealthOptions{
now: time.Now,
staleThreshold: defaultPipelineStaleThreshold,
}
for _, opt := range opts {
if opt == nil {
continue
}
opt(&options)
}
if options.now == nil {
options.now = time.Now
}
if options.staleThreshold <= 0 {
options.staleThreshold = defaultPipelineStaleThreshold
}
return &PipelineHealth{
now: options.now,
staleThreshold: options.staleThreshold,
startedAt: options.now(),
eventsByType: make(map[string]int64),
}
}
// RecordEvent records an event occurrence for health tracking.
func (p *PipelineHealth) RecordEvent(eventType string) {
if p == nil {
return
}
normalized := strings.TrimSpace(eventType)
if normalized == "" {
return
}
now := p.clockNow()
p.mu.Lock()
defer p.mu.Unlock()
if p.startedAt.IsZero() {
p.startedAt = now
}
if p.eventsByType == nil {
p.eventsByType = make(map[string]int64)
}
p.lastEventTime = now
p.eventsTotal++
p.eventsByType[normalized]++
}
// CheckHealth returns the current conversion pipeline health assessment.
func (p *PipelineHealth) CheckHealth() HealthStatus {
now := time.Now()
if p == nil {
return HealthStatus{
Status: "healthy",
LastEventAgeSeconds: 0,
EventsTotal: 0,
EventsByType: map[string]int64{},
StartedAt: now.UnixMilli(),
}
}
now = p.clockNow()
staleThreshold := p.staleThreshold
if staleThreshold <= 0 {
staleThreshold = defaultPipelineStaleThreshold
}
p.mu.RLock()
startedAt := p.startedAt
lastEventTime := p.lastEventTime
eventsTotal := p.eventsTotal
eventsByType := make(map[string]int64, len(p.eventsByType))
for eventType, count := range p.eventsByType {
eventsByType[eventType] = count
}
p.mu.RUnlock()
if startedAt.IsZero() {
startedAt = now
}
lastEventAge := now.Sub(startedAt).Seconds()
if !lastEventTime.IsZero() {
lastEventAge = now.Sub(lastEventTime).Seconds()
}
if lastEventAge < 0 {
lastEventAge = 0
}
status := "healthy"
if eventsTotal == 0 {
if now.Sub(startedAt) > staleThreshold {
status = "stale"
}
} else if now.Sub(lastEventTime) > staleThreshold {
status = "stale"
} else if !hasSufficientCoverage(eventsByType) {
status = "degraded"
}
return HealthStatus{
Status: status,
LastEventAgeSeconds: lastEventAge,
EventsTotal: eventsTotal,
EventsByType: eventsByType,
StartedAt: startedAt.UnixMilli(),
}
}
// KnownConversionEventTypes returns the event types used by quality coverage checks.
func KnownConversionEventTypes() []string {
out := make([]string, len(pipelineCoverageEventTypes))
copy(out, pipelineCoverageEventTypes)
return out
}
func (p *PipelineHealth) clockNow() time.Time {
if p == nil || p.now == nil {
return time.Now()
}
return p.now()
}
func hasSufficientCoverage(eventsByType map[string]int64) bool {
if len(pipelineCoverageEventTypes) == 0 {
return true
}
seen := 0
for _, eventType := range pipelineCoverageEventTypes {
if eventsByType[eventType] > 0 {
seen++
}
}
return float64(seen) >= float64(len(pipelineCoverageEventTypes))*0.5
}