feat: add UI usage analytics tracking
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m9s
CI / docker (push) Successful in 1m14s
CI / deploy (push) Successful in 46s
CI / deploy-feature (push) Has been skipped

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:
hsiegeln
2026-04-01 17:53:32 +02:00
parent ce4abaf862
commit aa2d203f4e
10 changed files with 351 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@@ -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/**")

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.cameleer3.server.core.analytics;
public record UsageStats(
String key,
long count,
long avgDurationMs
) {}

View File

@@ -0,0 +1,6 @@
package com.cameleer3.server.core.analytics;
public interface UsageTracker {
void track(UsageEvent event);
}