Operations 25 min read

How to Accurately Track API Calls per Minute: 5 Proven Monitoring Strategies

This article explores why precise per‑minute API call statistics are essential for performance bottleneck detection, capacity planning, security alerts, billing, and troubleshooting, and presents five practical implementations—including fixed‑window counters, sliding windows, AOP‑based interception, Redis time‑series storage, and Micrometer‑Prometheus integration—along with their trade‑offs and capacity‑planning guidelines.

Architect
Architect
Architect
How to Accurately Track API Calls per Minute: 5 Proven Monitoring Strategies

Developers often face scenarios where operations teams report a surge in API traffic, product managers suspect performance issues, or executives need hotspot insights. A monitoring system that can accurately count "calls per API per minute" helps quickly locate problems.

Why Count API Call Frequency?

Performance bottleneck detection – Identify the most frequently called interfaces for optimization or caching.

Capacity planning – Use call trends to decide when to add resources.

Security alerts – Sudden spikes may indicate attacks.

Billing basis – External APIs are often charged per call.

Problem troubleshooting – When the system misbehaves, check which APIs are busiest.

Key Factors for Designing a Solution

The solution must be accurate, low‑latency, scalable, and easy to integrate.

Solution 1: Fixed‑Window Counter

The simplest approach uses a Map<String, AtomicLong> to store per‑API counts and resets the map every minute with a scheduled task.

public class SimpleCounter {
    // Thread‑safe map
    private ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();

    // Record a call
    public void increment(String apiName) {
        counters.computeIfAbsent(apiName, k -> new AtomicLong(0)).incrementAndGet();
    }

    // Get current count
    public long getCount(String apiName) {
        return counters.getOrDefault(apiName, new AtomicLong(0)).get();
    }

    // Scheduled task to print and reset each minute
    @Scheduled(fixedRate = 60000)
    public void printAndReset() {
        System.out.println("=== API Minute Call Stats ===");
        counters.forEach((api, count) -> System.out.println(api + ": " + count.getAndSet(0)));
    }
}

This method is easy but suffers from boundary inaccuracies when the timer fires near the minute edge.

Solution 2: Sliding‑Window Counter (Lazy‑Load Optimized)

A sliding window splits a minute into six 10‑second slices, updating only when accessed, which eliminates boundary errors.

public class SlidingWindowCounter {
    private final ConcurrentHashMap<String, CounterEntry> apiCounters = new ConcurrentHashMap<>();
    private final int WINDOW_SIZE_SECONDS = 10; // each slice
    private final int WINDOW_COUNT = 6; // 6 slices = 1 minute
    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);
        if (entry == null) return 0;
        return entry.getTotal(currentTimeSlice);
    }

    private void slideWindow() {
        int newSlice = (int) (System.currentTimeMillis() / 1000 / WINDOW_SIZE_SECONDS);
        if (newSlice <= currentTimeSlice) return; // clock skew or no change
        currentTimeSlice = newSlice;
        cleanupIdleCounters();
    }

    private void cleanupIdleCounters() {
        long IDLE_THRESHOLD_MS = 300000; // 5 minutes
        long now = System.currentTimeMillis();
        apiCounters.entrySet().removeIf(e -> now - e.getValue().lastUpdateTime > IDLE_THRESHOLD_MS);
    }

    private static class CounterEntry {
        private final AtomicLong[] counters = new AtomicLong[WINDOW_COUNT];
        private volatile int lastAccessedSlice;
        private volatile long lastUpdateTime;

        CounterEntry() {
            for (int i = 0; i < WINDOW_COUNT; i++) counters[i] = new AtomicLong(0);
            lastAccessedSlice = 0;
            lastUpdateTime = System.currentTimeMillis();
        }

        void increment(int timeSlice) {
            updateWindowsIfNeeded(timeSlice);
            int index = timeSlice % WINDOW_COUNT;
            counters[index].incrementAndGet();
            lastAccessedSlice = timeSlice;
            lastUpdateTime = System.currentTimeMillis();
        }

        long getTotal(int currentSlice) {
            updateWindowsIfNeeded(currentSlice);
            long total = 0;
            for (AtomicLong c : counters) total += c.get();
            return total;
        }

        private void updateWindowsIfNeeded(int currentSlice) {
            int diff = currentSlice - lastAccessedSlice;
            if (diff <= 0) return;
            if (diff >= WINDOW_COUNT) {
                for (AtomicLong c : counters) c.set(0);
            } else {
                for (int i = 1; i <= diff; i++) {
                    int idx = (lastAccessedSlice + i) % WINDOW_COUNT;
                    counters[idx].set(0);
                }
            }
        }
    }
}

