feat: add analytics-service (Spring Cloud Gateway + Consul)

This commit is contained in:
2026-02-17 19:53:49 +01:00
parent 008aceb64b
commit 5f314bc00a
22 changed files with 884 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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<String, UserEvent> consumerFactory() {
JsonDeserializer<UserEvent> 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<String, UserEvent> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, UserEvent> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
return factory;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<DashboardResponse> getDashboard() {
return ResponseEntity.ok(queryService.getDashboard());
}
@GetMapping("/users/active")
public ResponseEntity<List<UserStats>> getActiveUsers(
@RequestParam(defaultValue = "7") int days) {
return ResponseEntity.ok(queryService.getActiveUsers(days));
}
@GetMapping("/queries/daily")
public ResponseEntity<List<DailyStatsResponse>> getDailyQueries(
@RequestParam(defaultValue = "30") int days) {
return ResponseEntity.ok(queryService.getDailyStats(days));
}
@GetMapping("/tokens/usage")
public ResponseEntity<List<DailyStatsResponse>> getTokenUsage(
@RequestParam(defaultValue = "30") int days) {
return ResponseEntity.ok(queryService.getTokenUsage(days));
}
}

View File

@@ -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;
}

View File

@@ -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<String, Long> eventBreakdown;
}

View File

@@ -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
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<DailyStats, Long> {
Optional<DailyStats> findByStatsDate(LocalDate statsDate);
List<DailyStats> findByStatsDateBetweenOrderByStatsDateAsc(LocalDate from, LocalDate to);
}

View File

@@ -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<EventLog, Long> {
@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<Object[]> countEventsByType(@Param("from") LocalDate from, @Param("to") LocalDate to);
}

View File

@@ -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<UserStats, Long> {
Optional<UserStats> findByUserId(String userId);
@Query("SELECT u FROM UserStats u WHERE u.lastActive >= :since ORDER BY u.lastActive DESC")
List<UserStats> findActiveUsersSince(@Param("since") Instant since);
}

View File

@@ -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<DailyStats> 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<String, Long> breakdown = new LinkedHashMap<>();
List<Object[]> 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<DailyStatsResponse> getDailyStats(int days) {
LocalDate today = LocalDate.now();
LocalDate from = today.minusDays(days);
return dailyStatsRepository.findByStatsDateBetweenOrderByStatsDateAsc(from, today)
.stream()
.map(this::toResponse)
.toList();
}
public List<UserStats> getActiveUsers(int days) {
Instant since = Instant.now().minus(days, ChronoUnit.DAYS);
return userStatsRepository.findActiveUsersSince(since);
}
public List<DailyStatsResponse> 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();
}
}

View File

@@ -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);
}
}