diff --git a/analytics-service/.gitignore b/analytics-service/.gitignore new file mode 100644 index 0000000..ee3df5e --- /dev/null +++ b/analytics-service/.gitignore @@ -0,0 +1,8 @@ +target/ +*.class +*.jar +*.log +.idea/ +*.iml +.DS_Store +.env diff --git a/analytics-service/docker/Dockerfile b/analytics-service/docker/Dockerfile new file mode 100644 index 0000000..b77c9c4 --- /dev/null +++ b/analytics-service/docker/Dockerfile @@ -0,0 +1,25 @@ +# --- Build stage --- +FROM maven:3.9.9-eclipse-temurin-24-alpine AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -B +COPY src ./src +RUN mvn clean package -DskipTests -B + +# --- Runtime stage --- +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +COPY --from=build /app/target/*.jar app.jar + +RUN chown -R appuser:appgroup /app +USER appuser + +EXPOSE 8082 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://localhost:8082/actuator/health || exit 1 + +ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"] diff --git a/analytics-service/pom.xml b/analytics-service/pom.xml new file mode 100644 index 0000000..40a2aeb --- /dev/null +++ b/analytics-service/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + com.posthub + analytics-service + 1.0.0 + Analytics Service + Kafka consumer + analytics REST API for Post Hub Platform + + + 25 + 3.5.7 + 2025.0.0 + 1.18.40 + + + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + org.springframework.kafka + spring-kafka + + + + + org.springframework.cloud + spring-cloud-starter-consul-discovery + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + + org.projectlombok + lombok + ${lombok.version} + true + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/analytics-service/src/main/java/com/posthub/analytics/AnalyticsApplication.java b/analytics-service/src/main/java/com/posthub/analytics/AnalyticsApplication.java new file mode 100644 index 0000000..fdf0da7 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/AnalyticsApplication.java @@ -0,0 +1,12 @@ +package com.posthub.analytics; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AnalyticsApplication { + + public static void main(String[] args) { + SpringApplication.run(AnalyticsApplication.class, args); + } +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/config/KafkaConsumerConfig.java b/analytics-service/src/main/java/com/posthub/analytics/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..ea5359c --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/config/KafkaConsumerConfig.java @@ -0,0 +1,52 @@ +package com.posthub.analytics.config; + +import com.posthub.analytics.dto.UserEvent; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.support.serializer.JsonDeserializer; + +import java.util.Map; + +@Configuration +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + @Bean + public ConsumerFactory consumerFactory() { + JsonDeserializer deserializer = new JsonDeserializer<>(UserEvent.class); + deserializer.setRemoveTypeHeaders(true); + deserializer.addTrustedPackages("*"); + deserializer.setUseTypeMapperForKey(false); + + return new DefaultKafkaConsumerFactory<>( + Map.of( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG, groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest", + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class + ), + new StringDeserializer(), + deserializer + ); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + return factory; + } +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/consumer/UserEventConsumer.java b/analytics-service/src/main/java/com/posthub/analytics/consumer/UserEventConsumer.java new file mode 100644 index 0000000..c12c023 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/consumer/UserEventConsumer.java @@ -0,0 +1,29 @@ +package com.posthub.analytics.consumer; + +import com.posthub.analytics.dto.UserEvent; +import com.posthub.analytics.service.AnalyticsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserEventConsumer { + + private final AnalyticsService analyticsService; + + @KafkaListener( + topics = "${analytics.kafka.topic:user-events}", + groupId = "${spring.kafka.consumer.group-id}" + ) + public void consume(UserEvent event) { + try { + log.info("Received event: type={}, userId={}", event.getType(), event.getUserId()); + analyticsService.processEvent(event); + } catch (Exception e) { + log.error("Failed to process event: {}", event, e); + } + } +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/controller/AnalyticsController.java b/analytics-service/src/main/java/com/posthub/analytics/controller/AnalyticsController.java new file mode 100644 index 0000000..6d39d55 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/controller/AnalyticsController.java @@ -0,0 +1,42 @@ +package com.posthub.analytics.controller; + +import com.posthub.analytics.dto.DailyStatsResponse; +import com.posthub.analytics.dto.DashboardResponse; +import com.posthub.analytics.model.UserStats; +import com.posthub.analytics.service.AnalyticsQueryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/analytics") +@RequiredArgsConstructor +public class AnalyticsController { + + private final AnalyticsQueryService queryService; + + @GetMapping("/dashboard") + public ResponseEntity getDashboard() { + return ResponseEntity.ok(queryService.getDashboard()); + } + + @GetMapping("/users/active") + public ResponseEntity> getActiveUsers( + @RequestParam(defaultValue = "7") int days) { + return ResponseEntity.ok(queryService.getActiveUsers(days)); + } + + @GetMapping("/queries/daily") + public ResponseEntity> getDailyQueries( + @RequestParam(defaultValue = "30") int days) { + return ResponseEntity.ok(queryService.getDailyStats(days)); + } + + @GetMapping("/tokens/usage") + public ResponseEntity> getTokenUsage( + @RequestParam(defaultValue = "30") int days) { + return ResponseEntity.ok(queryService.getTokenUsage(days)); + } +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/dto/DailyStatsResponse.java b/analytics-service/src/main/java/com/posthub/analytics/dto/DailyStatsResponse.java new file mode 100644 index 0000000..ca7b6e0 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/dto/DailyStatsResponse.java @@ -0,0 +1,23 @@ +package com.posthub.analytics.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DailyStatsResponse { + + private LocalDate date; + private Long queries; + private Long tokensUsed; + private Long ragHits; + private Long newUsers; + private Long newChats; + private Integer activeUsers; +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/dto/DashboardResponse.java b/analytics-service/src/main/java/com/posthub/analytics/dto/DashboardResponse.java new file mode 100644 index 0000000..f448dc3 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/dto/DashboardResponse.java @@ -0,0 +1,23 @@ +package com.posthub.analytics.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DashboardResponse { + + private Long totalQueries; + private Long totalTokensUsed; + private Long totalRagHits; + private Long totalUsers; + private Integer activeUsersToday; + private Map eventBreakdown; +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/dto/UserEvent.java b/analytics-service/src/main/java/com/posthub/analytics/dto/UserEvent.java new file mode 100644 index 0000000..39f8655 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/dto/UserEvent.java @@ -0,0 +1,32 @@ +package com.posthub.analytics.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserEvent { + + private EventType type; + private String userId; + private String chatId; + private Integer tokensUsed; + private Integer documentsFound; + private Instant timestamp; + + public enum EventType { + USER_CREATED, + CHAT_CREATED, + CHAT_DELETED, + QUERY_SENT, + RAG_CONTEXT_HIT + } +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/model/DailyStats.java b/analytics-service/src/main/java/com/posthub/analytics/model/DailyStats.java new file mode 100644 index 0000000..ac4afdc --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/model/DailyStats.java @@ -0,0 +1,49 @@ +package com.posthub.analytics.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "daily_stats", uniqueConstraints = { + @UniqueConstraint(columnNames = "statsDate") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DailyStats { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate statsDate; + + @Builder.Default + private Long totalQueries = 0L; + + @Builder.Default + private Long totalTokensUsed = 0L; + + @Builder.Default + private Long totalRagHits = 0L; + + @Builder.Default + private Long totalDocumentsFound = 0L; + + @Builder.Default + private Long newUsers = 0L; + + @Builder.Default + private Long newChats = 0L; + + @Builder.Default + private Long deletedChats = 0L; + + @Builder.Default + private Integer activeUsers = 0; +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/model/EventLog.java b/analytics-service/src/main/java/com/posthub/analytics/model/EventLog.java new file mode 100644 index 0000000..60bbd7f --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/model/EventLog.java @@ -0,0 +1,43 @@ +package com.posthub.analytics.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "event_log", indexes = { + @Index(name = "idx_event_log_type", columnList = "eventType"), + @Index(name = "idx_event_log_user", columnList = "userId"), + @Index(name = "idx_event_log_date", columnList = "eventDate") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EventLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String eventType; + + @Column(nullable = false) + private String userId; + + private String chatId; + + private Integer tokensUsed; + + private Integer documentsFound; + + @Column(nullable = false) + private Instant eventTimestamp; + + @Column(nullable = false) + private LocalDate eventDate; +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/model/UserStats.java b/analytics-service/src/main/java/com/posthub/analytics/model/UserStats.java new file mode 100644 index 0000000..4eac0cd --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/model/UserStats.java @@ -0,0 +1,41 @@ +package com.posthub.analytics.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "user_stats", uniqueConstraints = { + @UniqueConstraint(columnNames = "userId") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserStats { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userId; + + @Builder.Default + private Long totalQueries = 0L; + + @Builder.Default + private Long totalTokensUsed = 0L; + + @Builder.Default + private Long totalRagHits = 0L; + + @Builder.Default + private Integer totalChats = 0; + + private Instant firstSeen; + + private Instant lastActive; +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/repository/DailyStatsRepository.java b/analytics-service/src/main/java/com/posthub/analytics/repository/DailyStatsRepository.java new file mode 100644 index 0000000..3186748 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/repository/DailyStatsRepository.java @@ -0,0 +1,17 @@ +package com.posthub.analytics.repository; + +import com.posthub.analytics.model.DailyStats; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface DailyStatsRepository extends JpaRepository { + + Optional findByStatsDate(LocalDate statsDate); + + List findByStatsDateBetweenOrderByStatsDateAsc(LocalDate from, LocalDate to); +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/repository/EventLogRepository.java b/analytics-service/src/main/java/com/posthub/analytics/repository/EventLogRepository.java new file mode 100644 index 0000000..20c10fc --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/repository/EventLogRepository.java @@ -0,0 +1,22 @@ +package com.posthub.analytics.repository; + +import com.posthub.analytics.model.EventLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface EventLogRepository extends JpaRepository { + + @Query("SELECT COUNT(DISTINCT e.userId) FROM EventLog e " + + "WHERE e.eventDate BETWEEN :from AND :to") + Integer countActiveUsers(@Param("from") LocalDate from, @Param("to") LocalDate to); + + @Query("SELECT e.eventType, COUNT(e) FROM EventLog e " + + "WHERE e.eventDate BETWEEN :from AND :to GROUP BY e.eventType") + List countEventsByType(@Param("from") LocalDate from, @Param("to") LocalDate to); +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/repository/UserStatsRepository.java b/analytics-service/src/main/java/com/posthub/analytics/repository/UserStatsRepository.java new file mode 100644 index 0000000..2912539 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/repository/UserStatsRepository.java @@ -0,0 +1,20 @@ +package com.posthub.analytics.repository; + +import com.posthub.analytics.model.UserStats; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserStatsRepository extends JpaRepository { + + Optional findByUserId(String userId); + + @Query("SELECT u FROM UserStats u WHERE u.lastActive >= :since ORDER BY u.lastActive DESC") + List findActiveUsersSince(@Param("since") Instant since); +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/service/AnalyticsQueryService.java b/analytics-service/src/main/java/com/posthub/analytics/service/AnalyticsQueryService.java new file mode 100644 index 0000000..f9ebc16 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/service/AnalyticsQueryService.java @@ -0,0 +1,89 @@ +package com.posthub.analytics.service; + +import com.posthub.analytics.dto.DailyStatsResponse; +import com.posthub.analytics.dto.DashboardResponse; +import com.posthub.analytics.model.DailyStats; +import com.posthub.analytics.model.UserStats; +import com.posthub.analytics.repository.DailyStatsRepository; +import com.posthub.analytics.repository.EventLogRepository; +import com.posthub.analytics.repository.UserStatsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalyticsQueryService { + + private final EventLogRepository eventLogRepository; + private final DailyStatsRepository dailyStatsRepository; + private final UserStatsRepository userStatsRepository; + + public DashboardResponse getDashboard() { + LocalDate today = LocalDate.now(); + LocalDate thirtyDaysAgo = today.minusDays(30); + + List last30Days = dailyStatsRepository + .findByStatsDateBetweenOrderByStatsDateAsc(thirtyDaysAgo, today); + + long totalQueries = last30Days.stream().mapToLong(DailyStats::getTotalQueries).sum(); + long totalTokens = last30Days.stream().mapToLong(DailyStats::getTotalTokensUsed).sum(); + long totalRagHits = last30Days.stream().mapToLong(DailyStats::getTotalRagHits).sum(); + long totalUsers = userStatsRepository.count(); + Integer activeToday = eventLogRepository.countActiveUsers(today, today); + + Map breakdown = new LinkedHashMap<>(); + List eventCounts = eventLogRepository.countEventsByType(thirtyDaysAgo, today); + for (Object[] row : eventCounts) { + breakdown.put((String) row[0], (Long) row[1]); + } + + return DashboardResponse.builder() + .totalQueries(totalQueries) + .totalTokensUsed(totalTokens) + .totalRagHits(totalRagHits) + .totalUsers(totalUsers) + .activeUsersToday(activeToday != null ? activeToday : 0) + .eventBreakdown(breakdown) + .build(); + } + + public List getDailyStats(int days) { + LocalDate today = LocalDate.now(); + LocalDate from = today.minusDays(days); + + return dailyStatsRepository.findByStatsDateBetweenOrderByStatsDateAsc(from, today) + .stream() + .map(this::toResponse) + .toList(); + } + + public List getActiveUsers(int days) { + Instant since = Instant.now().minus(days, ChronoUnit.DAYS); + return userStatsRepository.findActiveUsersSince(since); + } + + public List getTokenUsage(int days) { + return getDailyStats(days); + } + + private DailyStatsResponse toResponse(DailyStats ds) { + return DailyStatsResponse.builder() + .date(ds.getStatsDate()) + .queries(ds.getTotalQueries()) + .tokensUsed(ds.getTotalTokensUsed()) + .ragHits(ds.getTotalRagHits()) + .newUsers(ds.getNewUsers()) + .newChats(ds.getNewChats()) + .activeUsers(ds.getActiveUsers()) + .build(); + } +} diff --git a/analytics-service/src/main/java/com/posthub/analytics/service/AnalyticsService.java b/analytics-service/src/main/java/com/posthub/analytics/service/AnalyticsService.java new file mode 100644 index 0000000..9456b77 --- /dev/null +++ b/analytics-service/src/main/java/com/posthub/analytics/service/AnalyticsService.java @@ -0,0 +1,102 @@ +package com.posthub.analytics.service; + +import com.posthub.analytics.dto.UserEvent; +import com.posthub.analytics.model.DailyStats; +import com.posthub.analytics.model.EventLog; +import com.posthub.analytics.model.UserStats; +import com.posthub.analytics.repository.DailyStatsRepository; +import com.posthub.analytics.repository.EventLogRepository; +import com.posthub.analytics.repository.UserStatsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AnalyticsService { + + private final EventLogRepository eventLogRepository; + private final DailyStatsRepository dailyStatsRepository; + private final UserStatsRepository userStatsRepository; + + @Transactional + public void processEvent(UserEvent event) { + Instant timestamp = event.getTimestamp() != null ? event.getTimestamp() : Instant.now(); + LocalDate eventDate = timestamp.atZone(ZoneOffset.UTC).toLocalDate(); + + // 1. Save raw event + EventLog eventLog = EventLog.builder() + .eventType(event.getType().name()) + .userId(event.getUserId()) + .chatId(event.getChatId()) + .tokensUsed(event.getTokensUsed()) + .documentsFound(event.getDocumentsFound()) + .eventTimestamp(timestamp) + .eventDate(eventDate) + .build(); + eventLogRepository.save(eventLog); + + // 2. Update daily stats + updateDailyStats(event, eventDate); + + // 3. Update user stats + updateUserStats(event, timestamp); + + log.debug("Processed event: type={}, userId={}", event.getType(), event.getUserId()); + } + + private void updateDailyStats(UserEvent event, LocalDate eventDate) { + DailyStats daily = dailyStatsRepository.findByStatsDate(eventDate) + .orElseGet(() -> DailyStats.builder().statsDate(eventDate).build()); + + switch (event.getType()) { + case USER_CREATED -> daily.setNewUsers(daily.getNewUsers() + 1); + case CHAT_CREATED -> daily.setNewChats(daily.getNewChats() + 1); + case CHAT_DELETED -> daily.setDeletedChats(daily.getDeletedChats() + 1); + case QUERY_SENT -> { + daily.setTotalQueries(daily.getTotalQueries() + 1); + if (event.getTokensUsed() != null) { + daily.setTotalTokensUsed(daily.getTotalTokensUsed() + event.getTokensUsed()); + } + } + case RAG_CONTEXT_HIT -> { + daily.setTotalRagHits(daily.getTotalRagHits() + 1); + if (event.getDocumentsFound() != null) { + daily.setTotalDocumentsFound(daily.getTotalDocumentsFound() + event.getDocumentsFound()); + } + } + } + + dailyStatsRepository.save(daily); + } + + private void updateUserStats(UserEvent event, Instant timestamp) { + UserStats userStats = userStatsRepository.findByUserId(event.getUserId()) + .orElseGet(() -> UserStats.builder() + .userId(event.getUserId()) + .firstSeen(timestamp) + .build()); + + userStats.setLastActive(timestamp); + + switch (event.getType()) { + case CHAT_CREATED -> userStats.setTotalChats(userStats.getTotalChats() + 1); + case QUERY_SENT -> { + userStats.setTotalQueries(userStats.getTotalQueries() + 1); + if (event.getTokensUsed() != null) { + userStats.setTotalTokensUsed(userStats.getTotalTokensUsed() + event.getTokensUsed()); + } + } + case RAG_CONTEXT_HIT -> userStats.setTotalRagHits(userStats.getTotalRagHits() + 1); + default -> { /* no user-level aggregation needed */ } + } + + userStatsRepository.save(userStats); + } +} diff --git a/analytics-service/src/main/resources/application-prod.yml b/analytics-service/src/main/resources/application-prod.yml new file mode 100644 index 0000000..1b19225 --- /dev/null +++ b/analytics-service/src/main/resources/application-prod.yml @@ -0,0 +1,16 @@ +spring: + datasource: + url: jdbc:postgresql://${DB_HOST:postgres}:${DB_PORT:5432}/${DB_NAME:appdb}?currentSchema=analytics + + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:kafka:9092} + + cloud: + consul: + host: ${CONSUL_HOST:consul} + port: ${CONSUL_PORT:8500} + +logging: + level: + root: WARN + com.posthub.analytics: INFO diff --git a/analytics-service/src/main/resources/application.yml b/analytics-service/src/main/resources/application.yml new file mode 100644 index 0000000..4cc839c --- /dev/null +++ b/analytics-service/src/main/resources/application.yml @@ -0,0 +1,62 @@ +server: + port: 8082 + +spring: + application: + name: analytics-service + + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:appdb}?currentSchema=analytics + username: ${DB_USERNAME:app} + password: ${DB_PASSWORD:} + hikari: + maximum-pool-size: 5 + + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + default_schema: analytics + open-in-view: false + + flyway: + enabled: true + schemas: analytics + default-schema: analytics + + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + consumer: + group-id: analytics-group + auto-offset-reset: earliest + + cloud: + consul: + host: ${CONSUL_HOST:localhost} + port: ${CONSUL_PORT:8500} + discovery: + register: true + enabled: true + health-check-path: /actuator/health + health-check-interval: 15s + prefer-ip-address: true + instance-id: ${spring.application.name}:${random.value} + +analytics: + kafka: + topic: user-events + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: always + +logging: + level: + root: INFO + com.posthub.analytics: DEBUG diff --git a/analytics-service/src/main/resources/db/migration/V1__create_analytics_tables.sql b/analytics-service/src/main/resources/db/migration/V1__create_analytics_tables.sql new file mode 100644 index 0000000..fabd492 --- /dev/null +++ b/analytics-service/src/main/resources/db/migration/V1__create_analytics_tables.sql @@ -0,0 +1,38 @@ +CREATE TABLE event_log ( + id BIGSERIAL PRIMARY KEY, + event_type VARCHAR(50) NOT NULL, + user_id VARCHAR(255) NOT NULL, + chat_id VARCHAR(255), + tokens_used INTEGER, + documents_found INTEGER, + event_timestamp TIMESTAMP WITH TIME ZONE NOT NULL, + event_date DATE NOT NULL +); + +CREATE INDEX idx_event_log_type ON event_log(event_type); +CREATE INDEX idx_event_log_user ON event_log(user_id); +CREATE INDEX idx_event_log_date ON event_log(event_date); + +CREATE TABLE daily_stats ( + id BIGSERIAL PRIMARY KEY, + stats_date DATE NOT NULL UNIQUE, + total_queries BIGINT DEFAULT 0, + total_tokens_used BIGINT DEFAULT 0, + total_rag_hits BIGINT DEFAULT 0, + total_documents_found BIGINT DEFAULT 0, + new_users BIGINT DEFAULT 0, + new_chats BIGINT DEFAULT 0, + deleted_chats BIGINT DEFAULT 0, + active_users INTEGER DEFAULT 0 +); + +CREATE TABLE user_stats ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL UNIQUE, + total_queries BIGINT DEFAULT 0, + total_tokens_used BIGINT DEFAULT 0, + total_rag_hits BIGINT DEFAULT 0, + total_chats INTEGER DEFAULT 0, + first_seen TIMESTAMP WITH TIME ZONE, + last_active TIMESTAMP WITH TIME ZONE +); diff --git a/analytics-service/src/main/resources/logback.xml b/analytics-service/src/main/resources/logback.xml new file mode 100644 index 0000000..ac67ff4 --- /dev/null +++ b/analytics-service/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + + ${LOG_PATTERN} + + + + + + +