feat: add UI usage analytics tracking
Tracks authenticated UI user requests to understand usage patterns:
- New ClickHouse usage_events table with 90-day TTL
- UsageTrackingInterceptor captures method, path, duration, user
- Path normalization groups dynamic segments ({id}, {hash})
- Buffered writes via WriteBuffer + periodic flush
- Admin endpoint GET /api/v1/admin/usage with groupBy=endpoint|user|hour
- Skips agent requests, health checks, and data ingestion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package com.cameleer3.server.app.analytics;
|
||||
|
||||
import com.cameleer3.server.app.storage.ClickHouseUsageTracker;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConditionalOnBean(ClickHouseUsageTracker.class)
|
||||
public class UsageFlushScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UsageFlushScheduler.class);
|
||||
|
||||
private final ClickHouseUsageTracker tracker;
|
||||
|
||||
public UsageFlushScheduler(ClickHouseUsageTracker tracker) {
|
||||
this.tracker = tracker;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${cameleer.usage.flush-interval-ms:5000}")
|
||||
public void flush() {
|
||||
try {
|
||||
tracker.flush();
|
||||
} catch (Exception e) {
|
||||
log.warn("Usage event flush failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.cameleer3.server.app.analytics;
|
||||
|
||||
import com.cameleer3.server.core.analytics.UsageEvent;
|
||||
import com.cameleer3.server.core.analytics.UsageTracker;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Tracks authenticated UI user requests for usage analytics.
|
||||
* Skips agent requests, health checks, data ingestion, and static assets.
|
||||
*/
|
||||
public class UsageTrackingInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final String START_ATTR = "usage.startNanos";
|
||||
|
||||
// Patterns for normalizing dynamic path segments
|
||||
private static final Pattern EXCHANGE_ID = Pattern.compile(
|
||||
"/[A-F0-9]{15,}-[A-F0-9]{16}(?=/|$)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern UUID = Pattern.compile(
|
||||
"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|$)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern HEX_HASH = Pattern.compile(
|
||||
"/[0-9a-f]{32,64}(?=/|$)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern NUMERIC_ID = Pattern.compile(
|
||||
"(?<=/)(\\d{2,})(?=/|$)");
|
||||
// Agent instance IDs like "cameleer3-sample-598867949d-g7nt4-1"
|
||||
private static final Pattern INSTANCE_ID = Pattern.compile(
|
||||
"(?<=/agents/)[^/]+(?=/)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private final UsageTracker usageTracker;
|
||||
|
||||
public UsageTrackingInterceptor(UsageTracker usageTracker) {
|
||||
this.usageTracker = usageTracker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
request.setAttribute(START_ATTR, System.nanoTime());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
|
||||
Object handler, Exception ex) {
|
||||
String username = extractUsername();
|
||||
if (username == null) return; // unauthenticated or agent request
|
||||
|
||||
Long startNanos = (Long) request.getAttribute(START_ATTR);
|
||||
long durationMs = startNanos != null ? (System.nanoTime() - startNanos) / 1_000_000 : 0;
|
||||
|
||||
String path = request.getRequestURI();
|
||||
String queryString = request.getQueryString();
|
||||
|
||||
usageTracker.track(new UsageEvent(
|
||||
Instant.now(),
|
||||
username,
|
||||
request.getMethod(),
|
||||
path,
|
||||
normalizePath(path),
|
||||
response.getStatus(),
|
||||
durationMs,
|
||||
queryString
|
||||
));
|
||||
}
|
||||
|
||||
private String extractUsername() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getName() == null) return null;
|
||||
String name = auth.getName();
|
||||
// Only track UI users (user:admin), not agents
|
||||
if (!name.startsWith("user:")) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
static String normalizePath(String path) {
|
||||
String normalized = EXCHANGE_ID.matcher(path).replaceAll("/{id}");
|
||||
normalized = UUID.matcher(normalized).replaceAll("/{id}");
|
||||
normalized = HEX_HASH.matcher(normalized).replaceAll("/{hash}");
|
||||
normalized = INSTANCE_ID.matcher(normalized).replaceAll("{id}");
|
||||
normalized = NUMERIC_ID.matcher(normalized).replaceAll("{id}");
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.app.search.ClickHouseLogStore;
|
||||
import com.cameleer3.server.app.storage.ClickHouseAgentEventRepository;
|
||||
import com.cameleer3.server.app.storage.ClickHouseUsageTracker;
|
||||
import com.cameleer3.server.app.storage.ClickHouseDiagramStore;
|
||||
import com.cameleer3.server.app.storage.ClickHouseMetricsQueryStore;
|
||||
import com.cameleer3.server.app.storage.ClickHouseMetricsStore;
|
||||
@@ -170,4 +171,21 @@ public class StorageBeanConfig {
|
||||
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||
return new ClickHouseLogStore(clickHouseJdbc);
|
||||
}
|
||||
|
||||
// ── Usage Analytics ──────────────────────────────────────────────
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||
public ClickHouseUsageTracker clickHouseUsageTracker(
|
||||
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||
return new ClickHouseUsageTracker(clickHouseJdbc,
|
||||
new com.cameleer3.server.core.ingestion.WriteBuffer<>(5000));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||
public com.cameleer3.server.app.analytics.UsageTrackingInterceptor usageTrackingInterceptor(
|
||||
ClickHouseUsageTracker usageTracker) {
|
||||
return new com.cameleer3.server.app.analytics.UsageTrackingInterceptor(usageTracker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.app.analytics.UsageTrackingInterceptor;
|
||||
import com.cameleer3.server.app.interceptor.AuditInterceptor;
|
||||
import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -14,11 +15,14 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
||||
private final AuditInterceptor auditInterceptor;
|
||||
private final UsageTrackingInterceptor usageTrackingInterceptor;
|
||||
|
||||
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
|
||||
AuditInterceptor auditInterceptor) {
|
||||
AuditInterceptor auditInterceptor,
|
||||
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor) {
|
||||
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
||||
this.auditInterceptor = auditInterceptor;
|
||||
this.usageTrackingInterceptor = usageTrackingInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -35,6 +39,18 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
"/api/v1/agents/*/refresh"
|
||||
);
|
||||
|
||||
// Usage analytics: tracks authenticated UI user requests
|
||||
if (usageTrackingInterceptor != null) {
|
||||
registry.addInterceptor(usageTrackingInterceptor)
|
||||
.addPathPatterns("/api/v1/**")
|
||||
.excludePathPatterns(
|
||||
"/api/v1/data/**",
|
||||
"/api/v1/agents/*/heartbeat",
|
||||
"/api/v1/agents/*/events",
|
||||
"/api/v1/health"
|
||||
);
|
||||
}
|
||||
|
||||
// Safety-net audit: catches any unaudited POST/PUT/DELETE
|
||||
registry.addInterceptor(auditInterceptor)
|
||||
.addPathPatterns("/api/v1/**")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.storage.ClickHouseUsageTracker;
|
||||
import com.cameleer3.server.core.analytics.UsageStats;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/usage")
|
||||
@ConditionalOnBean(ClickHouseUsageTracker.class)
|
||||
@Tag(name = "Usage Analytics", description = "UI usage pattern analytics")
|
||||
public class UsageAnalyticsController {
|
||||
|
||||
private final ClickHouseUsageTracker tracker;
|
||||
|
||||
public UsageAnalyticsController(ClickHouseUsageTracker tracker) {
|
||||
this.tracker = tracker;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Query usage statistics",
|
||||
description = "Returns aggregated API usage stats grouped by endpoint, user, or hour")
|
||||
public ResponseEntity<List<UsageStats>> getUsage(
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(defaultValue = "endpoint") String groupBy) {
|
||||
|
||||
Instant fromInstant = from != null ? Instant.parse(from) : Instant.now().minus(7, ChronoUnit.DAYS);
|
||||
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
||||
|
||||
List<UsageStats> stats = switch (groupBy) {
|
||||
case "user" -> tracker.queryByUser(fromInstant, toInstant);
|
||||
case "hour" -> tracker.queryByHour(fromInstant, toInstant, username);
|
||||
default -> tracker.queryByEndpoint(fromInstant, toInstant, username);
|
||||
};
|
||||
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.cameleer3.server.app.storage;
|
||||
|
||||
import com.cameleer3.server.core.analytics.UsageEvent;
|
||||
import com.cameleer3.server.core.analytics.UsageStats;
|
||||
import com.cameleer3.server.core.analytics.UsageTracker;
|
||||
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ClickHouseUsageTracker implements UsageTracker {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClickHouseUsageTracker.class);
|
||||
private static final DateTimeFormatter CH_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneOffset.UTC);
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final WriteBuffer<UsageEvent> buffer;
|
||||
|
||||
public ClickHouseUsageTracker(JdbcTemplate jdbc, WriteBuffer<UsageEvent> buffer) {
|
||||
this.jdbc = jdbc;
|
||||
this.buffer = buffer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void track(UsageEvent event) {
|
||||
buffer.offerOrWarn(event);
|
||||
}
|
||||
|
||||
public void flush() {
|
||||
List<UsageEvent> batch = buffer.drain(200);
|
||||
if (batch.isEmpty()) return;
|
||||
|
||||
jdbc.batchUpdate("""
|
||||
INSERT INTO usage_events (timestamp, username, method, path, normalized,
|
||||
status_code, duration_ms, query_params)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
batch.stream().map(e -> new Object[]{
|
||||
Timestamp.from(e.timestamp()),
|
||||
e.username(),
|
||||
e.method(),
|
||||
e.path(),
|
||||
e.normalized(),
|
||||
e.statusCode(),
|
||||
e.durationMs(),
|
||||
e.queryParams() != null ? e.queryParams() : ""
|
||||
}).toList());
|
||||
|
||||
log.debug("Flushed {} usage events to ClickHouse", batch.size());
|
||||
}
|
||||
|
||||
public List<UsageStats> queryByEndpoint(Instant from, Instant to, String username) {
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT concat(method, ' ', normalized) AS key,
|
||||
count() AS cnt,
|
||||
avg(duration_ms) AS avg_dur
|
||||
FROM usage_events
|
||||
WHERE timestamp >= '%s' AND timestamp < '%s'
|
||||
""".formatted(CH_FMT.format(from), CH_FMT.format(to)));
|
||||
|
||||
if (username != null && !username.isBlank()) {
|
||||
sql.append(" AND username = '").append(username.replace("'", "\\'")).append("'");
|
||||
}
|
||||
sql.append(" GROUP BY key ORDER BY cnt DESC LIMIT 100");
|
||||
|
||||
return jdbc.query(sql.toString(), (rs, i) -> new UsageStats(
|
||||
rs.getString("key"), rs.getLong("cnt"), rs.getLong("avg_dur")));
|
||||
}
|
||||
|
||||
public List<UsageStats> queryByUser(Instant from, Instant to) {
|
||||
String sql = """
|
||||
SELECT username AS key, count() AS cnt, avg(duration_ms) AS avg_dur
|
||||
FROM usage_events
|
||||
WHERE timestamp >= '%s' AND timestamp < '%s'
|
||||
GROUP BY key ORDER BY cnt DESC LIMIT 100
|
||||
""".formatted(CH_FMT.format(from), CH_FMT.format(to));
|
||||
|
||||
return jdbc.query(sql, (rs, i) -> new UsageStats(
|
||||
rs.getString("key"), rs.getLong("cnt"), rs.getLong("avg_dur")));
|
||||
}
|
||||
|
||||
public List<UsageStats> queryByHour(Instant from, Instant to, String username) {
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT formatDateTime(toStartOfHour(timestamp), '%%Y-%%m-%%d %%H:00') AS key,
|
||||
count() AS cnt,
|
||||
avg(duration_ms) AS avg_dur
|
||||
FROM usage_events
|
||||
WHERE timestamp >= '%s' AND timestamp < '%s'
|
||||
""".formatted(CH_FMT.format(from), CH_FMT.format(to)));
|
||||
|
||||
if (username != null && !username.isBlank()) {
|
||||
sql.append(" AND username = '").append(username.replace("'", "\\'")).append("'");
|
||||
}
|
||||
sql.append(" GROUP BY key ORDER BY key LIMIT 720");
|
||||
|
||||
return jdbc.query(sql.toString(), (rs, i) -> new UsageStats(
|
||||
rs.getString("key"), rs.getLong("cnt"), rs.getLong("avg_dur")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
timestamp DateTime64(3) DEFAULT now64(3),
|
||||
username LowCardinality(String),
|
||||
method LowCardinality(String),
|
||||
path String,
|
||||
normalized LowCardinality(String),
|
||||
status_code UInt16,
|
||||
duration_ms UInt32,
|
||||
query_params String DEFAULT ''
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
ORDER BY (username, timestamp)
|
||||
TTL toDateTime(timestamp) + INTERVAL 90 DAY;
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cameleer3.server.core.analytics;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record UsageEvent(
|
||||
Instant timestamp,
|
||||
String username,
|
||||
String method,
|
||||
String path,
|
||||
String normalized,
|
||||
int statusCode,
|
||||
long durationMs,
|
||||
String queryParams
|
||||
) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cameleer3.server.core.analytics;
|
||||
|
||||
public record UsageStats(
|
||||
String key,
|
||||
long count,
|
||||
long avgDurationMs
|
||||
) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.cameleer3.server.core.analytics;
|
||||
|
||||
public interface UsageTracker {
|
||||
|
||||
void track(UsageEvent event);
|
||||
}
|
||||
Reference in New Issue
Block a user