userInputFuture = CompletableFuture
+ .supplyAsync(() -> analyticsEnabledSupplier.apply(ACCEPTANCE_PROMPT));
+ final String userInput = userInputFuture.get(timeout, TimeUnit.SECONDS).toLowerCase().trim();
+ if (!validInput(userInput)) {
+ log.info("[Quarkus build analytics] Didn't receive a valid user's answer: `y` or `n`. " +
+ "The question will be asked again next time." + NEW_LINE);
+ return;
+ }
+ final boolean isActive = userInput.equals("y") || userInput.equals("yes") || userInput.startsWith("yy");
+ FileUtils.createFileAndParent(localConfigFile);
+ FileUtils.write(new LocalConfig(isActive), localConfigFile);
+ log.info("[Quarkus build analytics] Quarkus Build Analytics " + (isActive ? "enabled" : "disabled")
+ + " by the user." + NEW_LINE);
+ } catch (TimeoutException e) {
+ log.info("[Quarkus build analytics] Didn't receive the user's answer after " + timeout + " seconds. " +
+ "The question will be asked again next time." + NEW_LINE);
+ } catch (Exception e) {
+ log.info("[Quarkus build analytics] Analytics config file was not written successfully. " +
+ e.getClass().getName() + ": " + (e.getMessage() == null ? "(no message)" : e.getMessage()));
+ }
+ }
+ }
+
+ /**
+ * True if build time analytics can be gathered.
+ *
+ *
+ * Disabled by default.
+ *
+ * If Not explicitly approved by user in dev mode, false
+ *
+ * If analytics disabled by local property, false
+ *
+ * If remote config not accessible, false
+ *
+ * If disabled by remote config, false
+ *
+ * @return true if active
+ */
+ public boolean isActive() {
+ if (!Files.exists(localConfigFile)) {
+ return false; // disabled because user has not decided yet
+ } else if (!loadConfig(LocalConfig.class, localConfigFile)
+ .map(LocalConfig::isActive)
+ .orElse(true)) {
+ return false; // disabled by the user and recorded on the local config
+ }
+
+ if (Boolean.getBoolean(QUARKUS_ANALYTICS_DISABLED_LOCAL_PROP)) {
+ return false; // disabled by local property
+ }
+ AnalyticsRemoteConfig analyticsRemoteConfig = getRemoteConfig();
+ return analyticsRemoteConfig.isActive() && isUserEnabled(analyticsRemoteConfig, userId.getUuid());
+ }
+
+ /**
+ * If groupId has been disabled by local static config, false
+ * If Quarkus version has been disabled by remote config, false
+ *
+ * @param groupId
+ * @param quarkusVersion
+ * @return true if active
+ */
+ public boolean isArtifactActive(final String groupId, final String quarkusVersion) {
+ return GroupIdFilter.isAuthorizedGroupId(groupId, log) &&
+ this.getRemoteConfig().getDenyQuarkusVersions().stream()
+ .noneMatch(version -> version.equals(quarkusVersion));
+ }
+
+ boolean isUserEnabled(final AnalyticsRemoteConfig analyticsRemoteConfig, final String user) {
+ return analyticsRemoteConfig.getDenyUserIds().stream()
+ .noneMatch(uId -> uId.equals(user));
+ }
+
+ private boolean validInput(String input) {
+ String[] allowedValues = { "n", "nn", "no", "y", "yy", "yes" };
+ for (String allowedValue : allowedValues) {
+ if (input.equalsIgnoreCase(allowedValue)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private AnalyticsRemoteConfig getRemoteConfig() {
+ if (shouldRefresh()) {
+ loadConfigFromInternet();
+ }
+ return this.config;
+ }
+
+ private boolean shouldRefresh() {
+ return lastRefreshTime == null || Duration.between(
+ lastRefreshTime,
+ Instant.now()).compareTo(
+ this.config.getRefreshInterval()) > 0;
+ }
+
+ private Optional loadConfig(Class clazz, Path file) {
+ try {
+ if (Files.exists(file)) {
+ return FileUtils.read(clazz, file, log);
+ }
+ return Optional.empty();
+ } catch (IOException e) {
+ log.warn("[Quarkus build analytics] Failed to read " + file.getFileName() + ". Exception: " + e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private void loadConfigFromInternet() {
+ AnalyticsRemoteConfig analyticsRemoteConfig = this.client.getConfig().orElse(checkAgainConfig());
+ this.lastRefreshTime = Instant.now();
+ this.config = storeRemoteConfigOnDisk(analyticsRemoteConfig);
+ }
+
+ private AnalyticsRemoteConfig storeRemoteConfigOnDisk(AnalyticsRemoteConfig config) {
+ try {
+ if (!Files.exists(remoteConfigFile)) {
+ FileUtils.createFileAndParent(remoteConfigFile);
+ }
+ FileUtils.write(config, remoteConfigFile);
+ return config;
+ } catch (IOException e) {
+ log.warn("[Quarkus build analytics] Failed to save remote config file. Analytics will be skipped. Exception: "
+ + e.getMessage());
+ return NoopRemoteConfig.INSTANCE;// disable
+ }
+ }
+
+ private AnalyticsRemoteConfig checkAgainConfig() {
+ return RemoteConfig.builder()
+ .active(false)
+ .denyQuarkusVersions(Collections.emptyList())
+ .denyUserIds(Collections.emptyList())
+ .refreshInterval(Duration.ofHours(DEFAULT_REFRESH_HOURS)).build();
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/ExtensionsFilter.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/ExtensionsFilter.java
new file mode 100644
index 0000000000000..38a3700a21c61
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/ExtensionsFilter.java
@@ -0,0 +1,33 @@
+package io.quarkus.analytics.config;
+
+import java.util.List;
+
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+public class ExtensionsFilter {
+ private static final List AUTHORIZED_GROUPS = List.of(
+ "io.quarkus",
+ "io.quarkiverse",
+ "org.apache.camel.quarkus",
+ "io.debezium",
+ "org.drools",
+ "org.optaplanner",
+ "org.amqphub.quarkus",
+ "com.hazelcast",
+ "com.datastax.oss.quarkus");
+
+ public static boolean onlyPublic(String groupId, MessageWriter log) {
+ if (groupId == null) {
+ log.warn(
+ "[Quarkus build analytics] Extension with null or empty group ID will not be included in the build analytics.");
+ return false;
+ }
+ boolean result = AUTHORIZED_GROUPS.stream()
+ .anyMatch(groupId::startsWith);
+ if (!result) {
+ log.info("[Quarkus build analytics] Extension with group ID: " + groupId +
+ " will not be included in the build analytics because it's not part of the Quarkus platform extensions.");
+ }
+ return result;
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/FileLocations.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/FileLocations.java
new file mode 100644
index 0000000000000..9fd51c8ba71de
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/FileLocations.java
@@ -0,0 +1,40 @@
+package io.quarkus.analytics.config;
+
+import java.nio.file.Path;
+
+/**
+ * File location paths
+ */
+public interface FileLocations {
+ /**
+ * Returns the folder where all the build time analytics files are stored.
+ *
+ * @return
+ */
+ Path getFolder();
+
+ /**
+ * Returns the file where the user's UUID is stored.
+ *
+ * @return
+ */
+ Path getUUIDFile();
+
+ /**
+ * Returns the file where the build time analytics config is stored.
+ *
+ * @return
+ */
+ Path getRemoteConfigFile();
+
+ /**
+ * Returns the file where the last time the remote config was retrieved and stored.
+ *
+ * @return
+ */
+ Path getLastRemoteConfigTryFile();
+
+ Path getLocalConfigFile();
+
+ String lastTrackFileName();
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/FileLocationsImpl.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/FileLocationsImpl.java
new file mode 100644
index 0000000000000..7f99f36dbac97
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/FileLocationsImpl.java
@@ -0,0 +1,57 @@
+package io.quarkus.analytics.config;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * File location paths used in production code
+ */
+public class FileLocationsImpl implements FileLocations {
+
+ public static final FileLocations INSTANCE = new FileLocationsImpl();
+
+ private static final Path RED_HAT = Paths.get(
+ System.getProperty("user.home"),
+ ".redhat");
+
+ private static final Path UUID_FILE = RED_HAT.resolve("anonymousId");
+ private static final Path REMOTE_CONFIG_FILE = RED_HAT.resolve("com.redhat.devtools.quarkus.remoteconfig");
+ private static final Path LAST_REMOTE_CONFIG_TRY_FILE = RED_HAT.resolve(
+ "com.redhat.devtools.quarkus.analytics.lasttry");
+ private static final Path LOCAL_CONFIG_FILE = RED_HAT.resolve("com.redhat.devtools.quarkus.localconfig");
+ private static final String BUILD_ANALYTICS_EVENT_FILE_NAME = "build-analytics-event.json";
+
+ // singleton
+ private FileLocationsImpl() {
+ // not much
+ }
+
+ @Override
+ public Path getFolder() {
+ return RED_HAT;
+ }
+
+ @Override
+ public Path getUUIDFile() {
+ return UUID_FILE;
+ }
+
+ @Override
+ public Path getRemoteConfigFile() {
+ return REMOTE_CONFIG_FILE;
+ }
+
+ @Override
+ public Path getLastRemoteConfigTryFile() {
+ return LAST_REMOTE_CONFIG_TRY_FILE;
+ }
+
+ @Override
+ public Path getLocalConfigFile() {
+ return LOCAL_CONFIG_FILE;
+ }
+
+ public String lastTrackFileName() {
+ return BUILD_ANALYTICS_EVENT_FILE_NAME;
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/GroupIdFilter.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/GroupIdFilter.java
new file mode 100644
index 0000000000000..ed9cd9ab5e628
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/GroupIdFilter.java
@@ -0,0 +1,36 @@
+package io.quarkus.analytics.config;
+
+import java.util.List;
+
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+public class GroupIdFilter {
+ private static final List DENIED_GROUPS = List.of(
+ "io.quarkus",
+ "io.quarkiverse",
+ "org.acme",
+ "org.test",
+ "g1",
+ "g2",
+ "org.apache.camel.quarkus",
+ "io.debezium",
+ "org.drools",
+ "org.optaplanner",
+ "org.amqphub.quarkus",
+ "com.hazelcast",
+ "com.datastax.oss.quarkus");
+
+ public static boolean isAuthorizedGroupId(String groupId, MessageWriter log) {
+ if (groupId == null || groupId.isEmpty()) {
+ log.warn("[Quarkus build analytics] Artifact with empty or null group ID will not send analytics.");
+ return false;
+ }
+ boolean result = DENIED_GROUPS.stream()
+ .noneMatch(groupId::startsWith);
+ if (!result) {
+ log.info("[Quarkus build analytics] Artifact with group ID: " + groupId +
+ " will not send analytics because it's on the default deny list.");
+ }
+ return result;
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/AnalyticsLocalConfig.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/AnalyticsLocalConfig.java
new file mode 100644
index 0000000000000..339ba6a002839
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/AnalyticsLocalConfig.java
@@ -0,0 +1,9 @@
+package io.quarkus.analytics.dto.config;
+
+public interface AnalyticsLocalConfig {
+ /**
+ * @return true if the analytics is enabled
+ * @return
+ */
+ boolean isActive();
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/AnalyticsRemoteConfig.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/AnalyticsRemoteConfig.java
new file mode 100644
index 0000000000000..8b2805e2f9a5b
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/AnalyticsRemoteConfig.java
@@ -0,0 +1,38 @@
+package io.quarkus.analytics.dto.config;
+
+import java.time.Duration;
+import java.util.List;
+
+/**
+ * Allow to configure build analytics behaviour
+ */
+public interface AnalyticsRemoteConfig {
+ /**
+ * @return true if the analytics is enabled
+ * @return
+ */
+ boolean isActive();
+
+ /**
+ * List of anonymous UUID representing the users who will not send analytics.
+ * The data from particular UUIDs might contain issues and generation will be disabled at the source.
+ *
+ * @return
+ */
+ List getDenyUserIds();
+
+ /**
+ * List of quarkus versions that will not send analytics.
+ * The data from particular versions might contain issues and generation will be disabled at the source.
+ *
+ * @return
+ */
+ List getDenyQuarkusVersions();
+
+ /**
+ * Configuration refresh interval
+ *
+ * @return
+ */
+ Duration getRefreshInterval();
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/Identity.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/Identity.java
new file mode 100644
index 0000000000000..0d2ed7baacfc0
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/Identity.java
@@ -0,0 +1,99 @@
+package io.quarkus.analytics.dto.config;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import io.quarkus.analytics.dto.segment.SegmentContext;
+
+/**
+ * Identity of the user at the upstream collection tool.
+ */
+public class Identity implements Serializable, SegmentContext {
+ private String userId;
+ private Map context;
+ private Instant timestamp;
+
+ public Identity(String userId, Map context, Instant timestamp) {
+ this.userId = userId;
+ this.context = context;
+ this.timestamp = timestamp;
+ }
+
+ public static IdentityBuilder builder() {
+ return new IdentityBuilder();
+ }
+
+ /**
+ * The UUID of the user.
+ *
+ * @return
+ */
+ @JsonProperty("userId")
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ /**
+ * The context of the user. See: AnalyticsService.createContextMap() (package friendly) for details.
+ *
+ * @return
+ */
+ @Override
+ public Map getContext() {
+ return context;
+ }
+
+ public void setContext(Map context) {
+ this.context = context;
+ }
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
+ public Instant getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(Instant timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public static class IdentityBuilder {
+ private String userId;
+ private Map context;
+ private Instant timestamp;
+
+ IdentityBuilder() {
+ }
+
+ public IdentityBuilder userId(String userId) {
+ this.userId = userId;
+ return this;
+ }
+
+ public IdentityBuilder context(Map context) {
+ this.context = context;
+ return this;
+ }
+
+ public IdentityBuilder timestamp(Instant timestamp) {
+ this.timestamp = timestamp;
+ return this;
+ }
+
+ public Identity build() {
+ return new Identity(userId, context, timestamp);
+ }
+
+ public String toString() {
+ return "Identity.IdentityBuilder(userId=" + this.userId + ", context="
+ + this.context + ", timestamp=" + this.timestamp + ")";
+ }
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/LocalConfig.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/LocalConfig.java
new file mode 100644
index 0000000000000..914762a574e2c
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/LocalConfig.java
@@ -0,0 +1,23 @@
+package io.quarkus.analytics.dto.config;
+
+import java.io.Serializable;
+
+public class LocalConfig implements AnalyticsLocalConfig, Serializable {
+ private boolean active;
+
+ public LocalConfig(boolean active) {
+ this.active = active;
+ }
+
+ public LocalConfig() {
+ }
+
+ @Override
+ public boolean isActive() {
+ return active;
+ }
+
+ public void setActive(boolean active) {
+ this.active = active;
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/NoopRemoteConfig.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/NoopRemoteConfig.java
new file mode 100644
index 0000000000000..3ba69ff0ec244
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/NoopRemoteConfig.java
@@ -0,0 +1,37 @@
+package io.quarkus.analytics.dto.config;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Will not perform any operations
+ */
+public class NoopRemoteConfig implements AnalyticsRemoteConfig {
+ private static final Duration DONT_CHECK_ANYMORE = Duration.ofDays(365);
+ public static final NoopRemoteConfig INSTANCE = new NoopRemoteConfig();
+
+ private NoopRemoteConfig() {
+ // singleton
+ }
+
+ @Override
+ public boolean isActive() {
+ return false;
+ }
+
+ @Override
+ public List getDenyUserIds() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List getDenyQuarkusVersions() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public Duration getRefreshInterval() {
+ return DONT_CHECK_ANYMORE;
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/RemoteConfig.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/RemoteConfig.java
new file mode 100644
index 0000000000000..32201609abd5b
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/RemoteConfig.java
@@ -0,0 +1,126 @@
+package io.quarkus.analytics.dto.config;
+
+import java.io.Serializable;
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Allow to configure build analytics behaviour by downloading a remote configuration file from a public location.
+ */
+public class RemoteConfig implements AnalyticsRemoteConfig, Serializable {
+
+ private boolean active;
+ private List denyUserIds;
+ private List denyQuarkusVersions;
+ private Duration refreshInterval;
+
+ public RemoteConfig() {
+ }
+
+ RemoteConfig(boolean active, List denyUserIds, List denyQuarkusVersions, Duration refreshInterval) {
+ this.active = active;
+ this.denyUserIds = denyUserIds;
+ this.denyQuarkusVersions = denyQuarkusVersions;
+ this.refreshInterval = refreshInterval;
+ }
+
+ public static RemoteConfigBuilder builder() {
+ return new RemoteConfigBuilder();
+ }
+
+ @Override
+ public boolean isActive() {
+ return active;
+ }
+
+ public void setActive(boolean active) {
+ this.active = active;
+ }
+
+ @JsonProperty("deny_user_ids")
+ public List getDenyUserIds() {
+ return denyUserIds;
+ }
+
+ public void setDenyUserIds(List denyUserIds) {
+ this.denyUserIds = denyUserIds;
+ }
+
+ @JsonProperty("deny_quarkus_versions")
+ public List getDenyQuarkusVersions() {
+ return denyQuarkusVersions;
+ }
+
+ public void setDenyQuarkusVersions(List denyQuarkusVersions) {
+ this.denyQuarkusVersions = denyQuarkusVersions;
+ }
+
+ @JsonProperty("refresh_interval")
+ public Duration getRefreshInterval() {
+ return refreshInterval;
+ }
+
+ public void setRefreshInterval(Duration refreshInterval) {
+ this.refreshInterval = refreshInterval;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o)
+ return true;
+ if (o == null || getClass() != o.getClass())
+ return false;
+ RemoteConfig that = (RemoteConfig) o;
+ return active == that.active &&
+ Objects.equals(denyUserIds, that.denyUserIds) &&
+ Objects.equals(denyQuarkusVersions, that.denyQuarkusVersions) &&
+ Objects.equals(refreshInterval, that.refreshInterval);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(active, denyUserIds, denyQuarkusVersions, refreshInterval);
+ }
+
+ public static class RemoteConfigBuilder {
+ private boolean active;
+ private List denyUserIds;
+ private List denyQuarkusVersions;
+ private Duration refreshInterval;
+
+ RemoteConfigBuilder() {
+ }
+
+ public RemoteConfigBuilder active(boolean active) {
+ this.active = active;
+ return this;
+ }
+
+ public RemoteConfigBuilder denyUserIds(List denyUserIds) {
+ this.denyUserIds = denyUserIds;
+ return this;
+ }
+
+ public RemoteConfigBuilder denyQuarkusVersions(List denyQuarkusVersions) {
+ this.denyQuarkusVersions = denyQuarkusVersions;
+ return this;
+ }
+
+ public RemoteConfigBuilder refreshInterval(Duration refreshInterval) {
+ this.refreshInterval = refreshInterval;
+ return this;
+ }
+
+ public RemoteConfig build() {
+ return new RemoteConfig(active, denyUserIds, denyQuarkusVersions, refreshInterval);
+ }
+
+ public String toString() {
+ return "RemoteConfig.RemoteConfigBuilder(active=" + this.active + ", denyUserIds=" + this.denyUserIds +
+ ", denyQuarkusVersions=" + this.denyQuarkusVersions + ", refreshInterval=" + this.refreshInterval + ")";
+ }
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/ContextBuilder.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/ContextBuilder.java
new file mode 100644
index 0000000000000..d22753ee843dd
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/ContextBuilder.java
@@ -0,0 +1,108 @@
+package io.quarkus.analytics.dto.segment;
+
+import java.util.AbstractMap;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+public class ContextBuilder {
+
+ public static final String PROP_NAME = "name";
+ public static final String PROP_VERSION = "version";
+ public static final String PROP_APP = "app";
+ public static final String PROP_IP = "ip";
+ public static final String PROP_LOCALE_COUNTRY = "locale_country";
+ public static final String PROP_LOCATION = "location";
+ public static final String PROP_OS = "os";
+ public static final String PROP_OS_ARCH = "os_arch";
+ public static final String PROP_TIMEZONE = "timezone";
+
+ /**
+ * Must not track ips.
+ * We don't want the server side to try to infer the IP if the field is not present in the payload.
+ * Sending invalid data is safer and makes sure it's really anonymous.
+ */
+ public static final String VALUE_NULL_IP = "0.0.0.0";
+
+ public static final String PROP_JAVA = "java";
+ public static final String PROP_VENDOR = "vendor";
+ public static final String PROP_GRAALVM = "graalvm";
+ public static final String PROP_JAVA_VERSION = "java_version";
+
+ public static final String PROP_BUILD = "build";
+ public static final String PROP_MAVEN_VERSION = "maven_version";
+ public static final String PROP_GRADLE_VERSION = "gradle_version";
+
+ public static final String PROP_QUARKUS = "quarkus";
+
+ public static final String PROP_CI = "ci";
+ public static final String PROP_CI_NAME = "name";
+ public static final String PROP_KUBERNETES = "kubernetes";
+ public static final String PROP_DETECTED = "detected";
+
+ private final Map map = new HashMap<>();
+
+ public ContextBuilder pair(String key, String value) {
+ map.put(key, value);
+ return this;
+ }
+
+ public ContextBuilder pair(String key, Object value) {
+ map.put(key, value);
+ return this;
+ }
+
+ public ContextBuilder pairs(Collection> entries) {
+ if (entries == null) {
+ return this;
+ }
+ entries.stream().forEach(entry -> map.put(entry.getKey(), entry.getValue()));
+ return this;
+ }
+
+ public MapValueBuilder mapPair(String key) {
+ return new MapValueBuilder(key);
+ }
+
+ public Map build() {
+ return map;
+ }
+
+ public class MapValueBuilder {
+ private final Map map = new HashMap<>();
+ private final String key;
+
+ private MapValueBuilder(String key) {
+ this.key = key;
+ }
+
+ public MapValueBuilder pair(String key, Object value) {
+ map.put(key, value);
+ return this;
+ }
+
+ public MapValueBuilder pairs(Collection> entries) {
+ if (entries == null) {
+ return this;
+ }
+ entries.stream().forEach(entry -> map.put(entry.getKey(), entry.getValue()));
+ return this;
+ }
+
+ public ContextBuilder build() {
+ ContextBuilder.this.pair(key, map);
+ return ContextBuilder.this;
+ }
+ }
+
+ public static class CommonSystemProperties {
+ public static final String APP_NAME = "app.name";
+ public static final String MAVEN_VERSION = "maven.version";
+ public static final String GRADLE_VERSION = "gradle.version";
+ public static final String QUARKUS_VERSION = "quarkus.version";
+ public static final String GRAALVM_VERSION_VERSION = "graalvm.version.version";
+ public static final String GRAALVM_VERSION_JAVA = "graalvm.version.java";
+ public static final String GRAALVM_VERSION_DISTRIBUTION = "graalvm.version.distribution";
+ }
+
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/SegmentContext.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/SegmentContext.java
new file mode 100644
index 0000000000000..2996137122914
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/SegmentContext.java
@@ -0,0 +1,7 @@
+package io.quarkus.analytics.dto.segment;
+
+import java.util.Map;
+
+public interface SegmentContext {
+ Map getContext();
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/Track.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/Track.java
new file mode 100644
index 0000000000000..bac34d69465a5
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/Track.java
@@ -0,0 +1,125 @@
+package io.quarkus.analytics.dto.segment;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class Track implements Serializable {
+ private String userId;
+ private TrackEventType event;
+ private TrackProperties properties;
+ private Map context;
+ private Instant timestamp;
+
+ public Track() {
+ }
+
+ public Track(String userId, TrackEventType event, TrackProperties properties, Map context,
+ Instant timestamp) {
+ this.userId = userId;
+ this.event = event;
+ this.properties = properties;
+ this.context = context;
+ this.timestamp = timestamp;
+ }
+
+ public static TrackBuilder builder() {
+ return new TrackBuilder();
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ @JsonProperty("userId")
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ public TrackEventType getEvent() {
+ return event;
+ }
+
+ public void setEvent(TrackEventType event) {
+ this.event = event;
+ }
+
+ public TrackProperties getProperties() {
+ return properties;
+ }
+
+ public void setProperties(TrackProperties properties) {
+ this.properties = properties;
+ }
+
+ public Map getContext() {
+ return context;
+ }
+
+ public void setContext(Map context) {
+ this.context = context;
+ }
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING)
+ public Instant getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(Instant timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public static class TrackBuilder {
+ private String userId;
+ private TrackEventType event;
+ private TrackProperties properties;
+ private Map context;
+ private Instant timestamp;
+
+ TrackBuilder() {
+ }
+
+ public TrackBuilder userId(String userId) {
+ this.userId = userId;
+ return this;
+ }
+
+ public TrackBuilder event(TrackEventType event) {
+ this.event = event;
+ return this;
+ }
+
+ public TrackBuilder properties(TrackProperties properties) {
+ this.properties = properties;
+ return this;
+ }
+
+ public TrackBuilder context(Map context) {
+ this.context = context;
+ return this;
+ }
+
+ public TrackBuilder timestamp(Instant timestamp) {
+ this.timestamp = timestamp;
+ return this;
+ }
+
+ public Track build() {
+ return new Track(userId, event, properties, context, timestamp);
+ }
+
+ public String toString() {
+ return "Track.TrackBuilder(userId=" + this.userId + ", event=" + this.event +
+ ", properties=" + this.properties + ", context=" + this.context +
+ ", timestamp=" + this.timestamp + ")";
+ }
+ }
+
+ public static class EventPropertyNames {
+ public static final String BUILD_DIAGNOSTICS = "build_diagnostics";
+ public static final String APP_EXTENSIONS = "app_extensions";
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/TrackEventType.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/TrackEventType.java
new file mode 100644
index 0000000000000..ea98fabd043af
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/TrackEventType.java
@@ -0,0 +1,6 @@
+package io.quarkus.analytics.dto.segment;
+
+public enum TrackEventType {
+ BUILD,
+ DEV_MODE
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/TrackProperties.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/TrackProperties.java
new file mode 100644
index 0000000000000..134693266b0a7
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/TrackProperties.java
@@ -0,0 +1,128 @@
+package io.quarkus.analytics.dto.segment;
+
+import java.io.Serializable;
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class TrackProperties implements Serializable {
+ private List appExtensions;
+
+ public TrackProperties() {
+ }
+
+ public TrackProperties(List appExtensions) {
+ this.appExtensions = appExtensions;
+ }
+
+ public static TrackPropertiesBuilder builder() {
+ return new TrackPropertiesBuilder();
+ }
+
+ @JsonProperty("app_extensions")
+ public List getAppExtensions() {
+ return appExtensions;
+ }
+
+ public void setAppExtensions(List appExtensions) {
+ this.appExtensions = appExtensions;
+ }
+
+ public static class TrackPropertiesBuilder {
+ private List appExtensions;
+
+ TrackPropertiesBuilder() {
+ }
+
+ public TrackPropertiesBuilder appExtensions(List appExtensions) {
+ this.appExtensions = appExtensions;
+ return this;
+ }
+
+ public TrackProperties build() {
+ return new TrackProperties(appExtensions);
+ }
+
+ public String toString() {
+ return "TrackProperty.TrackPropertyBuilder(appExtensions=" + this.appExtensions + ")";
+ }
+ }
+
+ public static class AppExtension {
+ private String groupId;
+ private String artifactId;
+ private String version;
+
+ public AppExtension() {
+ }
+
+ public AppExtension(String groupId, String artifactId, String version) {
+ this.groupId = groupId;
+ this.artifactId = artifactId;
+ this.version = version;
+ }
+
+ public static AppExtensionBuilder builder() {
+ return new AppExtensionBuilder();
+ }
+
+ @JsonProperty("group_id")
+ public String getGroupId() {
+ return groupId;
+ }
+
+ public void setGroupId(String groupId) {
+ this.groupId = groupId;
+ }
+
+ @JsonProperty("artifact_id")
+ public String getArtifactId() {
+ return artifactId;
+ }
+
+ public void setArtifactId(String artifactId) {
+ this.artifactId = artifactId;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public void setVersion(String version) {
+ this.version = version;
+ }
+
+ public static class AppExtensionBuilder {
+ private String groupId;
+ private String artifactId;
+ private String version;
+
+ AppExtensionBuilder() {
+ }
+
+ public AppExtensionBuilder groupId(String groupId) {
+ this.groupId = groupId;
+ return this;
+ }
+
+ public AppExtensionBuilder artifactId(String artifactId) {
+ this.artifactId = artifactId;
+ return this;
+ }
+
+ public AppExtensionBuilder version(String version) {
+ this.version = version;
+ return this;
+ }
+
+ public AppExtension build() {
+ return new AppExtension(groupId, artifactId, version);
+ }
+
+ public String toString() {
+ return "TrackProperty.AppExtension.AppExtensionBuilder(groupId=" + this.groupId +
+ ", artifactId=" + this.artifactId + ", version=" + this.version + ")";
+ }
+ }
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/ConfigClient.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/ConfigClient.java
new file mode 100644
index 0000000000000..5a63d18fce857
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/ConfigClient.java
@@ -0,0 +1,12 @@
+package io.quarkus.analytics.rest;
+
+import java.util.Optional;
+
+import io.quarkus.analytics.dto.config.AnalyticsRemoteConfig;
+
+/**
+ * Client to retrieve the analytics config from the upstream public location.
+ */
+public interface ConfigClient {
+ Optional getConfig();
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/RestClient.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/RestClient.java
new file mode 100644
index 0000000000000..22e187c4192a0
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/RestClient.java
@@ -0,0 +1,157 @@
+package io.quarkus.analytics.rest;
+
+import static io.quarkus.analytics.util.StringUtils.getObjectMapper;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import io.quarkus.analytics.dto.config.AnalyticsRemoteConfig;
+import io.quarkus.analytics.dto.config.Identity;
+import io.quarkus.analytics.dto.config.RemoteConfig;
+import io.quarkus.analytics.dto.segment.Track;
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+/**
+ * Client to post the analytics data to the upstream collection tool.
+ * We use plain REST API calls and not any wrapping library.
+ */
+public class RestClient implements ConfigClient, SegmentClient {
+
+ public static final int DEFAULT_TIMEOUT = 3000;// milliseconds
+ static final String IDENTITY_ENDPOINT = "v1/identify";
+ static final String TRACK_ENDPOINT = "v1/track";
+ static final URI CONFIG_URI = getUri(
+ "https://raw.githubusercontent.com/brunobat/tests/main/03656937-19FD-4C83-9066-C76631D445EA");//FIXME config location
+ private static final String AUTH_HEADER = getAuthHeader("SGGi49IwHoDEpE4NVBEHJDZ4uyzeoI4M"); //FIXME dev key
+ private static final int SEGMENT_POST_RESPONSE_CODE = 200; // sad but true
+
+ static URI getUri(final String uri) {
+ try {
+ return new URI(uri);
+ } catch (URISyntaxException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * All with the same authentication
+ *
+ * @return BAsic Auth Header value
+ * @param key
+ */
+ static String getAuthHeader(final String key) {
+ final String auth = key + ":";
+ return "Basic " + Base64.getEncoder().encodeToString(
+ auth.getBytes(StandardCharsets.ISO_8859_1));
+ }
+
+ private final MessageWriter log;
+
+ private final URI segmentIdentityUri;
+
+ private final URI segmentTraceUri;
+
+ private final int timeoutMs = Integer.getInteger("quarkus.analytics.timeout", DEFAULT_TIMEOUT);
+
+ public RestClient(MessageWriter log) {
+ this.log = log;
+ final String segmentBaseUri = System.getProperty("quarkus.analytics.uri.base", "https://api.segment.io/");
+ this.segmentIdentityUri = getUri(segmentBaseUri + IDENTITY_ENDPOINT);
+ this.segmentTraceUri = getUri(segmentBaseUri + TRACK_ENDPOINT);
+ }
+
+ public RestClient() {
+ this(MessageWriter.info());
+ }
+
+ @Override
+ public CompletableFuture> postIdentity(final Identity identity) {
+ return post(identity, segmentIdentityUri);
+ }
+
+ @Override
+ public CompletableFuture> postTrack(Track track) {
+ return post(track, segmentTraceUri);
+ }
+
+ @Override
+ public Optional getConfig() {
+ return getConfig(CONFIG_URI);
+ }
+
+ Optional getConfig(final URI uri) {
+ try {
+ final HttpClient httpClient = createHttpClient();
+
+ final HttpRequest request = createRequest(uri)
+ .GET()
+ .build();
+
+ final CompletableFuture> responseFuture = httpClient.sendAsync(
+ request,
+ HttpResponse.BodyHandlers.ofString());
+
+ final HttpResponse response = responseFuture.get(timeoutMs, MILLISECONDS);
+ final int statusCode = response.statusCode();
+
+ if (statusCode == SEGMENT_POST_RESPONSE_CODE) {
+ final String body = response.body();
+ return Optional.of(getObjectMapper().readValue(body, RemoteConfig.class));
+ }
+ return Optional.empty();
+ } catch (IOException | InterruptedException | ExecutionException | TimeoutException e) {
+ log.warn("[Quarkus build analytics] Analytics remote config not received. " +
+ e.getClass().getName() + ": " +
+ (e.getMessage() == null ? "(no message)" : e.getMessage()));
+ }
+ return Optional.empty();
+ }
+
+ CompletableFuture> post(final Serializable payload, final URI url) {
+ try {
+ final HttpClient httpClient = createHttpClient();
+
+ final String toSend = getObjectMapper().writeValueAsString(payload);
+ if (log.isDebugEnabled()) {
+ log.debug("[Quarkus build analytics] Analytics to send: " + toSend);
+ }
+ final HttpRequest request = createRequest(url)
+ .POST(HttpRequest.BodyPublishers.ofString(toSend))
+ .build();
+
+ return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString());
+ } catch (IOException e) {
+ log.warn("[Quarkus build analytics] Analytics not sent. " + e.getMessage());
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ private HttpClient createHttpClient() {
+ return HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.ALWAYS)
+ .connectTimeout(Duration.ofMillis(timeoutMs))
+ .build();
+ }
+
+ private HttpRequest.Builder createRequest(URI uri) {
+ return HttpRequest.newBuilder(uri)
+ .header("authorization", AUTH_HEADER)
+ .header("accept", "application/json")
+ .header("content-type", "application/json")
+ // the JDK client does not close the connection
+ .timeout(Duration.ofMillis(timeoutMs));
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/SegmentClient.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/SegmentClient.java
new file mode 100644
index 0000000000000..5cf786bb2e067
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/SegmentClient.java
@@ -0,0 +1,28 @@
+package io.quarkus.analytics.rest;
+
+import java.net.http.HttpResponse;
+import java.util.concurrent.CompletableFuture;
+
+import io.quarkus.analytics.dto.config.Identity;
+import io.quarkus.analytics.dto.segment.Track;
+
+/**
+ * Client to post the analytics data to the upstream collection tool.
+ */
+public interface SegmentClient {
+ /**
+ * Posts the anonymous identity to the upstream collection tool.
+ * Usually this is done once per user's UUID
+ *
+ * @param identity
+ */
+ CompletableFuture> postIdentity(final Identity identity);
+
+ /**
+ * Posts the trace to the upstream collection tool.
+ * This contains the actual data to be collected.
+ *
+ * @param track
+ */
+ CompletableFuture> postTrack(final Track track);
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/FileUtils.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/FileUtils.java
new file mode 100644
index 0000000000000..609f4431d333f
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/FileUtils.java
@@ -0,0 +1,86 @@
+package io.quarkus.analytics.util;
+
+import static io.quarkus.analytics.util.StringUtils.getObjectMapper;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+public class FileUtils {
+
+ /**
+ * Creates the file for the given path and the folder that contains it.
+ * Does nothing if it any of those already exist.
+ *
+ * @param path the file to create
+ *
+ * @throws IOException if the file operation fails
+ */
+ public static void createFileAndParent(Path path) throws IOException {
+ if (!Files.exists(path.getParent())) {
+ Files.createDirectories(path.getParent());
+ }
+ if (!Files.exists(path)) {
+ Files.createFile(path);
+ }
+ }
+
+ /**
+ * Writes a String to file
+ *
+ * @param content
+ * @param path
+ * @throws IOException
+ */
+ public static void append(String content, Path path) throws IOException {
+ try (Writer writer = Files.newBufferedWriter(path)) {
+ writer.append(content);
+ }
+ }
+
+ /**
+ * Writes an object, as JSON to file
+ *
+ * @param content
+ * @param path
+ * @param
+ * @throws IOException
+ */
+ public static void write(T content, Path path) throws IOException {
+ final ObjectMapper mapper = getObjectMapper();
+ mapper.writeValue(path.toFile(), content);
+ }
+
+ /**
+ * Writes an object, as JSON to file. Deletes previous file if it exists, before writing the new one.
+ *
+ * @param content
+ * @param path
+ * @param
+ * @throws IOException
+ */
+ public static void overwrite(T content, Path path) throws IOException {
+ if (Files.exists(path)) {
+ Files.delete(path);
+ }
+ createFileAndParent(path);
+ final ObjectMapper mapper = getObjectMapper();
+ mapper.writeValue(path.toFile(), content);
+ }
+
+ public static Optional read(Class clazz, Path path, MessageWriter log) throws IOException {
+ try {
+ final ObjectMapper mapper = getObjectMapper();
+ return Optional.of(mapper.readValue(path.toFile(), clazz));
+ } catch (Exception e) {
+ log.warn("[Quarkus build analytics] Could not read {}", path.toString(), e);
+ return Optional.empty();
+ }
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/StringUtils.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/StringUtils.java
new file mode 100644
index 0000000000000..0913613edd438
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/StringUtils.java
@@ -0,0 +1,54 @@
+package io.quarkus.analytics.util;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+public class StringUtils {
+ private static final String CONCAT_DELIMITER = "; ";
+
+ public static ObjectMapper getObjectMapper() {
+ final ObjectMapper mapper = (new ObjectMapper()).findAndRegisterModules();
+ mapper.registerModule(new JavaTimeModule());
+ mapper.setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE);
+ return mapper;
+ }
+
+ /**
+ * Anonymize sensitive contents.
+ * This is non-reversible.
+ *
+ * @param input Any String
+ * @return human-readable deterministic gibberish String based on the input
+ */
+ public static String hashSHA256(final String input) {
+ if (isBlank(input)) {
+ return "4veeW2AzC7pMKJliIxtropV9CxTn3rMRBBcAPHnepjU="; // hashed N/A
+ }
+ try {
+ final MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ final byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
+ return Base64.getEncoder().encodeToString(hash);
+ } catch (NoSuchAlgorithmException e) {
+ return "N/A";
+ }
+ }
+
+ private static boolean isBlank(String input) {
+ return input == null || input.isBlank();
+ }
+
+ public static String concat(List stringList) {
+ if (stringList.isEmpty()) {
+ return "N/A";
+ }
+ return stringList.stream().collect(Collectors.joining(CONCAT_DELIMITER));
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServicePromptTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServicePromptTest.java
new file mode 100644
index 0000000000000..6499c898fe865
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServicePromptTest.java
@@ -0,0 +1,90 @@
+package io.quarkus.analytics;
+
+import static io.quarkus.analytics.ConfigService.ACCEPTANCE_PROMPT;
+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.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.analytics.config.FileLocations;
+import io.quarkus.analytics.config.TestFileLocationsImpl;
+import io.quarkus.analytics.dto.config.LocalConfig;
+import io.quarkus.analytics.util.FileUtils;
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+class AnalyticsServicePromptTest extends AnalyticsServiceTestBase {
+
+ private FileLocations fileLocations;
+ private AnalyticsService service;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ fileLocations = new TestFileLocationsImpl(true);
+ service = new AnalyticsService(fileLocations, MessageWriter.info());
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ ((TestFileLocationsImpl) fileLocations).deleteAll();
+ service = null;
+ }
+
+ @Test
+ void testConsoleQuestion_yes() throws IOException {
+ assertFalse(fileLocations.getLocalConfigFile().toFile().exists());
+ service.buildAnalyticsUserInput((String prompt) -> {
+ assertEquals(ACCEPTANCE_PROMPT, prompt);
+ return "y";
+ });
+ assertTrue(fileLocations.getLocalConfigFile().toFile().exists());
+ Optional localConfig = FileUtils.read(LocalConfig.class, fileLocations.getLocalConfigFile(),
+ MessageWriter.info());
+ assertTrue(localConfig.isPresent());
+ assertTrue(localConfig.get().isActive());
+ }
+
+ @Test
+ void testConsoleQuestion_no() throws IOException {
+ assertFalse(fileLocations.getLocalConfigFile().toFile().exists());
+ service.buildAnalyticsUserInput((String prompt) -> {
+ assertEquals(ACCEPTANCE_PROMPT, prompt);
+ return "n";
+ });
+ assertTrue(fileLocations.getLocalConfigFile().toFile().exists());
+ Optional localConfig = FileUtils.read(LocalConfig.class, fileLocations.getLocalConfigFile(),
+ MessageWriter.info());
+ assertTrue(localConfig.isPresent());
+ assertFalse(localConfig.get().isActive());
+ }
+
+ @Test
+ void testConsoleQuestion_promptTimeout() throws IOException {
+ System.setProperty("quarkus.analytics.prompt.timeout", "0");
+ assertFalse(fileLocations.getLocalConfigFile().toFile().exists());
+ service.buildAnalyticsUserInput((String prompt) -> {
+ assertEquals(ACCEPTANCE_PROMPT, prompt);
+ return "n";
+ });
+ assertFalse(fileLocations.getLocalConfigFile().toFile().exists());
+ System.clearProperty("quarkus.analytics.prompt.timeout");
+ }
+
+ @Test
+ void testConsoleQuestion_AnalyticsDisabled() throws IOException {
+ System.setProperty("quarkus.analytics.disabled", "true");
+ assertFalse(fileLocations.getLocalConfigFile().toFile().exists());
+ service.buildAnalyticsUserInput((String prompt) -> {
+ fail("Prompt should be disabled");
+ return "n";
+ });
+ assertFalse(fileLocations.getLocalConfigFile().toFile().exists());
+ System.clearProperty("quarkus.analytics.disabled");
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTest.java
new file mode 100644
index 0000000000000..1f6aa46b97add
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTest.java
@@ -0,0 +1,169 @@
+package io.quarkus.analytics;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.notMatching;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_APP;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_BUILD;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_CI;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_GRAALVM;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_GRADLE_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_IP;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_JAVA;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_JAVA_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_KUBERNETES;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_LOCATION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_MAVEN_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_NAME;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_OS;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_QUARKUS;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_VENDOR;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.GRAALVM_VERSION_DISTRIBUTION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.GRAALVM_VERSION_JAVA;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.GRAALVM_VERSION_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.GRADLE_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.MAVEN_VERSION;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+
+import io.quarkus.analytics.config.FileLocations;
+import io.quarkus.analytics.config.TestFileLocationsImpl;
+import io.quarkus.analytics.dto.segment.TrackEventType;
+import io.quarkus.analytics.dto.segment.TrackProperties;
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+class AnalyticsServiceTest extends AnalyticsServiceTestBase {
+
+ private static final int MOCK_SERVER_PORT = 9300;
+ private static final String TEST_CONFIG_URL = "http://localhost:" + MOCK_SERVER_PORT + "/" + "config";
+ private static final WireMockServer wireMockServer = new WireMockServer(MOCK_SERVER_PORT);
+ private static FileLocations FILE_LOCATIONS;
+
+ @BeforeAll
+ static void start() throws IOException {
+ FILE_LOCATIONS = new TestFileLocationsImpl();
+ System.setProperty("quarkus.analytics.uri.base", "http://localhost:" + MOCK_SERVER_PORT + "/");
+ wireMockServer.start();
+ wireMockServer.stubFor(post(urlEqualTo("/v1/identify"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"ok\"}")));
+ wireMockServer.stubFor(post(urlEqualTo("/v1/track"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"ok\"}")));
+ wireMockServer.stubFor(get(urlEqualTo("/config"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ // .withBody(getObjectMapper().writeValueAsString(createRemoteConfig()))));
+ .withBody(
+ "{\"active\":true,\"deny_user_ids\":[],\"deny_quarkus_versions\":[],\"refresh_interval\":43200.000000000}")));
+
+ }
+
+ @AfterAll
+ static void stop() throws IOException {
+ wireMockServer.stop();
+ System.clearProperty("quarkus.analytics.uri.base");
+ ((TestFileLocationsImpl) FILE_LOCATIONS).deleteAll();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void createContext() throws IOException {
+ AnalyticsService service = new AnalyticsService(FILE_LOCATIONS, MessageWriter.info());
+
+ final Map contextMap = service.createContextMap(
+ mockApplicationModel(),
+ Map.of(GRAALVM_VERSION_DISTRIBUTION, "Company name",
+ GRAALVM_VERSION_VERSION, "20.2.0",
+ GRAALVM_VERSION_JAVA, "17.0.0",
+ MAVEN_VERSION, "3.9.0",
+ GRADLE_VERSION, "8.0.1"));
+
+ assertNotNull(contextMap);
+ final Map app = (Map) contextMap.get(PROP_APP);
+ assertNotNull(app);
+ assertEquals("yRAqgUsoDknuOICn/0zeC14YwZYAxPxcycCw6MgGYfI=", app.get(PROP_NAME));
+ assertEquals("Uue4h73VUgajaMLTPcYAM4Fo+YAZx4LQ0OEdaBbQTtg=", app.get(PROP_VERSION));
+ assertMapEntriesNotEmpty(1, (Map) contextMap.get(PROP_KUBERNETES));
+ assertMapEntriesNotEmpty(1, (Map) contextMap.get(PROP_CI));
+ final Map java = (Map) contextMap.get(PROP_JAVA);
+ assertNotNull(java);
+ assertNotNull(java.get(PROP_VENDOR));
+ assertNotNull(java.get(PROP_VERSION));
+ assertMapEntriesNotEmpty(3, (Map) contextMap.get(PROP_OS));
+ final Map build = (Map) contextMap.get(PROP_BUILD);
+ assertNotNull(build);
+ // in reality, these are not both set at the same time, but we set them in the test
+ assertEquals("3.9.0", build.get(PROP_MAVEN_VERSION));
+ assertEquals("8.0.1", build.get(PROP_GRADLE_VERSION));
+ final Map graalvm = (Map) contextMap.get(PROP_GRAALVM);
+ assertNotNull(graalvm);
+ assertEquals("Company name", graalvm.get(PROP_VENDOR));
+ assertEquals("20.2.0", graalvm.get(PROP_VERSION));
+ assertEquals("17.0.0", graalvm.get(PROP_JAVA_VERSION));
+ assertNotNull(contextMap.get("timezone"));
+ assertMapEntriesNotEmpty(1, (Map) contextMap.get(PROP_QUARKUS));
+ assertEquals("0.0.0.0", contextMap.get(PROP_IP));
+ assertNotNull(contextMap.get(PROP_LOCATION));
+ }
+
+ @Test
+ void createExtensionsPropertyValue() {
+ AnalyticsService service = new AnalyticsService(FILE_LOCATIONS, MessageWriter.info());
+ List extensionsPropertyValue = service
+ .createExtensionsPropertyValue(mockApplicationModel());
+
+ assertNotNull(extensionsPropertyValue);
+ assertEquals(2, extensionsPropertyValue.size());
+ assertEquals(Set.of("quarkus-openapi", "quarkus-opentelemetry-jaeger"),
+ extensionsPropertyValue.stream()
+ .map(TrackProperties.AppExtension::getArtifactId)
+ .collect(Collectors.toSet()));
+ }
+
+ @Test
+ void sendAnalyticsTest() throws IOException {
+ AnalyticsService service = new AnalyticsService(FILE_LOCATIONS, MessageWriter.info());
+ service.sendAnalytics(TrackEventType.BUILD,
+ mockApplicationModel(),
+ Map.of(),
+ new File(FILE_LOCATIONS.getFolder().toUri()));
+ service.close();
+ wireMockServer.verify(postRequestedFor(urlEqualTo("/v1/track"))
+ .withRequestBody(notMatching("null")));
+ assertTrue(new File(FILE_LOCATIONS.getFolder().toString() + "/" + FILE_LOCATIONS.lastTrackFileName()).exists());
+ }
+
+ private void assertMapEntriesNotEmpty(int size, Map map) {
+ assertNotNull(map);
+ assertEquals(size, map.size());
+ map.entrySet().forEach(entry -> {
+ assertNotNull(entry.getValue());
+ assertFalse(entry.getValue().toString().isEmpty(), entry.toString() + " value is empty");
+ });
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTestBase.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTestBase.java
new file mode 100644
index 0000000000000..54d324eef3a20
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTestBase.java
@@ -0,0 +1,86 @@
+package io.quarkus.analytics;
+
+import java.nio.file.Path;
+import java.util.List;
+
+import org.mockito.Mockito;
+
+import io.quarkus.bootstrap.model.ApplicationModel;
+import io.quarkus.bootstrap.model.PlatformImports;
+import io.quarkus.bootstrap.workspace.WorkspaceModule;
+import io.quarkus.bootstrap.workspace.WorkspaceModuleId;
+import io.quarkus.maven.dependency.ArtifactCoords;
+import io.quarkus.maven.dependency.DependencyFlags;
+import io.quarkus.maven.dependency.ResolvedDependencyBuilder;
+
+public abstract class AnalyticsServiceTestBase {
+
+ protected ApplicationModel mockApplicationModel() {
+ ApplicationModel applicationModel = Mockito.mock(ApplicationModel.class);
+
+ PlatformImports platforms = Mockito.mock(PlatformImports.class);
+ WorkspaceModule module = Mockito.mock(WorkspaceModule.class);
+
+ Mockito.when(applicationModel.getApplicationModule()).thenReturn(module);
+ Mockito.when(applicationModel.getApplicationModule().getId())
+ .thenReturn(new WorkspaceModuleId() {
+ @Override
+ public String getGroupId() {
+ return "build-group-id"; // the artifact being built
+ }
+
+ @Override
+ public String getArtifactId() {
+ return "build-artifact-id";
+ }
+
+ @Override
+ public String getVersion() {
+ return "1.0.0-TEST";
+ }
+ });
+ Mockito.when(applicationModel.getPlatforms()).thenReturn(platforms);
+ Mockito.when(platforms.getImportedPlatformBoms())
+ .thenReturn(List.of(ArtifactCoords.of(
+ "quarkus-group", // the quarkus being used on the build
+ "quarkus",
+ "",
+ "",
+ "1.0.0-QUARKUSTEST")));
+ Mockito.when(applicationModel.getDependencies())
+ .thenReturn(List.of(
+ ResolvedDependencyBuilder.newInstance()
+ .setGroupId("io.quarkus")
+ .setArtifactId("quarkus-openapi") // will be ok
+ .setVersion("1.0.0-QUARKUSTEST")
+ .setRuntimeExtensionArtifact()
+ .setResolvedPath(Path.of("path/to/artifact.jar"))
+ .setFlags(DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT)
+ .build(),
+ ResolvedDependencyBuilder.newInstance()
+ .setGroupId("not.quarkus")
+ .setArtifactId("not-quarkus-openapi") // not a public extension
+ .setVersion("1.0.0-QUARKUSTEST")
+ .setRuntimeExtensionArtifact()
+ .setResolvedPath(Path.of("path/to/artifact.jar"))
+ .setFlags(DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT)
+ .build(),
+ ResolvedDependencyBuilder.newInstance()
+ .setGroupId("io.quarkiverse")
+ .setArtifactId("quarkus-opentelemetry-jaeger") // will be ok
+ .setVersion("1.0.0-QUARKUSTEST")
+ .setRuntimeExtensionArtifact()
+ .setResolvedPath(Path.of("path/to/artifact.jar"))
+ .setFlags(DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT)
+ .build(),
+ ResolvedDependencyBuilder.newInstance()
+ .setGroupId("io.quarkus")
+ .setArtifactId("quarkus-resteasy") // not a TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT
+ .setVersion("1.0.0-QUARKUSTEST")
+ .setRuntimeExtensionArtifact()
+ .setResolvedPath(Path.of("path/to/artifact.jar"))
+ .setFlags(DependencyFlags.OPTIONAL)
+ .build()));
+ return applicationModel;
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceManualTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceManualTest.java
new file mode 100644
index 0000000000000..183b659c79728
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceManualTest.java
@@ -0,0 +1,113 @@
+package io.quarkus.analytics;
+
+import static io.quarkus.analytics.common.TestFilesUtils.backupExisting;
+import static io.quarkus.analytics.common.TestFilesUtils.restore;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.analytics.common.TestRestClient;
+import io.quarkus.analytics.config.FileLocations;
+import io.quarkus.analytics.config.FileLocationsImpl;
+import io.quarkus.analytics.dto.config.NoopRemoteConfig;
+import io.quarkus.analytics.dto.config.RemoteConfig;
+import io.quarkus.analytics.util.FileUtils;
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+@Disabled("For manual testing only")
+class ConfigServiceManualTest {
+
+ private final FileLocations fileLocations = FileLocationsImpl.INSTANCE;
+
+ @Test
+ void activeWithConfig() throws IOException {
+ backupExisting(getBackupConfigFile(),
+ fileLocations.getRemoteConfigFile());
+
+ RemoteConfig remoteConfig = RemoteConfig.builder()
+ .active(true)
+ .denyQuarkusVersions(Collections.emptyList())
+ .denyUserIds(Collections.emptyList())
+ .refreshInterval(Duration.ofHours(12)).build();
+
+ FileUtils.write(remoteConfig, fileLocations.getRemoteConfigFile());
+ long lastModified = fileLocations.getRemoteConfigFile().toFile().lastModified();
+
+ ConfigService configService = new ConfigService(new TestRestClient(remoteConfig),
+ AnonymousUserId.getInstance(fileLocations, MessageWriter.info()),
+ fileLocations,
+ MessageWriter.info());
+ assertNotNull(configService);
+ assertTrue(configService.isActive());
+ assertEquals(lastModified, fileLocations.getRemoteConfigFile().toFile().lastModified(), "File must not change");
+
+ restore(getBackupConfigFile(),
+ fileLocations.getRemoteConfigFile());
+ }
+
+ @Test
+ void activeWithoutConfig() throws IOException {
+ backupExisting(getBackupConfigFile(),
+ fileLocations.getRemoteConfigFile());
+
+ RemoteConfig remoteConfig = RemoteConfig.builder()
+ .active(true)
+ .denyQuarkusVersions(Collections.emptyList())
+ .denyUserIds(Collections.emptyList())
+ .refreshInterval(Duration.ZERO).build();
+
+ assertFalse(Files.exists(fileLocations.getRemoteConfigFile()));
+
+ ConfigService configService = new ConfigService(new TestRestClient(remoteConfig),
+ AnonymousUserId.getInstance(fileLocations, MessageWriter.info()),
+ fileLocations,
+ MessageWriter.info());
+ assertNotNull(configService);
+ assertTrue(configService.isActive());
+
+ assertTrue(Files.exists(fileLocations.getRemoteConfigFile()));
+
+ restore(getBackupConfigFile(),
+ fileLocations.getRemoteConfigFile());
+ }
+
+ @Test
+ void remoteConfigOff() throws IOException {
+ backupExisting(getBackupConfigFile(),
+ fileLocations.getRemoteConfigFile());
+
+ ConfigService configService = new ConfigService(new TestRestClient(NoopRemoteConfig.INSTANCE),
+ AnonymousUserId.getInstance(fileLocations, MessageWriter.info()),
+ fileLocations,
+ MessageWriter.info());
+ assertNotNull(configService);
+ assertFalse(configService.isActive());
+
+ restore(getBackupConfigFile(),
+ fileLocations.getRemoteConfigFile());
+ }
+
+ @Test
+ void isArtifactActive() {
+
+ }
+
+ @Test
+ void isArtifactInactive() {
+
+ }
+
+ private Path getBackupConfigFile() {
+ return fileLocations.getFolder().resolve("com.redhat.devtools.quarkus.analytics.back");
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceTest.java
new file mode 100644
index 0000000000000..8bf72b469bdf4
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceTest.java
@@ -0,0 +1,233 @@
+package io.quarkus.analytics;
+
+import static io.quarkus.analytics.ConfigService.ACCEPTANCE_PROMPT;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.analytics.common.TestRestClient;
+import io.quarkus.analytics.config.FileLocations;
+import io.quarkus.analytics.config.TestFileLocationsImpl;
+import io.quarkus.analytics.dto.config.NoopRemoteConfig;
+import io.quarkus.analytics.dto.config.RemoteConfig;
+import io.quarkus.analytics.util.FileUtils;
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+class ConfigServiceTest {
+
+ private FileLocations fileLocations;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ fileLocations = new TestFileLocationsImpl();
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ ((TestFileLocationsImpl) fileLocations).deleteAll();
+ }
+
+ @Test
+ void activeWithConfig() throws IOException {
+ ConfigService configService = createConfigService();
+
+ long lastModified = fileLocations.getRemoteConfigFile().toFile().lastModified();
+
+ assertNotNull(configService);
+ assertTrue(configService.isActive()); // if remote config not found, it will be downloaded (it shouldn't)
+ assertEquals(lastModified, fileLocations.getRemoteConfigFile().toFile().lastModified(), "File must not change");
+ }
+
+ @Test
+ void inactiveNoQuestionAsked() throws IOException {
+ deleteLocalConfigFile();
+
+ ConfigService configService = createConfigService();
+
+ assertNotNull(configService);
+ assertFalse(configService.isActive());
+ }
+
+ @Test
+ void inactiveUserAnsweredNo() throws IOException {
+ deleteLocalConfigFile();
+ FileUtils.append("{\"active\":false}", fileLocations.getLocalConfigFile());
+
+ ConfigService configService = createConfigService();
+
+ assertNotNull(configService);
+ assertFalse(configService.isActive());
+ }
+
+ @Test
+ void activeWithoutConfig() throws IOException {
+ RemoteConfig remoteConfig = RemoteConfig.builder()
+ .active(true)
+ .denyQuarkusVersions(Collections.emptyList())
+ .denyUserIds(Collections.emptyList())
+ .refreshInterval(Duration.ZERO).build();
+
+ assertFalse(Files.exists(fileLocations.getRemoteConfigFile()));
+
+ ConfigService configService = new ConfigService(new TestRestClient(remoteConfig),
+ AnonymousUserId.getInstance(fileLocations, MessageWriter.info()),
+ fileLocations,
+ MessageWriter.info());
+
+ assertNotNull(configService);
+ assertTrue(configService.isActive());
+ assertTrue(Files.exists(fileLocations.getRemoteConfigFile()));
+ }
+
+ @Test
+ void remoteConfigOff() throws IOException {
+ ConfigService configService = new ConfigService(new TestRestClient(NoopRemoteConfig.INSTANCE),
+ AnonymousUserId.getInstance(fileLocations, MessageWriter.info()),
+ fileLocations,
+ MessageWriter.info());
+
+ assertNotNull(configService);
+ assertFalse(configService.isActive());
+ }
+
+ @Test
+ void isArtifactActive() throws IOException {
+ ConfigService configService = new ConfigService(new TestRestClient(NoopRemoteConfig.INSTANCE),
+ AnonymousUserId.getInstance(fileLocations, MessageWriter.info()),
+ fileLocations,
+ MessageWriter.info());
+
+ assertTrue(configService.isArtifactActive("allow.groupId",
+ "allow.quarkus.version"));
+ assertTrue(configService.isArtifactActive("allow.groupId",
+ null));
+ assertFalse(configService.isArtifactActive("",
+ "allow.quarkus.version"));
+ assertFalse(configService.isArtifactActive(null,
+ null));
+ assertFalse(configService.isArtifactActive("io.quarkus.opentelemetry",
+ null));
+ }
+
+ @Test
+ void isQuarkusVersionActive() throws IOException {
+ AnonymousUserId userId = AnonymousUserId.getInstance(fileLocations, MessageWriter.info());
+ RemoteConfig remoteConfig = RemoteConfig.builder()
+ .active(true)
+ .denyQuarkusVersions(List.of("deny.quarkus.version", "deny.quarkus.version2"))
+ .denyUserIds(Collections.emptyList())
+ .refreshInterval(Duration.ofHours(12)).build();
+ ConfigService configService = new ConfigService(new TestRestClient(remoteConfig),
+ AnonymousUserId.getInstance(fileLocations, MessageWriter.info()),
+ fileLocations,
+ MessageWriter.info());
+
+ assertTrue(configService.isArtifactActive("allow.groupId",
+ "allow.quarkus.version"));
+ assertFalse(configService.isArtifactActive("allow.groupId",
+ "deny.quarkus.version"));
+ assertFalse(configService.isArtifactActive("allow.groupId",
+ "deny.quarkus.version2"));
+ }
+
+ @Test
+ void isUserIsDisabled() throws IOException {
+ AnonymousUserId userId = AnonymousUserId.getInstance(fileLocations, MessageWriter.info());
+ RemoteConfig remoteConfig = RemoteConfig.builder()
+ .active(true)
+ .denyQuarkusVersions(Collections.emptyList())
+ .denyUserIds(List.of(userId.getUuid()))
+ .refreshInterval(Duration.ofHours(12)).build();
+ ConfigService configService = new ConfigService(new TestRestClient(remoteConfig),
+ userId,
+ fileLocations,
+ MessageWriter.info());
+
+ assertFalse(configService.isUserEnabled(remoteConfig, userId.getUuid()));
+ assertFalse(configService.isActive());
+ }
+
+ @Test
+ void userAcceptance_alreadyAnswered() throws IOException {
+ ConfigService configService = createConfigService();
+ configService.userAcceptance(s -> {
+ assertTrue(Files.exists(fileLocations.getLocalConfigFile()), "Local config file must be present");
+ fail("User already answered");
+ return "y";
+ });
+ assertTrue(Files.exists(fileLocations.getLocalConfigFile()), "Local config file must be present");
+ }
+
+ @Test
+ void userAcceptance_yes() throws IOException {
+ deleteLocalConfigFile();
+
+ ConfigService configService = createConfigService();
+
+ configService.userAcceptance(s -> {
+ assertEquals(ACCEPTANCE_PROMPT, s);
+ return "y";
+ });
+ assertTrue(Files.exists(fileLocations.getLocalConfigFile()), "Local config file must be present");
+ assertTrue(configService.isActive());
+ }
+
+ @Test
+ void userAcceptance_no() throws IOException {
+ deleteLocalConfigFile();
+
+ ConfigService configService = createConfigService();
+
+ configService.userAcceptance(s -> {
+ assertEquals(ACCEPTANCE_PROMPT, s);
+ return "n";
+ });
+ assertTrue(Files.exists(fileLocations.getLocalConfigFile()), "Local config file must be present");
+ assertFalse(configService.isActive());
+ }
+
+ @Test
+ void userAcceptance_fail() throws IOException {
+ deleteLocalConfigFile();
+
+ ConfigService configService = createConfigService();
+
+ configService.userAcceptance(s -> {
+ throw new RuntimeException("User input failed");
+ });
+ assertFalse(Files.exists(fileLocations.getLocalConfigFile()), "Local config file cannot be present");
+ assertFalse(configService.isActive());
+ }
+
+ private void deleteLocalConfigFile() {
+ fileLocations.getLocalConfigFile().toFile().delete();
+ }
+
+ private ConfigService createConfigService() throws IOException {
+ RemoteConfig remoteConfig = RemoteConfig.builder()
+ .active(true)
+ .denyQuarkusVersions(Collections.emptyList())
+ .denyUserIds(Collections.emptyList())
+ .refreshInterval(Duration.ofHours(12)).build();
+
+ FileUtils.write(remoteConfig, fileLocations.getRemoteConfigFile());
+
+ ConfigService configService = new ConfigService(new TestRestClient(remoteConfig),
+ AnonymousUserId.getInstance(fileLocations, MessageWriter.info()),
+ fileLocations,
+ MessageWriter.info());
+ return configService;
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/UserIdManualTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/UserIdManualTest.java
new file mode 100644
index 0000000000000..c738e252c0404
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/UserIdManualTest.java
@@ -0,0 +1,34 @@
+package io.quarkus.analytics;
+
+import static io.quarkus.analytics.common.TestFilesUtils.backupExisting;
+import static io.quarkus.analytics.common.TestFilesUtils.restore;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.analytics.config.FileLocations;
+import io.quarkus.analytics.config.FileLocationsImpl;
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+@Disabled("For manual testing purposes only")
+class UserIdManualTest {
+
+ private final FileLocations fileLocations = FileLocationsImpl.INSTANCE;
+
+ @Test
+ void testUUID() throws IOException {
+ backupExisting(fileLocations.getFolder().resolve("anonymousId.back"), fileLocations.getUUIDFile());
+
+ AnonymousUserId user = AnonymousUserId.getInstance(fileLocations, MessageWriter.info());
+ assertNotNull(user.getUuid());
+ assertTrue(user.getUuid().length() > 15);
+ assertTrue(Files.exists(fileLocations.getUUIDFile()));
+
+ restore(fileLocations.getFolder().resolve("anonymousId.back"), fileLocations.getUUIDFile());
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/ContextTestData.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/ContextTestData.java
new file mode 100644
index 0000000000000..ea7009f3002a0
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/ContextTestData.java
@@ -0,0 +1,60 @@
+package io.quarkus.analytics.common;
+
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_APP;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_BUILD;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_GRAALVM;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_GRADLE_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_IP;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_JAVA;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_JAVA_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_LOCALE_COUNTRY;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_LOCATION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_MAVEN_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_NAME;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_OS;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_OS_ARCH;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_QUARKUS;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_TIMEZONE;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_VENDOR;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_VERSION;
+import static io.quarkus.analytics.dto.segment.ContextBuilder.VALUE_NULL_IP;
+
+import java.util.Map;
+
+import io.quarkus.analytics.dto.segment.ContextBuilder;
+
+public class ContextTestData {
+ public static Map createContext() {
+ return new ContextBuilder()
+ .mapPair(PROP_APP)
+ .pair(PROP_NAME, "app-name")
+ .build()
+ .mapPair(PROP_JAVA)
+ .pair(PROP_VENDOR, "Eclipse")
+ .pair(PROP_VERSION, "17")
+ .build()
+ .mapPair(PROP_GRAALVM)
+ .pair(PROP_VENDOR, "N/A")
+ .pair(PROP_VERSION, "N/A")
+ .pair(PROP_JAVA_VERSION, "N/A")
+ .build()
+ .mapPair(PROP_BUILD)
+ .pair(PROP_MAVEN_VERSION, "3.8,1")
+ .pair(PROP_GRADLE_VERSION, "N/A")
+ .build()
+ .mapPair(PROP_QUARKUS)
+ .pair(PROP_VERSION, "N/A")
+ .build()
+ .pair(PROP_IP, VALUE_NULL_IP)
+ .mapPair(PROP_LOCATION)
+ .pair(PROP_LOCALE_COUNTRY, "Portugal")
+ .build()
+ .mapPair(PROP_OS)
+ .pair(PROP_NAME, "arm64")
+ .pair(PROP_VERSION, "1234")
+ .pair(PROP_OS_ARCH, "MacOs")
+ .build()
+ .pair(PROP_TIMEZONE, "Europe/Lisbon")
+ .build();
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/TestFilesUtils.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/TestFilesUtils.java
new file mode 100644
index 0000000000000..e37219b157da8
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/TestFilesUtils.java
@@ -0,0 +1,28 @@
+package io.quarkus.analytics.common;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+
+public class TestFilesUtils {
+ public static void restore(Path backupPath, Path targetPath) throws IOException {
+ if (Files.exists(backupPath)) {
+ // restore existing
+ Files.move(backupPath,
+ targetPath,
+ StandardCopyOption.REPLACE_EXISTING);
+ } else {
+ // delete file generated in test
+ Files.delete(targetPath);
+ }
+ }
+
+ public static void backupExisting(Path backupPath, Path targetPath) throws IOException {
+ if (Files.exists(targetPath)) {
+ Files.move(targetPath,
+ backupPath,
+ StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/TestRestClient.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/TestRestClient.java
new file mode 100644
index 0000000000000..dca0d1d846f64
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/TestRestClient.java
@@ -0,0 +1,20 @@
+package io.quarkus.analytics.common;
+
+import java.util.Optional;
+
+import io.quarkus.analytics.dto.config.AnalyticsRemoteConfig;
+import io.quarkus.analytics.rest.ConfigClient;
+
+public class TestRestClient implements ConfigClient {
+
+ private AnalyticsRemoteConfig analyticsRemoteConfig;
+
+ public TestRestClient(AnalyticsRemoteConfig analyticsRemoteConfig) {
+ this.analyticsRemoteConfig = analyticsRemoteConfig;
+ }
+
+ @Override
+ public Optional getConfig() {
+ return Optional.of(analyticsRemoteConfig);
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/ExtensionsFilterTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/ExtensionsFilterTest.java
new file mode 100644
index 0000000000000..e433bc765f3e1
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/ExtensionsFilterTest.java
@@ -0,0 +1,27 @@
+package io.quarkus.analytics.config;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+class ExtensionsFilterTest {
+
+ private static final MessageWriter log = MessageWriter.info();
+
+ @Test
+ void discardTest() {
+ assertFalse(ExtensionsFilter.onlyPublic("must.not.be.authorized", log));
+ assertFalse(ExtensionsFilter.onlyPublic(null, log));
+ assertFalse(ExtensionsFilter.onlyPublic("", log));
+ }
+
+ @Test
+ void acceptTest() {
+ assertTrue(ExtensionsFilter.onlyPublic("io.quarkus", log));
+ assertTrue(ExtensionsFilter.onlyPublic("io.quarkus.something", log));
+ assertTrue(ExtensionsFilter.onlyPublic("io.quarkiverse", log));
+ assertTrue(ExtensionsFilter.onlyPublic("io.quarkiverse.something", log));
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/GroupIdFilterTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/GroupIdFilterTest.java
new file mode 100644
index 0000000000000..c867b20c3a2de
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/GroupIdFilterTest.java
@@ -0,0 +1,29 @@
+package io.quarkus.analytics.config;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.devtools.messagewriter.MessageWriter;
+
+class GroupIdFilterTest {
+
+ private static final MessageWriter log = MessageWriter.info();
+
+ @Test
+ void isAuthorizedGroupId() {
+ assertTrue(GroupIdFilter.isAuthorizedGroupId("must.be.authorized", log));
+ }
+
+ @Test
+ void isDeniedGroupId() {
+ assertFalse(GroupIdFilter.isAuthorizedGroupId(null, log));
+ assertFalse(GroupIdFilter.isAuthorizedGroupId("", log));
+ assertFalse(GroupIdFilter.isAuthorizedGroupId("io.quarkus", log));
+ assertFalse(GroupIdFilter.isAuthorizedGroupId("io.quarkus.something", log));
+ assertFalse(GroupIdFilter.isAuthorizedGroupId("io.quarkiverse", log));
+ assertFalse(GroupIdFilter.isAuthorizedGroupId("io.quarkiverse.something", log));
+ assertFalse(GroupIdFilter.isAuthorizedGroupId("org.acme", log));
+ assertFalse(GroupIdFilter.isAuthorizedGroupId("org.acme.something", log));
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/TestFileLocationsImpl.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/TestFileLocationsImpl.java
new file mode 100644
index 0000000000000..8a7d794535552
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/TestFileLocationsImpl.java
@@ -0,0 +1,70 @@
+package io.quarkus.analytics.config;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+
+public class TestFileLocationsImpl implements FileLocations {
+
+ private final Path tempDir;
+ private final Path uuidFile;
+ private final Path remoteConfigFile;
+ private final Path lastTryFile;
+ private final Path localConfigFile;
+
+ public TestFileLocationsImpl() throws IOException {
+ this(false);
+ }
+
+ public TestFileLocationsImpl(final boolean skipLocal) throws IOException {
+ tempDir = Files.createTempDirectory("temp_test_" + UUID.randomUUID().toString());
+ uuidFile = tempDir.resolve("anonymousId");
+ remoteConfigFile = tempDir.resolve("com.redhat.devtools.quarkus.remoteconfig");
+ lastTryFile = tempDir.resolve("com.redhat.devtools.quarkus.analytics.lasttry");
+ localConfigFile = tempDir.resolve("com.redhat.devtools.quarkus.localconfig");
+ if (!skipLocal) {
+ Files.createFile(localConfigFile);
+ Files.write(localConfigFile, "{\"active\":true}".getBytes());
+ }
+ }
+
+ @Override
+ public Path getFolder() {
+ return tempDir;
+ }
+
+ @Override
+ public Path getUUIDFile() {
+ return uuidFile;
+ }
+
+ @Override
+ public Path getRemoteConfigFile() {
+ return remoteConfigFile;
+ }
+
+ @Override
+ public Path getLastRemoteConfigTryFile() {
+ return lastTryFile;
+ }
+
+ @Override
+ public Path getLocalConfigFile() {
+ return localConfigFile;
+ }
+
+ @Override
+ public String lastTrackFileName() {
+ return "lasttrack.json";
+ }
+
+ public void deleteAll() throws IOException {
+ Files.deleteIfExists(uuidFile);
+ Files.deleteIfExists(remoteConfigFile);
+ Files.deleteIfExists(lastTryFile);
+ Files.deleteIfExists(localConfigFile);
+ Files.deleteIfExists(Path.of(tempDir.toString() + "/" + lastTrackFileName()));
+ Files.deleteIfExists(tempDir);
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/rest/RestClientFailTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/rest/RestClientFailTest.java
new file mode 100644
index 0000000000000..425d1371137e7
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/rest/RestClientFailTest.java
@@ -0,0 +1,94 @@
+package io.quarkus.analytics.rest;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static io.quarkus.analytics.common.ContextTestData.createContext;
+import static io.quarkus.analytics.rest.RestClient.IDENTITY_ENDPOINT;
+import static io.quarkus.analytics.util.StringUtils.getObjectMapper;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.http.HttpResponse;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.github.tomakehurst.wiremock.WireMockServer;
+
+import io.quarkus.analytics.dto.config.AnalyticsRemoteConfig;
+import io.quarkus.analytics.dto.config.Identity;
+
+class RestClientFailTest {
+
+ private static final int MOCK_SERVER_PORT = 9300;
+ private static final String TEST_CONFIG_URL = "http://localhost:" + MOCK_SERVER_PORT + "/" + "config";
+ private static final WireMockServer wireMockServer = new WireMockServer(MOCK_SERVER_PORT);
+
+ @BeforeAll
+ static void start() throws JsonProcessingException {
+ System.setProperty("quarkus.analytics.timeout", "200");
+ wireMockServer.start();
+ wireMockServer.stubFor(post(urlEqualTo("/" + IDENTITY_ENDPOINT))
+ .willReturn(aResponse()
+ .withStatus(201)
+ .withFixedDelay(5000) // must be bigger than timeout
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"ok\"}")));
+ wireMockServer.stubFor(get(urlEqualTo("/config"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withFixedDelay(5000) // must be bigger than timeout
+ .withHeader("Content-Type", "application/json")
+ .withBody(
+ "{\"active\":true,\"deny_user_ids\":[],\"deny_quarkus_versions\":[],\"refresh_interval\":43200.000000000}")));
+
+ }
+
+ @AfterAll
+ static void stop() {
+ wireMockServer.stop();
+ System.clearProperty("quarkus.analytics.timeout");
+ }
+
+ @Test
+ void postIdentityServerTTLExceeded()
+ throws URISyntaxException, JsonProcessingException {
+ RestClient restClient = new RestClient();
+ Identity identity = createIdentity();
+ CompletableFuture> post = restClient.post(
+ identity,
+ new URI("http://localhost:" + MOCK_SERVER_PORT + "/" + IDENTITY_ENDPOINT));
+ assertThrows(TimeoutException.class, () -> post.get(100, TimeUnit.MILLISECONDS).statusCode());
+ wireMockServer.verify(postRequestedFor(urlEqualTo("/" + IDENTITY_ENDPOINT))
+ .withRequestBody(equalToJson(getObjectMapper().writeValueAsString(identity))));
+ }
+
+ @Test
+ void getConfigServerTTLExceeded() throws URISyntaxException {
+ RestClient restClient = new RestClient();
+ Optional analyticsConfig = restClient.getConfig(new URI(TEST_CONFIG_URL));
+ assertNotNull(analyticsConfig);
+ assertEquals(Optional.empty(), analyticsConfig);
+ }
+
+ private Identity createIdentity() {
+ return Identity.builder()
+ .context(createContext())
+ .userId("12345678901234567890")
+ .timestamp(Instant.now()).build();
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/rest/RestClientTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/rest/RestClientTest.java
new file mode 100644
index 0000000000000..b22fd1c2e21f9
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/rest/RestClientTest.java
@@ -0,0 +1,155 @@
+package io.quarkus.analytics.rest;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static io.quarkus.analytics.common.ContextTestData.createContext;
+import static io.quarkus.analytics.rest.RestClient.IDENTITY_ENDPOINT;
+import static io.quarkus.analytics.rest.RestClient.TRACK_ENDPOINT;
+import static io.quarkus.analytics.util.StringUtils.getObjectMapper;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.github.tomakehurst.wiremock.WireMockServer;
+
+import io.quarkus.analytics.dto.config.AnalyticsRemoteConfig;
+import io.quarkus.analytics.dto.config.Identity;
+import io.quarkus.analytics.dto.config.RemoteConfig;
+import io.quarkus.analytics.dto.segment.Track;
+import io.quarkus.analytics.dto.segment.TrackEventType;
+import io.quarkus.analytics.dto.segment.TrackProperties;
+
+class RestClientTest {
+
+ private static final int MOCK_SERVER_PORT = 9300;
+ private static final String TEST_CONFIG_URL = "http://localhost:" + MOCK_SERVER_PORT + "/" + "config";
+ private static final WireMockServer wireMockServer = new WireMockServer(MOCK_SERVER_PORT);
+
+ @BeforeAll
+ static void start() throws JsonProcessingException {
+ wireMockServer.start();
+ wireMockServer.stubFor(post(urlEqualTo("/" + IDENTITY_ENDPOINT))
+ .willReturn(aResponse()
+ .withStatus(201)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"ok\"}")));
+ wireMockServer.stubFor(post(urlEqualTo("/" + TRACK_ENDPOINT))
+ .willReturn(aResponse()
+ .withStatus(201)
+ .withHeader("Content-Type", "application/json")
+ .withBody("{\"status\":\"ok\"}")));
+ wireMockServer.stubFor(get(urlEqualTo("/config"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/json")
+ // .withBody(getObjectMapper().writeValueAsString(createRemoteConfig()))));
+ .withBody(
+ "{\"active\":true,\"deny_user_ids\":[],\"deny_quarkus_versions\":[],\"refresh_interval\":43200.000000000}")));
+
+ }
+
+ private static RemoteConfig createRemoteConfig() {
+ return RemoteConfig.builder()
+ .active(true)
+ .denyQuarkusVersions(Collections.emptyList())
+ .denyUserIds(Collections.emptyList())
+ .refreshInterval(Duration.ofHours(12)).build();
+ }
+
+ @AfterAll
+ static void stop() {
+ wireMockServer.stop();
+ }
+
+ @Test
+ void getUri() {
+ assertNotNull(RestClient.CONFIG_URI);
+ }
+
+ @Test
+ void getAuthHeaderFromDocs() {
+ assertEquals("Basic YWJjMTIzOg==", RestClient.getAuthHeader("abc123"));
+ }
+
+ @Test
+ void postIdentity()
+ throws URISyntaxException, JsonProcessingException, ExecutionException, InterruptedException, TimeoutException {
+ RestClient restClient = new RestClient();
+ Identity identity = createIdentity();
+ CompletableFuture> post = restClient.post(identity,
+ new URI("http://localhost:" + MOCK_SERVER_PORT + "/" + IDENTITY_ENDPOINT));
+ assertEquals(201, post.get(1, TimeUnit.SECONDS).statusCode());
+ wireMockServer.verify(postRequestedFor(urlEqualTo("/" + IDENTITY_ENDPOINT))
+ .withRequestBody(equalToJson(getObjectMapper().writeValueAsString(identity))));
+ }
+
+ @Test
+ void postTrace()
+ throws URISyntaxException, JsonProcessingException, ExecutionException, InterruptedException, TimeoutException {
+ RestClient restClient = new RestClient();
+ Track track = createTrack();
+ CompletableFuture> post = restClient.post(track,
+ new URI("http://localhost:" + MOCK_SERVER_PORT + "/" + TRACK_ENDPOINT));
+ assertEquals(201, post.get(1, TimeUnit.SECONDS).statusCode());
+ wireMockServer.verify(postRequestedFor(urlEqualTo("/" + TRACK_ENDPOINT))
+ .withRequestBody(equalToJson(getObjectMapper().writeValueAsString(track))));
+ }
+
+ @Test
+ void getConfig() throws URISyntaxException {
+ RestClient restClient = new RestClient();
+ RemoteConfig expectedRemoteConfig = createRemoteConfig();
+ AnalyticsRemoteConfig analyticsRemoteConfig = restClient.getConfig(new URI(TEST_CONFIG_URL)).get();
+ assertNotNull(analyticsRemoteConfig);
+ assertEquals(expectedRemoteConfig.isActive(), analyticsRemoteConfig.isActive());
+ assertEquals(expectedRemoteConfig.getDenyUserIds().size(), analyticsRemoteConfig.getDenyUserIds().size());
+ assertEquals(expectedRemoteConfig.getDenyQuarkusVersions().size(),
+ analyticsRemoteConfig.getDenyQuarkusVersions().size());
+ assertEquals(expectedRemoteConfig.getRefreshInterval(), analyticsRemoteConfig.getRefreshInterval());
+ }
+
+ private Identity createIdentity() {
+ return Identity.builder()
+ .context(createContext())
+ .userId("12345678901234567890")
+ .timestamp(Instant.now()).build();
+ }
+
+ private Track createTrack() {
+ return Track.builder()
+ .userId("12345678901234567890")
+ .event(TrackEventType.BUILD)
+ .properties(TrackProperties.builder()
+ .appExtensions(List.of(
+ TrackProperties.AppExtension.builder()
+ .groupId("group1")
+ .artifactId("artifact1")
+ .version("1.0").build(),
+ TrackProperties.AppExtension.builder()
+ .groupId("group2")
+ .artifactId("artifact2")
+ .version("2.0").build()))
+ .build())
+ .build();
+ }
+}
diff --git a/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/util/StringUtilsTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/util/StringUtilsTest.java
new file mode 100644
index 0000000000000..abb190e2b75e4
--- /dev/null
+++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/util/StringUtilsTest.java
@@ -0,0 +1,26 @@
+package io.quarkus.analytics.util;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+class StringUtilsTest {
+
+ @Test
+ void hashSHA256() {
+ assertEquals("GshzUynU9pmVBB/NRIxbbQKjlFEauyo619PRnhAI7zM=",
+ StringUtils.hashSHA256("Something/12@"));
+ }
+
+ @Test
+ void hashSHA256NA() {
+ assertEquals("4veeW2AzC7pMKJliIxtropV9CxTn3rMRBBcAPHnepjU=",
+ StringUtils.hashSHA256("N/A"));
+ }
+
+ @Test
+ void hashSHA256Null() {
+ assertEquals("4veeW2AzC7pMKJliIxtropV9CxTn3rMRBBcAPHnepjU=",
+ StringUtils.hashSHA256(null));
+ }
+}
diff --git a/independent-projects/tools/pom.xml b/independent-projects/tools/pom.xml
index dedd922bc6be2..26a12c6a597d1 100644
--- a/independent-projects/tools/pom.xml
+++ b/independent-projects/tools/pom.xml
@@ -75,6 +75,7 @@
base-codestarts
devtools-common
utilities
+ analytics-common
diff --git a/integration-tests/gradle/src/main/resources/basic-composite-build-extension-project/extensions/example-extension/runtime/src/main/resources/META-INF/quarkus-extension.properties b/integration-tests/gradle/src/main/resources/basic-composite-build-extension-project/extensions/example-extension/runtime/src/main/resources/META-INF/quarkus-extension.properties
index 359b22fafaff4..2e1a6326847e1 100644
--- a/integration-tests/gradle/src/main/resources/basic-composite-build-extension-project/extensions/example-extension/runtime/src/main/resources/META-INF/quarkus-extension.properties
+++ b/integration-tests/gradle/src/main/resources/basic-composite-build-extension-project/extensions/example-extension/runtime/src/main/resources/META-INF/quarkus-extension.properties
@@ -1 +1 @@
-deployment-artifact=org.acme.extensions\:example-extension-deploymnet\:1.0
\ No newline at end of file
+deployment-artifact=org.acme.extensions\:example-extension-deployment\:1.0
\ No newline at end of file
diff --git a/integration-tests/gradle/src/test/java/io/quarkus/gradle/QuarkusGradleWrapperTestBase.java b/integration-tests/gradle/src/test/java/io/quarkus/gradle/QuarkusGradleWrapperTestBase.java
index 3ac4c82177109..d3f3d8a9254c5 100644
--- a/integration-tests/gradle/src/test/java/io/quarkus/gradle/QuarkusGradleWrapperTestBase.java
+++ b/integration-tests/gradle/src/test/java/io/quarkus/gradle/QuarkusGradleWrapperTestBase.java
@@ -50,11 +50,19 @@ public BuildResult runGradleWrapper(File projectDir, String... args) throws IOEx
public BuildResult runGradleWrapper(boolean expectError, File projectDir, String... args)
throws IOException, InterruptedException {
+ return runGradleWrapper(expectError, projectDir, true, args);
+ }
+
+ public BuildResult runGradleWrapper(boolean expectError, File projectDir, boolean skipAnalytics, String... args)
+ throws IOException, InterruptedException {
setupTestCommand();
List command = new ArrayList<>();
command.add(getGradleWrapperCommand());
addSystemProperties(command);
command.add("-Dorg.gradle.console=plain");
+ if (skipAnalytics) {
+ command.add("-Dquarkus.analytics.disabled=true");
+ }
if (configurationCacheEnable) {
command.add("--configuration-cache");
}
diff --git a/integration-tests/maven/src/test/java/io/quarkus/maven/it/BuildIT.java b/integration-tests/maven/src/test/java/io/quarkus/maven/it/BuildIT.java
index 3b6e062265ca2..5fc605a235fb7 100644
--- a/integration-tests/maven/src/test/java/io/quarkus/maven/it/BuildIT.java
+++ b/integration-tests/maven/src/test/java/io/quarkus/maven/it/BuildIT.java
@@ -36,7 +36,8 @@ void testQuarkusBootstrapWorkspaceDiscovery() throws Exception {
testDir = initProject("projects/project-with-extension", "projects/project-with-extension-build");
running = new RunningInvoker(testDir, false);
MavenProcessInvocationResult result = running
- .execute(List.of("clean", "compile", "quarkus:build", "-Dquarkus.bootstrap.workspace-discovery"), Map.of());
+ .execute(List.of("clean", "compile", "quarkus:build", "-Dquarkus.bootstrap.workspace-discovery",
+ "-Dquarkus.analytics.disabled=true"), Map.of());
assertThat(result.getProcess().waitFor()).isZero();
launch(TestContext.FAST_NO_PREFIX, "/app/hello/local-modules", new File(testDir, "runner"), "",
@@ -48,7 +49,8 @@ void testCustomTestSourceSets()
throws MavenInvocationException, IOException, InterruptedException {
testDir = initProject("projects/test-source-sets");
running = new RunningInvoker(testDir, false);
- MavenProcessInvocationResult result = running.execute(List.of("clean", "verify"), Map.of());
+ MavenProcessInvocationResult result = running.execute(List.of("clean", "verify", "-Dquarkus.analytics.disabled=true"),
+ Map.of());
assertThat(result.getProcess().waitFor()).isZero();
}
diff --git a/integration-tests/maven/src/test/java/io/quarkus/maven/it/DevMojoIT.java b/integration-tests/maven/src/test/java/io/quarkus/maven/it/DevMojoIT.java
index e1704995f07f8..bf51aa8b805c2 100644
--- a/integration-tests/maven/src/test/java/io/quarkus/maven/it/DevMojoIT.java
+++ b/integration-tests/maven/src/test/java/io/quarkus/maven/it/DevMojoIT.java
@@ -827,7 +827,8 @@ public void testThatTheApplicationIsReloadedOnConfigChange() throws MavenInvocat
running = new RunningInvoker(testDir, false);
final Properties mvnRunProps = new Properties();
mvnRunProps.setProperty("debug", "false");
- running.execute(Arrays.asList("compile", "quarkus:dev"), Collections.emptyMap(), mvnRunProps);
+ running.execute(Arrays.asList("compile", "quarkus:dev", "-Dquarkus.analytics.disabled=true"), Collections.emptyMap(),
+ mvnRunProps);
String resp = DevModeTestUtils.getHttpResponse();
@@ -860,7 +861,8 @@ public void testThatAddingConfigFileWorksCorrectly() throws MavenInvocationExcep
running = new RunningInvoker(testDir, false);
final Properties mvnRunProps = new Properties();
mvnRunProps.setProperty("debug", "false");
- running.execute(Arrays.asList("compile", "quarkus:dev"), Collections.emptyMap(), mvnRunProps);
+ running.execute(Arrays.asList("compile", "quarkus:dev", "-Dquarkus.analytics.disabled=true"), Collections.emptyMap(),
+ mvnRunProps);
String resp = DevModeTestUtils.getHttpResponse();
@@ -1147,7 +1149,8 @@ public void testThatTheApplicationIsReloadedOnDotEnvConfigChange() throws MavenI
running = new RunningInvoker(testDir, false);
final Properties mvnRunProps = new Properties();
mvnRunProps.setProperty("debug", "false");
- running.execute(Arrays.asList("compile", "quarkus:dev"), Collections.emptyMap(), mvnRunProps);
+ running.execute(Arrays.asList("compile", "quarkus:dev", "-Dquarkus.analytics.disabled=true"), Collections.emptyMap(),
+ mvnRunProps);
String resp = DevModeTestUtils.getHttpResponse();
@@ -1206,7 +1209,8 @@ public void testResourcesFromClasspath() throws MavenInvocationException, IOExce
RunningInvoker invoker = new RunningInvoker(testDir, false);
// to properly surface the problem of multiple classpath entries, we need to install the project to the local m2
- MavenProcessInvocationResult installInvocation = invoker.execute(List.of("clean", "install", "-DskipTests"),
+ MavenProcessInvocationResult installInvocation = invoker.execute(
+ List.of("clean", "install", "-DskipTests", "-Dquarkus.analytics.disabled=true"),
Collections.emptyMap());
assertThat(installInvocation.getProcess().waitFor(2, TimeUnit.MINUTES)).isTrue();
assertThat(installInvocation.getExecutionException()).isNull();
diff --git a/integration-tests/maven/src/test/java/io/quarkus/maven/it/JarRunnerIT.java b/integration-tests/maven/src/test/java/io/quarkus/maven/it/JarRunnerIT.java
index f246f83c0cdd4..780542b39e845 100644
--- a/integration-tests/maven/src/test/java/io/quarkus/maven/it/JarRunnerIT.java
+++ b/integration-tests/maven/src/test/java/io/quarkus/maven/it/JarRunnerIT.java
@@ -260,7 +260,8 @@ public void reaugmentationWithRemovedArtifacts() throws Exception {
// The default build
MavenProcessInvocationResult result = running
- .execute(List.of("package", "-DskipTests", "-Dquarkus.package.type=mutable-jar"), Map.of());
+ .execute(List.of("package", "-DskipTests", "-Dquarkus.package.type=mutable-jar",
+ "-Dquarkus.analytics.disabled=true"), Map.of());
await().atMost(1, TimeUnit.MINUTES).until(() -> result.getProcess() != null && !result.getProcess().isAlive());
assertThat(running.log()).containsIgnoringCase("BUILD SUCCESS");
running.stop();
diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml
index 93b5cb20923b2..5ef1e6a719139 100644
--- a/integration-tests/pom.xml
+++ b/integration-tests/pom.xml
@@ -27,6 +27,10 @@
+
+ org.codehaus.mojo
+ properties-maven-plugin
+
org.apache.maven.plugins
maven-enforcer-plugin
diff --git a/test-framework/maven/src/main/java/io/quarkus/maven/it/RunAndCheckMojoTestBase.java b/test-framework/maven/src/main/java/io/quarkus/maven/it/RunAndCheckMojoTestBase.java
index e34098ba939a4..f766699a1b8dd 100644
--- a/test-framework/maven/src/main/java/io/quarkus/maven/it/RunAndCheckMojoTestBase.java
+++ b/test-framework/maven/src/main/java/io/quarkus/maven/it/RunAndCheckMojoTestBase.java
@@ -50,15 +50,23 @@ protected void run(boolean performCompile, String... options) throws FileNotFoun
}
protected void run(boolean performCompile, LaunchMode mode, String... options)
+ throws MavenInvocationException, FileNotFoundException {
+ run(performCompile, mode, true, options);
+ }
+
+ protected void run(boolean performCompile, LaunchMode mode, boolean skipAnalytics, String... options)
throws FileNotFoundException, MavenInvocationException {
assertThat(testDir).isDirectory();
running = new RunningInvoker(testDir, false);
- final List args = new ArrayList<>(2 + options.length);
+ final List args = new ArrayList<>(3 + options.length);
if (performCompile) {
args.add("compile");
}
args.add("quarkus:" + mode.getDefaultProfile());
+ if (skipAnalytics) {
+ args.add("-Dquarkus.analytics.disabled=true");
+ }
boolean hasDebugOptions = false;
for (String option : options) {
args.add(option);
diff --git a/test-framework/maven/src/main/java/io/quarkus/maven/it/RunAndCheckWithAgentMojoTestBase.java b/test-framework/maven/src/main/java/io/quarkus/maven/it/RunAndCheckWithAgentMojoTestBase.java
index 30259246aab5a..97134d1c05084 100644
--- a/test-framework/maven/src/main/java/io/quarkus/maven/it/RunAndCheckWithAgentMojoTestBase.java
+++ b/test-framework/maven/src/main/java/io/quarkus/maven/it/RunAndCheckWithAgentMojoTestBase.java
@@ -52,7 +52,8 @@ protected void runAndCheck(String... options) throws FileNotFoundException, Mave
RunningInvoker running = new RunningInvoker(testDir, false);
MavenProcessInvocationResult result = running
- .execute(Arrays.asList("package", "-DskipTests"), Collections.emptyMap());
+ .execute(Arrays.asList("package", "-DskipTests", "-Dquarkus.analytics.disabled=true"),
+ Collections.emptyMap());
await().atMost(1, TimeUnit.MINUTES).until(() -> result.getProcess() != null && !result.getProcess().isAlive());
assertThat(running.log()).containsIgnoringCase("BUILD SUCCESS");