374 lines
12 KiB
Go
374 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"veza-backend-api/internal/models"
|
|
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// PlaybackAlertsService gère la détection d'alertes pour les analytics de lecture
|
|
// T0374: Create Playback Analytics Alerts Service
|
|
type PlaybackAlertsService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// Alert représente une alerte détectée
|
|
type Alert struct {
|
|
Type string `json:"type"` // "anomaly", "low_completion_rate", "drop_off_point"
|
|
Severity string `json:"severity"` // "low", "medium", "high"
|
|
Message string `json:"message"` // Message descriptif
|
|
Value float64 `json:"value"` // Valeur qui a déclenché l'alerte
|
|
Threshold float64 `json:"threshold"` // Seuil utilisé
|
|
DetectedAt time.Time `json:"detected_at"` // Date de détection
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"` // Métadonnées supplémentaires
|
|
}
|
|
|
|
// AlertConfig représente la configuration des seuils d'alerte
|
|
type AlertConfig struct {
|
|
LowCompletionRateThreshold float64 // Seuil pour completion rate bas (défaut: 30%)
|
|
AnomalyDeviationThreshold float64 // Nombre d'écarts-types pour détecter une anomalie (défaut: 2.0)
|
|
DropOffPointThreshold float64 // Seuil de drop-off en pourcentage de la durée (défaut: 25%)
|
|
}
|
|
|
|
// NewPlaybackAlertsService crée un nouveau service d'alertes d'analytics
|
|
func NewPlaybackAlertsService(db *gorm.DB, logger *zap.Logger) *PlaybackAlertsService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &PlaybackAlertsService{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// CheckAlerts vérifie les alertes pour un track donné
|
|
// T0374: Create Playback Analytics Alerts Service
|
|
func (s *PlaybackAlertsService) CheckAlerts(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) {
|
|
if trackID == uuid.Nil {
|
|
return nil, fmt.Errorf("invalid track ID: %s", trackID)
|
|
}
|
|
|
|
// Utiliser la configuration par défaut si non fournie
|
|
if config == nil {
|
|
config = &AlertConfig{
|
|
LowCompletionRateThreshold: 30.0,
|
|
AnomalyDeviationThreshold: 2.0,
|
|
DropOffPointThreshold: 25.0,
|
|
}
|
|
}
|
|
|
|
// Vérifier que le track existe
|
|
var track models.Track
|
|
if err := s.db.WithContext(ctx).First(&track, trackID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("track not found: %d", trackID)
|
|
}
|
|
return nil, fmt.Errorf("failed to get track: %w", err)
|
|
}
|
|
|
|
alerts := make([]Alert, 0)
|
|
|
|
// Détecter les anomalies
|
|
anomalyAlerts, err := s.detectAnomalies(ctx, trackID, config)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to detect anomalies", zap.Error(err), zap.String("track_id", trackID.String()))
|
|
} else {
|
|
alerts = append(alerts, anomalyAlerts...)
|
|
}
|
|
|
|
// Détecter les completion rates bas
|
|
completionAlerts, err := s.detectLowCompletionRate(ctx, trackID, config)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to detect low completion rates", zap.Error(err), zap.String("track_id", trackID.String()))
|
|
} else {
|
|
alerts = append(alerts, completionAlerts...)
|
|
}
|
|
|
|
// Détecter les drop-off points
|
|
dropOffAlerts, err := s.detectDropOffPoints(ctx, trackID, config)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to detect drop-off points", zap.Error(err), zap.String("track_id", trackID.String()))
|
|
} else {
|
|
alerts = append(alerts, dropOffAlerts...)
|
|
}
|
|
|
|
s.logger.Info("Checked playback alerts",
|
|
zap.String("track_id", trackID.String()),
|
|
zap.Int("alerts_count", len(alerts)))
|
|
|
|
return alerts, nil
|
|
}
|
|
|
|
// detectAnomalies détecte les anomalies dans les statistiques de lecture
|
|
func (s *PlaybackAlertsService) detectAnomalies(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) {
|
|
var alerts []Alert
|
|
|
|
// Récupérer toutes les analytics récentes (30 derniers jours)
|
|
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
|
|
var analytics []models.PlaybackAnalytics
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ? AND created_at >= ?", trackID, thirtyDaysAgo).
|
|
Find(&analytics).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get analytics: %w", err)
|
|
}
|
|
|
|
if len(analytics) < 10 {
|
|
// Pas assez de données pour détecter des anomalies
|
|
return alerts, nil
|
|
}
|
|
|
|
// Calculer la moyenne et l'écart-type pour le play_time
|
|
var playTimes []float64
|
|
var completionRates []float64
|
|
for _, a := range analytics {
|
|
playTimes = append(playTimes, float64(a.PlayTime))
|
|
completionRates = append(completionRates, a.CompletionRate)
|
|
}
|
|
|
|
// Détecter les anomalies dans le play_time
|
|
playTimeMean, playTimeStdDev := s.calculateMeanAndStdDev(playTimes)
|
|
for _, a := range analytics {
|
|
playTime := float64(a.PlayTime)
|
|
deviation := math.Abs(playTime - playTimeMean)
|
|
if playTimeStdDev > 0 && deviation > config.AnomalyDeviationThreshold*playTimeStdDev {
|
|
severity := "medium"
|
|
if deviation > config.AnomalyDeviationThreshold*2*playTimeStdDev {
|
|
severity = "high"
|
|
}
|
|
alerts = append(alerts, Alert{
|
|
Type: "anomaly",
|
|
Severity: severity,
|
|
Message: fmt.Sprintf("Anomalous play time detected: %.0f seconds (mean: %.0f, std dev: %.0f)", playTime, playTimeMean, playTimeStdDev),
|
|
Value: playTime,
|
|
Threshold: playTimeMean + config.AnomalyDeviationThreshold*playTimeStdDev,
|
|
DetectedAt: time.Now(),
|
|
Metadata: map[string]interface{}{
|
|
"analytics_id": a.ID,
|
|
"user_id": a.UserID,
|
|
"mean": playTimeMean,
|
|
"std_dev": playTimeStdDev,
|
|
"deviation": deviation,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Détecter les anomalies dans le completion rate
|
|
completionMean, completionStdDev := s.calculateMeanAndStdDev(completionRates)
|
|
for _, a := range analytics {
|
|
deviation := math.Abs(a.CompletionRate - completionMean)
|
|
if completionStdDev > 0 && deviation > config.AnomalyDeviationThreshold*completionStdDev {
|
|
severity := "medium"
|
|
if deviation > config.AnomalyDeviationThreshold*2*completionStdDev {
|
|
severity = "high"
|
|
}
|
|
alerts = append(alerts, Alert{
|
|
Type: "anomaly",
|
|
Severity: severity,
|
|
Message: fmt.Sprintf("Anomalous completion rate detected: %.2f%% (mean: %.2f%%, std dev: %.2f%%)", a.CompletionRate, completionMean, completionStdDev),
|
|
Value: a.CompletionRate,
|
|
Threshold: completionMean + config.AnomalyDeviationThreshold*completionStdDev,
|
|
DetectedAt: time.Now(),
|
|
Metadata: map[string]interface{}{
|
|
"analytics_id": a.ID,
|
|
"user_id": a.UserID,
|
|
"mean": completionMean,
|
|
"std_dev": completionStdDev,
|
|
"deviation": deviation,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return alerts, nil
|
|
}
|
|
|
|
// detectLowCompletionRate détecte les completion rates bas
|
|
func (s *PlaybackAlertsService) detectLowCompletionRate(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) {
|
|
var alerts []Alert
|
|
|
|
// Récupérer les statistiques récentes (7 derniers jours)
|
|
sevenDaysAgo := time.Now().AddDate(0, 0, -7)
|
|
var analytics []models.PlaybackAnalytics
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ? AND created_at >= ?", trackID, sevenDaysAgo).
|
|
Find(&analytics).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get analytics: %w", err)
|
|
}
|
|
|
|
if len(analytics) == 0 {
|
|
return alerts, nil
|
|
}
|
|
|
|
// Calculer le taux de completion moyen
|
|
var totalCompletion float64
|
|
var lowCompletionCount int
|
|
for _, a := range analytics {
|
|
totalCompletion += a.CompletionRate
|
|
if a.CompletionRate < config.LowCompletionRateThreshold {
|
|
lowCompletionCount++
|
|
}
|
|
}
|
|
averageCompletion := totalCompletion / float64(len(analytics))
|
|
|
|
// Si le taux moyen est bas, créer une alerte
|
|
if averageCompletion < config.LowCompletionRateThreshold {
|
|
severity := "medium"
|
|
if averageCompletion < config.LowCompletionRateThreshold/2 {
|
|
severity = "high"
|
|
}
|
|
alerts = append(alerts, Alert{
|
|
Type: "low_completion_rate",
|
|
Severity: severity,
|
|
Message: fmt.Sprintf("Low average completion rate: %.2f%% (threshold: %.2f%%)", averageCompletion, config.LowCompletionRateThreshold),
|
|
Value: averageCompletion,
|
|
Threshold: config.LowCompletionRateThreshold,
|
|
DetectedAt: time.Now(),
|
|
Metadata: map[string]interface{}{
|
|
"total_sessions": len(analytics),
|
|
"low_completion_count": lowCompletionCount,
|
|
"percentage_low": float64(lowCompletionCount) / float64(len(analytics)) * 100.0,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Si un pourcentage élevé de sessions a un completion rate bas, créer une alerte
|
|
lowCompletionPercentage := float64(lowCompletionCount) / float64(len(analytics)) * 100.0
|
|
if lowCompletionPercentage > 50.0 {
|
|
severity := "medium"
|
|
if lowCompletionPercentage > 75.0 {
|
|
severity = "high"
|
|
}
|
|
alerts = append(alerts, Alert{
|
|
Type: "low_completion_rate",
|
|
Severity: severity,
|
|
Message: fmt.Sprintf("High percentage of sessions with low completion rate: %.2f%%", lowCompletionPercentage),
|
|
Value: lowCompletionPercentage,
|
|
Threshold: 50.0,
|
|
DetectedAt: time.Now(),
|
|
Metadata: map[string]interface{}{
|
|
"total_sessions": len(analytics),
|
|
"low_completion_count": lowCompletionCount,
|
|
"average_completion": averageCompletion,
|
|
},
|
|
})
|
|
}
|
|
|
|
return alerts, nil
|
|
}
|
|
|
|
// detectDropOffPoints détecte les points de drop-off (moments où les utilisateurs arrêtent de regarder)
|
|
func (s *PlaybackAlertsService) detectDropOffPoints(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) {
|
|
var alerts []Alert
|
|
|
|
// Récupérer le track pour connaître sa durée
|
|
var track models.Track
|
|
if err := s.db.WithContext(ctx).First(&track, trackID).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get track: %w", err)
|
|
}
|
|
|
|
if track.Duration <= 0 {
|
|
return alerts, nil
|
|
}
|
|
|
|
// Récupérer les analytics récentes (7 derniers jours)
|
|
sevenDaysAgo := time.Now().AddDate(0, 0, -7)
|
|
var analytics []models.PlaybackAnalytics
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ? AND created_at >= ?", trackID, sevenDaysAgo).
|
|
Find(&analytics).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get analytics: %w", err)
|
|
}
|
|
|
|
if len(analytics) == 0 {
|
|
return alerts, nil
|
|
}
|
|
|
|
// Calculer le pourcentage de la durée où les utilisateurs arrêtent
|
|
dropOffThresholdSeconds := float64(track.Duration) * (config.DropOffPointThreshold / 100.0)
|
|
var dropOffCount int
|
|
var dropOffTimes []float64
|
|
|
|
for _, a := range analytics {
|
|
// Si le play_time est inférieur au seuil de drop-off, c'est un drop-off
|
|
if float64(a.PlayTime) < dropOffThresholdSeconds {
|
|
dropOffCount++
|
|
dropOffTimes = append(dropOffTimes, float64(a.PlayTime))
|
|
}
|
|
}
|
|
|
|
dropOffPercentage := float64(dropOffCount) / float64(len(analytics)) * 100.0
|
|
|
|
// Si un pourcentage significatif de sessions s'arrête tôt, créer une alerte
|
|
if dropOffPercentage > 30.0 {
|
|
// Calculer le temps moyen de drop-off
|
|
var avgDropOffTime float64
|
|
if len(dropOffTimes) > 0 {
|
|
var sum float64
|
|
for _, t := range dropOffTimes {
|
|
sum += t
|
|
}
|
|
avgDropOffTime = sum / float64(len(dropOffTimes))
|
|
}
|
|
|
|
severity := "medium"
|
|
if dropOffPercentage > 50.0 {
|
|
severity = "high"
|
|
}
|
|
|
|
dropOffPointPercentage := (avgDropOffTime / float64(track.Duration)) * 100.0
|
|
|
|
alerts = append(alerts, Alert{
|
|
Type: "drop_off_point",
|
|
Severity: severity,
|
|
Message: fmt.Sprintf("Drop-off detected: %.2f%% of sessions stop before %.2f%% of track duration (avg drop-off at %.2f%%)", dropOffPercentage, config.DropOffPointThreshold, dropOffPointPercentage),
|
|
Value: dropOffPercentage,
|
|
Threshold: 30.0,
|
|
DetectedAt: time.Now(),
|
|
Metadata: map[string]interface{}{
|
|
"total_sessions": len(analytics),
|
|
"drop_off_count": dropOffCount,
|
|
"drop_off_threshold": config.DropOffPointThreshold,
|
|
"average_drop_off_time": avgDropOffTime,
|
|
"drop_off_point_percentage": dropOffPointPercentage,
|
|
"track_duration": track.Duration,
|
|
},
|
|
})
|
|
}
|
|
|
|
return alerts, nil
|
|
}
|
|
|
|
// calculateMeanAndStdDev calcule la moyenne et l'écart-type d'une série de valeurs
|
|
func (s *PlaybackAlertsService) calculateMeanAndStdDev(values []float64) (mean, stdDev float64) {
|
|
if len(values) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
// Calculer la moyenne
|
|
var sum float64
|
|
for _, v := range values {
|
|
sum += v
|
|
}
|
|
mean = sum / float64(len(values))
|
|
|
|
// Calculer l'écart-type
|
|
var variance float64
|
|
for _, v := range values {
|
|
diff := v - mean
|
|
variance += diff * diff
|
|
}
|
|
variance = variance / float64(len(values))
|
|
stdDev = math.Sqrt(variance)
|
|
|
|
return mean, stdDev
|
|
}
|