diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index 3a2b5ee5b..081474aa4 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -134,6 +134,9 @@ public LocalRegistryService( @Value("${ovsx.webui.url:}") String webuiUrl; + @Value("${ovsx.analytics.enabled:false}") + boolean analyticsEnabled; + @Value("${ovsx.registry.version:}") String registryVersion; @@ -1386,6 +1389,7 @@ public RegistryVersionJson getRegistryVersion() { json.setMaxExtensionSize(publishingConfig.getMaxContentSize()); json.setTrustedPublishingAudience( trustedPublishingConfig.isEnabled() ? trustedPublishingConfig.getAudience() : null); + json.setAnalyticsEnabled(analyticsEnabled); return json; } diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsAPI.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsAPI.java new file mode 100644 index 000000000..f3391fa2f --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsAPI.java @@ -0,0 +1,168 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.concurrent.TimeUnit; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.NotFoundException; + +/** + * Minimal REST surface over {@link DownloadAnalyticsService}. The bean only exists when download + * analytics is enabled, so the path stays unmapped (404) otherwise. + */ +@RestController +@ConditionalOnProperty(name = "ovsx.analytics.enabled", havingValue = "true") +public class DownloadAnalyticsAPI { + + private static final int MAX_RANGE_YEARS = 5; + + private final DownloadAnalyticsService service; + private final RepositoryService repositories; + private final Clock clock; + + @Autowired + public DownloadAnalyticsAPI(DownloadAnalyticsService service, RepositoryService repositories) { + this(service, repositories, Clock.systemUTC()); + } + + DownloadAnalyticsAPI( + DownloadAnalyticsService service, + RepositoryService repositories, + Clock clock + ) { + this.service = service; + this.repositories = repositories; + this.clock = clock; + } + + @GetMapping(path = "/api/{namespace}/{extension}/analytics/downloads", produces = MediaType.APPLICATION_JSON_VALUE) + @CrossOrigin + @Operation(summary = "Provides the download counts of an extension over time") + @ApiResponse( + responseCode = "200", + description = "The dense, zero-filled download series is returned in JSON format; the last point may still be partial" + ) + @ApiResponse( + responseCode = "400", + description = "A query parameter is invalid", + content = @Content() + ) + @ApiResponse( + responseCode = "404", + description = "The specified extension could not be found, or download analytics is disabled", + content = @Content() + ) + public ResponseEntity getDownloads( + @PathVariable + @Parameter(description = "Extension namespace", example = "redhat") String namespace, + @PathVariable + @Parameter(description = "Extension name", example = "java") String extension, + @RequestParam(required = false) + @Parameter( + description = "UTC start date (inclusive), defaults to 30 days before 'to' whatever the interval", + example = "2026-06-16" + ) String from, + @RequestParam(required = false) + @Parameter( + description = "UTC end date (exclusive), defaults to tomorrow", + example = "2026-07-16" + ) String to, + @RequestParam(defaultValue = "day") + @Parameter( + description = "Bucket interval", + schema = @Schema(type = "string", allowableValues = { "day", "week", "month" }, defaultValue = "day") + ) String interval + ) { + var extensionEntity = repositories.findActiveExtension(extension, namespace); + if (extensionEntity == null) { + throw new NotFoundException(); + } + + var request = buildRequest(extensionEntity.getId(), from, to, interval); + var points = service.getSeries(request).stream() + .map( + point -> new DownloadSeriesJson.DownloadSeriesPointJson( + LocalDate.ofInstant(point.bucketStart(), ZoneOffset.UTC).toString(), + point.count())) + .toList(); + // Aggregate, non-personal data that is identical for every caller, so it is publicly + // cacheable. Without an explicit value Spring Security defaults the response to no-store. + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic()) + .body(new DownloadSeriesJson(points)); + } + + private DownloadSeriesRequest buildRequest(long extensionId, String from, String to, String interval) { + DownloadSeriesInterval seriesInterval; + try { + seriesInterval = DownloadSeriesInterval.fromValue(interval); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + + var today = LocalDate.ofInstant(clock.instant(), ZoneOffset.UTC); + var toDate = parseDate(to, "to", today.plusDays(1)); + var fromDate = parseDate(from, "from", toDate.minusDays(30)); + if (!fromDate.isBefore(toDate)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "'from' must be before 'to'"); + } + if (fromDate.plusYears(MAX_RANGE_YEARS).isBefore(toDate)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "the requested range must not exceed " + MAX_RANGE_YEARS + " years"); + } + + return DownloadSeriesRequest.of( + extensionId, + fromDate.atStartOfDay(ZoneOffset.UTC).toInstant(), + toDate.atStartOfDay(ZoneOffset.UTC).toInstant(), + seriesInterval); + } + + private LocalDate parseDate(String value, String name, LocalDate defaultValue) { + if (value == null) { + return defaultValue; + } + + try { + return LocalDate.parse(value); + } catch (DateTimeParseException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "parameter '" + name + "' must be a date in the format yyyy-mm-dd"); + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsConfiguration.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsConfiguration.java new file mode 100644 index 000000000..ae5fd34cb --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsConfiguration.java @@ -0,0 +1,50 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Clock; +import java.time.Duration; + +import org.jooq.DSLContext; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +import org.eclipse.openvsx.analytics.timescale.TimescaleDownloadAnalyticsRepository; + +/** + * Wires download analytics when {@code ovsx.analytics.enabled=true}. The download_event schema + * lives in its own database, migrated and pooled separately from the registry, so the registry + * database image needs nothing beyond plain PostgreSQL. + */ +@Configuration +@ConditionalOnProperty(name = "ovsx.analytics.enabled", havingValue = "true") +class DownloadAnalyticsConfiguration { + + @Bean + DownloadAnalyticsRepository downloadAnalyticsRepository(@Qualifier("timeseriesDsl") DSLContext dsl) { + return new TimescaleDownloadAnalyticsRepository(dsl); + } + + @Bean + DownloadAnalyticsService downloadAnalyticsService( + DownloadAnalyticsRepository repository, + Environment environment + ) { + var settlingMargin = environment + .getProperty("ovsx.analytics.settling-margin", Duration.class, Duration.ofHours(2)); + return new DownloadAnalyticsService(repository, settlingMargin, Clock.systemUTC()); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsRepository.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsRepository.java index e21fc480a..aa299191b 100644 --- a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsRepository.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsRepository.java @@ -26,4 +26,10 @@ public interface DownloadAnalyticsRepository { * independently of these events. */ void save(List events); + + /** + * Returns the (sparse) aggregated download series for the given request. Buckets without + * downloads are absent; zero-filling is the {@link DownloadAnalyticsService}'s concern. + */ + List findSeries(DownloadSeriesRequest request); } diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsService.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsService.java new file mode 100644 index 000000000..ceda31e66 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadAnalyticsService.java @@ -0,0 +1,148 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.jspecify.annotations.Nullable; + +/** + * Query facade over a {@link DownloadAnalyticsRepository}: aligns ranges to UTC buckets, returns + * dense zero-filled series, marks trailing buckets that may still change as partial, and caches + * settled sub-ranges (data older than the settling margin never changes). + */ +public class DownloadAnalyticsService { + + private final DownloadAnalyticsRepository repository; + private final Duration settlingMargin; + private final Clock clock; + + private final Cache> settledCache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofHours(1)) + .build(); + + public DownloadAnalyticsService(DownloadAnalyticsRepository repository, Duration settlingMargin, Clock clock) { + this.repository = repository; + this.settlingMargin = settlingMargin; + this.clock = clock; + } + + /** + * Returns the dense, zero-filled download series for the given request, ordered by bucket + * start and group. The range is aligned outwards to full UTC buckets. + */ + public List getSeries(DownloadSeriesRequest request) { + var now = clock.instant(); + var interval = request.interval(); + var from = truncate(request.from(), interval).toInstant(); + var to = alignUp(request.to(), interval); + var aligned = new DownloadSeriesRequest(request.extensionIds(), from, to, interval, request.groupBy()); + + var settledEnd = truncate(now.minus(settlingMargin), interval).toInstant(); + List rows; + if (!to.isAfter(settledEnd)) { + rows = settledCache.get(aligned, repository::findSeries); + } else if (from.isBefore(settledEnd)) { + var settled = new DownloadSeriesRequest( + request.extensionIds(), + from, + settledEnd, + interval, + request.groupBy()); + var live = new DownloadSeriesRequest(request.extensionIds(), settledEnd, to, interval, request.groupBy()); + rows = Stream + .concat( + settledCache.get(settled, repository::findSeries).stream(), + repository.findSeries(live).stream()) + .toList(); + } else { + rows = repository.findSeries(aligned); + } + + return zeroFill(aligned, rows, now); + } + + private List zeroFill( + DownloadSeriesRequest request, + List rows, + Instant now + ) { + var interval = request.interval(); + var groups = rows.stream() + .map(DownloadSeriesRow::group) + .distinct() + .sorted(Comparator.nullsFirst(Comparator.naturalOrder())) + .toList(); + if (groups.isEmpty()) { + groups = Collections.singletonList(null); + } + + var counts = rows.stream().collect( + Collectors.toMap(row -> new BucketKey(row.bucketStart(), row.group()), DownloadSeriesRow::count)); + + var points = new ArrayList(); + for (var bucket = truncate(request.from(), interval); bucket.toInstant() + .isBefore(request.to()); bucket = next(bucket, interval)) { + var bucketEnd = next(bucket, interval).toInstant(); + var partial = bucketEnd.plus(settlingMargin).isAfter(now); + for (var group : groups) { + var count = counts.getOrDefault(new BucketKey(bucket.toInstant(), group), 0L); + points.add(new DownloadSeriesPoint(bucket.toInstant(), group, count, partial)); + } + } + + return points; + } + + private record BucketKey(Instant bucketStart, @Nullable String group) {} + + private ZonedDateTime truncate(Instant instant, DownloadSeriesInterval interval) { + var day = instant.atZone(ZoneOffset.UTC).truncatedTo(ChronoUnit.DAYS); + return switch (interval) { + case DAY -> day; + case WEEK -> day.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + case MONTH -> day.with(TemporalAdjusters.firstDayOfMonth()); + }; + } + + private Instant alignUp(Instant instant, DownloadSeriesInterval interval) { + var truncated = truncate(instant, interval); + return truncated.toInstant().equals(instant) + ? instant + : next(truncated, interval).toInstant(); + } + + private ZonedDateTime next(ZonedDateTime bucket, DownloadSeriesInterval interval) { + return switch (interval) { + case DAY -> bucket.plusDays(1); + case WEEK -> bucket.plusWeeks(1); + case MONTH -> bucket.plusMonths(1); + }; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesGroupBy.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesGroupBy.java new file mode 100644 index 000000000..343378a44 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesGroupBy.java @@ -0,0 +1,21 @@ +/****************************************************************************** + * 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.analytics; + +/** + * Optional grouping dimension of a download series. Because event counts are strictly + * additive, any grouping sums to the same total. + */ +public enum DownloadSeriesGroupBy { + NONE, VERSION, TARGET_PLATFORM, COUNTRY +} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesInterval.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesInterval.java new file mode 100644 index 000000000..7bff07b82 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesInterval.java @@ -0,0 +1,41 @@ +/****************************************************************************** + * 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.analytics; + +/** + * Bucket size of a download series. Buckets start at UTC midnight (day), UTC Monday (week) + * or the first of the month (month). + */ +public enum DownloadSeriesInterval { + DAY("day"), WEEK("week"), MONTH("month"); + + private final String value; + + DownloadSeriesInterval(String value) { + this.value = value; + } + + public String value() { + return value; + } + + public static DownloadSeriesInterval fromValue(String value) { + for (var interval : values()) { + if (interval.value.equals(value)) { + return interval; + } + } + + throw new IllegalArgumentException("unknown interval '" + value + "', expected day, week or month"); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesJson.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesJson.java new file mode 100644 index 000000000..d268dbb87 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesJson.java @@ -0,0 +1,31 @@ +/****************************************************************************** + * 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.analytics; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * REST payload of the download series endpoint. Points are dense and zero-filled; the last point + * may still be partial (its bucket has not ended, or logs are still being ingested). + */ +@Schema(name = "DownloadSeries", description = "Time series of download counts") +public record DownloadSeriesJson(List points) { + + @Schema(name = "DownloadSeriesPoint") + public record DownloadSeriesPointJson( + @Schema(description = "UTC start date of the bucket", example = "2026-07-01") String t, + @Schema(description = "Number of downloads in the bucket", example = "4321") long count + ) {} +} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesPoint.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesPoint.java new file mode 100644 index 000000000..c280c00cc --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesPoint.java @@ -0,0 +1,23 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Instant; + +import org.jspecify.annotations.Nullable; + +/** + * One bucket of a dense, zero-filled download series. {@code partial} marks buckets whose data + * may still change: the bucket has not yet ended, or the ingestion settling margin has not passed. + */ +public record DownloadSeriesPoint(Instant bucketStart, @Nullable String group, long count, boolean partial) {} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesRequest.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesRequest.java new file mode 100644 index 000000000..aa38c9f23 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesRequest.java @@ -0,0 +1,55 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * A download series query: one or more extensions, a UTC time range ({@code from} inclusive, + * {@code to} exclusive), a bucket interval and an optional grouping dimension. Deliberately + * richer than what the REST endpoint exposes, so downstream deployments can compose on it. + */ +public record DownloadSeriesRequest( + List extensionIds, + Instant from, + Instant to, + DownloadSeriesInterval interval, + DownloadSeriesGroupBy groupBy +) { + public DownloadSeriesRequest { + Objects.requireNonNull(extensionIds, "extensionIds must not be null"); + Objects.requireNonNull(from, "from must not be null"); + Objects.requireNonNull(to, "to must not be null"); + Objects.requireNonNull(interval, "interval must not be null"); + Objects.requireNonNull(groupBy, "groupBy must not be null"); + if (extensionIds.isEmpty()) { + throw new IllegalArgumentException("extensionIds must not be empty"); + } + if (!from.isBefore(to)) { + throw new IllegalArgumentException("from must be before to"); + } + + extensionIds = List.copyOf(extensionIds); + } + + public static DownloadSeriesRequest of( + long extensionId, + Instant from, + Instant to, + DownloadSeriesInterval interval + ) { + return new DownloadSeriesRequest(List.of(extensionId), from, to, interval, DownloadSeriesGroupBy.NONE); + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesRow.java b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesRow.java new file mode 100644 index 000000000..d70eb870e --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/DownloadSeriesRow.java @@ -0,0 +1,23 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Instant; + +import org.jspecify.annotations.Nullable; + +/** + * One bucket of an aggregated download series. {@code group} is the value of the requested + * grouping dimension, or null when grouping by {@link DownloadSeriesGroupBy#NONE}. + */ +public record DownloadSeriesRow(Instant bucketStart, @Nullable String group, long count) {} diff --git a/server/src/main/java/org/eclipse/openvsx/analytics/timescale/TimescaleDownloadAnalyticsRepository.java b/server/src/main/java/org/eclipse/openvsx/analytics/timescale/TimescaleDownloadAnalyticsRepository.java new file mode 100644 index 000000000..efc0941d0 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/analytics/timescale/TimescaleDownloadAnalyticsRepository.java @@ -0,0 +1,171 @@ +/****************************************************************************** + * 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.analytics.timescale; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import com.google.common.collect.Lists; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; + +import org.eclipse.openvsx.analytics.DownloadAnalyticsRepository; +import org.eclipse.openvsx.analytics.DownloadEvent; +import org.eclipse.openvsx.analytics.DownloadSeriesGroupBy; +import org.eclipse.openvsx.analytics.DownloadSeriesInterval; +import org.eclipse.openvsx.analytics.DownloadSeriesRequest; +import org.eclipse.openvsx.analytics.DownloadSeriesRow; + +/** + * {@link DownloadAnalyticsRepository} backed by TimescaleDB: writes to the download_event + * hypertable and reads from the download_stats_daily continuous aggregate. Both live in the + * separate time-series database, addressed by name rather than through generated jOOQ classes + * (codegen runs against the registry database, which no longer holds these tables). + *

+ * Writes run on the time-series connection pool and cannot join a caller's registry transaction: + * one {@link #save(List)} call is one transaction of its own, atomic across its batches and + * independent of whatever the registry does afterwards. + */ +public class TimescaleDownloadAnalyticsRepository implements DownloadAnalyticsRepository { + + private static final int BATCH_SIZE = 500; + + private static final Table EVENT = DSL.table(DSL.name("download_event")); + private static final Field EVENT_TIME = DSL + .field(DSL.name("download_event", "time"), OffsetDateTime.class); + private static final Field EVENT_EXTENSION_ID = DSL + .field(DSL.name("download_event", "extension_id"), Long.class); + private static final Field EVENT_EXTENSION_VERSION_ID = DSL + .field(DSL.name("download_event", "extension_version_id"), Long.class); + private static final Field EVENT_NAMESPACE = DSL + .field(DSL.name("download_event", "namespace"), String.class); + private static final Field EVENT_EXTENSION_NAME = DSL + .field(DSL.name("download_event", "extension_name"), String.class); + private static final Field EVENT_VERSION = DSL + .field(DSL.name("download_event", "version"), String.class); + private static final Field EVENT_TARGET_PLATFORM = DSL + .field(DSL.name("download_event", "target_platform"), String.class); + private static final Field EVENT_COUNTRY = DSL + .field(DSL.name("download_event", "country"), String.class); + private static final Field EVENT_IP = DSL.field(DSL.name("download_event", "ip"), String.class); + private static final Field EVENT_USER_AGENT = DSL + .field(DSL.name("download_event", "user_agent"), String.class); + private static final Field EVENT_COUNT = DSL + .field(DSL.name("download_event", "count"), Integer.class); + + private static final Table STATS = DSL.table(DSL.name("download_stats_daily")); + private static final Field STATS_DAY = DSL + .field(DSL.name("download_stats_daily", "day"), OffsetDateTime.class); + private static final Field STATS_EXTENSION_ID = DSL + .field(DSL.name("download_stats_daily", "extension_id"), Long.class); + private static final Field STATS_VERSION = DSL + .field(DSL.name("download_stats_daily", "version"), String.class); + private static final Field STATS_TARGET_PLATFORM = DSL + .field(DSL.name("download_stats_daily", "target_platform"), String.class); + private static final Field STATS_COUNTRY = DSL + .field(DSL.name("download_stats_daily", "country"), String.class); + private static final Field STATS_DOWNLOADS = DSL + .field(DSL.name("download_stats_daily", "downloads"), Long.class); + + private final DSLContext dsl; + + public TimescaleDownloadAnalyticsRepository(DSLContext dsl) { + this.dsl = dsl; + } + + @Override + public void save(List events) { + dsl.transaction(configuration -> { + for (var batch : Lists.partition(events, BATCH_SIZE)) { + var insert = DSL.using(configuration) + .insertInto( + EVENT, + EVENT_TIME, + EVENT_EXTENSION_ID, + EVENT_EXTENSION_VERSION_ID, + EVENT_NAMESPACE, + EVENT_EXTENSION_NAME, + EVENT_VERSION, + EVENT_TARGET_PLATFORM, + EVENT_COUNTRY, + EVENT_IP, + EVENT_USER_AGENT, + EVENT_COUNT); + for (var event : batch) { + insert = insert.values( + OffsetDateTime.ofInstant(event.time(), ZoneOffset.UTC), + event.extensionId(), + event.extensionVersionId(), + event.namespace(), + event.extensionName(), + event.version(), + event.targetPlatform(), + event.country(), + event.ip(), + event.userAgent(), + event.count()); + } + insert.execute(); + } + }); + } + + @Override + public List findSeries(DownloadSeriesRequest request) { + var bucket = bucketField(request.interval()); + var group = groupField(request.groupBy()); + var total = DSL.sum(STATS_DOWNLOADS).cast(Long.class); + + List> groupByFields = request.groupBy() == DownloadSeriesGroupBy.NONE + ? List.>of(bucket) + : List.>of(bucket, group); + return dsl.select(bucket, group, total) + .from(STATS) + .where( + STATS_EXTENSION_ID.in(request.extensionIds()), + STATS_DAY.greaterOrEqual(OffsetDateTime.ofInstant(request.from(), ZoneOffset.UTC)), + STATS_DAY.lessThan(OffsetDateTime.ofInstant(request.to(), ZoneOffset.UTC))) + .groupBy(groupByFields) + .orderBy(groupByFields) + .fetch(record -> new DownloadSeriesRow(record.value1().toInstant(), record.value2(), record.value3())); + } + + private Field bucketField(DownloadSeriesInterval interval) { + // `day` holds UTC-aligned buckets; date_trunc must not depend on the session time zone, + // hence the AT TIME ZONE round-trip + return switch (interval) { + case DAY -> STATS_DAY; + case WEEK -> DSL.field( + "(date_trunc('week', {0} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')", + OffsetDateTime.class, + STATS_DAY); + case MONTH -> DSL.field( + "(date_trunc('month', {0} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')", + OffsetDateTime.class, + STATS_DAY); + }; + } + + private Field groupField(DownloadSeriesGroupBy groupBy) { + return switch (groupBy) { + case NONE -> DSL.inline(null, String.class); + case VERSION -> STATS_VERSION; + case TARGET_PLATFORM -> STATS_TARGET_PLATFORM; + case COUNTRY -> STATS_COUNTRY; + }; + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/json/RegistryVersionJson.java b/server/src/main/java/org/eclipse/openvsx/json/RegistryVersionJson.java index 10ff9c373..f093f7769 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/RegistryVersionJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/RegistryVersionJson.java @@ -37,6 +37,9 @@ public static RegistryVersionJson error(String message) { @Nullable private String trustedPublishingAudience; + @Schema(description = "Whether download analytics are enabled and the analytics endpoints are available") + private boolean analyticsEnabled; + public String getVersion() { return version; } @@ -60,4 +63,12 @@ public String getTrustedPublishingAudience() { public void setTrustedPublishingAudience(String trustedPublishingAudience) { this.trustedPublishingAudience = trustedPublishingAudience; } + + public boolean isAnalyticsEnabled() { + return analyticsEnabled; + } + + public void setAnalyticsEnabled(boolean analyticsEnabled) { + this.analyticsEnabled = analyticsEnabled; + } } diff --git a/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsAPITest.java b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsAPITest.java new file mode 100644 index 000000000..8c6416460 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsAPITest.java @@ -0,0 +1,130 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.repositories.RepositoryService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class DownloadAnalyticsAPITest { + + private static final Instant NOW = Instant.parse("2026-07-15T10:00:00Z"); + + private final DownloadAnalyticsService service = Mockito.mock(DownloadAnalyticsService.class); + private final RepositoryService repositories = Mockito.mock(RepositoryService.class); + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + var controller = new DownloadAnalyticsAPI( + service, + repositories, + Clock.fixed(NOW, ZoneOffset.UTC)); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + var extension = new Extension(); + extension.setId(42L); + Mockito.when(repositories.findActiveExtension("bar", "foo")).thenReturn(extension); + } + + @Test + void testResponseShape() throws Exception { + Mockito.when(service.getSeries(any())).thenReturn( + List.of( + new DownloadSeriesPoint(Instant.parse("2026-07-01T00:00:00Z"), null, 4321, false), + new DownloadSeriesPoint(Instant.parse("2026-07-02T00:00:00Z"), null, 10, true))); + + mockMvc.perform(get("/api/foo/bar/analytics/downloads?from=2026-07-01&to=2026-07-03")) + .andExpect(status().isOk()) + .andExpect( + content().json( + "{\"points\":[{\"t\":\"2026-07-01\",\"count\":4321},{\"t\":\"2026-07-02\",\"count\":10}]}", + true)); + } + + @Test + void testSeriesIsPubliclyCacheable() throws Exception { + Mockito.when(service.getSeries(any())).thenReturn(List.of()); + + mockMvc.perform(get("/api/foo/bar/analytics/downloads")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "max-age=600, public")); + } + + @Test + void testRequestParametersArePassedToService() throws Exception { + Mockito.when(service.getSeries(any())).thenReturn(List.of()); + + mockMvc.perform(get("/api/foo/bar/analytics/downloads?from=2026-06-01&to=2026-07-01&interval=week")) + .andExpect(status().isOk()); + + var captor = ArgumentCaptor.forClass(DownloadSeriesRequest.class); + Mockito.verify(service).getSeries(captor.capture()); + var request = captor.getValue(); + assertEquals(List.of(42L), request.extensionIds()); + assertEquals(Instant.parse("2026-06-01T00:00:00Z"), request.from()); + assertEquals(Instant.parse("2026-07-01T00:00:00Z"), request.to()); + assertEquals(DownloadSeriesInterval.WEEK, request.interval()); + assertEquals(DownloadSeriesGroupBy.NONE, request.groupBy()); + } + + @Test + void testDefaultRangeIsTheLastThirtyDays() throws Exception { + Mockito.when(service.getSeries(any())).thenReturn(List.of()); + + mockMvc.perform(get("/api/foo/bar/analytics/downloads")).andExpect(status().isOk()); + + var captor = ArgumentCaptor.forClass(DownloadSeriesRequest.class); + Mockito.verify(service).getSeries(captor.capture()); + // now is 2026-07-15T10:00Z: the range ends after today (partial) and spans 30 days + assertEquals(Instant.parse("2026-07-16T00:00:00Z"), captor.getValue().to()); + assertEquals(Instant.parse("2026-06-16T00:00:00Z"), captor.getValue().from()); + assertEquals(DownloadSeriesInterval.DAY, captor.getValue().interval()); + } + + @Test + void testParameterValidation() throws Exception { + mockMvc.perform(get("/api/foo/bar/analytics/downloads?interval=hour")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/api/foo/bar/analytics/downloads?from=not-a-date")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/api/foo/bar/analytics/downloads?from=2026-07-02&to=2026-07-01")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/api/foo/bar/analytics/downloads?from=2000-01-01&to=2026-07-01")) + .andExpect(status().isBadRequest()); + } + + @Test + void testUnknownExtensionIsNotFound() throws Exception { + mockMvc.perform(get("/api/foo/unknown/analytics/downloads")).andExpect(status().isNotFound()); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsDisabledTest.java b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsDisabledTest.java new file mode 100644 index 000000000..0e9d26419 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsDisabledTest.java @@ -0,0 +1,67 @@ +/****************************************************************************** + * 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.analytics; + +import java.util.List; +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.ApplicationContext; +import org.springframework.test.web.servlet.MockMvc; + +import org.eclipse.openvsx.AbstractPostgresContainerTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * ovsx.analytics.enabled defaults to false: no analytics beans exist and the endpoint is not + * mapped, byte-for-byte current behavior. The property is pinned so this holds even in the + * analytics-on test matrix run. + */ +@SpringBootTest(properties = "ovsx.analytics.enabled=false") +@AutoConfigureMockMvc +class DownloadAnalyticsDisabledTest extends AbstractPostgresContainerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ApplicationContext context; + + @Test + void testEndpointIsNotFoundWhenAnalyticsIsDisabled() throws Exception { + mockMvc.perform(get("/api/foo/bar/analytics/downloads")).andExpect(status().isNotFound()); + } + + @Test + void testNoAnalyticsBeansWhenDisabled() { + assertTrue(context.getBeanNamesForType(DownloadAnalyticsRepository.class).length == 0); + assertTrue(context.getBeanNamesForType(DownloadAnalyticsService.class).length == 0); + assertTrue(context.getBeanNamesForType(DownloadAnalyticsAPI.class).length == 0); + } + + @Test + void testNoTimeseriesDatabaseWhenDisabled() { + // no second pool, and none of the ovsx.analytics.datasource.* properties are read + var dataSources = List.of(context.getBeanNamesForType(DataSource.class)); + assertEquals(1, dataSources.size()); + assertFalse(dataSources.contains("timeseriesDataSource")); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsEndpointTest.java b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsEndpointTest.java new file mode 100644 index 000000000..d9edfb0df --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsEndpointTest.java @@ -0,0 +1,215 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Instant; +import java.util.List; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import org.eclipse.openvsx.AbstractTimeseriesContainerTest; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.FileResource; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.storage.StorageUtilService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Full-stack proof of the enabled configuration: the TimescaleDB-backed repository on its own + * database, queried through the public REST endpoint. + */ +@SpringBootTest +@AutoConfigureMockMvc +class DownloadAnalyticsEndpointTest extends AbstractTimeseriesContainerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + DownloadAnalyticsRepository repository; + + @Autowired + @Qualifier("timeseriesDataSource") + javax.sql.DataSource dataSource; + + @Autowired + EntityManager entityManager; + + @Autowired + PlatformTransactionManager transactionManager; + + @Autowired + StorageUtilService storageUtilService; + + private Extension extension; + + @AfterEach + void cleanUp() { + RequestContextHolder.resetRequestAttributes(); + new JdbcTemplate(dataSource).execute("TRUNCATE download_event"); + if (extension != null) { + inTransaction(() -> { + var managed = entityManager.find(Extension.class, extension.getId()); + managed.getVersions().forEach(extVersion -> { + entityManager + .createQuery("delete from FileResource fr where fr.extension = :extVersion") + .setParameter("extVersion", extVersion) + .executeUpdate(); + entityManager.remove(extVersion); + }); + var namespace = managed.getNamespace(); + entityManager.remove(managed); + entityManager.remove(namespace); + return null; + }); + extension = null; + } + } + + @Test + void testDownloadSeriesEndToEnd() throws Exception { + extension = seedExtension("e2ens", "e2e-ext"); + repository.save( + List.of( + event(Instant.parse("2026-07-01T10:00:00Z"), extension.getId(), 3), + event(Instant.parse("2026-07-01T18:00:00Z"), extension.getId(), 1), + event(Instant.parse("2026-07-03T00:00:00Z"), extension.getId(), 5))); + + mockMvc.perform(get("/api/e2ens/e2e-ext/analytics/downloads?from=2026-07-01&to=2026-07-04")) + .andExpect(status().isOk()) + .andExpect( + content().json( + "{\"points\":[{\"t\":\"2026-07-01\",\"count\":4},{\"t\":\"2026-07-02\",\"count\":0}," + + "{\"t\":\"2026-07-03\",\"count\":5}]}", + true)); + } + + @Test + void testUnknownExtensionIsNotFound() throws Exception { + mockMvc.perform(get("/api/nowhere/nothing/analytics/downloads")).andExpect(status().isNotFound()); + } + + /** + * Without a log-based source covering the file, a request-path download produces an analytics + * event alongside the counter update, with client data taken from the current HTTP request. + */ + @Test + void testRequestPathDownloadProducesAnalyticsEvent() throws Exception { + var resource = seedExtensionWithResource("e2ereq", "e2e-req-ext", "e2ereq.e2e-req-ext-1.0.0.vsix"); + var extVersion = resource.getExtension(); + + var request = new MockHttpServletRequest(); + request.addHeader("User-Agent", "VSCode 1.90.2 (Microsoft Visual Studio Code)"); + request.addHeader("X-Forwarded-For", "203.0.113.9, 10.0.0.1"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + inTransaction(() -> { + storageUtilService.increaseDownloadCount(entityManager.find(FileResource.class, resource.getId())); + return null; + }); + + // the counter committed in the registry database + var downloadCount = inTransaction( + () -> entityManager.find(Extension.class, extension.getId()).getDownloadCount()); + assertEquals(1, downloadCount); + + var jdbc = new JdbcTemplate(dataSource); + var event = jdbc.queryForMap( + "SELECT extension_id, extension_version_id, ip, user_agent, count FROM download_event"); + assertEquals(extension.getId(), event.get("extension_id")); + assertEquals(extVersion.getId(), event.get("extension_version_id")); + assertEquals("203.0.113.9", event.get("ip")); + assertEquals("VSCode 1.90.2 (Microsoft Visual Studio Code)", event.get("user_agent")); + assertEquals(1, event.get("count")); + + // and the event is visible through the endpoint: the default range ends tomorrow, + // so the last of the 30 points is today's (partial) bucket + mockMvc.perform(get("/api/e2ereq/e2e-req-ext/analytics/downloads")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.points[29].count").value(1)); + } + + private DownloadEvent event(Instant time, long extensionId, int count) { + return new DownloadEvent( + time, + extensionId, + extensionId * 100, + "e2ens", + "e2e-ext", + "1.0.0", + "universal", + "US", + "9.9.9.9", + "VSCode 1.90.2", + count); + } + + private FileResource seedExtensionWithResource(String namespaceName, String extensionName, String vsixFilename) { + extension = seedExtension(namespaceName, extensionName); + return inTransaction(() -> { + var extVersion = entityManager.find(Extension.class, extension.getId()).getVersions().get(0); + var resource = new FileResource(); + resource.setName(vsixFilename); + resource.setType(FileResource.DOWNLOAD); + resource.setStorageType(FileResource.STORAGE_LOCAL); + resource.setExtension(extVersion); + entityManager.persist(resource); + return resource; + }); + } + + private Extension seedExtension(String namespaceName, String extensionName) { + return inTransaction(() -> { + var namespace = new Namespace(); + namespace.setName(namespaceName); + entityManager.persist(namespace); + + var seeded = new Extension(); + seeded.setName(extensionName); + seeded.setNamespace(namespace); + seeded.setActive(true); + entityManager.persist(seeded); + + var extVersion = new ExtensionVersion(); + extVersion.setVersion("1.0.0"); + extVersion.setTargetPlatform("universal"); + extVersion.setExtension(seeded); + extVersion.setActive(true); + entityManager.persist(extVersion); + return seeded; + }); + } + + private T inTransaction(java.util.function.Supplier action) { + return new TransactionTemplate(transactionManager).execute(status -> action.get()); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsServiceTest.java b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsServiceTest.java new file mode 100644 index 000000000..788e7ee75 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadAnalyticsServiceTest.java @@ -0,0 +1,213 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DownloadAnalyticsServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-15T10:00:00Z"); + private static final Duration SETTLING_MARGIN = Duration.ofHours(2); + + private final FakeRepository repository = new FakeRepository(); + private final DownloadAnalyticsService service = new DownloadAnalyticsService( + repository, + SETTLING_MARGIN, + Clock.fixed(NOW, ZoneOffset.UTC)); + + @Test + void testDenseZeroFilledSeries() { + repository.rows = List.of( + new DownloadSeriesRow(Instant.parse("2026-07-11T00:00:00Z"), null, 5), + new DownloadSeriesRow(Instant.parse("2026-07-13T00:00:00Z"), null, 2)); + + var points = service.getSeries(dayRequest("2026-07-10T00:00:00Z", "2026-07-15T00:00:00Z")); + + assertEquals(5, points.size()); + assertEquals(point("2026-07-10T00:00:00Z", 0, false), points.get(0)); + assertEquals(point("2026-07-11T00:00:00Z", 5, false), points.get(1)); + assertEquals(point("2026-07-12T00:00:00Z", 0, false), points.get(2)); + assertEquals(point("2026-07-13T00:00:00Z", 2, false), points.get(3)); + assertEquals(point("2026-07-14T00:00:00Z", 0, false), points.get(4)); + } + + @Test + void testBucketsStartAtUtcBoundaries() { + var points = service.getSeries(dayRequest("2026-07-10T15:30:00Z", "2026-07-12T01:00:00Z")); + + assertEquals( + List.of( + Instant.parse("2026-07-10T00:00:00Z"), + Instant.parse("2026-07-11T00:00:00Z"), + Instant.parse("2026-07-12T00:00:00Z")), + points.stream().map(DownloadSeriesPoint::bucketStart).toList()); + } + + @Test + void testTrailingPointsAreMarkedPartial() { + var points = service.getSeries(dayRequest("2026-07-13T00:00:00Z", "2026-07-16T00:00:00Z")); + + assertEquals(3, points.size()); + // 2026-07-13 ended at 07-14T00:00; well past the settling margin + assertFalse(points.get(0).partial()); + // 2026-07-14 ended at 07-15T00:00 + 2h margin = 07-15T02:00 <= now, settled + assertFalse(points.get(1).partial()); + // 2026-07-15 is still running + assertTrue(points.get(2).partial()); + } + + @Test + void testLastCompletedDayStaysPartialWithinSettlingMargin() { + var earlyMorning = Instant.parse("2026-07-15T01:00:00Z"); + var service = new DownloadAnalyticsService( + repository, + SETTLING_MARGIN, + Clock.fixed(earlyMorning, ZoneOffset.UTC)); + + var points = service.getSeries(dayRequest("2026-07-13T00:00:00Z", "2026-07-15T00:00:00Z")); + + assertFalse(points.get(0).partial()); + // 2026-07-14 ended at 07-15T00:00, but the settling margin has not passed yet + assertTrue(points.get(1).partial()); + } + + @Test + void testSettledRangesAreCached() { + var request = dayRequest("2026-07-01T00:00:00Z", "2026-07-10T00:00:00Z"); + service.getSeries(request); + service.getSeries(request); + + assertEquals(1, repository.calls.get()); + } + + @Test + void testUnsettledTailIsNotCached() { + // the settled part [07-13, 07-15) is cached, the live part [07-15, 07-16) is re-queried + var request = dayRequest("2026-07-13T00:00:00Z", "2026-07-16T00:00:00Z"); + service.getSeries(request); + assertEquals(2, repository.calls.get()); + assertEquals( + List.of(Instant.parse("2026-07-13T00:00:00Z"), Instant.parse("2026-07-15T00:00:00Z")), + repository.requests.stream().map(DownloadSeriesRequest::from).toList()); + + service.getSeries(request); + assertEquals(3, repository.calls.get()); + assertEquals(Instant.parse("2026-07-15T00:00:00Z"), repository.requests.get(2).from()); + } + + @Test + void testGroupedSeriesIsZeroFilledPerGroup() { + repository.rows = List.of( + new DownloadSeriesRow(Instant.parse("2026-07-10T00:00:00Z"), "US", 3), + new DownloadSeriesRow(Instant.parse("2026-07-11T00:00:00Z"), "DE", 2)); + + var points = service.getSeries( + new DownloadSeriesRequest( + List.of(1L), + Instant.parse("2026-07-10T00:00:00Z"), + Instant.parse("2026-07-12T00:00:00Z"), + DownloadSeriesInterval.DAY, + DownloadSeriesGroupBy.COUNTRY)); + + assertEquals( + List.of( + point("2026-07-10T00:00:00Z", "DE", 0, false), + point("2026-07-10T00:00:00Z", "US", 3, false), + point("2026-07-11T00:00:00Z", "DE", 2, false), + point("2026-07-11T00:00:00Z", "US", 0, false)), + points); + } + + @Test + void testWeeklyBucketsStartOnUtcMondays() { + repository.rows = List.of(new DownloadSeriesRow(Instant.parse("2026-06-08T00:00:00Z"), null, 4)); + + var points = service.getSeries( + new DownloadSeriesRequest( + List.of(1L), + Instant.parse("2026-06-03T00:00:00Z"), + Instant.parse("2026-06-22T00:00:00Z"), + DownloadSeriesInterval.WEEK, + DownloadSeriesGroupBy.NONE)); + + // 2026-06-03 is a Wednesday; its bucket starts Monday 2026-06-01 + assertEquals( + List.of( + point("2026-06-01T00:00:00Z", 0, false), + point("2026-06-08T00:00:00Z", 4, false), + point("2026-06-15T00:00:00Z", 0, false)), + points); + } + + @Test + void testMonthlyBucketsStartOnFirstOfMonth() { + repository.rows = List.of(new DownloadSeriesRow(Instant.parse("2026-06-01T00:00:00Z"), null, 9)); + + var points = service.getSeries( + new DownloadSeriesRequest( + List.of(1L), + Instant.parse("2026-05-15T00:00:00Z"), + Instant.parse("2026-07-01T00:00:00Z"), + DownloadSeriesInterval.MONTH, + DownloadSeriesGroupBy.NONE)); + + assertEquals( + List.of(point("2026-05-01T00:00:00Z", 0, false), point("2026-06-01T00:00:00Z", 9, false)), + points); + } + + private DownloadSeriesRequest dayRequest(String from, String to) { + return DownloadSeriesRequest.of(1L, Instant.parse(from), Instant.parse(to), DownloadSeriesInterval.DAY); + } + + private DownloadSeriesPoint point(String bucketStart, long count, boolean partial) { + return point(bucketStart, null, count, partial); + } + + private DownloadSeriesPoint point(String bucketStart, String group, long count, boolean partial) { + return new DownloadSeriesPoint(Instant.parse(bucketStart), group, count, partial); + } + + private static class FakeRepository implements DownloadAnalyticsRepository { + List rows = List.of(); + final AtomicInteger calls = new AtomicInteger(); + final List requests = new ArrayList<>(); + + @Override + public void save(List events) { + } + + @Override + public List findSeries(DownloadSeriesRequest request) { + calls.incrementAndGet(); + requests.add(request); + return rows.stream() + .filter( + row -> !row.bucketStart().isBefore(request.from()) + && row.bucketStart().isBefore(request.to())) + .toList(); + } + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/analytics/DownloadEventTest.java b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadEventTest.java new file mode 100644 index 000000000..159f7dacc --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/analytics/DownloadEventTest.java @@ -0,0 +1,113 @@ +/****************************************************************************** + * 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.analytics; + +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class DownloadEventTest { + + private static final Instant TIME = Instant.parse("2026-07-01T14:00:00Z"); + + @Test + public void testValidEvent() { + var event = new DownloadEvent( + TIME, + 42L, + 7L, + "redhat", + "java", + "1.2.3", + "universal", + "US", + "9.9.9.9", + "VSCode 1.90.2", + 7); + assertEquals(TIME, event.time()); + assertEquals(42L, event.extensionId()); + assertEquals(7L, event.extensionVersionId()); + assertEquals("redhat", event.namespace()); + assertEquals("java", event.extensionName()); + assertEquals("1.2.3", event.version()); + assertEquals("universal", event.targetPlatform()); + assertEquals("US", event.country()); + assertEquals("9.9.9.9", event.ip()); + assertEquals("VSCode 1.90.2", event.userAgent()); + assertEquals(7, event.count()); + } + + @Test + public void testIpAndUserAgentAreOptional() { + var event = new DownloadEvent(TIME, 42L, 7L, "redhat", "java", "1.2.3", "universal", null, null, null, 1); + assertNull(event.ip()); + assertNull(event.userAgent()); + // blank values are normalized to null + var blank = new DownloadEvent(TIME, 42L, 7L, "redhat", "java", "1.2.3", "universal", null, " ", " ", 1); + assertNull(blank.ip()); + assertNull(blank.userAgent()); + } + + @Test + public void testCountMustBeAdditive() { + assertThrows(IllegalArgumentException.class, () -> event("US", 0)); + assertThrows(IllegalArgumentException.class, () -> event("US", -1)); + assertEquals(1, event("US", 1).count()); + } + + @Test + public void testCountryIsOptionalAndNormalized() { + assertNull(event(null, 1).country()); + assertEquals("DE", event("de", 1).country()); + assertThrows(IllegalArgumentException.class, () -> event("DEU", 1)); + assertThrows(IllegalArgumentException.class, () -> event("1!", 1)); + } + + @Test + public void testRequiredFields() { + assertThrows( + NullPointerException.class, + () -> new DownloadEvent(null, 42L, 7L, "n", "e", "1.0.0", "universal", null, null, null, 1)); + assertThrows( + NullPointerException.class, + () -> new DownloadEvent(TIME, 42L, 7L, null, "e", "1.0.0", "universal", null, null, null, 1)); + assertThrows( + NullPointerException.class, + () -> new DownloadEvent(TIME, 42L, 7L, "n", null, "1.0.0", "universal", null, null, null, 1)); + assertThrows( + NullPointerException.class, + () -> new DownloadEvent(TIME, 42L, 7L, "n", "e", null, "universal", null, null, null, 1)); + assertThrows( + NullPointerException.class, + () -> new DownloadEvent(TIME, 42L, 7L, "n", "e", "1.0.0", null, null, null, null, 1)); + } + + private DownloadEvent event(String country, int count) { + return new DownloadEvent( + TIME, + 42L, + 7L, + "redhat", + "java", + "1.2.3", + "universal", + country, + "9.9.9.9", + "agent", + count); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/analytics/ingestion/DownloadIngestionProcessorTest.java b/server/src/test/java/org/eclipse/openvsx/analytics/ingestion/DownloadIngestionProcessorTest.java index 90d47f97e..89a0f2117 100644 --- a/server/src/test/java/org/eclipse/openvsx/analytics/ingestion/DownloadIngestionProcessorTest.java +++ b/server/src/test/java/org/eclipse/openvsx/analytics/ingestion/DownloadIngestionProcessorTest.java @@ -32,6 +32,8 @@ import org.eclipse.openvsx.AbstractPostgresContainerTest; import org.eclipse.openvsx.analytics.DownloadAnalyticsRepository; import org.eclipse.openvsx.analytics.DownloadEvent; +import org.eclipse.openvsx.analytics.DownloadSeriesRequest; +import org.eclipse.openvsx.analytics.DownloadSeriesRow; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.entities.FileResource; @@ -299,5 +301,10 @@ public void save(List events) { saved.addAll(events); } + + @Override + public List findSeries(DownloadSeriesRequest request) { + return List.of(); + } } } diff --git a/server/src/test/java/org/eclipse/openvsx/analytics/timescale/TimescaleDownloadAnalyticsRepositoryTest.java b/server/src/test/java/org/eclipse/openvsx/analytics/timescale/TimescaleDownloadAnalyticsRepositoryTest.java new file mode 100644 index 000000000..043943d88 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/analytics/timescale/TimescaleDownloadAnalyticsRepositoryTest.java @@ -0,0 +1,344 @@ +/****************************************************************************** + * 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.analytics.timescale; + +import java.time.Instant; +import java.util.List; +import java.util.stream.IntStream; +import javax.sql.DataSource; + +import org.jooq.exception.DataAccessException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import org.eclipse.openvsx.AbstractTimeseriesContainerTest; +import org.eclipse.openvsx.analytics.DownloadAnalyticsRepository; +import org.eclipse.openvsx.analytics.DownloadEvent; +import org.eclipse.openvsx.analytics.DownloadSeriesGroupBy; +import org.eclipse.openvsx.analytics.DownloadSeriesInterval; +import org.eclipse.openvsx.analytics.DownloadSeriesRequest; +import org.eclipse.openvsx.analytics.DownloadSeriesRow; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class TimescaleDownloadAnalyticsRepositoryTest extends AbstractTimeseriesContainerTest { + + @Autowired + DownloadAnalyticsRepository repository; + + @Autowired + PlatformTransactionManager transactionManager; + + JdbcTemplate jdbc; + + JdbcTemplate registryJdbc; + + // the time-series pool is not a default autowiring candidate; only the qualifier reaches it + @Autowired + void initJdbc(@Qualifier("timeseriesDataSource") DataSource timeseries, DataSource registry) { + this.jdbc = new JdbcTemplate(timeseries); + this.registryJdbc = new JdbcTemplate(registry); + } + + @AfterEach + void cleanUp() { + jdbc.execute("TRUNCATE download_event"); + } + + @Test + void testMigrationApplied() { + assertEquals( + 1, + jdbc.queryForObject( + "SELECT COUNT(*) FROM timescaledb_information.hypertables WHERE hypertable_name = 'download_event'", + Integer.class)); + assertEquals( + 1, + jdbc.queryForObject( + "SELECT COUNT(*) FROM timescaledb_information.continuous_aggregates WHERE view_name = 'download_stats_daily'", + Integer.class)); + // the time-series database has its own migration chain, starting over at version 1 + assertEquals( + 1, + jdbc.queryForObject( + "SELECT COUNT(*) FROM flyway_schema_history WHERE version = '1' AND success", + Integer.class)); + } + + @Test + void testAnalyticsSchemaIsAbsentFromRegistryDatabase() { + assertNull(registryJdbc.queryForObject("SELECT to_regclass('download_event')::text", String.class)); + assertNull(registryJdbc.queryForObject("SELECT to_regclass('download_stats_daily')::text", String.class)); + } + + @Test + void testSaveBatches() { + var events = IntStream.range(0, 1500) + .mapToObj( + i -> event( + Instant.parse("2026-07-01T00:00:00Z").plusSeconds(i * 3600L), + 1L, + "1.0.0", + "US", + 2)) + .toList(); + repository.save(events); + + assertEquals(1500, jdbc.queryForObject("SELECT COUNT(*) FROM download_event", Integer.class)); + assertEquals( + 1500, + jdbc.queryForObject( + "SELECT COUNT(*) FROM download_event WHERE extension_version_id = 100", + Integer.class)); + assertEquals(3000, jdbc.queryForObject("SELECT SUM(count) FROM download_event", Integer.class)); + // the raw client ip and user agent are persisted as found in the logs + assertEquals( + 1500, + jdbc.queryForObject( + "SELECT COUNT(*) FROM download_event WHERE ip = '9.9.9.9' AND user_agent = 'VSCode 1.90.2'", + Integer.class)); + } + + @Test + void testSaveSurvivesRolledBackCallerTransaction() { + var transaction = new TransactionTemplate(transactionManager); + assertThrows(IllegalStateException.class, () -> transaction.execute(status -> { + repository.save( + List.of( + event( + Instant.parse("2026-07-01T10:00:00Z"), + 1L, + "1.0.0", + "US", + 1))); + throw new IllegalStateException("induced failure after save"); + })); + + // the time-series database is not part of the registry transaction + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM download_event", Integer.class)); + } + + @Test + void testSaveIsAtomicAcrossItsBatches() { + // version is VARCHAR(255), so the 501st event lands in a second batch and fails it + var overlongVersion = "1.0.0-" + "x".repeat(300); + var events = IntStream.rangeClosed(0, 500) + .mapToObj( + i -> event( + Instant.parse("2026-07-01T00:00:00Z").plusSeconds(i * 60L), + 1L, + i == 500 ? overlongVersion : "1.0.0", + "US", + 1)) + .toList(); + + assertThrows(DataAccessException.class, () -> repository.save(events)); + + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM download_event", Integer.class)); + } + + @Test + void testFindSeriesByDay() { + repository.save( + List.of( + event(Instant.parse("2026-06-30T10:00:00Z"), 1L, "1.0.0", "US", 3), + event(Instant.parse("2026-06-30T23:00:00Z"), 1L, "1.0.0", "DE", 2), + event(Instant.parse("2026-07-01T00:00:00Z"), 1L, "1.0.0", "US", 5), + // different extension, not requested + event(Instant.parse("2026-07-01T00:00:00Z"), 2L, "1.0.0", "US", 100))); + + var rows = repository.findSeries( + DownloadSeriesRequest.of( + 1L, + Instant.parse("2026-06-29T00:00:00Z"), + Instant.parse("2026-07-02T00:00:00Z"), + DownloadSeriesInterval.DAY)); + + assertEquals( + List.of( + new DownloadSeriesRow(Instant.parse("2026-06-30T00:00:00Z"), null, 5), + new DownloadSeriesRow(Instant.parse("2026-07-01T00:00:00Z"), null, 5)), + rows); + } + + @Test + void testFindSeriesRangeFilter() { + repository.save( + List.of( + event(Instant.parse("2026-06-28T10:00:00Z"), 1L, "1.0.0", "US", 1), + event(Instant.parse("2026-06-29T10:00:00Z"), 1L, "1.0.0", "US", 2), + event(Instant.parse("2026-06-30T10:00:00Z"), 1L, "1.0.0", "US", 4))); + + // from is inclusive, to is exclusive + var rows = repository.findSeries( + DownloadSeriesRequest.of( + 1L, + Instant.parse("2026-06-29T00:00:00Z"), + Instant.parse("2026-06-30T00:00:00Z"), + DownloadSeriesInterval.DAY)); + + assertEquals(List.of(new DownloadSeriesRow(Instant.parse("2026-06-29T00:00:00Z"), null, 2)), rows); + } + + @Test + void testFindSeriesByWeekAndMonth() { + repository.save( + List.of( + // Sunday of the week starting Monday 2026-06-22, and June + event(Instant.parse("2026-06-28T10:00:00Z"), 1L, "1.0.0", "US", 1), + // Monday 2026-06-29 week, June + event(Instant.parse("2026-06-29T10:00:00Z"), 1L, "1.0.0", "US", 2), + // Wednesday of the same week, but July + event(Instant.parse("2026-07-01T10:00:00Z"), 1L, "1.0.0", "US", 4))); + + var weekly = repository.findSeries( + DownloadSeriesRequest.of( + 1L, + Instant.parse("2026-06-01T00:00:00Z"), + Instant.parse("2026-08-01T00:00:00Z"), + DownloadSeriesInterval.WEEK)); + assertEquals( + List.of( + new DownloadSeriesRow(Instant.parse("2026-06-22T00:00:00Z"), null, 1), + new DownloadSeriesRow(Instant.parse("2026-06-29T00:00:00Z"), null, 6)), + weekly); + + var monthly = repository.findSeries( + DownloadSeriesRequest.of( + 1L, + Instant.parse("2026-06-01T00:00:00Z"), + Instant.parse("2026-08-01T00:00:00Z"), + DownloadSeriesInterval.MONTH)); + assertEquals( + List.of( + new DownloadSeriesRow(Instant.parse("2026-06-01T00:00:00Z"), null, 3), + new DownloadSeriesRow(Instant.parse("2026-07-01T00:00:00Z"), null, 4)), + monthly); + } + + @Test + void testOutOfOrderSavesLandInCorrectBuckets() { + repository.save( + List.of( + event(Instant.parse("2026-07-02T10:00:00Z"), 1L, "1.0.0", "US", 1))); + // a late-arriving event for an earlier day + repository.save( + List.of( + event(Instant.parse("2026-06-30T10:00:00Z"), 1L, "1.0.0", "US", 7))); + + var rows = repository.findSeries( + DownloadSeriesRequest.of( + 1L, + Instant.parse("2026-06-29T00:00:00Z"), + Instant.parse("2026-07-03T00:00:00Z"), + DownloadSeriesInterval.DAY)); + + assertEquals( + List.of( + new DownloadSeriesRow(Instant.parse("2026-06-30T00:00:00Z"), null, 7), + new DownloadSeriesRow(Instant.parse("2026-07-02T00:00:00Z"), null, 1)), + rows); + } + + @Test + void testFindSeriesGroupBy() { + repository.save( + List.of( + event(Instant.parse("2026-07-01T08:00:00Z"), 1L, "1.0.0", "US", 1), + event(Instant.parse("2026-07-01T09:00:00Z"), 1L, "2.0.0", "DE", 2), + event(Instant.parse("2026-07-01T10:00:00Z"), 1L, "2.0.0", null, 4))); + + var from = Instant.parse("2026-07-01T00:00:00Z"); + var to = Instant.parse("2026-07-02T00:00:00Z"); + + var byVersion = repository.findSeries( + new DownloadSeriesRequest( + List.of(1L), + from, + to, + DownloadSeriesInterval.DAY, + DownloadSeriesGroupBy.VERSION)); + assertEquals( + List.of(new DownloadSeriesRow(from, "1.0.0", 1), new DownloadSeriesRow(from, "2.0.0", 6)), + byVersion); + + var byCountry = repository.findSeries( + new DownloadSeriesRequest( + List.of(1L), + from, + to, + DownloadSeriesInterval.DAY, + DownloadSeriesGroupBy.COUNTRY)); + assertEquals(3, byCountry.size()); + assertTrue(byCountry.contains(new DownloadSeriesRow(from, "US", 1))); + assertTrue(byCountry.contains(new DownloadSeriesRow(from, "DE", 2))); + assertTrue(byCountry.contains(new DownloadSeriesRow(from, null, 4))); + + var byTargetPlatform = repository.findSeries( + new DownloadSeriesRequest( + List.of(1L), + from, + to, + DownloadSeriesInterval.DAY, + DownloadSeriesGroupBy.TARGET_PLATFORM)); + assertEquals(List.of(new DownloadSeriesRow(from, "universal", 7)), byTargetPlatform); + } + + @Test + void testFindSeriesForMultipleExtensions() { + repository.save( + List.of( + event(Instant.parse("2026-07-01T08:00:00Z"), 1L, "1.0.0", "US", 1), + event(Instant.parse("2026-07-01T09:00:00Z"), 2L, "1.0.0", "US", 2), + event(Instant.parse("2026-07-01T09:00:00Z"), 3L, "1.0.0", "US", 100))); + + var rows = repository.findSeries( + new DownloadSeriesRequest( + List.of(1L, 2L), + Instant.parse("2026-07-01T00:00:00Z"), + Instant.parse("2026-07-02T00:00:00Z"), + DownloadSeriesInterval.DAY, + DownloadSeriesGroupBy.NONE)); + + assertEquals(List.of(new DownloadSeriesRow(Instant.parse("2026-07-01T00:00:00Z"), null, 3)), rows); + } + + private DownloadEvent event( + Instant time, + long extensionId, + String version, + String country, + int count + ) { + return new DownloadEvent( + time, + extensionId, + extensionId * 100, + "ns", + "ext", + version, + "universal", + country, + "9.9.9.9", + "VSCode 1.90.2", + count); + } + +}