Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a193587
feat: time-series download analytics backed by TimescaleDB
gnugomez Jul 31, 2026
d10acaf
feat: exposing analytics enabled flag on version endpoint
gnugomez Jul 31, 2026
c1e7648
feat: download analytics on extension details page
gnugomez Jul 31, 2026
2f55282
fix: bad fill color for downloads chart
gnugomez Jul 31, 2026
e0a4859
refactor: rework the navbar chrome and scroll restoration
gnugomez Aug 24, 2026
2f96cbc
feat: add a Pill component and lift shared page primitives
gnugomez Aug 24, 2026
1208a19
feat: expose userLoading and account-menu entries to consumers
gnugomez Aug 24, 2026
5bba107
feat: make the admin dashboard extensible
gnugomez Aug 24, 2026
ad058b3
feat: widen the published API for consumers building their own pages
gnugomez Aug 24, 2026
f72f64f
refactor: rework the weekly downloads card
gnugomez Aug 24, 2026
bd193c6
feat: give download analytics its own database
gnugomez Aug 24, 2026
b84a70c
fix: save download events outside the registry transaction
gnugomez Aug 24, 2026
4867772
feat: make the download series publicly cacheable
gnugomez Aug 24, 2026
3485e52
docs: fold the changelog entries into the existing next section
gnugomez Aug 24, 2026
35e5126
test: cap datasource pools so the suite does not exhaust postgres con…
gnugomez Aug 24, 2026
2d2937f
Potential fix for pull request finding
gnugomez Aug 31, 2026
2f859ff
fix: materialize download events that arrive outside the refresh window
gnugomez Aug 31, 2026
4ec48b1
fix: apply review fixes
gnugomez Aug 31, 2026
0ea3b1f
Merge branch 'main' into feat/download-analytics-timescale
netomi Aug 31, 2026
2312570
fix import
netomi Aug 31, 2026
4feac79
fix unused imports
netomi Aug 31, 2026
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
7 changes: 7 additions & 0 deletions deploy/docker/configuration/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ spring:
url: jdbc:postgresql://localhost:5432/openvsx
username: openvsx
password: openvsx
# Download analytics is disabled by default. When enabled it keeps its time-series schema in a
# separate database, migrated on its own and requiring the timescaledb extension:
# ovsx.analytics.enabled: true
# ovsx.analytics.datasource.url: jdbc:postgresql://localhost:5433/openvsx_timeseries
# ovsx.analytics.datasource.username: openvsx
# ovsx.analytics.datasource.password: openvsx
# ovsx.analytics.datasource.maximum-pool-size: 5
flyway:
baseline-on-migrate: true
baseline-version: 0.1.0
Expand Down
7 changes: 7 additions & 0 deletions deploy/kubernetes/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ data:
url: jdbc:postgresql://postgresql:5432/openvsx
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
# Download analytics is disabled by default. When enabled it keeps its time-series schema
# in a separate database, migrated on its own and requiring the timescaledb extension:
# ovsx.analytics.enabled: true
# ovsx.analytics.datasource.url: jdbc:postgresql://postgresql-timeseries:5432/openvsx_timeseries
# ovsx.analytics.datasource.username: ${TIMESERIES_DB_USERNAME}
# ovsx.analytics.datasource.password: ${TIMESERIES_DB_PASSWORD}
# ovsx.analytics.datasource.maximum-pool-size: 5
flyway:
baseline-on-migrate: true
baseline-version: 0.1.0
Expand Down
7 changes: 7 additions & 0 deletions deploy/openshift/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ spring:
url: jdbc:postgresql://postgresql:5432/openvsx
username: openvsx
password: openvsx
# Download analytics is disabled by default. When enabled it keeps its time-series schema in a
# separate database, migrated on its own and requiring the timescaledb extension:
# ovsx.analytics.enabled: true
# ovsx.analytics.datasource.url: jdbc:postgresql://postgresql-timeseries:5432/openvsx_timeseries
# ovsx.analytics.datasource.username: openvsx
# ovsx.analytics.datasource.password: openvsx
# ovsx.analytics.datasource.maximum-pool-size: 5
flyway:
baseline-on-migrate: true
baseline-version: 0.1.0
Expand Down
2 changes: 1 addition & 1 deletion doc/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ To run the Open VSX registry in a development environment, you can use `docker c

* Verify Docker Compose is installed by running `docker compose version`. If an error occurs, you may need to [install docker compose](https://docs.docker.com/compose/install/) on your machine.
* Decide which profile(s) to run based on your needs. The [docker-compose.yml] file defines profiles for specific components:
* `db`: Starts the PostgreSQL container.
* `db`: Starts the PostgreSQL containers: the registry database, and the separate TimescaleDB one used by download analytics.
* `es`: Starts the Elasticsearch container.
* `debug`: Starts the PostgreSQL and Elasticsearch containers, which suits running the OpenVSX server and web UI locally for easier debugging.
* `backend`: Starts the OpenVSX server container (java).
Expand Down
24 changes: 24 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ services:
- db
- debug

postgres-timeseries:
# download analytics only: PostgreSQL plus the timescaledb extension, kept apart from the
# registry database so the registry never needs the extension
image: timescale/timescaledb:2.17.2-pg16
environment:
- POSTGRES_USER=openvsx
- POSTGRES_PASSWORD=openvsx
- POSTGRES_DB=openvsx_timeseries
logging:
options:
max-size: 10m
max-file: "3"
ports:
- '5433:5432'
volumes:
- postgres-timeseries-data:/var/lib/postgresql/data
profiles:
- db
- debug

elasticsearch:
image: elasticsearch:9.2.8
environment:
Expand Down Expand Up @@ -213,6 +233,7 @@ services:
- 8080:8080
depends_on:
- postgres
- postgres-timeseries
- elasticsearch
healthcheck:
test: "curl --fail --silent localhost:8081/actuator/health | grep UP || exit 1"
Expand Down Expand Up @@ -285,3 +306,6 @@ services:
"
profiles:
- minio

volumes:
postgres-timeseries-data:
8 changes: 8 additions & 0 deletions server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,14 @@ test {
// observed as an OutOfMemoryError during unrelated context bootstrapping on CI.
jvmArgs = ['--enable-native-access=ALL-UNNAMED', '-Xmx6144m', '-Xshare:off'] // due to https://github.com/netty/netty/issues/15161
useJUnitPlatform()

// registry tests run on plain postgres, analytics tests on timescale/timescaledb; override
// either image with -Dovsx.test.postgres.image=... / -Dovsx.test.timeseries.image=...
['ovsx.test.postgres.image', 'ovsx.test.timeseries.image'].each { property ->
if (System.getProperty(property) != null) {
systemProperty property, System.getProperty(property)
}
}
}

tasks.register('unitTests', Test) {
Expand Down
5 changes: 5 additions & 0 deletions server/scripts/generate-properties.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ then
echo "spring.datasource.url=jdbc:postgresql://postgres:5432/postgres"
echo "spring.datasource.username=openvsx"
echo "spring.datasource.password=openvsx"

# Set the download analytics (timeseries) Postgres host
echo "ovsx.analytics.datasource.url=jdbc:postgresql://postgres-timeseries:5432/openvsx_timeseries"
echo "ovsx.analytics.datasource.username=openvsx"
echo "ovsx.analytics.datasource.password=openvsx"
} >> "${OVSX_APP_PROFILE}"
else
# Set the Elasticsearch host
Expand Down
7 changes: 7 additions & 0 deletions server/src/dev/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ ovsx:
# path-style-access: true
local:
directory: /tmp/ovsx
analytics:
enabled: true
# the postgres-timeseries service of docker-compose.yml
datasource:
url: jdbc:postgresql://localhost:5433/openvsx_timeseries
username: openvsx
password: openvsx
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 @@ -137,6 +137,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 @@ -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());
}
}
Loading