feat: add analytics-service (Spring Cloud Gateway + Consul)
This commit is contained in:
8
analytics-service/.gitignore
vendored
Normal file
8
analytics-service/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.log
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
.env
|
||||
25
analytics-service/docker/Dockerfile
Normal file
25
analytics-service/docker/Dockerfile
Normal file
@@ -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"]
|
||||
125
analytics-service/pom.xml
Normal file
125
analytics-service/pom.xml
Normal file
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.posthub</groupId>
|
||||
<artifactId>analytics-service</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Analytics Service</name>
|
||||
<description>Kafka consumer + analytics REST API for Post Hub Platform</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<spring-boot.version>3.5.7</spring-boot.version>
|
||||
<spring-cloud.version>2025.0.0</spring-cloud.version>
|
||||
<lombok.version>1.18.40</lombok.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.7</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-dependencies</artifactId>
|
||||
<version>${spring-cloud.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Web (MVC, not WebFlux) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Data JPA -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- PostgreSQL -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Kafka -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.kafka</groupId>
|
||||
<artifactId>spring-kafka</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Consul service discovery -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Health checks -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Flyway migrations -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
16
analytics-service/src/main/resources/application-prod.yml
Normal file
16
analytics-service/src/main/resources/application-prod.yml
Normal file
@@ -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
|
||||
62
analytics-service/src/main/resources/application.yml
Normal file
62
analytics-service/src/main/resources/application.yml
Normal file
@@ -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
|
||||
@@ -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
|
||||
);
|
||||
14
analytics-service/src/main/resources/logback.xml
Normal file
14
analytics-service/src/main/resources/logback.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>${LOG_PATTERN}</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user