diff --git a/bom/application/pom.xml b/bom/application/pom.xml index a9ae775c7c7fe..7522ca5cf41ac 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -6568,6 +6568,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 ccfb87f0672d0..11137e01a737a 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 982e951a8a941..8a140099907ff 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 a2007cf6732db..ecba0b08bbfbe 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 @@ -191,7 +191,8 @@ 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, + nativeImageBuildItem != null ? nativeImageBuildItem.getGraalVMInfo().toMap() : Collections.emptyMap()); } private void writeDebugSourceFile(BuildResult result) { diff --git a/core/runtime/src/main/java/io/quarkus/runtime/BuildAnalyticsConfig.java b/core/runtime/src/main/java/io/quarkus/runtime/BuildAnalyticsConfig.java new file mode 100644 index 0000000000000..ff17d0c6cc2f6 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkus/runtime/BuildAnalyticsConfig.java @@ -0,0 +1,34 @@ +package io.quarkus.runtime; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * Build time analytics configuration. + * This is a dummy config class to hide the warnings on the comment line. + * All properties in here are actually used in the build tools. + */ +@ConfigRoot(name = "analytics", phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public class BuildAnalyticsConfig { + + /** + * If Build time analytics are disabled. + */ + @ConfigItem + public Optional disabled; + + /** + * The Segment base URI. + */ + @ConfigItem + public Optional uriBase; + + /** + * The Timeout to send the build time analytics to segment. + */ + @ConfigItem(defaultValue = "3000") + public Optional timeout; +} diff --git a/devtools/gradle/gradle-application-plugin/build.gradle.kts b/devtools/gradle/gradle-application-plugin/build.gradle.kts index b10d179997a7d..3b14b942c8e01 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 9ade7029a0fd2..f509c3e126758 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 0000000000000..ed1d49a5e9113 --- /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/tasks/QuarkusBuildTask.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusBuildTask.java index ec21628c731d0..b4c664b88e2e1 100644 --- a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusBuildTask.java +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusBuildTask.java @@ -222,6 +222,7 @@ void generateBuild() { params.getBaseName().set(extension().finalName()); params.getTargetDirectory().set(buildDir.toFile()); params.getAppModel().set(appModel); + params.getGradleVersion().set(getProject().getGradle().getGradleVersion()); }); workQueue.await(); diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusDev.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusDev.java index f9f42451bd761..688568d01e336 100644 --- a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusDev.java +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusDev.java @@ -1,5 +1,9 @@ package io.quarkus.gradle.tasks; +import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.GRADLE_VERSION; +import static io.quarkus.analytics.dto.segment.TrackEventType.*; +import static java.util.Collections.*; + import java.io.BufferedWriter; import java.io.File; import java.io.IOException; @@ -15,6 +19,7 @@ import java.util.Map; import java.util.Properties; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import javax.inject.Inject; @@ -52,6 +57,8 @@ import org.gradle.jvm.toolchain.JavaToolchainSpec; import org.gradle.util.GradleVersion; +import io.quarkus.analytics.AnalyticsService; +import io.quarkus.analytics.config.FileLocationsImpl; import io.quarkus.bootstrap.BootstrapConstants; import io.quarkus.bootstrap.app.ConfiguredClassLoading; import io.quarkus.bootstrap.app.QuarkusBootstrap; @@ -63,6 +70,7 @@ import io.quarkus.deployment.dev.DevModeContext; import io.quarkus.deployment.dev.DevModeMain; import io.quarkus.deployment.dev.QuarkusDevModeLauncher; +import io.quarkus.gradle.Prompter; import io.quarkus.gradle.dependency.ApplicationDeploymentClasspathBuilder; import io.quarkus.gradle.dsl.CompilerOption; import io.quarkus.gradle.dsl.CompilerOptions; @@ -314,9 +322,23 @@ public void startDev() { "this should not happen as build should have been executed first. " + "Does the project have any source files?"); } + AnalyticsService analyticsService = new AnalyticsService(FileLocationsImpl.INSTANCE, + new GradleMessageWriter(getLogger())); + analyticsService.buildAnalyticsUserInput((String prompt) -> { + try { + final AtomicReference userInput = new AtomicReference<>(""); + final Prompter prompter = new Prompter(); + prompter.addPrompt(prompt, input -> userInput.set(input)); + prompter.collectInput(); + return userInput.get(); + } catch (IOException e) { + getLogger().debug("Failed to collect user input for analytics", e); + return ""; + } + }); try { - QuarkusDevModeLauncher runner = newLauncher(); + QuarkusDevModeLauncher runner = newLauncher(analyticsService); String outputFile = System.getProperty(IO_QUARKUS_DEVMODE_ARGS); if (outputFile == null) { getProject().exec(action -> { @@ -337,6 +359,8 @@ public void startDev() { } catch (Exception e) { throw new GradleException("Failed to run", e); + } finally { + analyticsService.close(); } } @@ -364,7 +388,7 @@ private boolean classesExist() { return false; } - private QuarkusDevModeLauncher newLauncher() throws Exception { + private QuarkusDevModeLauncher newLauncher(final AnalyticsService analyticsService) throws Exception { final Project project = getProject(); final JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class); @@ -420,6 +444,13 @@ private QuarkusDevModeLauncher newLauncher() throws Exception { builder.sourceEncoding(getSourceEncoding()); final ApplicationModel appModel = extension().getApplicationModel(LaunchMode.DEVELOPMENT); + + analyticsService.sendAnalytics( + DEV_MODE, + appModel, + Map.of(GRADLE_VERSION, getProject().getGradle().getGradleVersion()), + getProject().getBuildDir().getAbsoluteFile()); + final Set projectDependencies = new HashSet<>(); for (ResolvedDependency localDep : DependenciesFilter.getReloadableModules(appModel)) { addLocalProject(localDep, builder, projectDependencies, appModel.getAppArtifact().getWorkspaceModule().getId() diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusGenerateCode.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusGenerateCode.java index 3a71110e64d33..83ccc825f07d2 100644 --- a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusGenerateCode.java +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusGenerateCode.java @@ -110,6 +110,7 @@ public void generateCode() { .setFrom(sourcesDirectories.stream().map(Path::toFile).collect(Collectors.toList())); params.getOutputPath().set(outputPath); params.getLaunchMode().set(launchMode); + params.getGradleVersion().set(getProject().getGradle().getGradleVersion()); }); workQueue.await(); diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusTask.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusTask.java index 2e01013032bae..09d16509a05e5 100644 --- a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusTask.java +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/QuarkusTask.java @@ -21,7 +21,7 @@ public abstract class QuarkusTask extends DefaultTask { private static final List WORKER_BUILD_FORK_OPTIONS = List.of("quarkus.package.", - "quarkus.application.", "quarkus.gradle-worker."); + "quarkus.application.", "quarkus.gradle-worker.", "quarkus.analytics."); private final transient QuarkusPluginExtension extension; protected final File projectDir; diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/worker/BuildWorker.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/worker/BuildWorker.java index 00a31cffb9a93..8f0648f2f3060 100644 --- a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/worker/BuildWorker.java +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/worker/BuildWorker.java @@ -1,8 +1,12 @@ package io.quarkus.gradle.tasks.worker; +import static io.quarkus.analytics.dto.segment.ContextBuilder.CommonSystemProperties.GRADLE_VERSION; + import java.nio.file.Path; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Properties; import java.util.stream.Collectors; @@ -10,12 +14,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.quarkus.analytics.AnalyticsService; +import io.quarkus.analytics.config.FileLocationsImpl; +import io.quarkus.analytics.dto.segment.TrackEventType; import io.quarkus.bootstrap.BootstrapException; import io.quarkus.bootstrap.app.ArtifactResult; import io.quarkus.bootstrap.app.AugmentAction; import io.quarkus.bootstrap.app.AugmentResult; import io.quarkus.bootstrap.app.CuratedApplication; import io.quarkus.bootstrap.app.JarResult; +import io.quarkus.devtools.messagewriter.MessageWriter; import io.quarkus.gradle.QuarkusPlugin; import io.quarkus.maven.dependency.ResolvedDependency; @@ -35,8 +43,12 @@ public void execute() { LOGGER.info(" configured package type: {}", props.getProperty(QuarkusPlugin.QUARKUS_PACKAGE_TYPE)); LOGGER.info(" configured output directory: {}", props.getProperty("quarkus.package.output-directory")); LOGGER.info(" configured output name: {}", props.getProperty("quarkus.package.output-name")); + LOGGER.info(" Gradle version: {}", params.getGradleVersion().get()); - try (CuratedApplication appCreationContext = createAppCreationContext()) { + try (CuratedApplication appCreationContext = createAppCreationContext(); + AnalyticsService analyticsService = new AnalyticsService( + FileLocationsImpl.INSTANCE, + new Slf4JMessageWriter(LOGGER))) { // Processes launched from within the build task of Gradle (daemon) lose content // generated on STDOUT/STDERR by the process (see https://github.com/gradle/gradle/issues/13522). @@ -52,6 +64,13 @@ public void execute() { if (result == null) { System.err.println("createProductionApplication() returned 'null' AugmentResult"); } else { + Map buildInfo = new HashMap<>(result.getGraalVMInfo()); + buildInfo.put(GRADLE_VERSION, params.getGradleVersion().get()); + analyticsService.sendAnalytics( + TrackEventType.BUILD, + appCreationContext.getApplicationModel(), + buildInfo, + params.getTargetDirectory().getAsFile().get()); Path nativeResult = result.getNativeResult(); LOGGER.info("AugmentResult.nativeResult = {}", nativeResult); List results = result.getResults(); @@ -72,7 +91,6 @@ public void execute() { LOGGER.info(" .jar.uberJar = {}", jar.isUberJar()); } } - LOGGER.info("Quarkus application build was successful"); } catch (BootstrapException e) { // Gradle "abbreviates" the stacktrace to something human-readable, but here the underlying cause might @@ -80,4 +98,37 @@ public void execute() { throw new GradleException("Failed to build Quarkus application for " + gav + " due to " + e, e); } } + + private static class Slf4JMessageWriter implements MessageWriter { + private final Logger LOGGER; + + public Slf4JMessageWriter(final Logger logger) { + this.LOGGER = logger; + } + + @Override + public void info(String msg) { + this.LOGGER.info(msg); + } + + @Override + public void error(String msg) { + this.LOGGER.error(msg); + } + + @Override + public boolean isDebugEnabled() { + return this.LOGGER.isDebugEnabled(); + } + + @Override + public void debug(String msg) { + this.LOGGER.debug(msg); + } + + @Override + public void warn(String msg) { + this.LOGGER.warn(msg); + } + } } diff --git a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/worker/QuarkusParams.java b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/worker/QuarkusParams.java index 534fe0e6d04fd..e53a6bd651e49 100644 --- a/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/worker/QuarkusParams.java +++ b/devtools/gradle/gradle-application-plugin/src/main/java/io/quarkus/gradle/tasks/worker/QuarkusParams.java @@ -15,4 +15,6 @@ public interface QuarkusParams extends WorkParameters { Property getBaseName(); Property getAppModel(); + + Property getGradleVersion(); } 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 908011a1c41f3..f5f6a34471cb1 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,6 @@ package io.quarkus.gradle; +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.*; @@ -38,7 +39,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)); @@ -57,7 +58,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 +78,12 @@ 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); } @Test @@ -165,6 +164,15 @@ 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); + } + private static List getDependantProvidedTaskName(Task task) { List dependantTaskNames = new ArrayList<>(); for (Object t : task.getDependsOn()) { diff --git a/devtools/maven/pom.xml b/devtools/maven/pom.xml index 6d208fd254f51..25ea08cca9a49 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 0000000000000..ee33134dacba8 --- /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 = new AnalyticsService(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.close(); + } + + 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 0efc3d5f20a7c..77db06a5e18d0 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.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 8c26e261a3bed..4625ec038b02f 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.DEV_MODE; 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.AtomicReference; 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; @@ -347,6 +351,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 */ @@ -403,6 +410,19 @@ public void execute() throws MojoFailureException, MojoExecutionException { saveTerminalState(); + analyticsProvider.buildAnalyticsUserInput((String prompt) -> { + try { + final AtomicReference userInput = new AtomicReference<>(""); + final Prompter prompter = new Prompter(); + prompter.addPrompt(prompt, input -> userInput.set(input)); + prompter.collectInput(); + return userInput.get(); + } catch (IOException e) { + getLog().debug("Failed to collect user input for analytics", e); + return ""; + } + }); + try { DevModeRunner runner = new DevModeRunner(); @@ -1182,7 +1202,7 @@ private QuarkusDevModeLauncher newLauncher(Boolean debugPortOk) throws Exception if (argsString != null) { builder.applicationArgs(argsString); } - + analyticsProvider.sendAnalytics(DEV_MODE, 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 31b338f88497f..5d1e56104052f 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 0000000000000..ab968a42f47cd --- /dev/null +++ b/docs/src/main/asciidoc/build-analytics.adoc @@ -0,0 +1,186 @@ +//// +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, of the remarkable growth of Quarkus and the number of users reporting issues/concerns. Still, we need more insight into the platforms, operating system, java combinations, and build tools our users employ. +The build analytics tool aims to provide us with this information. + +== How it works + +*Collection happens only at build-time* and not at runtime. Collection occurs during usage of Quarkus build tooling (Maven, Gradle). Your built application does not contain any data collection code. + +*All data collected is anonymous*. There is no way to link this data back to an individual or organization. The intent is to learn about general patterns and trends; therefore, only anonymous data is needed. + +*Collection is Disabled by default* You will be asked if you want to opt in to data collection the first time you use devMode. The console will wait for a few seconds for your input. + +== 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, such as Maven, Gradle, and so on. +* If a CI system was detected +* Country and timezone +* Extensions enabled. Only extensions whose `groupIds` start with `io.quarkus`, `io.quarkiverse` or are included in the quarkus platform 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 in the user's home directory: + +`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`. + +== How to disable analytics? + +*Build time analytics is not active by default.* + +If you have opted in and would like to disable build time analytics, you can do so in two ways: + +* Globally, by editing the `com.redhat.devtools.quarkus.localconfig` file in the '.redhat' folder of your user’s home directory. Update the file as follows: +[source,json] +---- +{"active":false} +---- + +* Per project, by using the system property `quarkus.analytics.disabled=true` when building the project. + +[source,bash] +.Example using maven +---- +mvn clean install -Dquarkus.analytics.disabled=true +---- + +== 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 +| [[build-analytics.quarkus.analytics.uri.base]]`link:#build-analytics.quarkus.analytics.uri.base[quarkus.analytics.uri.base]` + +Base URL of the analytics service. This is were all the data is sent to. +| String +| "https://api.segment.io/" +| [[build-analytics.quarkus.analytics.timeout]]`link:#build-analytics.quarkus.analytics.timeout[quarkus.analytics.timeout]` + +Value in milliseconds for the timeout of the HTTP request to submit the analytics service. +| int +| 3000 +|=== \ 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 cecfeb7343860..4a653541674e6 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,16 @@ 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, + Map graalVMInfo) { this.results = results; this.jar = jar; this.nativeImagePath = nativeImagePath; + this.graalVMInfo = graalVMInfo; } public List getResults() { @@ -44,4 +50,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 0000000000000..95284b02278c4 --- /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 0000000000000..6dcdc6013adbd --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnalyticsService.java @@ -0,0 +1,251 @@ +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_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_LOCALE_COUNTRY; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_LOCATION; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_MAVEN_VERSION; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_NAME; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_OS; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_OS_ARCH; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_QUARKUS; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_TIMEZONE; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_VENDOR; +import static io.quarkus.analytics.dto.segment.ContextBuilder.PROP_VERSION; +import static io.quarkus.analytics.dto.segment.ContextBuilder.VALUE_NULL_IP; +import 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.DEFAULT_TIMEOUT; +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 implements AutoCloseable { + private final Queue>> postFutures; + + final private RestClient restClient; + final private ConfigService config; + final private AnonymousUserId userId; + final private MessageWriter log; + final FileLocations fileLocations; + + public 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("[Quarkus build analytics] Failed to write the last analytics file. " + e.getMessage()); + } + } + } + + @Override + public void close() { + try { + // complete all. Normally, the queue should have only 1 element. + CompletableFuture.allOf(postFutures.toArray(new CompletableFuture[0])).get( + Integer.getInteger("quarkus.analytics.timeout", DEFAULT_TIMEOUT), + TimeUnit.MILLISECONDS); + if (log.isDebugEnabled() && !postFutures.isEmpty()) { + log.debug("[Quarkus build analytics] Build analytics sent successfully. Sent event can be seen at .../target/" + + fileLocations.lastTrackFileName()); + } + } catch (Exception e) { + log.warn("[Quarkus build analytics] Failed to send build analytics to Segment: " + + 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(), log)) + .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_LOCALE_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 0000000000000..fd39099934fc2 --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/AnonymousUserId.java @@ -0,0 +1,77 @@ +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("[Quarkus build analytics] 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("[Quarkus build analytics] 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 0000000000000..2830f67946c1f --- /dev/null +++ b/independent-projects/tools/analytics-common/src/main/java/io/quarkus/analytics/ConfigService.java @@ -0,0 +1,219 @@ +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.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +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"; + public static final String QUARKUS_ANALYTICS_PROMPT_TIMEOUT = "quarkus.analytics.prompt.timeout"; + private static final String NEW_LINE = System.lineSeparator(); + public static final String ACCEPTANCE_PROMPT = NEW_LINE + + "********************************************************************************************************" + + NEW_LINE + + "********************************************************************************************************" + + NEW_LINE + + "********************************************************************************************************" + + NEW_LINE + + "* Please help improve Quarkus!" + NEW_LINE + + "* Learn more: https://quarkus.io/usage-policy/" + NEW_LINE + + "* Do you agree to contribute anonymous build time data to the Quarkus community? (y/n) " + NEW_LINE; + private static final int DEFAULT_REFRESH_HOURS = 12; + + 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) { + final int timeout = Integer.getInteger(QUARKUS_ANALYTICS_PROMPT_TIMEOUT, 10); + if (Files.exists(localConfigFile) || Boolean.getBoolean(QUARKUS_ANALYTICS_DISABLED_LOCAL_PROP)) { + return; // ask nothing + } else { + try { + CompletableFuture userInputFuture = CompletableFuture + .supplyAsync(() -> analyticsEnabledSupplier.apply(ACCEPTANCE_PROMPT)); + final String userInput = userInputFuture.get(timeout, TimeUnit.SECONDS).toLowerCase().trim(); + if (!validInput(userInput)) { + log.info("[Quarkus build analytics] Didn't receive a valid user's answer: `y` or `n`. " + + "The question will be asked again next time." + NEW_LINE); + return; + } + final boolean isActive = userInput.equals("y") || userInput.equals("yes") || userInput.startsWith("yy"); + FileUtils.createFileAndParent(localConfigFile); + FileUtils.write(new LocalConfig(isActive), localConfigFile); + log.info("[Quarkus build analytics] Quarkus Build Analytics " + (isActive ? "enabled" : "disabled") + + " by the user." + NEW_LINE); + } catch (TimeoutException e) { + log.info("[Quarkus build analytics] Didn't receive the user's answer after " + timeout + " seconds. " + + "The question will be asked again next time." + NEW_LINE); + } catch (Exception e) { + log.info("[Quarkus build analytics] Analytics config file was not written successfully. " + + e.getClass().getName() + ": " + (e.getMessage() == null ? "(no message)" : e.getMessage())); + } + } + } + + /** + * True if build time analytics can be gathered. + *

+ *

+ * Disabled by default. + *

+ * If Not explicitly approved by user in dev mode, false + *

+ * If analytics disabled by local property, false + *

+ * If remote config not accessible, false + *

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