Implementing Precise Per‑Minute API Call Statistics in Java: Multiple Solutions and Best Practices
This article explains why per‑minute API call counting is essential for performance bottleneck detection, capacity planning, security alerts and billing, and presents five concrete Java‑based implementations—including a fixed‑window counter, a sliding‑window counter, AOP‑based transparent monitoring, a Redis time‑series solution, and Micrometer‑Prometheus integration—along with a hybrid architecture, performance benchmarks, and practical capacity‑planning advice.
As a frontline developer you often face situations where operations report an API being hammered at night, product managers suspect performance problems, and managers request hot‑spot statistics; a system that can accurately count "calls per API per minute" solves these pain points.
Why Count API Call Frequency?
Accurate statistics help identify performance bottlenecks, guide capacity planning, trigger security alerts for abnormal spikes, provide billing metrics for external APIs, and simplify problem isolation when a system misbehaves.
Key Design Considerations
When building such a monitor you must balance accuracy, memory footprint, thread‑safety, clock‑skew handling, and distributed aggregation.
Solution 1: Fixed‑Window Counter
public class SimpleCounter {
// Thread‑safe map
private ConcurrentHashMap
counters = new ConcurrentHashMap<>();
// Record a call
public void increment(String apiName) {
counters.computeIfAbsent(apiName, k -> new AtomicLong(0)).incrementAndGet();
}
// Get count
public long getCount(String apiName) {
return counters.getOrDefault(apiName, new AtomicLong(0)).get();
}
// Reset every minute
@Scheduled(fixedRate = 60000)
public void printAndReset() {
System.out.println("=== API minute stats ===");
counters.forEach((api, count) -> System.out.println(api + ": " + count.getAndSet(0)));
}
}The approach is simple but suffers from boundary errors when the scheduled reset does not align perfectly with the minute boundary.
Solution 2: Sliding‑Window Counter (Lazy‑Load Optimized)
public class SlidingWindowCounter {
private final ConcurrentHashMap
apiCounters = new ConcurrentHashMap<>();
private final int WINDOW_SIZE_SECONDS = 10; // each slice = 10 s
private final int WINDOW_COUNT = 6; // 6 slices = 1 min
private volatile int currentTimeSlice;
public SlidingWindowCounter() {
currentTimeSlice = (int)(System.currentTimeMillis() / 1000 / WINDOW_SIZE_SECONDS);
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
long initialDelay = WINDOW_SIZE_SECONDS - (System.currentTimeMillis() / 1000 % WINDOW_SIZE_SECONDS);
scheduler.scheduleAtFixedRate(this::slideWindow, initialDelay, WINDOW_SIZE_SECONDS, TimeUnit.SECONDS);
}
public void increment(String apiName) {
int timeSlice = currentTimeSlice;
CounterEntry entry = apiCounters.computeIfAbsent(apiName, k -> new CounterEntry());
entry.increment(timeSlice);
}
public long getMinuteCount(String apiName) {
CounterEntry entry = apiCounters.get(apiName);
return entry == null ? 0 : entry.getTotal(currentTimeSlice);
}
private void slideWindow() {
try {
int newSlice = (int)(System.currentTimeMillis() / 1000 / WINDOW_SIZE_SECONDS);
if (newSlice <= currentTimeSlice) {
System.err.println("Clock skew detected: " + newSlice + " <= " + currentTimeSlice);
return;
}
currentTimeSlice = newSlice;
cleanupIdleCounters();
} catch (Exception e) {
System.err.println("Error in slideWindow: " + e.getMessage());
}
}
// CounterEntry implements lazy window clearing, total aggregation and idle cleanup (code omitted for brevity)
}This design eliminates boundary errors, provides millisecond‑level precision, and updates windows only when accessed, reducing overhead.
Solution 3: AOP‑Based Transparent Statistics (Async Optimized)
@Aspect
@Component
public class ApiMonitorAspect {
private final Logger logger = LoggerFactory.getLogger(ApiMonitorAspect.class);
@Autowired private SlidingWindowCounter counter;
private final ThreadPoolExecutor asyncExecutor = new ThreadPoolExecutor(
2, 5, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy());
@Pointcut("@within(org.springframework.web.bind.annotation.RestController) || @within(org.springframework.stereotype.Controller)")
public void apiPointcut() {}
@Around("apiPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
boolean success = false;
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
String methodName = sig.getDeclaringType().getName() + "." + sig.getName();
try {
Object result = joinPoint.proceed();
success = true;
return result;
} finally {
long execTime = System.currentTimeMillis() - start;
asyncExecutor.execute(() -> {
try {
counter.increment(methodName);
counter.increment(methodName + ":" + (success ? "success" : "failure"));
String speed;
if (execTime < 100) speed = "fast";
else if (execTime < 1000) speed = "medium";
else speed = "slow";
counter.increment(methodName + ":" + speed);
} catch (Exception ex) {
logger.error("Failed to record API metrics", ex);
}
});
}
}
public long getApiCallCount(String apiName) { return counter.getMinuteCount(apiName); }
}The aspect adds no‑intrusive monitoring, records success/failure and latency categories asynchronously, and protects the business thread from metric‑collection overhead.
Solution 4: Distributed Counting with Redis (Time‑Series Optimized)
@Service
public class RedisTimeSeriesCounter {
@Autowired private StringRedisTemplate redisTemplate;
private final int MAX_RETRIES = 3;
private final long[] RETRY_DELAYS = {10L, 50L, 200L};
public void increment(String apiName) {
long ts = System.currentTimeMillis();
String key = getBaseKey(apiName);
String script = "local minute = math.floor(ARGV[1]/60000)*60000; " +
"redis.call('ZINCRBY', KEYS[1], 1, minute); " +
"redis.call('EXPIRE', KEYS[1], 86400); " +
"return 1;";
Exception last = null;
for (int i = 0; i < MAX_RETRIES; i++) {
try {
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key), String.valueOf(ts));
return;
} catch (Exception e) {
last = e;
if (i < MAX_RETRIES - 1) Thread.sleep(RETRY_DELAYS[i]);
}
}
// fallback to basic ops
try {
long minute = ts / 60000 * 60000;
redisTemplate.opsForZSet().incrementScore(key, String.valueOf(minute), 1);
redisTemplate.expire(key, 1, TimeUnit.DAYS);
} catch (Exception e) {
logger.error("Failed to increment API counter for {}", apiName, e);
}
}
public long getCurrentMinuteCount(String apiName) {
long minute = System.currentTimeMillis() / 60000 * 60000;
return getCountByMinute(apiName, minute);
}
public long getCountByMinute(String apiName, long minute) {
String key = getBaseKey(apiName);
Double score = redisTemplate.opsForZSet().score(key, String.valueOf(minute));
return score == null ? 0 : score.longValue();
}
public Map
getCountTrend(String apiName, long start, long end) {
String key = getBaseKey(apiName);
long startMin = start / 60000 * 60000;
long endMin = end / 60000 * 60000;
Set
> set = redisTemplate.opsForZSet()
.rangeByScoreWithScores(key, startMin, endMin);
Map
trend = new TreeMap<>();
if (set != null) {
for (ZSetOperations.TypedTuple
t : set) {
trend.put(Long.parseLong(t.getValue()), t.getScore().longValue());
}
}
return trend;
}
private String getBaseKey(String apiName) { return "api:timeseries:" + apiName; }
}Redis stores per‑minute counts in a sorted set, enabling distributed aggregation, historical queries and automatic expiration.
Solution 5: Micrometer + Prometheus for Multi‑Dimensional Monitoring
@Configuration
public class MetricsConfig {
@Bean
public MeterRegistry meterRegistry() {
return new PrometheusMeterRegistry(PrometheusConfig.DEFAULT,
new CollectorRegistry(), Clock.SYSTEM,
new CommonTags("application", "my-app", "env", "prod"));
}
@Bean
public MeterFilter dimensionFilter() {
return MeterFilter.maximumAllowableTags("api.calls", "uri", 100);
}
@Bean
public MeterFilter cardinalityLimiter(MeterRegistry registry) {
return new MeterFilter() {
@Override
public Meter.Id map(Meter.Id id) {
if (id.getName().equals("api.calls") &&
registry.find(id.getName()).tagKeys().size() > 5000) {
return id.withTag("name", "other");
}
return id;
}
};
}
}
@Component
public class ApiMetricsInterceptor implements HandlerInterceptor {
private final MeterRegistry meterRegistry;
private final ThreadLocal
startTime = new ThreadLocal<>();
private final PathParameterResolver resolver = new PathParameterResolver();
@Autowired public ApiMetricsInterceptor(MeterRegistry mr) { this.meterRegistry = mr; }
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
startTime.set(System.currentTimeMillis());
if (handler instanceof HandlerMethod) {
HandlerMethod hm = (HandlerMethod) handler;
String api = hm.getBeanType().getName() + "." + hm.getMethod().getName();
String uri = resolver.standardizePath(req.getRequestURI());
meterRegistry.counter("api.calls", "name", api, "method", req.getMethod(), "uri", uri).increment();
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) {
if (handler instanceof HandlerMethod && startTime.get() != null) {
HandlerMethod hm = (HandlerMethod) handler;
String api = hm.getBeanType().getName() + "." + hm.getMethod().getName();
long latency = System.currentTimeMillis() - startTime.get();
meterRegistry.timer("api.latency", "name", api, "status", String.valueOf(resp.getStatus()))
.record(latency, TimeUnit.MILLISECONDS);
startTime.remove();
}
}
// PathParameterResolver normalizes numeric path segments to {id}
private static class PathParameterResolver {
private final Pattern pathParamPattern = Pattern.compile("/\\d+(/|$)");
private final Set
preserved = Set.of("/v1","/v2","/v3","/2fa","/oauth2");
public String standardizePath(String uri) {
for (String p : preserved) if (uri.contains(p)) return uri;
Matcher m = pathParamPattern.matcher(uri);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String repl = m.group().endsWith("/") ? "/{id}/" : "/{id}";
m.appendReplacement(sb, repl);
}
m.appendTail(sb);
return sb.toString();
}
}
}Micrometer records multi‑dimensional counters and latency histograms, while Prometheus stores them for long‑term trend analysis and alerting.
Hybrid Monitoring Architecture
In production the most robust setup combines a local sliding‑window counter for millisecond‑level real‑time QPS, a Redis time‑series for minute‑level distributed aggregation, and Prometheus for hour‑to‑day trend visualisation.
@Service
public class HybridApiMonitor {
@Autowired private SlidingWindowCounter local;
@Autowired private RedisTimeSeriesCounter redis;
@Autowired private MeterRegistry registry;
public void recordApiCall(String api) {
local.increment(api);
// batch write to Redis (implementation omitted)
registry.counter("api.calls", "name", api).increment();
}
@Scheduled(fixedRate = 60000)
public void flushToRedis() {
// iterate local counters, write aggregated minute data to Redis
}
public ApiStats getApiStats(String api) {
return ApiStats.builder()
.realtimeQps(local.getMinuteCount(api) / 60.0)
.last5MinutesTrend(redis.getCountTrend(api, System.currentTimeMillis()-300000, System.currentTimeMillis()))
.prometheusQueryUrl("/grafana/d/apis?var-name=" + api)
.build();
}
}This layered approach satisfies real‑time debugging, short‑term capacity planning, and long‑term business intelligence.
Performance Benchmarks & Memory Footprint
JMeter tests (4 CPU, 8 GB RAM, 100 threads, 10 min) showed that the fixed‑window and standard sliding‑window implementations consume ~15 MB per 10 k APIs and OOM beyond 100 k, while the lazy‑load sliding window reduces memory to ~15 MB for 10 k APIs and ~1.3 GB for 1 M APIs. Redis adds ~100 bytes per API‑minute; a 7‑day retention of 10 k APIs requires ~10 GB.
API Count
Fixed Window
Standard Sliding
Lazy Sliding
1 k
1 MB
2 MB
2 MB
10 k
8 MB
20 MB
15 MB
100 k
70 MB
200 MB
140 MB
1 M
OOM
OOM
1.3 GB
Practical Issues & Mitigations
Memory OOM: Use Guava Cache with size limits and expiration to prune rarely‑used keys.
private LoadingCache
counters = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterAccess(30, TimeUnit.MINUTES)
.build(new CacheLoader
() {
@Override public AtomicLong load(String key) { return new AtomicLong(0); }
});Clock Drift in Distributed Pods: Synchronise clocks via NTP or a Redis‑based clock service to keep window boundaries consistent.
@Service
public class RedisClockService implements ClockService {
@Autowired private StringRedisTemplate redis;
@Override public long currentTimeMillis() {
return redis.execute((RedisCallback
) conn -> conn.time());
}
}Redis Write Saturation: Buffer increments locally and flush in batches using pipelining.
public class BufferedRedisCounter {
private final ConcurrentHashMap
buffer = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@Autowired private RedisTemplate
redis;
public BufferedRedisCounter() {
scheduler.scheduleAtFixedRate(this::flushToRedis, 1, 1, TimeUnit.SECONDS);
}
public void increment(String api) {
buffer.computeIfAbsent(api, k -> new AtomicLong()).incrementAndGet();
}
private void flushToRedis() {
if (buffer.isEmpty()) return;
redis.executePipelined((RedisCallback
) conn -> {
buffer.forEach((api, cnt) -> {
long v = cnt.getAndSet(0);
if (v > 0) {
String key = "api:counter:" + api + ":" +
ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
conn.incrBy(key.getBytes(), v);
conn.expire(key.getBytes(), 3600);
}
});
return null;
});
}
}Capacity‑Planning Recommendations
Fixed/Sliding Window (In‑Process): Roughly 15‑20 MB per 10 k APIs; allocate ≥512 MB heap for < 10 k APIs, ≥2 GB for < 100 k APIs; beyond that switch to Redis.
Redis Time‑Series: Approx. 100 bytes per API‑minute; 1 k APIs for 7 days ≈ 1 GB, 10 k APIs ≈ 10 GB; use a 3‑master‑3‑replica cluster with 16 GB per node.
Prometheus: Disk ≈ samples × sample‑size × retention; 1 k APIs sampled every 15 s for 30 days ≈ 50 GB; enforce ≤5 000 tag combinations per metric.
Conclusion
Combining a low‑latency sliding‑window counter, a Redis‑backed time‑series store, and Micrometer‑Prometheus visualisation provides a complete monitoring stack that covers real‑time debugging, short‑term capacity planning, and long‑term trend analysis while keeping memory usage and operational overhead under control.
Selected Java Interview Questions
A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.