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