The lazy‑load design updates windows only when a request arrives, offering high precision with modest overhead.

Solution 3: Transparent AOP‑Based Statistics (Async Optimized)

Using Spring AOP, API calls are intercepted without modifying business code. Statistics are recorded asynchronously to avoid affecting request latency.

@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();
        Object result = null;
        boolean success = false;
        MethodSignature sig = (MethodSignature) joinPoint.getSignature();
        String methodName = sig.getDeclaringType().getName() + "." + sig.getName();
        try {
            result = joinPoint.proceed();
            success = true;
            return result;
        } catch (Exception e) {
            success = false;
            throw e;
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            asyncExecutor.execute(() -> {
                try {
                    counter.increment(methodName);
                    counter.increment(methodName + ":" + (success ? "success" : "failure"));
                    String speed;
                    if (elapsed < 100) speed = "fast";
                    else if (elapsed < 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);
    }
}

This approach provides zero‑intrusion monitoring with asynchronous aggregation.

Solution 4: Distributed Counting with Redis (Time‑Series Optimized)

In a distributed environment each instance writes to a Redis sorted set keyed by minute, enabling global aggregation and historical queries.

@Service
public class RedisTimeSeriesCounter {
    @Autowired
    private StringRedisTemplate redisTemplate;
    private static final int MAX_RETRIES = 3;
    private static final long[] RETRY_DELAYS = {10L, 50L, 200L};

    public void increment(String apiName) {
        long ts = System.currentTimeMillis();
        String key = "api:timeseries:" + 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) {
                    try { Thread.sleep(RETRY_DELAYS[i]); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; }
                }
            }
        }
        // Fallback to basic ops
        try {
            logger.warn("Redis script failed after retries, falling back", MAX_RETRIES, last);
            long minuteKey = ts / 60000 * 60000;
            redisTemplate.opsForZSet().incrementScore(key, String.valueOf(minuteKey), 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 minuteTimestamp) {
        String key = "api:timeseries:" + apiName;
        Double score = redisTemplate.opsForZSet().score(key, String.valueOf(minuteTimestamp));
        return score == null ? 0 : score.longValue();
    }

    public Map<Long, Long> getCountTrend(String apiName, long startTime, long endTime) {
        String key = "api:timeseries:" + apiName;
        long startMinute = startTime / 60000 * 60000;
        long endMinute = endTime / 60000 * 60000;
        Set<ZSetOperations.TypedTuple<String>> results = redisTemplate.opsForZSet()
                .rangeByScoreWithScores(key, startMinute, endMinute);
        Map<Long, Long> trend = new TreeMap<>();
        if (results != null) {
            for (ZSetOperations.TypedTuple<String> t : results) {
                trend.put(Long.parseLong(t.getValue()), t.getScore().longValue());
            }
        }
        return trend;
    }

    private String getBaseKey(String apiName) {
        return "api:timeseries:" + apiName;
    }
}

Redis provides high‑throughput distributed aggregation and natural time‑ordered storage.

Solution 5: Micrometer + Prometheus for Multi‑Dimensional Monitoring

For long‑term trends and rich dashboards, expose metrics via Micrometer and scrape them with Prometheus.

@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() {
        return new MeterFilter() {
            @Override
            public Meter.Id map(Meter.Id id) {
                if (id.getName().equals("api.calls") &&
                    meterRegistry.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<Long> startTime = new ThreadLocal<>();
    private final PathParameterResolver resolver = new PathParameterResolver();

    public ApiMetricsInterceptor(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        startTime.set(System.currentTimeMillis());
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
            String apiName = hm.getBeanType().getName() + "." + hm.getMethod().getName();
            String uri = resolver.standardizePath(request.getRequestURI());
            meterRegistry.counter("api.calls", "name", apiName, "method", request.getMethod(), "uri", uri).increment();
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        if (handler instanceof HandlerMethod && startTime.get() != null) {
            HandlerMethod hm = (HandlerMethod) handler;
            String apiName = hm.getBeanType().getName() + "." + hm.getMethod().getName();
            long latency = System.currentTimeMillis() - startTime.get();
            meterRegistry.timer("api.latency", "name", apiName, "status", String.valueOf(response.getStatus()))
                    .record(latency, TimeUnit.MILLISECONDS);
            startTime.remove();
        }
    }

    private static class PathParameterResolver {
        private final Pattern pattern = Pattern.compile("/\\d+(/|$)");
        private final Set<String> preserve = Set.of("/v1", "/v2", "/v3", "/2fa", "/oauth2");
        public String standardizePath(String uri) {
            for (String p : preserve) if (uri.contains(p)) return uri;
            Matcher m = pattern.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();
        }
    }
}

Prometheus stores counters as monotonic values, enabling rate calculations that survive service restarts.

Data‑Flow Architecture Comparison

The diagram shows how each solution fits into the overall monitoring pipeline.

Hybrid Solution: Full‑Stack Monitoring

Combining a local sliding window for real‑time QPS, Redis for distributed minute‑level aggregation, and Prometheus for long‑term trends yields a complete observability stack.

@Service
public class HybridApiMonitor {
    @Autowired private SlidingWindowCounter localCounter;
    @Autowired private RedisTimeSeriesCounter redisCounter;
    @Autowired private MeterRegistry meterRegistry;

    public void recordApiCall(String apiName) {
        localCounter.increment(apiName);
        // async batch to Redis could be added here
        meterRegistry.counter("api.calls", "name", apiName).increment();
    }

    @Scheduled(fixedRate = 60000)
    public void flushToRedis() {
        // iterate localCounter entries and write to Redis in bulk
    }

    public ApiStats getApiStats(String apiName) {
        return ApiStats.builder()
                .realtimeQps(localCounter.getMinuteCount(apiName) / 60.0)
                .last5MinutesTrend(redisCounter.getCountTrend(apiName, System.currentTimeMillis() - 300000, System.currentTimeMillis()))
                .prometheusQueryUrl("/grafana/d/apis?var-name=" + apiName)
                .build();
    }
}

Capacity Planning Recommendations

Fixed/Sliding Window – ~15‑20 MB per 10 k APIs; JVM heap ≥ 512 MB for < 10 k APIs, ≥ 2 GB for < 100 k APIs.

Redis – Approx. 100 bytes per API‑minute. 1 k APIs for 7 days ≈ 1 GB; 10 k APIs ≈ 10 GB. Use a 3‑master‑3‑slave cluster with 16 GB per node.

Prometheus – Disk ≈ samples × sample‑size × retention. 1 k APIs sampled every 15 s for 30 days ≈ 50 GB. Limit tag cardinality to ≤ 5 000.

Summary

Accurate per‑minute API call statistics are vital for performance debugging, capacity planning, security monitoring, and billing. Fixed‑window counters are simple but imprecise; sliding windows provide accurate timing; AOP adds zero‑intrusion collection; Redis enables distributed aggregation; Micrometer + Prometheus offers rich, long‑term observability. A hybrid approach leverages the strengths of each method to deliver real‑time insights and historical analysis while respecting memory and storage constraints.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaPerformance OptimizationredismetricsspringPrometheusAPI monitoring
Architect
Written by

Architect

Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.