From 385a377d38628482bf7afe398fe275c22f699565 Mon Sep 17 00:00:00 2001 From: brunobat Date: Thu, 9 Mar 2023 11:50:14 +0000 Subject: [PATCH] Build time analytics --- bom/application/pom.xml | 5 + build-parent/pom.xml | 24 ++ .../pkg/builditem/NativeImageBuildItem.java | 11 + .../runner/bootstrap/AugmentActionImpl.java | 7 +- .../build.gradle.kts | 1 + .../gradle/gradle-application-plugin/pom.xml | 4 + .../main/java/io/quarkus/gradle/Prompter.java | 85 ++++++ .../java/io/quarkus/gradle/QuarkusPlugin.java | 40 ++- .../gradle/extension/AnalyticsExtension.java | 17 ++ .../gradle/tasks/QuarkusAnalytics.java | 79 ++++++ .../gradle/tasks/QuarkusAnalyticsClose.java | 22 ++ .../gradle/tasks/QuarkusAnalyticsDev.java | 33 +++ .../gradle/tasks/QuarkusAnalyticsProd.java | 25 ++ .../io/quarkus/gradle/QuarkusPluginTest.java | 37 ++- devtools/maven/pom.xml | 5 + .../quarkus/maven/BuildAnalyticsProvider.java | 70 +++++ .../main/java/io/quarkus/maven/BuildMojo.java | 13 +- .../main/java/io/quarkus/maven/DevMojo.java | 22 +- .../components/BootstrapSessionListener.java | 6 +- docs/src/main/asciidoc/build-analytics.adoc | 156 +++++++++++ .../quarkus/bootstrap/app/AugmentResult.java | 13 +- .../tools/analytics-common/pom.xml | 66 +++++ .../quarkus/analytics/AnalyticsService.java | 260 ++++++++++++++++++ .../io/quarkus/analytics/AnonymousUserId.java | 76 +++++ .../io/quarkus/analytics/ConfigService.java | 180 ++++++++++++ .../analytics/config/ExtensionsFilter.java | 24 ++ .../analytics/config/FileLocations.java | 40 +++ .../analytics/config/FileLocationsImpl.java | 57 ++++ .../analytics/config/GroupIdFilter.java | 21 ++ .../dto/config/AnalyticsLocalConfig.java | 9 + .../dto/config/AnalyticsRemoteConfig.java | 38 +++ .../analytics/dto/config/Identity.java | 99 +++++++ .../analytics/dto/config/LocalConfig.java | 23 ++ .../dto/config/NoopRemoteConfig.java | 37 +++ .../analytics/dto/config/RemoteConfig.java | 126 +++++++++ .../analytics/dto/segment/ContextBuilder.java | 108 ++++++++ .../analytics/dto/segment/SegmentContext.java | 7 + .../quarkus/analytics/dto/segment/Track.java | 125 +++++++++ .../analytics/dto/segment/TrackEventType.java | 6 + .../dto/segment/TrackProperties.java | 128 +++++++++ .../quarkus/analytics/rest/ConfigClient.java | 12 + .../io/quarkus/analytics/rest/RestClient.java | 154 +++++++++++ .../quarkus/analytics/rest/SegmentClient.java | 28 ++ .../io/quarkus/analytics/util/FileUtils.java | 86 ++++++ .../quarkus/analytics/util/StringUtils.java | 54 ++++ .../analytics/AnalyticsServiceTest.java | 247 +++++++++++++++++ .../analytics/ConfigServiceManualTest.java | 113 ++++++++ .../quarkus/analytics/ConfigServiceTest.java | 233 ++++++++++++++++ .../quarkus/analytics/UserIdManualTest.java | 34 +++ .../analytics/common/ContextTestData.java | 60 ++++ .../analytics/common/TestFilesUtils.java | 28 ++ .../analytics/common/TestRestClient.java | 20 ++ .../config/ExtensionsFilterTest.java | 23 ++ .../analytics/config/GroupIdFilterTest.java | 25 ++ .../config/TestFileLocationsImpl.java | 64 +++++ .../analytics/rest/RestClientFailTest.java | 94 +++++++ .../analytics/rest/RestClientTest.java | 155 +++++++++++ .../analytics/util/StringUtilsTest.java | 26 ++ independent-projects/tools/pom.xml | 1 + integration-tests/pom.xml | 4 + 60 files changed, 3555 insertions(+), 11 deletions(-) create mode 100644 devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/Prompter.java create mode 100644 devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/extension/AnalyticsExtension.java create mode 100644 devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalytics.java create mode 100644 devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsClose.java create mode 100644 devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsDev.java create mode 100644 devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsProd.java create mode 100644 devtools/maven/src/main/java/io/quarkus/maven/BuildAnalyticsProvider.java create mode 100644 docs/src/main/asciidoc/build-analytics.adoc create mode 100644 independent-projects/tools/analytics-common/pom.xml create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnalyticsService.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnonymousUserId.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/ConfigService.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/ExtensionsFilter.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/FileLocations.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/FileLocationsImpl.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/GroupIdFilter.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/AnalyticsLocalConfig.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/AnalyticsRemoteConfig.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/Identity.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/LocalConfig.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/NoopRemoteConfig.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/config/RemoteConfig.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/ContextBuilder.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/SegmentContext.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/Track.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/TrackEventType.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/dto/segment/TrackProperties.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/ConfigClient.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/RestClient.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/SegmentClient.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/FileUtils.java create mode 100644 independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/util/StringUtils.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTest.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceManualTest.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceTest.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/UserIdManualTest.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/ContextTestData.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/TestFilesUtils.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/common/TestRestClient.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/ExtensionsFilterTest.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/GroupIdFilterTest.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/TestFileLocationsImpl.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/rest/RestClientFailTest.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/rest/RestClientTest.java create mode 100644 independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/util/StringUtilsTest.java diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 44a354f97935ec..96f568ec8fc95a 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -6524,6 +6524,11 @@ quarkus-devtools-common ${project.version} + + io.quarkus + quarkus-analytics-common + ${project.version} + io.quarkus quarkus-apache-httpclient diff --git a/build-parent/pom.xml b/build-parent/pom.xml index aecfcfa96f8488..aa5c894e4d8d21 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -179,6 +179,10 @@ 1.1.1 3.1.0 + + + 1.1.0 + true @@ -431,6 +435,26 @@ maven-compiler-plugin ${compiler-plugin.version} + + org.codehaus.mojo + properties-maven-plugin + ${properties-maven-plugin.version} + + + + set-system-properties + + + + + quarkus.analytics.disabled + ${quarkus.analytics.disabled} + + + + + + maven-surefire-plugin ${version.surefire.plugin} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/pkg/builditem/NativeImageBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/pkg/builditem/NativeImageBuildItem.java index 982e951a8a9417..8a140099907ff5 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/pkg/builditem/NativeImageBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/pkg/builditem/NativeImageBuildItem.java @@ -1,6 +1,8 @@ package io.quarkus.deployment.pkg.builditem; import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; import io.quarkus.builder.item.SimpleBuildItem; @@ -54,5 +56,14 @@ public String getDistribution() { public static GraalVMVersion unknown() { return new GraalVMVersion("unknown", "unknown", -1, "unknown"); } + + public Map toMap() { + final Map graalVMVersion = new HashMap<>(); + graalVMVersion.put("graalvm.version.full", fullVersion); + graalVMVersion.put("graalvm.version.version", version); + graalVMVersion.put("graalvm.version.java", String.valueOf(javaVersion)); + graalVMVersion.put("graalvm.version.distribution", distribution); + return graalVMVersion; + } } } diff --git a/core/deployment/src/main/java/io/quarkus/runner/bootstrap/AugmentActionImpl.java b/core/deployment/src/main/java/io/quarkus/runner/bootstrap/AugmentActionImpl.java index a2007cf6732db6..19aad9fafd36ef 100644 --- a/core/deployment/src/main/java/io/quarkus/runner/bootstrap/AugmentActionImpl.java +++ b/core/deployment/src/main/java/io/quarkus/runner/bootstrap/AugmentActionImpl.java @@ -35,6 +35,7 @@ import io.quarkus.bootstrap.classloading.QuarkusClassLoader; import io.quarkus.builder.BuildChainBuilder; import io.quarkus.builder.BuildResult; +import io.quarkus.builder.diag.Diagnostic; import io.quarkus.builder.item.BuildItem; import io.quarkus.deployment.QuarkusAugmentor; import io.quarkus.deployment.builditem.ApplicationClassNameBuildItem; @@ -191,7 +192,11 @@ public AugmentResult createProductionApplication() { .map(a -> new ArtifactResult(a.getPath(), a.getType(), a.getMetadata())) .collect(Collectors.toList()), jarBuildItem != null ? jarBuildItem.toJarResult() : null, - nativeImageBuildItem != null ? nativeImageBuildItem.getPath() : null); + nativeImageBuildItem != null ? nativeImageBuildItem.getPath() : null, + result.getDiagnostics().stream() + .map(Diagnostic::toString) + .collect(Collectors.toList()), + nativeImageBuildItem != null ? nativeImageBuildItem.getGraalVMInfo().toMap() : Collections.emptyMap()); } private void writeDebugSourceFile(BuildResult result) { diff --git a/devtools/gradle/gradle-application-plugin/build.gradle.kts b/devtools/gradle/gradle-application-plugin/build.gradle.kts index b10d179997a7d9..3b14b942c8e010 100644 --- a/devtools/gradle/gradle-application-plugin/build.gradle.kts +++ b/devtools/gradle/gradle-application-plugin/build.gradle.kts @@ -4,6 +4,7 @@ plugins { dependencies { implementation(libs.smallrye.config.yaml) + implementation("io.quarkus:quarkus-analytics-common") testImplementation(libs.quarkus.project.core.extension.codestarts) } diff --git a/devtools/gradle/gradle-application-plugin/pom.xml b/devtools/gradle/gradle-application-plugin/pom.xml index 9ade7029a0fd2b..f509c3e126758b 100644 --- a/devtools/gradle/gradle-application-plugin/pom.xml +++ b/devtools/gradle/gradle-application-plugin/pom.xml @@ -36,6 +36,10 @@ io.quarkus quarkus-devtools-common + + io.quarkus + quarkus-analytics-common + io.quarkus quarkus-project-core-extension-codestarts diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/Prompter.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/Prompter.java new file mode 100644 index 00000000000000..ed1d49a5e91136 --- /dev/null +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/Prompter.java @@ -0,0 +1,85 @@ +package io.quarkus.gradle; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.function.Consumer; + +import org.aesh.readline.Readline; +import org.aesh.readline.ReadlineBuilder; +import org.aesh.readline.tty.terminal.TerminalConnection; +import org.aesh.terminal.tty.Signal; + +/** + * Prompt implementation. + * + * @author Clement Escoffier + */ +public class Prompter { + + private static class Prompt { + private final String prompt; + private final String defaultValue; + private final Consumer inputConsumer; + + public Prompt(String prompt, String defaultValue, Consumer inputConsumer) { + this.prompt = prompt; + this.defaultValue = defaultValue; + this.inputConsumer = inputConsumer; + } + } + + private final List prompts = new ArrayList<>(); + + public Prompter() throws IOException { + } + + public Prompter addPrompt(String prompt, Consumer inputConsumer) { + prompts.add(new Prompt(prompt, null, inputConsumer)); + return this; + } + + public Prompter addPrompt(String prompt, String defaultValue, Consumer inputConsumer) { + prompts.add(new Prompt(prompt, defaultValue, inputConsumer)); + return this; + } + + public void collectInput() throws IOException { + if (prompts.isEmpty()) { + return; + } + final TerminalConnection connection = new TerminalConnection(); + connection.setSignalHandler(interruptionSignalHandler()); + try { + read(connection, ReadlineBuilder.builder().enableHistory(false).build(), prompts.iterator()); + connection.openBlocking(); + } finally { + connection.close(); + } + } + + private static void read(TerminalConnection connection, Readline readline, Iterator prompts) { + final Prompt prompt = prompts.next(); + readline.readline(connection, prompt.prompt, input -> { + prompt.inputConsumer.accept( + (input == null || input.isBlank()) && prompt.defaultValue != null ? prompt.defaultValue : input); + if (!prompts.hasNext()) { + connection.close(); + } else { + read(connection, readline, prompts); + } + }); + } + + private Consumer interruptionSignalHandler() { + return new Consumer() { + @Override + public void accept(Signal signal) { + if (signal == Signal.INT) { + throw new RuntimeException("Process interrupted"); + } + } + }; + } +} diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/QuarkusPlugin.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/QuarkusPlugin.java index 1e6faad2163596..fdbd28c3c13f77 100644 --- a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/QuarkusPlugin.java +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/QuarkusPlugin.java @@ -1,5 +1,8 @@ package io.quarkus.gradle; +import static io.quarkus.gradle.extension.AnalyticsExtension.ANALYTICS_EXTENSION_NAME; +import static java.util.Collections.emptyMap; + import java.io.File; import java.nio.file.Path; import java.util.HashSet; @@ -34,12 +37,16 @@ import org.gradle.util.GradleVersion; import io.quarkus.gradle.dependency.ApplicationDeploymentClasspathBuilder; +import io.quarkus.gradle.extension.AnalyticsExtension; import io.quarkus.gradle.extension.QuarkusPluginExtension; import io.quarkus.gradle.extension.SourceSetExtension; import io.quarkus.gradle.tasks.Deploy; import io.quarkus.gradle.tasks.ImageBuild; import io.quarkus.gradle.tasks.ImagePush; import io.quarkus.gradle.tasks.QuarkusAddExtension; +import io.quarkus.gradle.tasks.QuarkusAnalyticsClose; +import io.quarkus.gradle.tasks.QuarkusAnalyticsDev; +import io.quarkus.gradle.tasks.QuarkusAnalyticsProd; import io.quarkus.gradle.tasks.QuarkusBuild; import io.quarkus.gradle.tasks.QuarkusBuildCacheableAppParts; import io.quarkus.gradle.tasks.QuarkusBuildDependencies; @@ -88,6 +95,9 @@ public class QuarkusPlugin implements Plugin { public static final String IMAGE_BUILD_TASK_NAME = "imageBuild"; public static final String IMAGE_PUSH_TASK_NAME = "imagePush"; public static final String DEPLOY_TASK_NAME = "deploy"; + public static final String QUARKUS_ANALYTICS_PROD_TASK_NAME = "quarkusAnalyticsProd"; + public static final String QUARKUS_ANALYTICS_DEV_TASK_NAME = "quarkusAnalyticsDev"; + public static final String QUARKUS_ANALYTICS_CLOSE = "quarkusAnalyticsClose"; @Deprecated public static final String BUILD_NATIVE_TASK_NAME = "buildNative"; @@ -129,6 +139,8 @@ public void apply(Project project) { final QuarkusPluginExtension quarkusExt = project.getExtensions().create(EXTENSION_NAME, QuarkusPluginExtension.class, project); + project.getExtensions().create(ANALYTICS_EXTENSION_NAME, AnalyticsExtension.class); + createConfigurations(project); registerTasks(project, quarkusExt); } @@ -203,8 +215,18 @@ public boolean isSatisfiedBy(Task t) { }); }); + final AnalyticsExtension analyticsData = project.getExtensions().getByType(AnalyticsExtension.class); + final TaskProvider quarkusAnalyticsProd = tasks.register( + QUARKUS_ANALYTICS_PROD_TASK_NAME, QuarkusAnalyticsProd.class); + TaskProvider quarkusBuild = tasks.register(QUARKUS_BUILD_TASK_NAME, QuarkusBuild.class, build -> { build.dependsOn(quarkusBuildDependencies, quarkusBuildCacheableAppParts); + build.doLast(task -> { + quarkusAnalyticsProd.configure(analyticsDataTask -> { + analyticsDataTask.setGraalVMInfo(analyticsData.getGraalVMInfo()); + }); + }); + build.finalizedBy(quarkusAnalyticsProd); build.getOutputs().doNotCacheIf( "Only collects and combines the outputs of " + QUARKUS_BUILD_APP_PARTS_TASK_NAME + " and " + QUARKUS_BUILD_DEP_TASK_NAME + ", see 'cacheLargeArtifacts' in the 'quarkus' Gradle extension " + @@ -223,8 +245,16 @@ public boolean isSatisfiedBy(Task t) { tasks.register(DEPLOY_TASK_NAME, Deploy.class, task -> task.finalizedBy(quarkusBuild)); - TaskProvider quarkusDev = tasks.register(QUARKUS_DEV_TASK_NAME, QuarkusDev.class, devRuntimeDependencies, + final TaskProvider quarkusAnalyticsDev = tasks.register( + QUARKUS_ANALYTICS_DEV_TASK_NAME, QuarkusAnalyticsDev.class); + quarkusAnalyticsDev.configure(task -> { + task.setGraalVMInfo(emptyMap()); + }); + + TaskProvider quarkusDev = tasks.register(QUARKUS_DEV_TASK_NAME, QuarkusDev.class, + devRuntimeDependencies, quarkusExt); + TaskProvider quarkusRemoteDev = tasks.register(QUARKUS_REMOTE_DEV_TASK_NAME, QuarkusRemoteDev.class, devRuntimeDependencies, quarkusExt); TaskProvider quarkusTest = tasks.register(QUARKUS_TEST_TASK_NAME, QuarkusTest.class, @@ -289,7 +319,8 @@ public boolean isSatisfiedBy(Task t) { quarkusDev.configure(task -> { task.dependsOn(classesTask, resourcesTask, testClassesTask, testResourcesTask, quarkusGenerateCodeDev, - quarkusGenerateCodeTests); + quarkusGenerateCodeTests, + quarkusAnalyticsDev); }); quarkusRemoteDev.configure(task -> { task.dependsOn(classesTask, resourcesTask); @@ -402,6 +433,11 @@ public void execute(Task task) { }); tasks.named("compileTestKotlin", task -> task.dependsOn(quarkusGenerateCodeTests)); }); + + TaskProvider analyticsClose = tasks.register(QUARKUS_ANALYTICS_CLOSE, + QuarkusAnalyticsClose.class); + project.getTasks().getByName("build").finalizedBy(analyticsClose.get()); + } private static void configureGenerateCodeTask(QuarkusGenerateCode task, String generateSourcesDir) { diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/extension/AnalyticsExtension.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/extension/AnalyticsExtension.java new file mode 100644 index 00000000000000..45278ccc0859bf --- /dev/null +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/extension/AnalyticsExtension.java @@ -0,0 +1,17 @@ +package io.quarkus.gradle.extension; + +import java.util.Map; + +public class AnalyticsExtension { + public static final String ANALYTICS_EXTENSION_NAME = AnalyticsExtension.class.getName(); + + private Map graalVMInfo; + + public Map getGraalVMInfo() { + return graalVMInfo; + } + + public void setGraalVMInfo(Map graalVMInfo) { + this.graalVMInfo = graalVMInfo; + } +} diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalytics.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalytics.java new file mode 100644 index 00000000000000..9bd90889f32412 --- /dev/null +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalytics.java @@ -0,0 +1,79 @@ +package io.quarkus.gradle.tasks; + +import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.GRADLE_VERSION; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.inject.Inject; + +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.TaskAction; + +import io.quarkus.analytics.AnalyticsService; +import io.quarkus.analytics.config.FileLocationsImpl; +import io.quarkus.analytics.dto.segment.TrackEventType; +import io.quarkus.gradle.Prompter; + +public abstract class QuarkusAnalytics extends QuarkusTask { + + private AnalyticsService analyticsService = AnalyticsService.getInstance( + FileLocationsImpl.INSTANCE, + new GradleMessageWriter(getLogger())); + + @Optional + @Input + private Map graalVMInfo; + + @Inject + public QuarkusAnalytics() { + super("Anonymous analytics for quarkus build"); + } + + QuarkusAnalytics(String description) { + super(description); + } + + @TaskAction + public void sendAnalytics() { + final Map buildInfo = new HashMap<>(); + if (graalVMInfo != null) { + buildInfo.putAll(graalVMInfo); + } + + buildInfo.put(GRADLE_VERSION, getProject().getGradle().getGradleVersion()); + + analyticsService.sendAnalytics(getTrackEventType(), extension().getApplicationModel(), buildInfo, + getProject().getBuildDir()); + } + + public void buildAnalyticsUserInput() { + analyticsService.buildAnalyticsUserInput((String prompt) -> { + try { + final AtomicBoolean analyticsOk = new AtomicBoolean(false); + final Prompter prompter = new Prompter(); + prompter.addPrompt(prompt + "\n\n", input -> analyticsOk.set("y".equalsIgnoreCase(input.trim()))); + prompter.collectInput(); + return analyticsOk.get(); + } catch (IOException e) { + getLogger().debug("Failed to collect user input for analytics", e); + return false; + } + }); + } + + public Map getGraalVMInfo() { + return graalVMInfo; + } + + public void setGraalVMInfo(Map graalVMInfo) { + this.graalVMInfo = graalVMInfo; + } + + @Internal + abstract TrackEventType getTrackEventType(); +} diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsClose.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsClose.java new file mode 100644 index 00000000000000..0a273a1607e250 --- /dev/null +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsClose.java @@ -0,0 +1,22 @@ +package io.quarkus.gradle.tasks; + +import javax.inject.Inject; + +import org.gradle.api.DefaultTask; +import org.gradle.api.tasks.TaskAction; + +import io.quarkus.analytics.AnalyticsService; + +public abstract class QuarkusAnalyticsClose extends DefaultTask { + + @Inject + public QuarkusAnalyticsClose() { + } + + @TaskAction + public void close() { + if (AnalyticsService.isInstantiated()) { + AnalyticsService.getInstance().cleanup(); + } + } +} diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsDev.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsDev.java new file mode 100644 index 00000000000000..c889ea026241b4 --- /dev/null +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsDev.java @@ -0,0 +1,33 @@ +package io.quarkus.gradle.tasks; + +import javax.inject.Inject; + +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.TaskAction; + +import io.quarkus.analytics.dto.segment.TrackEventType; + +public abstract class QuarkusAnalyticsDev extends QuarkusAnalytics { + + @Inject + public QuarkusAnalyticsDev() { + super("Anonymous analytics for quarkus dev mode"); + } + + QuarkusAnalyticsDev(String description) { + super(description); + } + + @Override + @Internal + TrackEventType getTrackEventType() { + return TrackEventType.DEV_MODE_BUILD; + } + + @Override + @TaskAction + public void sendAnalytics() { + super.buildAnalyticsUserInput(); + super.sendAnalytics(); + } +} diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsProd.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsProd.java new file mode 100644 index 00000000000000..f941545b219c22 --- /dev/null +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusAnalyticsProd.java @@ -0,0 +1,25 @@ +package io.quarkus.gradle.tasks; + +import javax.inject.Inject; + +import org.gradle.api.tasks.Internal; + +import io.quarkus.analytics.dto.segment.TrackEventType; + +public abstract class QuarkusAnalyticsProd extends QuarkusAnalytics { + + @Inject + public QuarkusAnalyticsProd() { + super("Anonymous analytics for prod quarkus build"); + } + + QuarkusAnalyticsProd(String description) { + super(description); + } + + @Override + @Internal + TrackEventType getTrackEventType() { + return TrackEventType.PROD_BUILD; + } +} diff --git a/devtools/gradle/gradle-application-plugin/src/test/java/io/quarkus/gradle/QuarkusPluginTest.java b/devtools/gradle/gradle-application-plugin/src/test/java/io/quarkus/gradle/QuarkusPluginTest.java index d7f672ffc5ad8a..8b2e0ce5f11572 100644 --- a/devtools/gradle/gradle-application-plugin/src/test/java/io/quarkus/gradle/QuarkusPluginTest.java +++ b/devtools/gradle/gradle-application-plugin/src/test/java/io/quarkus/gradle/QuarkusPluginTest.java @@ -1,5 +1,8 @@ package io.quarkus.gradle; +import static io.quarkus.gradle.QuarkusPlugin.QUARKUS_ANALYTICS_DEV_TASK_NAME; +import static io.quarkus.gradle.QuarkusPlugin.QUARKUS_ANALYTICS_PROD_TASK_NAME; +import static io.quarkus.gradle.QuarkusPlugin.QUARKUS_BUILD_TASK_NAME; import static org.assertj.core.api.Assertions.assertThat; import static org.gradle.testkit.runner.TaskOutcome.SUCCESS; import static org.junit.jupiter.api.Assertions.*; @@ -11,9 +14,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import org.gradle.api.Project; import org.gradle.api.Task; +import org.gradle.api.internal.tasks.DefaultTaskDependency; import org.gradle.api.plugins.BasePlugin; import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.provider.Provider; @@ -38,7 +43,7 @@ public void shouldCreateTasks() { TaskContainer tasks = project.getTasks(); assertNotNull(tasks.getByName(QuarkusPlugin.QUARKUS_BUILD_APP_PARTS_TASK_NAME)); assertNotNull(tasks.getByName(QuarkusPlugin.QUARKUS_BUILD_DEP_TASK_NAME)); - assertNotNull(tasks.getByName(QuarkusPlugin.QUARKUS_BUILD_TASK_NAME)); + assertNotNull(tasks.getByName(QUARKUS_BUILD_TASK_NAME)); assertNotNull(tasks.getByName(QuarkusPlugin.QUARKUS_DEV_TASK_NAME)); assertNotNull(tasks.getByName(QuarkusPlugin.BUILD_NATIVE_TASK_NAME)); assertNotNull(tasks.getByName(QuarkusPlugin.LIST_EXTENSIONS_TASK_NAME)); @@ -46,6 +51,8 @@ public void shouldCreateTasks() { assertNotNull(tasks.getByName(QuarkusPlugin.IMAGE_BUILD_TASK_NAME)); assertNotNull(tasks.getByName(QuarkusPlugin.IMAGE_PUSH_TASK_NAME)); assertNotNull(tasks.getByName(QuarkusPlugin.DEPLOY_TASK_NAME)); + assertNotNull(tasks.getByName(QUARKUS_ANALYTICS_PROD_TASK_NAME)); + assertNotNull(tasks.getByName(QUARKUS_ANALYTICS_DEV_TASK_NAME)); } @Test @@ -57,7 +64,7 @@ public void shouldMakeAssembleDependOnQuarkusBuild() { TaskContainer tasks = project.getTasks(); Task assemble = tasks.getByName(BasePlugin.ASSEMBLE_TASK_NAME); assertThat(getDependantProvidedTaskName(assemble)) - .contains(QuarkusPlugin.QUARKUS_BUILD_TASK_NAME); + .contains(QUARKUS_BUILD_TASK_NAME); } @Test @@ -77,14 +84,14 @@ public void shouldMakeQuarkusDevAndQuarkusBuildDependOnClassesTask() { assertThat(getDependantProvidedTaskName(quarkusDepBuild)) .isEmpty(); - Task quarkusBuild = tasks.getByName(QuarkusPlugin.QUARKUS_BUILD_TASK_NAME); + Task quarkusBuild = tasks.getByName(QUARKUS_BUILD_TASK_NAME); assertThat(getDependantProvidedTaskName(quarkusBuild)) .contains(QuarkusPlugin.QUARKUS_BUILD_APP_PARTS_TASK_NAME) .contains(QuarkusPlugin.QUARKUS_BUILD_APP_PARTS_TASK_NAME); Task quarkusDev = tasks.getByName(QuarkusPlugin.QUARKUS_DEV_TASK_NAME); assertThat(getDependantProvidedTaskName(quarkusDev)) - .contains(JavaPlugin.CLASSES_TASK_NAME); + .contains(JavaPlugin.CLASSES_TASK_NAME, QUARKUS_ANALYTICS_DEV_TASK_NAME); } @Test @@ -165,6 +172,17 @@ public void shouldNotFailOnProjectDependenciesWithoutMain(@TempDir Path testProj assertEquals(SUCCESS, result.task(":quarkus:quarkusGenerateCode").getOutcome()); } + @Test + public void analyticsAfterBuild() { + Project project = ProjectBuilder.builder().build(); + project.getPluginManager().apply(QuarkusPlugin.ID); + + TaskContainer tasks = project.getTasks(); + Task quarkusBuild = tasks.getByName(QUARKUS_BUILD_TASK_NAME); + assertThat(getFinalizedByTaskName(quarkusBuild)) + .contains(QuarkusPlugin.QUARKUS_ANALYTICS_PROD_TASK_NAME); + } + private static List getDependantProvidedTaskName(Task task) { List dependantTaskNames = new ArrayList<>(); for (Object t : task.getDependsOn()) { @@ -176,4 +194,15 @@ private static List getDependantProvidedTaskName(Task task) { } return dependantTaskNames; } + + private static List getFinalizedByTaskName(Task task) { + try { + return ((DefaultTaskDependency) task.getFinalizedBy()).getMutableValues().stream() + .map(o -> ((Provider) o).get().getName()) + .collect(Collectors.toList()); + } catch (ClassCastException e) { + // Nothing to do here + return null; + } + } } diff --git a/devtools/maven/pom.xml b/devtools/maven/pom.xml index 6d208fd254f51d..25ea08cca9a49d 100644 --- a/devtools/maven/pom.xml +++ b/devtools/maven/pom.xml @@ -17,6 +17,7 @@ ${project.version} + io.quarkus @@ -56,6 +57,10 @@ + + io.quarkus + quarkus-analytics-common + org.apache.maven maven-plugin-api diff --git a/devtools/maven/src/main/java/io/quarkus/maven/BuildAnalyticsProvider.java b/devtools/maven/src/main/java/io/quarkus/maven/BuildAnalyticsProvider.java new file mode 100644 index 00000000000000..0880ee1ed9ba15 --- /dev/null +++ b/devtools/maven/src/main/java/io/quarkus/maven/BuildAnalyticsProvider.java @@ -0,0 +1,70 @@ +package io.quarkus.maven; + +import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.MAVEN_VERSION; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.apache.maven.rtinfo.RuntimeInformation; + +import io.quarkus.analytics.AnalyticsService; +import io.quarkus.analytics.config.FileLocationsImpl; +import io.quarkus.analytics.dto.segment.TrackEventType; +import io.quarkus.bootstrap.model.ApplicationModel; +import io.quarkus.platform.tools.maven.MojoMessageWriter; + +@Singleton +@Named +public class BuildAnalyticsProvider { + + private RuntimeInformation runtimeInformation; + + private AnalyticsService analyticsService; + + private Log log; + + @Inject + public BuildAnalyticsProvider(RuntimeInformation runtimeInformation) { + this.runtimeInformation = runtimeInformation; + analyticsService = AnalyticsService.getInstance(FileLocationsImpl.INSTANCE, new MojoMessageWriter(getLog())); + } + + public void sendAnalytics(TrackEventType trackEventType, + ApplicationModel applicationModel, + Map graalVMInfo, + File localBuildDir) { + final long start = System.currentTimeMillis(); + + final Map buildInfo = new HashMap<>(); + buildInfo.putAll(graalVMInfo); + buildInfo.put(MAVEN_VERSION, runtimeInformation.getMavenVersion()); + analyticsService.sendAnalytics(trackEventType, applicationModel, buildInfo, localBuildDir); + + if (getLog().isDebugEnabled()) { + getLog().debug("Analytics took " + (System.currentTimeMillis() - start) + "ms"); + } + } + + public void buildAnalyticsUserInput(Function analyticsEnabledSupplier) { + analyticsService.buildAnalyticsUserInput(analyticsEnabledSupplier); + } + + public void close() { + analyticsService.cleanup(); + } + + private Log getLog() { + if (log == null) { + log = new SystemStreamLog(); + } + return log; + } +} diff --git a/devtools/maven/src/main/java/io/quarkus/maven/BuildMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/BuildMojo.java index 0efc3d5f20a7c9..7160600525fc3d 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/BuildMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/BuildMojo.java @@ -25,6 +25,7 @@ import org.apache.maven.project.MavenProjectHelper; import org.eclipse.aether.repository.RemoteRepository; +import io.quarkus.analytics.dto.segment.TrackEventType; import io.quarkus.bootstrap.app.AugmentAction; import io.quarkus.bootstrap.app.AugmentResult; import io.quarkus.bootstrap.app.CuratedApplication; @@ -44,6 +45,9 @@ public class BuildMojo extends QuarkusBootstrapMojo { @Component MavenProjectHelper projectHelper; + @Component + BuildAnalyticsProvider analyticsProvider; + /** * The project's remote repositories to use for the resolution of plugins and their dependencies. */ @@ -66,6 +70,9 @@ public class BuildMojo extends QuarkusBootstrapMojo { @Parameter(property = "skipOriginalJarRename") boolean skipOriginalJarRename; + @Parameter(defaultValue = "${project.build.directory}", readonly = true) + File buildDirectory; + /** * The list of system properties defined for the plugin. */ @@ -131,7 +138,11 @@ && isNativeProfileEnabled(mavenProject())) { try (CuratedApplication curatedApplication = bootstrapApplication()) { AugmentAction action = curatedApplication.createAugmentor(); AugmentResult result = action.createProductionApplication(); - + analyticsProvider.sendAnalytics( + TrackEventType.PROD_BUILD, + curatedApplication.getApplicationModel(), + result.getGraalVMInfo(), + buildDirectory); Artifact original = mavenProject().getArtifact(); if (result.getJar() != null) { diff --git a/devtools/maven/src/main/java/io/quarkus/maven/DevMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/DevMojo.java index dcca17357e4f94..a86f242f60ea8c 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/DevMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/DevMojo.java @@ -1,7 +1,9 @@ package io.quarkus.maven; +import static io.quarkus.analytics.dto.segment.TrackEventType.*; import static io.smallrye.common.expression.Expression.Flag.LENIENT_SYNTAX; import static io.smallrye.common.expression.Expression.Flag.NO_TRIM; +import static java.util.Collections.*; import static java.util.function.Predicate.not; import static org.twdata.maven.mojoexecutor.MojoExecutor.artifactId; import static org.twdata.maven.mojoexecutor.MojoExecutor.configuration; @@ -30,6 +32,7 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -99,6 +102,7 @@ import io.quarkus.maven.MavenDevModeLauncher.Builder; import io.quarkus.maven.components.CompilerOptions; import io.quarkus.maven.components.MavenVersionEnforcer; +import io.quarkus.maven.components.Prompter; import io.quarkus.maven.components.QuarkusWorkspaceProvider; import io.quarkus.maven.dependency.ArtifactCoords; import io.quarkus.maven.dependency.ArtifactKey; @@ -372,6 +376,9 @@ public class DevMojo extends AbstractMojo { @Parameter(defaultValue = "${mojoExecution}", readonly = true, required = true) MojoExecution mojoExecution; + @Component + BuildAnalyticsProvider analyticsProvider; + /** * console attributes, used to restore the console state */ @@ -426,6 +433,19 @@ public void execute() throws MojoFailureException, MojoExecutionException { saveTerminalState(); + analyticsProvider.buildAnalyticsUserInput((String prompt) -> { + try { + final AtomicBoolean analyticsOk = new AtomicBoolean(false); + final Prompter prompter = new Prompter(); + prompter.addPrompt(prompt, input -> analyticsOk.set("y".equalsIgnoreCase(input.trim()))); + prompter.collectInput(); + return analyticsOk.get(); + } catch (IOException e) { + getLog().debug("Failed to collect user input for analytics", e); + return false; + } + }); + try { DevModeRunner runner = new DevModeRunner(); @@ -1194,7 +1214,7 @@ private QuarkusDevModeLauncher newLauncher(Boolean debugPortOk) throws Exception if (argsString != null) { builder.applicationArgs(argsString); } - + analyticsProvider.sendAnalytics(DEV_MODE_BUILD, appModel, emptyMap(), buildDir); return builder.build(); } diff --git a/devtools/maven/src/main/java/io/quarkus/maven/components/BootstrapSessionListener.java b/devtools/maven/src/main/java/io/quarkus/maven/components/BootstrapSessionListener.java index 31b338f88497f7..5d1e56104052fb 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/components/BootstrapSessionListener.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/components/BootstrapSessionListener.java @@ -10,6 +10,7 @@ import org.apache.maven.MavenExecutionException; import org.apache.maven.execution.MavenSession; +import io.quarkus.maven.BuildAnalyticsProvider; import io.quarkus.maven.QuarkusBootstrapProvider; @Singleton @@ -17,18 +18,21 @@ public class BootstrapSessionListener extends AbstractMavenLifecycleParticipant { private final QuarkusBootstrapProvider bootstrapProvider; + private final BuildAnalyticsProvider buildAnalyticsProvider; private boolean enabled; @Inject - public BootstrapSessionListener(QuarkusBootstrapProvider bootstrapProvider) { + public BootstrapSessionListener(QuarkusBootstrapProvider bootstrapProvider, BuildAnalyticsProvider buildAnalyticsProvider) { this.bootstrapProvider = bootstrapProvider; + this.buildAnalyticsProvider = buildAnalyticsProvider; } @Override public void afterSessionEnd(MavenSession session) throws MavenExecutionException { try { bootstrapProvider.close(); + buildAnalyticsProvider.close(); } catch (IOException e) { e.printStackTrace(); } diff --git a/docs/src/main/asciidoc/build-analytics.adoc b/docs/src/main/asciidoc/build-analytics.adoc new file mode 100644 index 00000000000000..c8bffa97908903 --- /dev/null +++ b/docs/src/main/asciidoc/build-analytics.adoc @@ -0,0 +1,156 @@ +//// +This guide is maintained in the main Quarkus repository +and pull requests should be submitted there: +https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc +//// += Build Analytics +:categories: analytics +:summary: This guide presents what build analytics is and how to configure it. + +The Quarkus team has limited knowledge, from Maven download numbers, on the excellent growth of Quarkus and the amount of users reporting issues/concerns, but we are really flying blind on which platforms, OS, java combinations, build tools etc. +The build analytics tool aims to provide us with this information. + +== How does it work? + +*Collection happens only at build-time* and not at runtime. Collection occurs during usage of Quarkus build tooling (maven, gradle). Your built application will not contain any data collection code. + +*All data collected is anonymous*. There will be no way to link this data back to an individual or organization. The intent is to learn about general patterns and trends therefore there is no need to gather anything but anonymous data. + +*Collection is Disabled by default* You will be asked if you want to opt in to data collection the first time you use devMode. + +== This is the data being collected + +* Anonymous application name. SHA-256 hash of the Group Id + Artifact Id. +* Anonymous application version. SHA-256 hash of the Artifact Id + Version. +* Anonymous user id. During the 1st build, this is generated locally at random, and subsequently reused. +* Quarkus version +* Java version +* GraalVM version +* Operating system details +* Build system details (Maven, Gradle, etc) +* If a CI system was detected +* Country and timezone +* Extensions enabled. Only extensions with grouIds starting with `io.quarkus` or `io.quarkiverse` are collected. + +And here is an example of the data payload being sent: + +[source,json] +.Example analytics payload +---- +{ + "context": { + "app": { + "name": "ZzOlt3q0VgNK0oyUd6NDAhPqJjTSxNOAnqHb5dHfS7M=", + "version": "FUDdh16tTKtuJkcoY2Wp57IKKwxZ/Hx42tTvi3Izpmc=" + }, + "build": { + "gradle_version": "N/A", + "maven_version": "3.8.7" + }, + "ci": { + "name": "unknown" + }, + "graalvm": { + "java_version": "N/A", + "vendor": "N/A", + "version": "N/A" + }, + "ip": "0.0.0.0", + "java": { + "vendor": "Eclipse Adoptium", + "version": "17.0.4.1" + }, + "kubernetes": { + "detected": "false" + }, + "library": { + "name": "unknown", + "version": "unknown" + }, + "location": { + "country": "PT" + }, + "os": { + "name": "Mac OS X", + "os_arch": "aarch64", + "version": "13.3.1" + }, + "quarkus": { + "version": "999-SNAPSHOT" + }, + "timezone": "Europe/Lisbon" + }, + "event": "PROD_BUILD", + "integrations": {}, + "messageId": "api-2PCI9LWQM8oRyHIS6fCQMIQ0dlR", + "originalTimestamp": "2023-05-01T15:21:22.266033Z", + "properties": { + "app_extensions": [ + { + "artifact_id": "quarkus-resteasy-jackson", + "group_id": "io.quarkus", + "version": "999-SNAPSHOT" + }, + { + "artifact_id": "quarkus-smallrye-openapi", + "group_id": "io.quarkus", + "version": "999-SNAPSHOT" + }, + { + "artifact_id": "quarkus-hibernate-orm", + "group_id": "io.quarkus", + "version": "999-SNAPSHOT" + }, + { + "artifact_id": "quarkus-opentelemetry", + "group_id": "io.quarkus", + "version": "999-SNAPSHOT" + }, + { + "artifact_id": "quarkus-jdbc-postgresql", + "group_id": "io.quarkus", + "version": "999-SNAPSHOT" + }, + { + "artifact_id": "quarkus-arc", + "group_id": "io.quarkus", + "version": "999-SNAPSHOT" + } + ] + }, + "receivedAt": "2023-05-01T15:21:23.184Z", + "timestamp": "2023-05-01T15:21:22.266Z", + "type": "track", + "userId": "872332b4-4b21-42ee-854b-d86c8629e78b" +} +---- + +== Where are the config files? +Analytics configurations are stored at the `~/redhat` folder: + +`anonymousId` is the locally generated anonymous user id. + +`com.redhat.devtools.quarkus.localconfig` holds if the user has agreed to send analytics data or not. + +`com.redhat.devtools.quarkus.remoteconfig` cache of the remote configuration that allows to disable analytics globally. + +== Show me the code + +The bulk of the code is implemented in the `analytics-common` module. Data is sent using calls to `io.quarkus.analytics.AnalyticsService.sendAnalytics(...)` from the Maven and Gradle plugins. + +The uploaded events are stored under the project's `/target` folder with the name `build-analytics-event.json`. + +== Configuration +// Build telemetry runs outside quarkus, therefore no @ConfigRoot to generate the config part + +|=== +| Configuration property | Type | Default + +| [[build-analytics.quarkus.analytics.disabled]]`link:#build-analytics.quarkus.analytics.disabled[quarkus.analytics.disabled]` + +Values can be `true` or `false`. + +Setting this property to `true` will disable the collection of analytics data on that specific project regardless of any other configuration. +| boolean +| false +|=== \ No newline at end of file diff --git a/independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/app/AugmentResult.java b/independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/app/AugmentResult.java index cecfeb73438607..cb4a2719f0a9dd 100644 --- a/independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/app/AugmentResult.java +++ b/independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/app/AugmentResult.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.function.Predicate; /** @@ -13,11 +14,17 @@ public class AugmentResult { private final List results; private final JarResult jar; private final Path nativeImagePath; + private final Map graalVMInfo; - public AugmentResult(List results, JarResult jar, Path nativeImagePath) { + public AugmentResult(List results, + JarResult jar, + Path nativeImagePath, + List diagnostics, + Map graalVMInfo) { this.results = results; this.jar = jar; this.nativeImagePath = nativeImagePath; + this.graalVMInfo = graalVMInfo; } public List getResults() { @@ -44,4 +51,8 @@ public List resultsMatchingType(Predicate typePredicate) } return res; } + + public Map getGraalVMInfo() { + return graalVMInfo; + } } diff --git a/independent-projects/tools/analytics-common/pom.xml b/independent-projects/tools/analytics-common/pom.xml new file mode 100644 index 00000000000000..95284b02278c4d --- /dev/null +++ b/independent-projects/tools/analytics-common/pom.xml @@ -0,0 +1,66 @@ + + + + quarkus-tools-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-analytics-common + Quarkus - Analytics - Common + + + + 3.3.1 + 4.5.14 + 2.35.0 + 1.0.0.Final + + + + + io.quarkus + quarkus-bootstrap-app-model + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + io.quarkus + quarkus-devtools-message-writer + + + + + org.junit.jupiter + junit-jupiter + test + + + com.github.tomakehurst + wiremock-jre8-standalone + ${wiremock-jre8.version} + test + + + commons-logging + commons-logging + + + + + org.mockito + mockito-core + test + + + + \ No newline at end of file diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnalyticsService.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnalyticsService.java new file mode 100644 index 00000000000000..5781e037da9722 --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnalyticsService.java @@ -0,0 +1,260 @@ +package io.quarkus.analytics; + +import static io.quarkus.analytics.config.ExtensionsFilter.onlyPublic; +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_CI_NAME; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_COUNTRY; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_DETECTED; +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_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 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 io.quarkus.analytics.rest.RestClient.TTL; +import static io.quarkus.analytics.util.StringUtils.hashSHA256; +import static io.quarkus.maven.dependency.DependencyFlags.TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT; +import static java.lang.System.getProperty; +import static java.lang.System.getenv; +import static java.util.Optional.ofNullable; + +import java.io.File; +import java.io.IOException; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; + +import io.quarkus.analytics.config.FileLocations; +import io.quarkus.analytics.dto.config.Identity; +import io.quarkus.analytics.dto.segment.ContextBuilder; +import io.quarkus.analytics.dto.segment.Track; +import io.quarkus.analytics.dto.segment.TrackEventType; +import io.quarkus.analytics.dto.segment.TrackProperties; +import io.quarkus.analytics.rest.RestClient; +import io.quarkus.analytics.util.FileUtils; +import io.quarkus.bootstrap.model.ApplicationModel; +import io.quarkus.bootstrap.workspace.WorkspaceModuleId; +import io.quarkus.devtools.messagewriter.MessageWriter; +import io.quarkus.maven.dependency.ArtifactCoords; + +public class AnalyticsService { + private static AnalyticsService INSTANCE = null; + private final Queue>> postFutures; + + public static AnalyticsService getInstance(final FileLocations fileLocations, MessageWriter log) { + if (INSTANCE == null) { + INSTANCE = new AnalyticsService(fileLocations, log); + } + return INSTANCE; + } + + public static boolean isInstantiated() { + return INSTANCE != null; + } + + public static AnalyticsService getInstance() { + return INSTANCE; + } + + final private RestClient restClient; + final private ConfigService config; + final private AnonymousUserId userId; + final private MessageWriter log; + final FileLocations fileLocations; + + // Singleton + private AnalyticsService(final FileLocations fileLocations, MessageWriter log) { + this.fileLocations = fileLocations; + this.log = log; + this.postFutures = new ConcurrentLinkedQueue<>(); + this.restClient = new RestClient(log); + this.userId = AnonymousUserId.getInstance(fileLocations, log); + this.config = new ConfigService(this.restClient, this.userId, fileLocations, log); + } + + public void buildAnalyticsUserInput(Function analyticsEnabledSupplier) { + this.config.userAcceptance(analyticsEnabledSupplier); + } + + public void sendAnalytics(final TrackEventType trackEventType, + ApplicationModel applicationModel, + final Map buildInfo, + final File localBuildDir) { + + if (this.config.isActive() && + this.config.isArtifactActive( + applicationModel.getApplicationModule().getId().getGroupId(), + getQuarkusVersion(applicationModel))) { + + final Map context = createContextMap(applicationModel, buildInfo); + sendIdentity(context); + Track trackEvent = Track.builder() + .userId(userId.getUuid()) + .context(context) + .event(trackEventType) + .properties(TrackProperties.builder() + .appExtensions(createExtensionsPropertyValue(applicationModel)) + .build()) + .timestamp(Instant.now()) + .build(); + postFutures.add(this.restClient.postTrack(trackEvent)); + try { + FileUtils.overwrite(trackEvent, + Path.of(localBuildDir.getAbsolutePath(), fileLocations.lastTrackFileName())); + } catch (IOException e) { + log.warn("Failed to write the last analytics file. " + e.getMessage()); + } + } + } + + public void cleanup() { + try { + // complete all. Normally, the queue should have only 1 element. + CompletableFuture.allOf(postFutures.toArray(new CompletableFuture[0])).get(TTL, TimeUnit.MILLISECONDS); + } catch (Exception e) { + log.warn("No analytics sent. " + e.getClass().getName() + ": " + + (e.getMessage() == null ? "(no message)" : e.getMessage())); + } + } + + List createExtensionsPropertyValue(ApplicationModel applicationModel) { + return applicationModel.getDependencies().stream() + .filter(dep -> dep.isResolved() && + dep.isFlagSet(TOP_LEVEL_RUNTIME_EXTENSION_ARTIFACT) && + onlyPublic(dep.getGroupId())) + .map(dep -> TrackProperties.AppExtension.builder() + .groupId(dep.getGroupId()) + .artifactId(dep.getArtifactId()) + .version(dep.getVersion()) + .build()) + .collect(Collectors.toList()); + } + + void sendIdentity(final Map context) { + if (this.userId.isNew()) { // fixme when inactive on 1st call it will not send identity. + this.restClient.postIdentity(Identity.builder() + .userId(this.userId.getUuid()) + .context(context) + .timestamp(Instant.now()) + .build()); + } + } + + Map createContextMap(ApplicationModel applicationModel, + Map buildInfo) { + WorkspaceModuleId moduleId = applicationModel.getApplicationModule().getId(); + + return new ContextBuilder() + .mapPair(PROP_APP) + .pair(PROP_NAME, hashSHA256(moduleId.getGroupId() + ":" + moduleId.getArtifactId())) + .pair(PROP_VERSION, hashSHA256(moduleId.getArtifactId() + ":" + moduleId.getVersion())) + .build() + .mapPair(PROP_JAVA) + .pair(PROP_VENDOR, getProperty("java.vendor", "N/A")) + .pair(PROP_VERSION, getProperty("java.version", "N/A")) + .build() + .mapPair(PROP_GRAALVM) + .pair(PROP_VENDOR, ofNullable(buildInfo.get(GRAALVM_VERSION_DISTRIBUTION)).orElse("N/A")) + .pair(PROP_VERSION, ofNullable(buildInfo.get(GRAALVM_VERSION_VERSION)).orElse("N/A")) + .pair(PROP_JAVA_VERSION, ofNullable(buildInfo.get(GRAALVM_VERSION_JAVA)).orElse("N/A")) + .build() + .mapPair(PROP_BUILD) + .pair(PROP_MAVEN_VERSION, ofNullable(buildInfo.get(MAVEN_VERSION)).orElse("N/A")) + .pair(PROP_GRADLE_VERSION, ofNullable(buildInfo.get(GRADLE_VERSION)).orElse("N/A")) + .build() + .mapPair(PROP_QUARKUS) + .pair(PROP_VERSION, getQuarkusVersion(applicationModel)) + .build() + .pair(PROP_IP, VALUE_NULL_IP) + .mapPair(PROP_LOCATION) + .pair(PROP_COUNTRY, Locale.getDefault().getCountry()) + .build() + .mapPair(PROP_OS) + .pair(PROP_NAME, getProperty("os.name", "N/A")) + .pair(PROP_VERSION, getProperty("os.version", "N/A")) + .pair(PROP_OS_ARCH, getProperty("os.arch", "N/A")) + .build() + .mapPair(PROP_CI) + .pair(PROP_CI_NAME, getBuildSystemName()) + .build() + .mapPair(PROP_KUBERNETES) + .pair(PROP_DETECTED, isKubernetesDetected()) + .build() + .pair(PROP_TIMEZONE, ZoneId.systemDefault().getDisplayName(TextStyle.NARROW, Locale.ENGLISH)) + .build(); + } + + private String isKubernetesDetected() { + return Boolean.toString(allEnvSet( + "KUBERNETES_SERVICE_HOST", + "KUBERNETES_SERVICE_PORT")); + } + + private String getBuildSystemName() { + String travis = getenv("TRAVIS"); + String user = getenv("USER"); + if ("true".equals(travis) && "travis".equals(user)) { + return "travis"; + } + + if (allEnvSet("JENKINS_URL", "JENKINS_HOME", "WORKSPACE")) { + return "jenkins"; + } + + if (allEnvSet("GITHUB_WORKFLOW", "GITHUB_WORKSPACE", "GITHUB_RUN_ID")) { + return "github-actions"; + } + + // https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml + if (allEnvSet("BUILD_REASON", "AGENT_JOBSTATUS")) { + return "azure-pipelines"; + } + + return "unknown"; + } + + private boolean allEnvSet(String... names) { + for (String name : names) { + if (getenv(name) == null) { + return false; + } + } + return true; + } + + private String getQuarkusVersion(ApplicationModel applicationModel) { + return applicationModel.getPlatforms().getImportedPlatformBoms().stream() + .filter(artifactCoords -> artifactCoords.getArtifactId().equals("quarkus-bom")) + .map(ArtifactCoords::getVersion) + .findFirst() + .orElse("N/A"); + } +} diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnonymousUserId.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnonymousUserId.java new file mode 100644 index 00000000000000..dcd9c039da9de7 --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnonymousUserId.java @@ -0,0 +1,76 @@ +package io.quarkus.analytics; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.stream.Stream; + +import io.quarkus.analytics.config.FileLocations; +import io.quarkus.analytics.util.FileUtils; +import io.quarkus.devtools.messagewriter.MessageWriter; + +/** + * Anonymous user identity generated and stored locally. + */ +public class AnonymousUserId { + private static AnonymousUserId INSTANCE = null; + + public static AnonymousUserId getInstance(final FileLocations fileLocations, final MessageWriter log) { + if (INSTANCE == null) { + INSTANCE = new AnonymousUserId(fileLocations, log); + } + return INSTANCE; + } + + private final String uuid; + private boolean isNew; + private final MessageWriter log; + + // Singleton + private AnonymousUserId(final FileLocations fileLocations, final MessageWriter log) { + this.uuid = loadOrCreate(fileLocations.getUUIDFile()); + this.log = log; + } + + public String getUuid() { + return uuid; + } + + public boolean isNew() { + return isNew; + } + + private String loadOrCreate(Path file) { + if (Files.exists(file)) { + return load(file); + } else { + String uuid = UUID.randomUUID().toString(); + write(uuid, file); + isNew = true; + return uuid; + } + } + + private String load(Path file) { + String uuid = "N/A"; + try (Stream lines = Files.lines(file)) { + uuid = lines + .findAny() + .map(String::trim) + .orElse("empty"); + } catch (IOException e) { + log.warn("Could not read redhat anonymous UUID file at " + file.toAbsolutePath(), e); + } + return uuid; + } + + private void write(String uuid, Path uuidFile) { + try { + FileUtils.createFileAndParent(uuidFile); + FileUtils.append(uuid, uuidFile); + } catch (IOException e) { + log.warn("Could not write redhat anonymous UUID to file at " + uuidFile.toAbsolutePath(), e); + } + } +} diff --git a/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/ConfigService.java b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/ConfigService.java new file mode 100644 index 00000000000000..608f5521fe28d0 --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/ConfigService.java @@ -0,0 +1,180 @@ +package io.quarkus.analytics; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.Optional; +import java.util.function.Function; + +import io.quarkus.analytics.config.FileLocations; +import io.quarkus.analytics.config.GroupIdFilter; +import io.quarkus.analytics.dto.config.AnalyticsRemoteConfig; +import io.quarkus.analytics.dto.config.LocalConfig; +import io.quarkus.analytics.dto.config.NoopRemoteConfig; +import io.quarkus.analytics.dto.config.RemoteConfig; +import io.quarkus.analytics.rest.ConfigClient; +import io.quarkus.analytics.util.FileUtils; +import io.quarkus.devtools.messagewriter.MessageWriter; + +/** + * Decided the build analytics behaviour. Retrieves, stores and provides the configuration. + */ +public class ConfigService { + public static final String QUARKUS_ANALYTICS_DISABLED_LOCAL_PROP = "quarkus.analytics.disabled"; + private static final int DEFAULT_REFRESH_HOURS = 12; + static final String ACCEPTANCE_PROMPT = "*******************************************************************************************\n" + + " Do you agree to send anonymous usage data to the Quarkus community to help improve Quarkus? (Y/n) "; + + private AnalyticsRemoteConfig config; + private Instant lastRefreshTime; + final private ConfigClient client; + final private AnonymousUserId userId; + final private Path remoteConfigFile; + final private Path localConfigFile; + final private MessageWriter log; + + private static Instant initLastRefreshTime(final Path configFile) { + if (Files.exists(configFile)) { + try { + return Files.getLastModifiedTime(configFile).toInstant(); + } catch (IOException e) { + return null; + } + } else { + return null; + } + } + + public ConfigService(final ConfigClient client, final AnonymousUserId userId, final FileLocations fileLocations, + final MessageWriter log) { + this.client = client; + this.userId = userId; + this.log = log; + this.lastRefreshTime = initLastRefreshTime(fileLocations.getRemoteConfigFile()); + this.remoteConfigFile = fileLocations.getRemoteConfigFile(); + this.localConfigFile = fileLocations.getLocalConfigFile(); + loadConfig(RemoteConfig.class, remoteConfigFile) + .ifPresentOrElse(c -> this.config = c, this::loadConfigFromInternet); + } + + public void userAcceptance(Function analyticsEnabledSupplier) { + if (Files.exists(localConfigFile) || Boolean.getBoolean(QUARKUS_ANALYTICS_DISABLED_LOCAL_PROP)) { + return; // ask nothing + } else { + try { + Boolean isActive = analyticsEnabledSupplier.apply(ACCEPTANCE_PROMPT); + FileUtils.createFileAndParent(localConfigFile); + FileUtils.write(new LocalConfig(isActive), localConfigFile); + } catch (Exception e) { + log.warn("Failed to create local analytics config file. Exception: " + 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) && + 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 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("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("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 00000000000000..20443782c380d7 --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/ExtensionsFilter.java @@ -0,0 +1,24 @@ +package io.quarkus.analytics.config; + +import java.util.List; + +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) { + if (groupId == null) { + return false; + } + return AUTHORIZED_GROUPS.stream() + .anyMatch(groupId::startsWith); + } +} 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 00000000000000..9fd51c8ba71de7 --- /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 00000000000000..7f99f36dbac972 --- /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 00000000000000..b27b17cbd906ee --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/config/GroupIdFilter.java @@ -0,0 +1,21 @@ +package io.quarkus.analytics.config; + +import java.util.List; + +public class GroupIdFilter { + private static final List DENIED_GROUPS = List.of( + "io.quarkus", + "io.quarkiverse", + "org.acme", + "org.test", + "g1", + "g2"); + + public static boolean isAuthorizedGroupId(String groupId) { + if (groupId == null || groupId.isEmpty()) { + return false; + } + return DENIED_GROUPS.stream() + .noneMatch(groupId::startsWith); + } +} 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 00000000000000..339ba6a0028391 --- /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 00000000000000..8b2805e2f9a5b4 --- /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 00000000000000..0d2ed7baacfc0e --- /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 00000000000000..914762a574e2cc --- /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 00000000000000..3ba69ff0ec244e --- /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 00000000000000..32201609abd5b6 --- /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 00000000000000..a62bcc2e35ce16 --- /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_COUNTRY = "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 00000000000000..2996137122914c --- /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 00000000000000..bac34d69465a50 --- /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 00000000000000..3cf39d0858e1c2 --- /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 { + PROD_BUILD, + DEV_MODE_BUILD +} 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 00000000000000..134693266b0a70 --- /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 00000000000000..5a63d18fce857a --- /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 00000000000000..3888e6374de4b4 --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/rest/RestClient.java @@ -0,0 +1,154 @@ +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 { + + static final String IDENTITY_ENDPOINT = "v1/identify"; + static final String TRACK_ENDPOINT = "v1/track"; + static final int TIMEOUT = 1000;// milliseconds + public static final int TTL = TIMEOUT * 2;// milliseconds + 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; + + 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(TTL, 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("Analytics config not received. " + 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("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("Analytics not sent. " + e.getMessage()); + return CompletableFuture.failedFuture(e); + } + } + + private HttpClient createHttpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .connectTimeout(Duration.ofMillis(TIMEOUT)) + .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(TIMEOUT)); + } +} 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 00000000000000..5cf786bb2e067a --- /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 00000000000000..6f7503aeaf8521 --- /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("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 00000000000000..0913613edd4389 --- /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/AnalyticsServiceTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTest.java new file mode 100644 index 00000000000000..34628ff3b9d0d9 --- /dev/null +++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/AnalyticsServiceTest.java @@ -0,0 +1,247 @@ +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.nio.file.Path; +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 org.mockito.Mockito; + +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.bootstrap.model.ApplicationModel; +import io.quarkus.bootstrap.model.PlatformImports; +import io.quarkus.bootstrap.workspace.WorkspaceModule; +import io.quarkus.bootstrap.workspace.WorkspaceModuleId; +import io.quarkus.devtools.messagewriter.MessageWriter; +import io.quarkus.maven.dependency.ArtifactCoords; +import io.quarkus.maven.dependency.DependencyFlags; +import io.quarkus.maven.dependency.ResolvedDependencyBuilder; + +class AnalyticsServiceTest { + + 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 = AnalyticsService.getInstance(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 = AnalyticsService.getInstance(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 = AnalyticsService.getInstance(FILE_LOCATIONS, MessageWriter.info()); + service.sendAnalytics(TrackEventType.PROD_BUILD, + mockApplicationModel(), + Map.of(), + new File(FILE_LOCATIONS.getFolder().toUri())); + service.cleanup(); + wireMockServer.verify(postRequestedFor(urlEqualTo("/v1/track")) + .withRequestBody(notMatching("null"))); + assertTrue(new File(FILE_LOCATIONS.getFolder().toString() + "/" + FILE_LOCATIONS.lastTrackFileName()).exists()); + } + + private 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; + } + + 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/ConfigServiceManualTest.java b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/ConfigServiceManualTest.java new file mode 100644 index 00000000000000..183b659c79728e --- /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 00000000000000..d40b11919faba1 --- /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 true; + }); + 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 true; + }); + 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 false; + }); + 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 00000000000000..c738e252c04048 --- /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 00000000000000..a9ef33bbfa6513 --- /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_COUNTRY; +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_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_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 00000000000000..e37219b157da8b --- /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 00000000000000..dca0d1d846f64c --- /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 00000000000000..34fbe44171708a --- /dev/null +++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/ExtensionsFilterTest.java @@ -0,0 +1,23 @@ +package io.quarkus.analytics.config; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ExtensionsFilterTest { + + @Test + void discardTest() { + assertFalse(ExtensionsFilter.onlyPublic("must.not.be.authorized")); + assertFalse(ExtensionsFilter.onlyPublic(null)); + assertFalse(ExtensionsFilter.onlyPublic("")); + } + + @Test + void acceptTest() { + assertTrue(ExtensionsFilter.onlyPublic("io.quarkus")); + assertTrue(ExtensionsFilter.onlyPublic("io.quarkus.something")); + assertTrue(ExtensionsFilter.onlyPublic("io.quarkiverse")); + assertTrue(ExtensionsFilter.onlyPublic("io.quarkiverse.something")); + } +} 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 00000000000000..11ce090fe3367d --- /dev/null +++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/GroupIdFilterTest.java @@ -0,0 +1,25 @@ +package io.quarkus.analytics.config; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class GroupIdFilterTest { + + @Test + void isAuthorizedGroupId() { + assertTrue(GroupIdFilter.isAuthorizedGroupId("must.be.authorized")); + } + + @Test + void isDeniedGroupId() { + assertFalse(GroupIdFilter.isAuthorizedGroupId(null)); + assertFalse(GroupIdFilter.isAuthorizedGroupId("")); + assertFalse(GroupIdFilter.isAuthorizedGroupId("io.quarkus")); + assertFalse(GroupIdFilter.isAuthorizedGroupId("io.quarkus.something")); + assertFalse(GroupIdFilter.isAuthorizedGroupId("io.quarkiverse")); + assertFalse(GroupIdFilter.isAuthorizedGroupId("io.quarkiverse.something")); + assertFalse(GroupIdFilter.isAuthorizedGroupId("org.acme")); + assertFalse(GroupIdFilter.isAuthorizedGroupId("org.acme.something")); + } +} 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 00000000000000..73873fdee38e88 --- /dev/null +++ b/independent-projects/tools/analytics-common/src/test/java/io/quarkus/analytics/config/TestFileLocationsImpl.java @@ -0,0 +1,64 @@ +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 { + 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"); + 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 00000000000000..d54dfc7194337e --- /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.rest.RestClient.TTL; +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.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; + +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 { + wireMockServer.start(); + wireMockServer.stubFor(post(urlEqualTo("/" + IDENTITY_ENDPOINT)) + .willReturn(aResponse() + .withStatus(201) + .withFixedDelay(TTL + 50) // will take too long to respond + .withHeader("Content-Type", "application/json") + .withBody("{\"status\":\"ok\"}"))); + wireMockServer.stubFor(get(urlEqualTo("/config")) + .willReturn(aResponse() + .withStatus(200) + .withFixedDelay(TTL + 50) // will take too long to respond + .withHeader("Content-Type", "application/json") + .withBody( + "{\"active\":true,\"deny_user_ids\":[],\"deny_quarkus_versions\":[],\"refresh_interval\":43200.000000000}"))); + + } + + @AfterAll + static void stop() { + wireMockServer.stop(); + } + + @Test + void postIdentityServerTTLExceeded() + 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)); + assertThrows(TimeoutException.class, () -> post.get(500, 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 00000000000000..cbac5e8631dd31 --- /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.PROD_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 00000000000000..abb190e2b75e4d --- /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 2b02c489716d04..6b9dac1bc40785 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/pom.xml b/integration-tests/pom.xml index 93b5cb20923b20..5ef1e6a719139f 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