Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -1386,6 +1389,7 @@ public RegistryVersionJson getRegistryVersion() {
json.setMaxExtensionSize(publishingConfig.getMaxContentSize());
json.setTrustedPublishingAudience(
trustedPublishingConfig.isEnabled() ? trustedPublishingConfig.getAudience() : null);
json.setAnalyticsEnabled(analyticsEnabled);
return json;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<DownloadSeriesJson> 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");
}
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ public interface DownloadAnalyticsRepository {
* independently of these events.
*/
void save(List<DownloadEvent> 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<DownloadSeriesRow> findSeries(DownloadSeriesRequest request);
}
Original file line number Diff line number Diff line change
@@ -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<DownloadSeriesRequest, List<DownloadSeriesRow>> 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<DownloadSeriesPoint> 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<DownloadSeriesRow> 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<DownloadSeriesPoint> zeroFill(
DownloadSeriesRequest request,
List<DownloadSeriesRow> 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<DownloadSeriesPoint>();
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);
};
}
}
Loading
Loading