tercul-backend/internal/app/analytics/service.go
google-labs-jules[bot] 781b313bf1 feat: Complete all pending tasks from TASKS.md
This commit addresses all the high-priority tasks outlined in the TASKS.md file, significantly improving the application's observability, completing key features, and refactoring critical parts of the codebase.

### Observability

- **Centralized Logging:** Implemented a new structured, context-aware logging system using `zerolog`. A new logging middleware injects request-specific information (request ID, user ID, trace ID) into the logger, and all application logging has been refactored to use this new system.
- **Prometheus Metrics:** Added Prometheus metrics for database query performance by creating a GORM plugin that automatically records query latency and totals.
- **OpenTelemetry Tracing:** Fully instrumented all application services in `internal/app` and data repositories in `internal/data/sql` with OpenTelemetry tracing, providing deep visibility into application performance.

### Features

- **Analytics:** Implemented like, comment, and bookmark counting. The respective command handlers now call the analytics service to increment counters when these actions are performed.
- **Enrichment Tool:** Built a new, extensible `enrich` command-line tool to fetch data from external sources. The initial implementation enriches author data using the Open Library API.

### Refactoring & Fixes

- **Decoupled Testing:** Refactored the testing utilities in `internal/testutil` to be database-agnostic, promoting the use of mock-based unit tests and improving test speed and reliability.
- **Build Fixes:** Resolved numerous build errors, including a critical import cycle between the logging, observability, and authentication packages.
- **Search Service:** Fixed the search service integration by implementing the `GetWorkContent` method in the localization service, allowing the search indexer to correctly fetch and index work content.
2025-10-05 05:26:27 +00:00

351 lines
11 KiB
Go

