mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 02:51:34 +00:00
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.
351 lines
11 KiB
Go
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)
|
|
}
|