diff --git a/server/src/main/java/org/eclipse/openvsx/metrics/DownloadCountValidator.java b/server/src/main/java/org/eclipse/openvsx/metrics/DownloadCountValidator.java
new file mode 100644
index 000000000..465e992c8
--- /dev/null
+++ b/server/src/main/java/org/eclipse/openvsx/metrics/DownloadCountValidator.java
@@ -0,0 +1,220 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+package org.eclipse.openvsx.metrics;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.eclipse.openvsx.entities.Extension;
+import org.eclipse.openvsx.metrics.config.DownloadCountValidationProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.expression.ExpressionParser;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.stereotype.Service;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HexFormat;
+import java.util.Locale;
+
+/**
+ * Validates whether a download should increment the download count.
+ * Prevents malicious actors from inflating download counts through:
+ * - Per-IP per-extension hourly rate limiting (hourly-limit-per-ip)
+ * - Filtering out automated/bot downloads
+ *
+ * Only active when Redis is enabled (ovsx.redis.enabled=true).
+ * When Redis is unavailable, all downloads are counted (no filtering).
+ */
+@Service
+@ConditionalOnProperty(value = "ovsx.redis.enabled", havingValue = "true")
+public class DownloadCountValidator {
+
+ private static final Logger logger = LoggerFactory.getLogger(DownloadCountValidator.class);
+
+ private final StringRedisTemplate redisTemplate;
+ private final DownloadCountValidationProperties properties;
+ private final ExpressionParser expressionParser = new SpelExpressionParser();
+
+ /**
+ * SpEL expression evaluated against the HttpServletRequest to extract the client IP.
+ * Shares the same property as the rate limiter (ovsx.rate-limit.ip-address-function)
+ * so both systems resolve IPs consistently.
+ *
+ * Default is getRemoteAddr() (TCP source IP — safe, but returns the proxy IP
+ * when behind a reverse proxy). Override in application.yml for proxied deployments.
+ */
+ private final String ipAddressFunction;
+
+ public DownloadCountValidator(
+ StringRedisTemplate redisTemplate,
+ @Value("${ovsx.rate-limit.ip-address-function:getRemoteAddr()}") String ipAddressFunction,
+ DownloadCountValidationProperties properties
+ ) {
+ this.redisTemplate = redisTemplate;
+ this.ipAddressFunction = ipAddressFunction;
+ this.properties = properties;
+ }
+
+ /**
+ * Determines if this download should increment the extension's download count.
+ */
+ public boolean shouldCountDownload(Extension extension, HttpServletRequest request) {
+ if (!isValidationEnabled()) {
+ return true;
+ }
+
+ if (request == null) {
+ // Fail closed when validation is enabled but request context is missing.
+ return false;
+ }
+
+ String userAgent = extractUserAgent(request);
+ String ipAddress = extractClientIp(request);
+ // API download flow does not carry a log event timestamp, so use request time
+ // as event-time for dedup bucketing.
+ return shouldCountDownload(extension.getId(), ipAddress, userAgent, Instant.now());
+ }
+
+ /**
+ * Determines if a download should be counted for non-request contexts
+ * using event-time bucketing.
+ *
+ * The Redis key includes a time bucket computed from the event timestamp.
+ * This makes dedup independent of when the handler runs.
+ */
+ public boolean shouldCountDownload(Long extensionId, String clientIp, String userAgent, Instant eventTime) {
+ if (!isValidationEnabled()) {
+ return true;
+ }
+
+ if (isAutomatedClient(userAgent)) {
+ return false;
+ }
+
+ if (clientIp == null || clientIp.isBlank()) {
+ // Fail closed when validation is enabled but client IP cannot be resolved.
+ return false;
+ }
+
+ if (eventTime == null) {
+ // Event-time bucketing is required; without an event timestamp the event
+ // cannot be placed in a deterministic dedup bucket.
+ return false;
+ }
+
+ return isUnderHourlyLimit(clientIp, extensionId, eventTime);
+ }
+
+ public boolean isValidationEnabled() {
+ return properties.getEnabled();
+ }
+
+ /**
+ * Checks whether this download is within the per-IP per-extension hourly limit.
+ *
+ * Redis key: {@code {prefix}:{hashedIp}:{extensionId}:{hourBucket}}
+ * TTL: 1 hour + {@code lateArrivalHours} so late CDN log entries can still
+ * deduplicate against the correct bucket after the hour rolls over.
+ *
+ * The key is created with the TTL atomically via SET NX before incrementing,
+ * so the TTL is always set at creation with no race window.
+ */
+ private boolean isUnderHourlyLimit(String ipAddress, Long extensionId, Instant eventTime) {
+ String key = buildRateLimitKey(ipAddress, extensionId, eventTime);
+ Duration ttl = Duration.ofHours(1).plusHours(properties.getLateArrivalHours());
+
+ // Create the key with TTL atomically if it doesn't exist yet.
+ // This guarantees the TTL is always set before any increment happens.
+ redisTemplate.opsForValue().setIfAbsent(key, "0", ttl);
+
+ Long count = redisTemplate.opsForValue().increment(key);
+ return count != null && count <= properties.getHourlyLimitPerIp();
+ }
+
+ /**
+ * Builds the Redis key for per-IP per-extension hourly rate limiting.
+ *
+ * Format: {@code {prefix}:{hashedIp}:{extensionId}:{hourBucket}}
+ * where {@code hourBucket} is the event timestamp divided by 3600 (epoch seconds),
+ * so all events from the same IP + extension within the same clock-hour share one counter.
+ */
+ private String buildRateLimitKey(String ipAddress, Long extensionId, Instant eventTime) {
+ // Truncate to the hour so all events in the same hour share one key.
+ long hourBucket = eventTime.getEpochSecond() / 3600;
+ return String.format("%s:%s:%d:%d",
+ properties.getKeyPrefix(),
+ hashIp(ipAddress),
+ extensionId,
+ hourBucket
+ );
+ }
+
+ private String hashIp(String ipAddress) {
+ try {
+ var digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(ipAddress.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash, 0, 8);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+
+ /**
+ * Extracts client IP by evaluating the same SpEL expression used by the rate limiter.
+ * The expression runs against the HttpServletRequest as root object, so methods
+ * like getHeader(), getRemoteAddr(), getParameter() are all available.
+ */
+ private String extractClientIp(HttpServletRequest request) {
+ try {
+ var expr = expressionParser.parseExpression(ipAddressFunction);
+ var result = expr.getValue(request, String.class);
+ if (result != null && !result.isEmpty()) {
+ return result;
+ }
+ } catch (Exception e) {
+ logger.warn("Failed to evaluate ip-address-function '{}': {}", ipAddressFunction, e.getMessage());
+ }
+
+ // Fallback to TCP source IP if the expression fails or returns empty
+ String remoteAddr = request.getRemoteAddr();
+ return (remoteAddr != null && !remoteAddr.isEmpty()) ? remoteAddr : null;
+ }
+
+ private String extractUserAgent(HttpServletRequest request) {
+ String userAgent = request.getHeader("User-Agent");
+ return (userAgent != null && !userAgent.isEmpty()) ? userAgent : null;
+ }
+
+ /**
+ * Heuristic check for automated HTTP clients.
+ * These downloads are served normally but don't inflate the count.
+ */
+ private boolean isAutomatedClient(String userAgent) {
+ if (userAgent == null || userAgent.isEmpty()) {
+ return false;
+ }
+
+ String ua = userAgent.toLowerCase(Locale.ROOT);
+ return properties.getAutomatedClientKeywords().stream()
+ .filter(keyword -> keyword != null && !keyword.isBlank())
+ .map(keyword -> keyword.toLowerCase(Locale.ROOT))
+ .anyMatch(ua::contains);
+ }
+
+}
diff --git a/server/src/main/java/org/eclipse/openvsx/metrics/config/DownloadCountValidationProperties.java b/server/src/main/java/org/eclipse/openvsx/metrics/config/DownloadCountValidationProperties.java
new file mode 100644
index 000000000..cba9e59ac
--- /dev/null
+++ b/server/src/main/java/org/eclipse/openvsx/metrics/config/DownloadCountValidationProperties.java
@@ -0,0 +1,156 @@
+/**
+ * ******************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ * ******************************************************************************
+ */
+package org.eclipse.openvsx.metrics.config;
+
+import jakarta.annotation.PostConstruct;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+@Configuration
+public class DownloadCountValidationProperties {
+
+ private static final Logger logger = LoggerFactory.getLogger(DownloadCountValidationProperties.class);
+
+ /**
+ * Master toggle for download count validation.
+ * When false, all downloads are counted.
+ */
+ @Value("${ovsx.download-count.validation.enabled:false}")
+ private boolean enabled;
+
+ /**
+ * Maximum number of downloads counted per IP per extension per hour.
+ * Downloads beyond this limit within the same hour window are ignored.
+ * Default: 3 — allows a few legitimate re-downloads per hour while
+ * preventing bulk inflation from a single IP.
+ */
+ @Value("${ovsx.download-count.validation.hourly-limit-per-ip:50}")
+ private int hourlyLimitPerIp;
+
+ /**
+ * Redis key prefix used for deduplication entries.
+ */
+ @Value("${ovsx.download-count.validation.key-prefix:download:dedup}")
+ private String keyPrefix;
+
+ /**
+ * Extra hours added to the Redis TTL beyond the dedup window when event-time bucketing is on.
+ * Covers late log delivery so out-of-order events still dedup correctly.
+ *
+ * Default: {@code 24}
+ */
+ @Value("${ovsx.download-count.validation.late-arrival-hours:2}")
+ private int lateArrivalHours;
+
+ /**
+ * User-Agent substrings treated as automated clients.
+ */
+ @Value("${ovsx.download-count.validation.automated-client-keywords:}")
+ private String automatedClientKeywordsValue;
+
+ private List automatedClientKeywords = new ArrayList<>();
+
+ /**
+ * Validates the configuration at startup.
+ * Fails fast with a clear message rather than letting bad config cause
+ * subtle runtime bugs (e.g. negative TTLs, ineffective late-arrival buffer).
+ */
+ @PostConstruct
+ public void validate() {
+ // hourly-limit-per-ip must be at least 1.
+ if (hourlyLimitPerIp < 1) {
+ throw new IllegalStateException(
+ "ovsx.download-count.validation.hourly-limit-per-ip must be >= 1, got: " + hourlyLimitPerIp);
+ }
+
+ // late-arrival-hours must be non-negative.
+ if (lateArrivalHours < 0) {
+ throw new IllegalStateException(
+ "ovsx.download-count.validation.late-arrival-hours must be >= 0, got: " + lateArrivalHours);
+ }
+
+ // The late-arrival buffer should be at least 1 hour so that log entries
+ // arriving after the top of the hour still dedup against the correct bucket.
+ if (lateArrivalHours < 1) {
+ logger.warn("ovsx.download-count.validation.late-arrival-hours ({}) is less than 1 hour. "
+ + "Late CDN log entries may not deduplicate correctly against the hourly bucket.",
+ lateArrivalHours);
+ }
+
+ // key-prefix must not be blank — an empty prefix would produce malformed Redis keys.
+ if (keyPrefix == null || keyPrefix.isBlank()) {
+ throw new IllegalStateException(
+ "ovsx.download-count.validation.key-prefix must not be blank");
+ }
+ }
+
+ public boolean getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public int getHourlyLimitPerIp() {
+ return hourlyLimitPerIp;
+ }
+
+ public void setHourlyLimitPerIp(int hourlyLimitPerIp) {
+ this.hourlyLimitPerIp = hourlyLimitPerIp;
+ }
+
+ public String getKeyPrefix() {
+ return keyPrefix;
+ }
+
+ public void setKeyPrefix(String keyPrefix) {
+ this.keyPrefix = keyPrefix;
+ }
+
+ public int getLateArrivalHours() {
+ return lateArrivalHours;
+ }
+
+ public void setLateArrivalHours(int lateArrivalHours) {
+ this.lateArrivalHours = lateArrivalHours;
+ }
+
+ public List getAutomatedClientKeywords() {
+ if (automatedClientKeywords.isEmpty() && automatedClientKeywordsValue != null) {
+ String normalized = automatedClientKeywordsValue
+ .replace("[", "")
+ .replace("]", "")
+ .replace("\"", "")
+ .replace("'", "");
+ automatedClientKeywords = Arrays.stream(normalized.split(","))
+ .map(String::trim)
+ .filter(value -> !value.isEmpty())
+ .toList();
+ }
+ return automatedClientKeywords;
+ }
+
+ public void setAutomatedClientKeywords(List automatedClientKeywords) {
+ this.automatedClientKeywords = automatedClientKeywords;
+ }
+
+}
diff --git a/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java b/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java
index c25a80d1d..b70b60f7e 100644
--- a/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java
+++ b/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java
@@ -19,6 +19,7 @@
import org.eclipse.openvsx.entities.ExtensionVersion;
import org.eclipse.openvsx.entities.FileResource;
import org.eclipse.openvsx.entities.Namespace;
+import org.eclipse.openvsx.metrics.DownloadCountValidator;
import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics;
import org.eclipse.openvsx.repositories.RepositoryService;
import org.eclipse.openvsx.search.SearchUtilService;
@@ -31,6 +32,7 @@
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
@@ -38,6 +40,8 @@
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
import static org.eclipse.openvsx.entities.FileResource.*;
import static org.eclipse.openvsx.util.UrlUtil.createApiFileUrl;
@@ -61,6 +65,7 @@ public class StorageUtilService implements IStorageService {
private final EntityManager entityManager;
private final FileCacheDurationConfig fileCacheDurationConfig;
private final CdnServiceConfig cdnServiceConfig;
+ private final Optional downloadCountValidator;
/** Determines which external storage service to use in case multiple services are configured. */
@Value("${ovsx.storage.primary-service:}")
@@ -82,7 +87,8 @@ public StorageUtilService(
CacheService cache,
EntityManager entityManager,
FileCacheDurationConfig fileCacheDurationConfig,
- CdnServiceConfig cdnServiceConfig
+ CdnServiceConfig cdnServiceConfig,
+ Optional downloadCountValidator
) {
this.repositories = repositories;
this.googleStorage = googleStorage;
@@ -96,6 +102,7 @@ public StorageUtilService(
this.entityManager = entityManager;
this.fileCacheDurationConfig = fileCacheDurationConfig;
this.cdnServiceConfig = cdnServiceConfig;
+ this.downloadCountValidator = downloadCountValidator;
}
public boolean shouldStoreExternally(FileResource resource) {
@@ -291,6 +298,14 @@ public void increaseDownloadCount(FileResource resource) {
var managedResource = entityManager.find(FileResource.class, resource.getId());
var extension = managedResource.getExtension().getExtension();
+
+ if (downloadCountValidator.isPresent() && downloadCountValidator.get().isValidationEnabled()) {
+ var request = getCurrentHttpRequest();
+ if (!downloadCountValidator.get().shouldCountDownload(extension, request)) {
+ return;
+ }
+ }
+
extension.setDownloadCount(extension.getDownloadCount() + 1);
cache.evictNamespaceDetails(extension);
@@ -300,6 +315,18 @@ public void increaseDownloadCount(FileResource resource) {
}
}
+ /**
+ * Gets the current HttpServletRequest from Spring's RequestContextHolder.
+ * Returns null if called outside a servlet request context (e.g., batch jobs).
+ */
+ private HttpServletRequest getCurrentHttpRequest() {
+ var attrs = RequestContextHolder.getRequestAttributes();
+ if (attrs instanceof ServletRequestAttributes servletAttrs) {
+ return servletAttrs.getRequest();
+ }
+ return null;
+ }
+
public ResponseEntity getFileResponse(FileResource resource) {
if (resource.getStorageType().equals(STORAGE_LOCAL)) {
return localStorage.getFile(resource);
diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java b/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java
index 6eb90c01b..72ace70b6 100644
--- a/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java
+++ b/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java
@@ -12,6 +12,7 @@
import org.apache.commons.lang3.StringUtils;
import org.eclipse.openvsx.entities.Extension;
import org.eclipse.openvsx.entities.FileResource;
+import org.eclipse.openvsx.metrics.DownloadCountValidator;
import org.eclipse.openvsx.migration.HandlerJobRequest;
import org.eclipse.openvsx.storage.AwsStorageService;
import org.eclipse.openvsx.util.TempFile;
@@ -33,9 +34,11 @@
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.stream.Collectors;
import java.util.zip.GZIPInputStream;
@@ -63,6 +66,7 @@ public class AwsDownloadCountHandler implements JobRequestHandler downloadCountValidator;
@Value("${ovsx.logs.aws.bucket:}")
String bucket;
@@ -81,9 +85,14 @@ public class AwsDownloadCountHandler implements JobRequestHandler downloadCountValidator
+ ) {
this.awsStorageService = awsStorageService;
this.processor = processor;
+ this.downloadCountValidator = downloadCountValidator;
}
@PostConstruct
@@ -222,7 +231,7 @@ private Map processLogFile(String fileName) throws IOException
var gzipStream = new GZIPInputStream(fileStream);
var reader = new BufferedReader(new InputStreamReader(gzipStream, StandardCharsets.UTF_8));
) {
- var fileCounts = new HashMap();
+ var events = new ArrayList();
var lines = reader.lines().iterator();
while (lines.hasNext()) {
var line = lines.next();
@@ -236,11 +245,39 @@ var record = logFileParser.parse(line);
var uri = record.url();
var uriComponents = uri.split("/");
var vsixFile = UriUtils.decode(uriComponents[uriComponents.length - 1], StandardCharsets.UTF_8).toUpperCase();
- fileCounts.merge(vsixFile, 1, Integer::sum);
+ events.add(new DownloadEvent(vsixFile, record.clientIp(), record.userAgent(), record.eventTime()));
}
}
+ return countValidatedEvents(events);
+ }
+ }
+
+ Map countValidatedEvents(List events) {
+ var fileCounts = new HashMap();
+ if (events.isEmpty()) {
return fileCounts;
}
+
+ var fileNames = events.stream()
+ .map(DownloadEvent::fileName)
+ .distinct()
+ .toList();
+ var fileToExtensionId = processor.resolveDownloadFileToExtensionId(FileResource.STORAGE_AWS, fileNames);
+
+ for (var event : events) {
+ Long extensionId = fileToExtensionId.get(event.fileName());
+ if (extensionId == null) {
+ continue;
+ }
+
+ boolean shouldCount = downloadCountValidator
+ .map(validator -> validator.shouldCountDownload(extensionId, event.clientIp(), event.userAgent(), event.eventTime()))
+ .orElse(true);
+ if (shouldCount) {
+ fileCounts.merge(event.fileName(), 1, Integer::sum);
+ }
+ }
+ return fileCounts;
}
private boolean isGetOperation(LogRecord record) {
@@ -282,4 +319,7 @@ private ListObjectsV2Response listObjects(String continuationToken) {
return getS3Client().listObjectsV2(builder.build());
}
+
+ record DownloadEvent(String fileName, String clientIp, String userAgent, java.time.Instant eventTime) {
+ }
}
diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java b/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java
index f11855567..1a81cf8e6 100644
--- a/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java
+++ b/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java
@@ -21,10 +21,10 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.openvsx.entities.FileResource;
+import org.eclipse.openvsx.metrics.DownloadCountValidator;
import org.eclipse.openvsx.migration.HandlerJobRequest;
import org.eclipse.openvsx.util.TempFile;
import org.jobrunr.jobs.annotations.Job;
-import org.jobrunr.jobs.annotations.Recurring;
import org.jobrunr.jobs.lambdas.JobRequestHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -40,10 +40,13 @@
import java.nio.file.Files;
import java.time.Duration;
import java.time.LocalDateTime;
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.regex.Pattern;
import static org.eclipse.openvsx.storage.AzureBlobStorageService.AZURE_USER_AGENT;
@@ -58,6 +61,7 @@ public class AzureDownloadCountHandler implements JobRequestHandler downloadCountValidator;
private BlobContainerClient containerClient;
private ObjectMapper objectMapper;
private Pattern blobItemNamePattern;
@@ -80,8 +84,12 @@ public class AzureDownloadCountHandler implements JobRequestHandler downloadCountValidator
+ ) {
this.processor = processor;
+ this.downloadCountValidator = downloadCountValidator;
}
public String getRecurringJobId() {
@@ -194,7 +202,7 @@ private Map processBlobItem(String blobName) throws IOException
var downloadsTempFile = downloadBlobItem(blobName);
var reader = Files.newBufferedReader(downloadsTempFile.getPath())
) {
- var fileCounts = new HashMap();
+ var events = new ArrayList();
var lines = reader.lines().iterator();
while(lines.hasNext()) {
var line = lines.next();
@@ -206,11 +214,43 @@ private Map processBlobItem(String blobName) throws IOException
}
if(pathParams != null && storageBlobContainer.equals(pathParams[1])) {
var fileName = UriUtils.decode(pathParams[pathParams.length - 1], StandardCharsets.UTF_8).toUpperCase();
- fileCounts.merge(fileName, 1, Integer::sum);
+ String clientIp = node.path("callerIpAddress").asText(null);
+ String userAgent = node.path("properties").path("userAgentHeader").asText(null);
+ // Azure blob log lines have a root "time" field in ISO-8601 UTC format.
+ Instant eventTime = parseEventTime(node.path("time").asText(null));
+ events.add(new DownloadEvent(fileName, clientIp, userAgent, eventTime));
}
}
+ return countValidatedEvents(events);
+ }
+ }
+
+ Map countValidatedEvents(List events) {
+ var fileCounts = new HashMap();
+ if (events.isEmpty()) {
return fileCounts;
}
+
+ var fileNames = events.stream()
+ .map(DownloadEvent::fileName)
+ .distinct()
+ .toList();
+ var fileToExtensionId = processor.resolveDownloadFileToExtensionId(FileResource.STORAGE_AZURE, fileNames);
+
+ for (var event : events) {
+ Long extensionId = fileToExtensionId.get(event.fileName());
+ if (extensionId == null) {
+ continue;
+ }
+
+ boolean shouldCount = downloadCountValidator
+ .map(validator -> validator.shouldCountDownload(extensionId, event.clientIp(), event.userAgent(), event.eventTime()))
+ .orElse(true);
+ if (shouldCount) {
+ fileCounts.merge(event.fileName(), 1, Integer::sum);
+ }
+ }
+ return fileCounts;
}
private boolean isGetBlobOperation(JsonNode node) {
@@ -298,4 +338,23 @@ private Pattern getBlobItemNamePattern() {
return blobItemNamePattern;
}
+
+ /**
+ * Parses Azure blob log ISO-8601 UTC timestamp (e.g. "2026-02-09T04:20:50Z") to Instant.
+ * Returns null on parse failure so callers can fall back gracefully.
+ */
+ private Instant parseEventTime(String timestamp) {
+ if (timestamp == null || timestamp.isBlank()) {
+ return null;
+ }
+ try {
+ return Instant.parse(timestamp);
+ } catch (DateTimeParseException e) {
+ logger.debug("Failed to parse Azure event timestamp: {}", timestamp);
+ return null;
+ }
+ }
+
+ record DownloadEvent(String fileName, String clientIp, String userAgent, Instant eventTime) {
+ }
}
diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java b/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java
index 73ea9e8a7..8679eaeea 100644
--- a/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java
+++ b/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java
@@ -12,7 +12,19 @@
*****************************************************************************/
package org.eclipse.openvsx.storage.log;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeParseException;
+
class CloudFrontLogFileParser implements LogFileParser {
+
+ private static final Logger logger = LoggerFactory.getLogger(CloudFrontLogFileParser.class);
+
@Override
public LogRecord parse(String line) {
if (line.startsWith("#")) {
@@ -22,6 +34,29 @@ public LogRecord parse(String line) {
// Format:
// date time x-edge-location sc-bytes c-ip cs-method cs(Host) cs-uri-stem sc-status cs(Referer) cs(User-Agent) cs-uri-query cs(Cookie) x-edge-result-type x-edge-request-id x-host-header cs-protocol cs-bytes time-taken x-forwarded-for ssl-protocol ssl-cipher x-edge-response-result-type cs-protocol-version fle-status fle-encrypted-fields c-port time-to-first-byte x-edge-detailed-result-type sc-content-type sc-content-len sc-range-start sc-range-end
var components = line.split("[ \t]+");
- return new LogRecord(components[5], Integer.parseInt(components[8]), components[7]);
+ return new LogRecord(
+ components[5],
+ Integer.parseInt(components[8]),
+ components[7],
+ components[4],
+ components[10],
+ parseEventTime(components[0], components[1])
+ );
+ }
+
+ /**
+ * Parses CloudFront date and time columns into a UTC Instant.
+ * CloudFront logs always use UTC for these fields.
+ * Returns null if parsing fails, so callers can fall back gracefully.
+ */
+ private Instant parseEventTime(String date, String time) {
+ try {
+ return LocalDate.parse(date)
+ .atTime(LocalTime.parse(time))
+ .toInstant(ZoneOffset.UTC);
+ } catch (DateTimeParseException e) {
+ logger.debug("Failed to parse CloudFront event time: date={}, time={}", date, time);
+ return null;
+ }
}
}
diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java b/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java
index 40b27213b..3f53195fb 100644
--- a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java
+++ b/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java
@@ -24,6 +24,7 @@
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -75,6 +76,18 @@ public Map processDownloadCounts(String storageType, Map resolveDownloadFileToExtensionId(String storageType, List fileNames) {
+ return Observation.createNotStarted("DownloadCountProcessor#resolveDownloadFileToExtensionId", observations).observe(() -> {
+ var fileToExtension = new HashMap();
+ repositories.findDownloadsByStorageTypeAndName(storageType, fileNames).forEach(fileResource -> {
+ String normalizedFileName = fileResource.getName().toUpperCase();
+ Long extensionId = fileResource.getExtension().getExtension().getId();
+ fileToExtension.put(normalizedFileName, extensionId);
+ });
+ return fileToExtension;
+ });
+ }
+
@Transactional
public List increaseDownloadCounts(Map extensionDownloads) {
return Observation.createNotStarted("DownloadCountProcessor#increaseDownloadCounts", observations).observe(() -> {
diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java b/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java
index 936daf33f..8e2d0f0b5 100644
--- a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java
+++ b/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java
@@ -77,7 +77,7 @@ public void applicationStarted(ApplicationStartedEvent event) {
azureDownloadCountHandler.getRecurringJobId(),
azureDownloadCountHandler.getCronSchedule(),
ZoneId.of("UTC"),
- new HandlerJobRequest<>(AwsDownloadCountHandler.class)
+ new HandlerJobRequest<>(AzureDownloadCountHandler.class)
);
} else {
scheduler.deleteRecurringJob(azureDownloadCountHandler.getRecurringJobId());
diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java b/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java
index f7d1e135d..808fa424a 100644
--- a/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java
+++ b/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java
@@ -24,6 +24,10 @@
import jakarta.annotation.Nullable;
import java.io.IOException;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
class FastlyLogFileParser implements LogFileParser {
private final Logger logger = LoggerFactory.getLogger(FastlyLogFileParser.class);
@@ -56,6 +60,8 @@ public FastlyLogFileParser() {
class LogRecordDeserializer extends StdDeserializer {
+ private static final Logger logger = LoggerFactory.getLogger(LogRecordDeserializer.class);
+
public LogRecordDeserializer() {
this(null);
}
@@ -71,6 +77,39 @@ public LogRecord deserialize(JsonParser jp, DeserializationContext ctxt)
String operation = node.get("request_method").asText();
int status = (Integer) node.get("response_status").numberValue();
String url = node.get("url").asText();
- return new LogRecord(operation, status, url);
+ String clientIp = node.path("client_ip").asText(null);
+ String userAgent = node.path("request_user_agent").asText(null);
+ Instant eventTime = parseEventTime(node.path("timestamp").asText(null));
+ return new LogRecord(operation, status, url, clientIp, userAgent, eventTime);
+ }
+
+ /**
+ * Parses Fastly timestamp to UTC Instant.
+ *
+ * Fastly uses the basic-offset ISO-8601 form "2026-02-09T04:20:50+0000"
+ * (no colon in offset) which the default ISO parser rejects.
+ * We try standard ISO first (covers "+00:00" and "Z"), then fall back
+ * to a basic-offset formatter (covers "+0000").
+ */
+ private static final DateTimeFormatter FASTLY_TS_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXX");
+
+ private Instant parseEventTime(String timestamp) {
+ if (timestamp == null || timestamp.isBlank()) {
+ return null;
+ }
+ // Try standard ISO offset first ("+00:00", "Z")
+ try {
+ return OffsetDateTime.parse(timestamp).toInstant();
+ } catch (DateTimeParseException ignored) {
+ // fall through
+ }
+ // Try basic offset format ("+0000") used by Fastly
+ try {
+ return OffsetDateTime.parse(timestamp, FASTLY_TS_FORMATTER).toInstant();
+ } catch (DateTimeParseException e) {
+ logger.debug("Failed to parse Fastly event timestamp: {}", timestamp);
+ return null;
+ }
}
}
diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java b/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java
index 18e4febe8..bba37596f 100644
--- a/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java
+++ b/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java
@@ -13,5 +13,24 @@
package org.eclipse.openvsx.storage.log;
import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
-public record LogRecord(@Nonnull String method, int status, @Nonnull String url) {}
+import java.time.Instant;
+
+public record LogRecord(
+ @Nonnull String method,
+ int status,
+ @Nonnull String url,
+ @Nullable String clientIp,
+ @Nullable String userAgent,
+ @Nullable Instant eventTime
+) {
+ public LogRecord(@Nonnull String method, int status, @Nonnull String url) {
+ this(method, status, url, null, null, null);
+ }
+
+ public LogRecord(@Nonnull String method, int status, @Nonnull String url,
+ @Nullable String clientIp, @Nullable String userAgent) {
+ this(method, status, url, clientIp, userAgent, null);
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java
index 8950b37a8..fc770d2d0 100644
--- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java
+++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java
@@ -2661,7 +2661,8 @@ StorageUtilService storageUtilService(
cache,
entityManager,
fileCacheDurationConfig,
- cdnServiceConfig
+ cdnServiceConfig,
+ Optional.empty()
);
}
diff --git a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java
index 5e611e7bc..59fa380f1 100644
--- a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java
+++ b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java
@@ -1114,7 +1114,8 @@ StorageUtilService storageUtilService(
cache,
entityManager,
fileCacheDurationConfig,
- cdnServiceConfig
+ cdnServiceConfig,
+ Optional.empty()
);
}
diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java
index eff82205f..94d9f458a 100644
--- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java
+++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java
@@ -1616,7 +1616,8 @@ StorageUtilService storageUtilService(
cache,
entityManager,
fileCacheDurationConfig,
- cdnServiceConfig
+ cdnServiceConfig,
+ Optional.empty()
);
}
diff --git a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java
index bfc9771ec..0c381967e 100644
--- a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java
@@ -52,6 +52,7 @@
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.fail;
@@ -464,7 +465,8 @@ StorageUtilService storageUtilService(
cache,
entityManager,
fileCacheDurationConfig,
- cdnServiceConfig
+ cdnServiceConfig,
+ Optional.empty()
);
}
diff --git a/server/src/test/java/org/eclipse/openvsx/metrics/DownloadCountValidatorTest.java b/server/src/test/java/org/eclipse/openvsx/metrics/DownloadCountValidatorTest.java
new file mode 100644
index 000000000..ccab251cb
--- /dev/null
+++ b/server/src/test/java/org/eclipse/openvsx/metrics/DownloadCountValidatorTest.java
@@ -0,0 +1,136 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+package org.eclipse.openvsx.metrics;
+
+import org.eclipse.openvsx.metrics.config.DownloadCountValidationProperties;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DownloadCountValidatorTest {
+
+ private StringRedisTemplate redisTemplate;
+ private ValueOperations valueOps;
+ private DownloadCountValidationProperties properties;
+ private DownloadCountValidator validator;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ public void beforeEach() {
+ redisTemplate = Mockito.mock(StringRedisTemplate.class);
+ valueOps = Mockito.mock(ValueOperations.class);
+ Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOps);
+
+ properties = new DownloadCountValidationProperties();
+ properties.setEnabled(true);
+ properties.setHourlyLimitPerIp(3);
+ properties.setKeyPrefix("download:dedup");
+ properties.setLateArrivalHours(2);
+ properties.setAutomatedClientKeywords(List.of("curl", "bot"));
+
+ validator = new DownloadCountValidator(redisTemplate, "getRemoteAddr()", properties);
+ }
+
+ @Test
+ public void shouldSkipValidationWhenDisabled() {
+ properties.setEnabled(false);
+
+ assertTrue(validator.shouldCountDownload(42L, "1.1.1.1", "curl/8.0", Instant.now()));
+ Mockito.verifyNoInteractions(valueOps);
+ }
+
+ @Test
+ public void shouldRejectAutomatedUserAgents() {
+ assertFalse(validator.shouldCountDownload(42L, "1.1.1.1", "curl/8.0", Instant.now()));
+ Mockito.verifyNoInteractions(valueOps);
+ }
+
+ @Test
+ public void shouldCountWhenUnderHourlyLimit() {
+ properties.setHourlyLimitPerIp(5);
+ properties.setLateArrivalHours(2);
+ properties.setKeyPrefix("custom:dedup");
+
+ // Simulate first download in this hour (count = 1, under limit of 5)
+ Mockito.when(valueOps.increment(Mockito.anyString())).thenReturn(1L);
+
+ Instant eventTime = Instant.parse("2026-01-01T01:10:00Z");
+ boolean result = validator.shouldCountDownload(99L, "10.10.10.10", "Mozilla/5.0", eventTime);
+
+ assertTrue(result);
+
+ // Key must include the custom prefix and extension ID
+ Duration expectedTtl = Duration.ofHours(1).plusHours(2);
+ Mockito.verify(valueOps).setIfAbsent(
+ Mockito.argThat(key -> key.startsWith("custom:dedup:") && key.contains(":99:")),
+ Mockito.eq("0"),
+ Mockito.eq(expectedTtl)
+ );
+ }
+
+ @Test
+ public void shouldNotCountWhenHourlyLimitExceeded() {
+ properties.setHourlyLimitPerIp(3);
+
+ // Simulate 4th download — over the limit of 3
+ Mockito.when(valueOps.increment(Mockito.anyString())).thenReturn(4L);
+
+ boolean result = validator.shouldCountDownload(42L, "1.1.1.1", "Mozilla/5.0", Instant.now());
+
+ assertFalse(result);
+ }
+
+ @Test
+ public void shouldNotCountWhenClientIpUnavailable() {
+ assertFalse(validator.shouldCountDownload(42L, null, "Mozilla/5.0", Instant.now()));
+ Mockito.verifyNoInteractions(valueOps);
+ }
+
+ @Test
+ public void shouldUseEventTimeBucketInKey() {
+ properties.setLateArrivalHours(24);
+
+ // Simulate first download in this hour (count = 1, under limit)
+ Mockito.when(valueOps.increment(Mockito.anyString())).thenReturn(1L);
+
+ // 2026-01-01T00:00:00Z → epochSecond = 1735689600 → hourBucket = 482136
+ Instant eventTime = Instant.parse("2026-01-01T00:00:00Z");
+ boolean result = validator.shouldCountDownload(99L, "10.0.0.1", "Mozilla/5.0", eventTime);
+
+ assertTrue(result);
+
+ // TTL = 1 hour + 24 late-arrival hours = 25 hours
+ Duration expectedTtl = Duration.ofHours(1).plusHours(24);
+ Mockito.verify(valueOps).setIfAbsent(
+ Mockito.argThat(key -> key.startsWith("download:dedup:") && key.contains(":99:")),
+ Mockito.eq("0"),
+ Mockito.eq(expectedTtl)
+ );
+ }
+
+ @Test
+ public void shouldNotCountWhenEventTimeIsMissing() {
+ boolean result = validator.shouldCountDownload(99L, "10.0.0.1", "Mozilla/5.0", null);
+ assertFalse(result);
+ Mockito.verifyNoInteractions(valueOps);
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java b/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java
index f8ead9208..a7ec97d14 100644
--- a/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java
@@ -31,6 +31,7 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
+import java.util.Optional;
import static org.eclipse.openvsx.entities.FileResource.README;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -193,7 +194,8 @@ StorageUtilService storageUtilService(
cache,
entityManager,
fileCacheDurationConfig,
- cdnServiceConfig
+ cdnServiceConfig,
+ Optional.empty()
);
}
}
diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandlerTest.java
new file mode 100644
index 000000000..44e9c95a8
--- /dev/null
+++ b/server/src/test/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandlerTest.java
@@ -0,0 +1,72 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+package org.eclipse.openvsx.storage.log;
+
+import org.eclipse.openvsx.entities.FileResource;
+import org.eclipse.openvsx.metrics.DownloadCountValidator;
+import org.eclipse.openvsx.storage.AwsStorageService;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class AwsDownloadCountHandlerTest {
+
+ @Test
+ public void shouldFilterEventsWhenValidatorRejectsSome() {
+ var processor = Mockito.mock(DownloadCountProcessor.class);
+ var awsStorage = Mockito.mock(AwsStorageService.class);
+ var validator = Mockito.mock(DownloadCountValidator.class);
+
+ Mockito.when(processor.resolveDownloadFileToExtensionId(Mockito.eq(FileResource.STORAGE_AWS), Mockito.anyList()))
+ .thenReturn(Map.of("TEST.VSIX", 10L));
+ Instant ts = Instant.parse("2026-01-01T00:10:00Z");
+ Mockito.when(validator.shouldCountDownload(10L, "1.1.1.1", "Mozilla/5.0", ts))
+ .thenReturn(true);
+ Mockito.when(validator.shouldCountDownload(10L, "2.2.2.2", "curl/8.0", ts))
+ .thenReturn(false);
+
+ var handler = new AwsDownloadCountHandler(awsStorage, processor, Optional.of(validator));
+ var events = List.of(
+ new AwsDownloadCountHandler.DownloadEvent("TEST.VSIX", "1.1.1.1", "Mozilla/5.0", ts),
+ new AwsDownloadCountHandler.DownloadEvent("TEST.VSIX", "2.2.2.2", "curl/8.0", ts)
+ );
+
+ var counts = handler.countValidatedEvents(events);
+
+ assertEquals(Map.of("TEST.VSIX", 1), counts);
+ }
+
+ @Test
+ public void shouldCountAllEventsWhenValidatorIsMissing() {
+ var processor = Mockito.mock(DownloadCountProcessor.class);
+ var awsStorage = Mockito.mock(AwsStorageService.class);
+ Mockito.when(processor.resolveDownloadFileToExtensionId(Mockito.eq(FileResource.STORAGE_AWS), Mockito.anyList()))
+ .thenReturn(Map.of("TEST.VSIX", 10L));
+
+ var handler = new AwsDownloadCountHandler(awsStorage, processor, Optional.empty());
+ var events = List.of(
+ new AwsDownloadCountHandler.DownloadEvent("TEST.VSIX", "1.1.1.1", "Mozilla/5.0", null),
+ new AwsDownloadCountHandler.DownloadEvent("TEST.VSIX", "2.2.2.2", "curl/8.0", null)
+ );
+
+ var counts = handler.countValidatedEvents(events);
+
+ assertEquals(Map.of("TEST.VSIX", 2), counts);
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandlerTest.java b/server/src/test/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandlerTest.java
new file mode 100644
index 000000000..9d283ca6d
--- /dev/null
+++ b/server/src/test/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandlerTest.java
@@ -0,0 +1,51 @@
+/********************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * https://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ ********************************************************************************/
+package org.eclipse.openvsx.storage.log;
+
+import org.eclipse.openvsx.entities.FileResource;
+import org.eclipse.openvsx.metrics.DownloadCountValidator;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class AzureDownloadCountHandlerTest {
+
+ @Test
+ public void shouldFilterEventsWhenValidatorRejectsSome() {
+ var processor = Mockito.mock(DownloadCountProcessor.class);
+ var validator = Mockito.mock(DownloadCountValidator.class);
+ Mockito.when(processor.resolveDownloadFileToExtensionId(Mockito.eq(FileResource.STORAGE_AZURE), Mockito.anyList()))
+ .thenReturn(Map.of("TEST.VSIX", 10L));
+ Instant ts = Instant.parse("2026-01-01T00:10:00Z");
+ Mockito.when(validator.shouldCountDownload(10L, "1.1.1.1", "Mozilla/5.0", ts))
+ .thenReturn(true);
+ Mockito.when(validator.shouldCountDownload(10L, "2.2.2.2", "curl/8.0", ts))
+ .thenReturn(false);
+
+ var handler = new AzureDownloadCountHandler(processor, Optional.of(validator));
+ var events = List.of(
+ new AzureDownloadCountHandler.DownloadEvent("TEST.VSIX", "1.1.1.1", "Mozilla/5.0", ts),
+ new AzureDownloadCountHandler.DownloadEvent("TEST.VSIX", "2.2.2.2", "curl/8.0", ts)
+ );
+
+ var counts = handler.countValidatedEvents(events);
+
+ assertEquals(Map.of("TEST.VSIX", 1), counts);
+ }
+}
diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java b/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java
index 2f7d7fce3..1031d09f0 100644
--- a/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java
@@ -17,6 +17,7 @@
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
+import java.time.Instant;
import static org.junit.jupiter.api.Assertions.*;
@@ -39,6 +40,10 @@ record = parser.parse(reader.readLine());
assertEquals("OPTIONS", record.method());
assertEquals(200, record.status());
assertEquals("/vscjava/vscode-java-pack/0.30.4/package.json", record.url());
+ assertEquals("1.1.1.1", record.clientIp());
+ assertEquals("Mozilla/5.0", record.userAgent());
+ // Date "2025-12-03" + time "13:17:20" UTC → verify event-time is parsed correctly
+ assertEquals(Instant.parse("2025-12-03T13:17:20Z"), record.eventTime());
}
}
}
diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java b/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java
index b29f03ee9..6c4349c19 100644
--- a/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java
+++ b/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java
@@ -17,6 +17,7 @@
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
+import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -35,6 +36,10 @@ var record = parser.parse(reader.readLine());
assertEquals("GET", record.method());
assertEquals(301, record.status());
assertEquals("/favicon.ico", record.url());
+ assertEquals("1.1.1.1", record.clientIp());
+ assertEquals("Mozilla/5.0", record.userAgent());
+ // "2026-02-09T04:20:50+0000" → UTC Instant
+ assertEquals(Instant.parse("2026-02-09T04:20:50Z"), record.eventTime());
}
}
}