Skip to content
Draft
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
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
services:

postgres:
image: postgres:16.2
# PostgreSQL with the timescaledb extension: the main migration chain contains the
# download analytics schema, which requires the extension
image: timescale/timescaledb:2.17.2-pg16
environment:
- POSTGRES_USER=openvsx
- POSTGRES_PASSWORD=openvsx
Expand Down
13 changes: 12 additions & 1 deletion server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,12 @@ jooq {
name = 'org.jooq.meta.postgres.PostgresDatabase'
inputSchema = 'public'
includes = '.*'
excludes = 'jobrunr.*'
// jobrunr manages its own tables; the timescaledb extension contributes
// public-schema (table-valued) functions that we never call through jOOQ
excludes = 'jobrunr.*' +
'|add_dimension|alter_job|create_hypertable|disable_chunk_skipping|drop_chunks' +
'|enable_chunk_skipping|show_chunks|show_tablespaces' +
'|chunk_compression_stats|chunks_detailed_size|hypertable_.*'
includeRoutines = false
}
target {
Expand Down Expand Up @@ -275,6 +280,12 @@ testlogger {
test {
jvmArgs = ['--enable-native-access=ALL-UNNAMED', '-Xmx4096m'] // due to https://github.com/netty/netty/issues/15161
useJUnitPlatform()

// the test database image is timescale/timescaledb by default (the main migration chain
// requires the extension); override with -Dovsx.test.postgres.image=... if needed
if (System.getProperty('ovsx.test.postgres.image') != null) {
systemProperty 'ovsx.test.postgres.image', System.getProperty('ovsx.test.postgres.image')
}
}

tasks.register('unitTests', Test) {
Expand Down
2 changes: 2 additions & 0 deletions server/src/dev/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ ovsx:
# path-style-access: true
local:
directory: /tmp/ovsx
analytics:
enabled: true
access-token:
prefix: dev_ovsxat_ # use a token prefix that clearly indicates that it's for development
expiration: 0 # do not expire tokens in a dev environment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ public LocalRegistryService(
@Value("${ovsx.registry.version:}")
String registryVersion;

@Value("${ovsx.analytics.enabled:false}")
boolean analyticsEnabled;

@Override
public NamespaceJson getNamespace(String namespaceName) {
return getNamespace(namespaceName, false);
Expand Down Expand Up @@ -1326,6 +1329,7 @@ public RegistryVersionJson getRegistryVersion() {
var json = new RegistryVersionJson();
json.setVersion(registryVersion);
json.setMaxExtensionSize(publishingConfig.getMaxContentSize());
json.setAnalyticsEnabled(analyticsEnabled);
return json;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/******************************************************************************
* 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 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.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 buckets before 'to'",
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();
return ResponseEntity.ok(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,49 @@
/******************************************************************************
* 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.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
* is part of the main migration chain, so the database image must provide the timescaledb
* extension.
*/
@Configuration
@ConditionalOnProperty(name = "ovsx.analytics.enabled", havingValue = "true")
class DownloadAnalyticsConfiguration {

@Bean
DownloadAnalyticsRepository downloadAnalyticsRepository(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
@@ -0,0 +1,33 @@
/******************************************************************************
* 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;

/**
* Storage for download analytics: one interface for writing events and reading series.
*/
public interface DownloadAnalyticsRepository {

/**
* Persists the given events. Implementations must participate in the caller's transaction,
* so that events, the extension download counter and the download ingestion entry commit atomically.
*/
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);
}
Loading