package analytics
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"tercul/internal/domain"
"tercul/internal/domain/work"
"tercul/internal/jobs/linguistics"
"tercul/internal/platform/log"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
type Service interface {
IncrementWorkViews(ctx context.Context, workID uint) error
IncrementWorkLikes(ctx context.Context, workID uint) error
IncrementWorkComments(ctx context.Context, workID uint) error
IncrementWorkBookmarks(ctx context.Context, workID uint) error
IncrementWorkShares(ctx context.Context, workID uint) error
IncrementWorkTranslationCount(ctx context.Context, workID uint) error
IncrementTranslationViews(ctx context.Context, translationID uint) error
IncrementTranslationLikes(ctx context.Context, translationID uint) error
IncrementTranslationComments(ctx context.Context, translationID uint) error
IncrementTranslationShares(ctx context.Context, translationID uint) error
GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error)
GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error)
UpdateWorkReadingTime(ctx context.Context, workID uint) error
UpdateWorkComplexity(ctx context.Context, workID uint) error
UpdateWorkSentiment(ctx context.Context, workID uint) error
UpdateTranslationReadingTime(ctx context.Context, translationID uint) error
UpdateTranslationSentiment(ctx context.Context, translationID uint) error
UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error
UpdateTrending(ctx context.Context) error
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error)
}
type service struct {
repo Repository
analysisRepo linguistics.AnalysisRepository
translationRepo domain.TranslationRepository
workRepo work.WorkRepository
sentimentProvider linguistics.SentimentProvider
tracer trace.Tracer
}
func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo work.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service {
return &service{
repo: repo,
analysisRepo: analysisRepo,
translationRepo: translationRepo,
workRepo: workRepo,
sentimentProvider: sentimentProvider,
tracer: otel.Tracer("analytics.service"),
}
}
func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkViews")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "views", 1)
}
func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkLikes")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1)
}
func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkComments")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1)
}
func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkBookmarks")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1)
}
func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkShares")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1)
}
func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkTranslationCount")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1)
}
func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationViews")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1)
}
func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationLikes")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1)
}
func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationComments")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1)
}
func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationShares")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1)
}
func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) {
ctx, span := s.tracer.Start(ctx, "GetOrCreateWorkStats")
defer span.End()
return s.repo.GetOrCreateWorkStats(ctx, workID)
}
func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {
ctx, span := s.tracer.Start(ctx, "GetOrCreateTranslationStats")
defer span.End()
return s.repo.GetOrCreateTranslationStats(ctx, translationID)
}
func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkReadingTime")
defer span.End()
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
textMetadata, _, _, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
return err
}
if textMetadata == nil {
return errors.New("text metadata not found")
}
readingTime := 0
if textMetadata.WordCount > 0 {
readingTime = (textMetadata.WordCount + 199) / 200 // Ceil division
}
stats.ReadingTime = readingTime
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkComplexity")
defer span.End()
logger := log.FromContext(ctx).With("workID", workID)
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
_, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
logger.Error(err, "could not get readability score for work")
return nil
}
if readabilityScore == nil {
return errors.New("readability score not found")
}
stats.Complexity = readabilityScore.Score
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkSentiment")
defer span.End()
logger := log.FromContext(ctx).With("workID", workID)
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
_, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
logger.Error(err, "could not get language analysis for work")
return nil
}
if languageAnalysis == nil {
return errors.New("language analysis not found")
}
sentiment, ok := languageAnalysis.Analysis["sentiment"].(float64)
if !ok {
return errors.New("sentiment score not found in language analysis")
}
stats.Sentiment = sentiment
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateTranslationReadingTime")
defer span.End()
stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID)
if err != nil {
return err
}
translation, err := s.translationRepo.GetByID(ctx, translationID)
if err != nil {
return err
}
if translation == nil {
return errors.New("translation not found")
}
wordCount := len(strings.Fields(translation.Content))
readingTime := 0
if wordCount > 0 {
readingTime = (wordCount + 199) / 200 // Ceil division
}
stats.ReadingTime = readingTime
return s.repo.UpdateTranslationStats(ctx, translationID, *stats)
}
func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateTranslationSentiment")
defer span.End()
stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID)
if err != nil {
return err
}
translation, err := s.translationRepo.GetByID(ctx, translationID)
if err != nil {
return err
}
if translation == nil {
return errors.New("translation not found")
}
sentiment, err := s.sentimentProvider.Score(translation.Content, translation.Language)
if err != nil {
return err
}
stats.Sentiment = sentiment
return s.repo.UpdateTranslationStats(ctx, translationID, *stats)
}
func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error {
ctx, span := s.tracer.Start(ctx, "UpdateUserEngagement")
defer span.End()
today := time.Now().UTC().Truncate(24 * time.Hour)
engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today)
if err != nil {
return err
}
switch eventType {
case "work_read":
engagement.WorksRead++
case "comment_made":
engagement.CommentsMade++
case "like_given":
engagement.LikesGiven++
case "bookmark_made":
engagement.BookmarksMade++
case "translation_made":
engagement.TranslationsMade++
default:
return errors.New("invalid engagement event type")
}
return s.repo.UpdateUserEngagement(ctx, engagement)
}
func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) {
ctx, span := s.tracer.Start(ctx, "GetTrendingWorks")
defer span.End()
return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
}
func (s *service) UpdateTrending(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "UpdateTrending")
defer span.End()
logger := log.FromContext(ctx)
logger.Info("Updating trending works")
works, err := s.workRepo.ListAll(ctx)
if err != nil {
return fmt.Errorf("failed to list works: %w", err)
}
trendingWorks := make([]*domain.Trending, 0, len(works))
for _, aWork := range works {
stats, err := s.repo.GetOrCreateWorkStats(ctx, aWork.ID)
if err != nil {
logger.With("workID", aWork.ID).Error(err, "failed to get work stats")
continue
}
score := float64(stats.Views*1 + stats.Likes*2 + stats.Comments*3)
trendingWorks = append(trendingWorks, &domain.Trending{
EntityType: "Work",
EntityID: aWork.ID,
Score: score,
TimePeriod: "daily", // Hardcoded for now
Date: time.Now().UTC(),
})
}
// Sort by score
sort.Slice(trendingWorks, func(i, j int) bool {
return trendingWorks[i].Score > trendingWorks[j].Score
})
// Get top 10
if len(trendingWorks) > 10 {
trendingWorks = trendingWorks[:10]
}
// Set ranks
for i := range trendingWorks {
trendingWorks[i].Rank = i + 1
}
return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks)
}