diff --git a/build.gradle b/build.gradle index 23169421..8553f2b1 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,7 @@ dependencies { } test { + // Discover and execute all other JUnit5-based tests useJUnitPlatform() afterSuite { desc, result -> if (!desc.parent) @@ -64,6 +65,15 @@ test { } } +tasks.register('platformTests', Test) { + // Discover and execute JUnit4-based EventCountsTest + useJUnit() + description = 'Runs the platform tests.' + group = 'verification' + outputs.upToDateWhen { false } + mustRunAfter test +} + configurations { implementation { exclude group: 'org.slf4j', module: 'slf4j-api' diff --git a/gradle.properties b/gradle.properties index a6ef35cc..fd990117 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ -ideaVersion = IC-2020.3 +ideaVersion = IC-2022.1 # build number ranges # https://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/build_number_ranges.html -sinceIdeaBuild=203 +sinceIdeaBuild=211 projectVersion=1.2.0-SNAPSHOT intellijPluginVersion=1.16.1 jetBrainsToken=invalid diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/ClasspathConfiguration.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/ClasspathConfiguration.java index c3add95b..710acbf0 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/ClasspathConfiguration.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/ClasspathConfiguration.java @@ -10,7 +10,6 @@ ******************************************************************************/ package com.redhat.devtools.intellij.telemetry.core.configuration; -import java.io.FileNotFoundException; import java.io.InputStream; import java.nio.file.Path; @@ -28,7 +27,7 @@ public ClasspathConfiguration(Path file, ClassLoader classLoader) { } @Override - protected InputStream createInputStream(Path path) throws FileNotFoundException { + protected InputStream createInputStream(Path path) { if (path == null) { return null; } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/CompositeConfiguration.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/CompositeConfiguration.java index ced3802d..a004887d 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/CompositeConfiguration.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/CompositeConfiguration.java @@ -10,11 +10,14 @@ ******************************************************************************/ package com.redhat.devtools.intellij.telemetry.core.configuration; +import org.jetbrains.annotations.Nullable; + import java.util.List; import java.util.Objects; public abstract class CompositeConfiguration implements IConfiguration { + @Nullable @Override public String get(final String key) { List configurations = getConfigurations(); diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/TelemetryConfiguration.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/TelemetryConfiguration.java index 15e266dc..789b3f8f 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/TelemetryConfiguration.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/TelemetryConfiguration.java @@ -25,7 +25,7 @@ public class TelemetryConfiguration extends CompositeConfiguration { private static final SaveableFileConfiguration FILE = new SaveableFileConfiguration( Directories.RED_HAT.resolve("com.redhat.devtools.intellij.telemetry")); - private static TelemetryConfiguration INSTANCE = new TelemetryConfiguration(); + private static final TelemetryConfiguration INSTANCE = new TelemetryConfiguration(); public static TelemetryConfiguration getInstance() { return INSTANCE; diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Enabled.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Enabled.java new file mode 100644 index 00000000..83e5b6d5 --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Enabled.java @@ -0,0 +1,42 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.intellij.openapi.util.text.StringUtil; + +import java.util.Arrays; + +public enum Enabled { + ALL("all"), + ERROR("error"), + CRASH("crash"), + OFF("off"); + + private final String value; + + Enabled(String value) { + this.value = value; + } + + private boolean hasValue(String value) { + if (StringUtil.isEmptyOrSpaces(value)) { + return this.value == null; + } + return value.equals(this.value); + } + + public static Enabled safeValueOf(String value) { + return Arrays.stream(values()) + .filter(instance -> instance.hasValue(value)) + .findAny() + .orElse(ALL); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventCounts.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventCounts.java new file mode 100644 index 00000000..0c50ba5f --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventCounts.java @@ -0,0 +1,183 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import com.intellij.util.xmlb.XmlSerializerUtil; +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import org.apache.commons.lang.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.redhat.devtools.intellij.telemetry.core.util.TimeUtils.isToday; + +/** + * A counter that stores daily occurrences of events. + * The state is persisted and loaded by the IDEA platform + * + * @see PersistentStateComponent + */ +@Service +@State( + name = " com.redhat.devtools.intellij.telemetry.core.configuration.limits.EventCounts", + storages = @Storage(value = "eventCounts.xml") +) +public final class EventCounts implements PersistentStateComponent { + + public static EventCounts getInstance() { + return ApplicationManager.getApplication().getService(EventCounts.class); + } + + private static final String COUNT_VALUES_SEPARATOR = ","; + + EventCounts() {} + + public final Map counts = new HashMap<>(); + + @Override + public EventCounts getState() { + return this; + } + + @Override + public void loadState(@NotNull EventCounts state) { + XmlSerializerUtil.copyBean(state, this); + } + + @Override + public void noStateLoaded() { + PersistentStateComponent.super.noStateLoaded(); + } + + @Override + public void initializeComponent() { + PersistentStateComponent.super.initializeComponent(); + } + + @Nullable + public Count get(Event event) { + if (event == null) { + return null; + } + String countString = counts.get(event.getName()); + return toCount(countString); + } + + public void put(Event event) { + Count count = createOrUpdateCount(event); + put(event, count); + } + + EventCounts put(Event event, Count count) { + if (event == null) { + return this; + } + counts.put(event.getName(), toString(count)); + return this; + } + + private Count createOrUpdateCount(Event event) { + Count count = get(event); + if (count != null) { + // update existing + count = count.newOccurrence(); + } else { + // create new + count = new Count(); + } + return count; + } + + private Count toCount(String string) { + if (StringUtils.isEmpty(string)) { + return null; + } + String[] split = string.split(COUNT_VALUES_SEPARATOR); + LocalDateTime lastOccurrence = toLastOccurrence(split[0]); + int total = toTotal(split[1]); + return new Count(lastOccurrence, total); + } + + private LocalDateTime toLastOccurrence(String value) { + try { + long epochSeconds = Long.parseLong(value); + return LocalDateTime.ofEpochSecond(epochSeconds, 0, ZonedDateTime.now().getOffset()); + } catch (NumberFormatException e) { + return null; + } + } + + private int toTotal(String value) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return 0; + } + } + + String toString(@NotNull Count count) { + long epochSecond = count.lastOccurrence.toEpochSecond(ZonedDateTime.now().getOffset()); + return epochSecond + COUNT_VALUES_SEPARATOR + count.dailyTotal; + } + + public static class Count { + private final LocalDateTime lastOccurrence; + private final int dailyTotal; + + Count() { + this(LocalDateTime.now(), 1); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Count)) return false; + Count count = (Count) o; + return dailyTotal == count.dailyTotal && Objects.equals(lastOccurrence, count.lastOccurrence); + } + + @Override + public int hashCode() { + return Objects.hash(lastOccurrence, dailyTotal); + } + + Count(LocalDateTime lastOccurrence, int dailyTotal) { + this.lastOccurrence = lastOccurrence; + this.dailyTotal = dailyTotal; + } + + public LocalDateTime getLastOccurrence() { + return lastOccurrence; + } + + public int getDailyTotal() { + if (isToday(lastOccurrence)) { + return dailyTotal; + } else { + return 0; + } + } + + public Count newOccurrence() { + return new Count(LocalDateTime.now(), getDailyTotal() + 1); + } + } +} diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventLimits.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventLimits.java new file mode 100644 index 00000000..57051a60 --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventLimits.java @@ -0,0 +1,180 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.text.StringUtil; +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import com.redhat.devtools.intellij.telemetry.core.util.TimeUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.attribute.FileTime; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.EventCounts.Count; + +public class EventLimits implements IEventLimits { + + private static final Logger LOGGER = Logger.getInstance(EventLimits.class); + + static final Duration DEFAULT_REFRESH_PERIOD = Duration.ofHours(6); + private final String pluginId; + private final PluginLimitsFactory factory; + private final LimitsConfigurations configuration; + private final EventCounts counts; + private List limits; + + interface PluginLimitsFactory { + List create(String json) throws IOException; + } + + public EventLimits(String pluginId) { + this(pluginId, null, PluginLimitsDeserialization::create, new LimitsConfigurations(), EventCounts.getInstance()); + } + + EventLimits(String pluginId, + List limits, + PluginLimitsFactory factory, + LimitsConfigurations configuration, + EventCounts counts) { + this.pluginId = pluginId; + this.limits = limits; + this.factory = factory; + this.configuration = configuration; + this.counts = counts; + } + + public boolean canSend(Event event) { + List all = getAllLimits(); + PluginLimits pluginLimits = getPluginLimits(pluginId, all); + int total = getApplicableTotal(counts.get(event)); + if (pluginLimits != null) { + return pluginLimits.canSend(event, total); + } else { + PluginLimits defaultLimits = getDefaultLimits(all); + if (defaultLimits == null) { + return true; + } + return defaultLimits.canSend(event, total); + } + } + + public void wasSent(Event event) { + counts.put(event); + } + + private int getApplicableTotal(Count count) { + if (occurredToday(count)) { + return count.getDailyTotal(); + } else { + return 0; + } + } + + private static boolean occurredToday(Count count) { + return count != null + && count.getLastOccurrence() != null + && TimeUtils.isToday(count.getLastOccurrence()); + } + + + /* for testing purposes */ + List getAllLimits() { + PluginLimits defaults = getDefaultLimits(limits); + Duration refreshAfter = getRefreshAfter(defaults); + FileTime lastModified = configuration.getLocalLastModified(); + if (needsRefresh(refreshAfter, lastModified)) { + this.limits = downloadRemote(configuration, factory); + } else if (limits == null) { + this.limits = readLocal(configuration, factory); + } + return limits; + } + + private boolean needsRefresh(Duration refreshAfter, FileTime modified) { + if (modified == null) { + return true; + } + LocalDateTime modificationLocalTime = LocalDateTime.ofInstant(modified.toInstant(), ZoneId.systemDefault()); + LocalDateTime refreshAt = modificationLocalTime.plus(refreshAfter); + return refreshAt.isBefore(LocalDateTime.now()); + } + + @Nullable + private PluginLimits getDefaultLimits(List limits) { + if (limits == null) { + return null; + } + return limits.stream() + .filter(PluginLimits::isDefault) + .findAny() + .orElse(null); + } + + @Nullable + private PluginLimits getPluginLimits(String pluginId, List allLimits) { + if (allLimits == null + || StringUtil.isEmptyOrSpaces(pluginId)) { + return null; + } + return allLimits.stream() + .filter(limits -> pluginId.equals(limits.getPluginId())) + .findAny() + .orElse(null); + } + + @NotNull + private Duration getRefreshAfter(PluginLimits defaults) { + if (defaults == null + || defaults.getRefresh() == -1) { + return DEFAULT_REFRESH_PERIOD; + } + return Duration.ofHours(defaults.getRefresh()); + } + + private List readLocal(LimitsConfigurations configuration, PluginLimitsFactory factory) { + try { + String config = configuration.readLocal(); + if (StringUtil.isEmptyOrSpaces(config)) { + return downloadRemote(configuration, factory); + } + return factory.create(config); + } catch (Exception e) { + return downloadRemote(configuration, factory); + } + } + + @Nullable + private List downloadRemote(LimitsConfigurations configuration, PluginLimitsFactory factory) { + try { + String config = configuration.downloadRemote(); + if (StringUtil.isEmptyOrSpaces(config)) { + return createEmbeddedLimits(configuration, factory); + } + return factory.create(config); + } catch (Exception e) { + return createEmbeddedLimits(configuration, factory); + } + } + + private List createEmbeddedLimits(LimitsConfigurations configuration, PluginLimitsFactory factory) { + try { + return factory.create(configuration.readEmbedded()); + } catch (IOException e) { + return null; + } + } +} diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Filter.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Filter.java new file mode 100644 index 00000000..2e4e423d --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Filter.java @@ -0,0 +1,104 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import com.redhat.devtools.intellij.telemetry.core.util.BasicGlobPattern; + +public interface Filter { + + boolean isMatching(Event event); + + boolean isIncludedByRatio(float percentile); + + boolean isExcludedByRatio(float percentile); + + boolean isWithinDailyLimit(int total); + + class EventPropertyFilter implements Filter { + private final String name; + private final BasicGlobPattern glob; + + EventPropertyFilter(String name, String valueGlob) { + this.name = name; + this.glob = BasicGlobPattern.compile(valueGlob); + } + + @Override + public boolean isMatching(Event event) { + String value = event.getProperties().get(name); + return glob.matches(value); + } + + @Override + public boolean isIncludedByRatio(float percentile) { + return true; + } + + @Override + public boolean isExcludedByRatio(float percentile) { + return false; + } + + @Override + public boolean isWithinDailyLimit(int total) { + return true; + } + } + + class EventNameFilter implements Filter { + + static final int DAILY_LIMIT_UNSPECIFIED = -1; + + private final BasicGlobPattern name; + private final float ratio; + private final int dailyLimit; + + EventNameFilter(String name, float ratio, int dailyLimit) { + this.name = BasicGlobPattern.compile(name); + this.ratio = ratio; + this.dailyLimit = dailyLimit; + } + + public float getRatio() { + return ratio; + } + + public int getDailyLimit() { + return dailyLimit; + } + + @Override + public boolean isMatching(Event event) { + return name.matches(event.getName()); + } + + @Override + public boolean isIncludedByRatio(float percentile) { + return ratio != 0 + && percentile <= ratio; + } + + @Override + public boolean isExcludedByRatio(float percentile) { + return 1 - ratio < percentile; + } + + @Override + public boolean isWithinDailyLimit(int total) { + if (dailyLimit == DAILY_LIMIT_UNSPECIFIED) { + return true; + } else { + return total < dailyLimit; // at least 1 more to go + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/IEventLimits.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/IEventLimits.java new file mode 100644 index 00000000..a799fcba --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/IEventLimits.java @@ -0,0 +1,20 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.service.Event; + +public interface IEventLimits { + + boolean canSend(Event event); + void wasSent(Event event); + +} diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/LimitsConfigurations.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/LimitsConfigurations.java new file mode 100644 index 00000000..44084b71 --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/LimitsConfigurations.java @@ -0,0 +1,141 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.intellij.openapi.diagnostic.Logger; +import com.redhat.devtools.intellij.telemetry.core.util.Directories; +import com.redhat.devtools.intellij.telemetry.core.util.FileUtils; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static com.redhat.devtools.intellij.telemetry.core.util.FileUtils.ensureExists; +import static com.redhat.devtools.intellij.telemetry.core.util.FileUtils.getPathForFileUrl; + +class LimitsConfigurations { + + private static final Logger LOGGER = Logger.getInstance(LimitsConfigurations.class); + static final Path LOCAL = Directories.RED_HAT.resolve("telemetry-config.json"); + static final String EMBEDDED = "/telemetry-config.json"; + static final String REMOTE = "https://raw.githubusercontent.com/redhat-developer/intellij-redhat-telemetry/blob/main/src/main/resources/telemetry-config.json"; + static final String SYSTEM_PROP_REMOTE = "REDHAT_TELEMETRY_REMOTE_CONFIG_URL"; + + protected final OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build(); + + @Nullable + public String downloadRemote() { + String url = System.getProperty(SYSTEM_PROP_REMOTE); + + String content = readFile(url); + if (content != null) { + return content; + } + + if (FileUtils.isFileUrl(url) + || !isValidURL(url)) { + // file-url to missing/unreadable file or missing/invalid url + url = REMOTE; + } + + return download(url); + } + + private @Nullable String readFile(String url) { + Path path = getPathForFileUrl(url); + if (path == null) { + return null; + } + try { + return toString(Files.newInputStream(path)); + } catch (IOException e) { + LOGGER.warn("Could not read remote limits configurations file from " + path, e); + return null; + } + } + + @Nullable String download(String url) { + Request request = new Request.Builder() + .url(url) + .addHeader("Content-Type", "application/json") + .build(); + try (Response response = client.newCall(request).execute()) { + if (response.body() != null) { + Files.copy(response.body().byteStream(), ensureExists(LOCAL), StandardCopyOption.REPLACE_EXISTING); + } + return readLocal(); + } catch (Exception e) { + LOGGER.warn("Could not download remote limits configurations from " + url, e); + return null; + } + } + + @Nullable + public FileTime getLocalLastModified() { + try { + if (!Files.exists(LOCAL)) { + return null; + } + return Files.getLastModifiedTime(LOCAL); + } catch (Throwable e) { + return null; + } + } + + @Nullable + public String readLocal() { + try { + return toString(Files.newInputStream(LOCAL)); + } catch (IOException e) { + return null; + } + } + + public String readEmbedded() throws IOException { + return toString(LimitsConfigurations.class.getResourceAsStream(EMBEDDED)); + } + + private String toString(InputStream in) throws IOException { + if (in == null) { + return null; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) { + return reader.lines().collect(Collectors.joining()); + } + } + + private boolean isValidURL(String url) { + try { + new URL(url); + return true; + } catch (MalformedURLException e) { + return false; + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimits.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimits.java new file mode 100644 index 00000000..138be142 --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimits.java @@ -0,0 +1,125 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import com.redhat.devtools.intellij.telemetry.core.service.UserId; + +import java.util.List; + +public class PluginLimits { + private final String pluginId; + private final Enabled enabled; + private final int refresh; + private final float ratio; + private final List includes; + private final List excludes; + private final UserId userId; + + PluginLimits(String pluginId, Enabled enabled, int refresh, float ratio, List includes, List excludes) { + this(pluginId, enabled, refresh, ratio, includes, excludes, UserId.INSTANCE); + } + + PluginLimits(String pluginId, Enabled enabled, int refresh, float ratio, List includes, List excludes, UserId userId) { + this.pluginId = pluginId; + this.enabled = enabled; + this.refresh = refresh; + this.ratio = ratio; + this.includes = includes; + this.excludes = excludes; + this.userId = userId; + } + + public String getPluginId() { + return pluginId; + } + + public boolean isDefault() { + return "*".equals(pluginId); + } + + Enabled getEnabled() { + return enabled; + } + + int getRefresh() { + return refresh; + } + + float getRatio() { + return ratio; + } + + public boolean canSend(Event event, int currentTotal) { + if (event == null) { + return false; + } + if (!isEnabled() + || (isErrorOnly() && !event.hasError())) { + return false; + } + + if (!isInRatio()) { + return false; + } + + return isIncluded(event, currentTotal) + && !isExcluded(event); + } + + private boolean isInRatio() { + if (userId == null) { + return true; + } + return ratio > 0 + && ratio >= userId.getPercentile(); + } + + boolean isEnabled() { + Enabled enabled = getEnabled(); + return enabled != null + && enabled != Enabled.OFF; + } + + boolean isErrorOnly() { + Enabled enabled = getEnabled(); + return enabled == Enabled.CRASH + || enabled == Enabled.ERROR; + } + + List getIncludes() { + return includes; + } + + boolean isIncluded(Event event, int currentTotal) { + Filter matching = includes.stream() + .filter(filter -> filter.isMatching(event)) + .findAny() + .orElse(null); + return matching == null || + (matching.isIncludedByRatio(userId.getPercentile()) + && matching.isWithinDailyLimit(currentTotal)); + } + + boolean isExcluded(Event event) { + Filter matching = excludes.stream() + .filter(filter -> filter.isMatching(event)) + .findAny() + .orElse(null); + return matching != null + && matching.isExcludedByRatio(userId.getPercentile()); + } + + List getExcludes() { + return excludes; + } + +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsDeserialization.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsDeserialization.java new file mode 100644 index 00000000..f4a33bf2 --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsDeserialization.java @@ -0,0 +1,182 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.text.StringUtil; +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.Filter.EventNameFilter; +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.Filter.EventPropertyFilter; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +class PluginLimitsDeserialization extends StdDeserializer> { + + private static final Logger LOGGER = Logger.getInstance(PluginLimitsDeserialization.class); + + public static final String FIELDNAME_ENABLED = "enabled"; + public static final String FIELDNAME_REFRESH = "refresh"; + public static final String FIELDNAME_RATIO = "ratio"; + public static final String FIELDNAME_INCLUDES = "includes"; + public static final String FIELDNAME_EXCLUDES = "excludes"; + public static final String FIELDNAME_PROPERTY = "property"; + public static final String FIELDNAME_VALUE = "value"; + public static final String FIELDNAME_DAILY_LIMIT = "dailyLimit"; + public static final String FIELDNAME_NAME = "name"; + + public static final int DEFAULT_NUMERIC_VALUE = -1; + + public static List create(String json) throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(List.class, new PluginLimitsDeserialization()); + mapper.registerModule(module); + return mapper.readValue(json, List.class); + } + + PluginLimitsDeserialization() { + this(null); + } + + PluginLimitsDeserialization(Class clazz) { + super(clazz); + } + + @Override + public List deserialize(JsonParser parser, DeserializationContext ctx) throws IOException { + JsonNode node = parser.getCodec().readTree(parser); + Spliterator> spliterator = Spliterators.spliteratorUnknownSize(node.fields(), Spliterator.IMMUTABLE); + return StreamSupport.stream(spliterator, false) + .map(this::createMessageLimit) + .collect(Collectors.toList()); + } + + @NotNull + private PluginLimits createMessageLimit(Map.Entry entry) { + String pattern = entry.getKey(); + JsonNode properties = entry.getValue(); + Enabled enabled = getEnabled(properties.get(FIELDNAME_ENABLED)); + int refresh = getRefresh(properties.get(FIELDNAME_REFRESH)); + float ratio = getRatio(properties.get(FIELDNAME_RATIO)); + List includes = getFilters(properties.get(FIELDNAME_INCLUDES)); + List excludes = getFilters(properties.get(FIELDNAME_EXCLUDES)); + + return new PluginLimits(pattern, enabled, refresh, ratio, includes, excludes); + } + + private Enabled getEnabled(JsonNode node) { + String value = node != null ? node.asText() : null; + return Enabled.safeValueOf(value); + } + + private int getRefresh(JsonNode node) { + int numeric = DEFAULT_NUMERIC_VALUE; + if (node != null) { + String refresh = getNumericPortion(node.asText().toCharArray()); + if (!StringUtil.isEmptyOrSpaces(refresh)) { + try { + numeric = Integer.parseInt(refresh); + } catch (NumberFormatException e) { + LOGGER.warn("Could not convert " + FIELDNAME_REFRESH + " to integer value: " + refresh); + } + } + } + return numeric; + } + + private List getFilters(JsonNode node) { + if (node == null + || !node.isArray()) { + return Collections.emptyList(); + } + Spliterator spliterator = Spliterators.spliteratorUnknownSize(node.elements(), Spliterator.IMMUTABLE); + return StreamSupport.stream(spliterator, false) + .map(this::createMessageLimitFilter) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + private Filter createMessageLimitFilter(JsonNode node) { + if (node.has(FIELDNAME_NAME)) { + return createEventNameFilter(node); + } else if (node.has(FIELDNAME_PROPERTY) + && node.has(FIELDNAME_VALUE)) { + return createEventPropertyFilter(node); + } else { + return null; + } + } + + private EventNameFilter createEventNameFilter(JsonNode node) { + String name = getStringValue(FIELDNAME_NAME, node); + float ratio = getRatio(node.get(FIELDNAME_RATIO)); + int dailyLimit = getIntValue(FIELDNAME_DAILY_LIMIT, node); + return new EventNameFilter(name, ratio, dailyLimit); + } + + private EventPropertyFilter createEventPropertyFilter(JsonNode node) { + String property = getStringValue(FIELDNAME_PROPERTY, node); + String value = getStringValue(FIELDNAME_VALUE, node); + return new EventPropertyFilter(property, value); + } + + private static float getRatio(JsonNode node) { + float numeric = 1f; + if (node != null) { + try { + numeric = Float.parseFloat(node.asText()); + } catch (NumberFormatException e) { + // swallow + } + } + return numeric; + } + + private static String getStringValue(String name, JsonNode node) { + if (node == null + || node.get(name) == null) { + return null; + } + return node.get(name).asText(); + } + + private static int getIntValue(String name, JsonNode node) { + int numeric = DEFAULT_NUMERIC_VALUE; + if (node != null + && node.get(name) != null) { + numeric = node.get(name).asInt(DEFAULT_NUMERIC_VALUE); + } + return numeric; + } + + private static String getNumericPortion(char[] characters) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < characters.length && Character.isDigit(characters[i]); i++) { + builder.append(characters[i]); + } + return builder.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Country.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Country.java index 3d3919ba..f0709bbb 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Country.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Country.java @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.intellij.openapi.diagnostic.Logger; import com.redhat.devtools.intellij.telemetry.core.util.Lazy; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -46,6 +47,7 @@ protected Country() { // for testing purposes } + @Nullable public String get(TimeZone timeZone) { if (timeZone == null) { return null; @@ -53,6 +55,7 @@ public String get(TimeZone timeZone) { return get(timeZone.getID()); } + @Nullable public String get(String timezoneId) { Map timezone = timezones.get().get(timezoneId); if (timezone == null) { diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Environment.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Environment.java index b9d8d23a..509f72a7 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Environment.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Environment.java @@ -134,7 +134,7 @@ public Environment build() { /** * Returns the plugin from which Telemetry events are sent. */ - public Application getPlugin() { + public Plugin getPlugin() { return plugin; } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Event.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Event.java index 7b8ca510..803a3387 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Event.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Event.java @@ -13,6 +13,8 @@ import java.util.HashMap; import java.util.Map; +import static com.redhat.devtools.intellij.telemetry.core.service.Message.PROP_ERROR; + public class Event { public enum Type { @@ -44,4 +46,9 @@ public String getName() { public Map getProperties() { return properties; } + + public boolean hasError() { + return properties != null + && properties.containsKey(PROP_ERROR); + } } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/FeedbackService.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/FeedbackService.java index 43d0db3b..c1953b3c 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/FeedbackService.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/FeedbackService.java @@ -11,10 +11,8 @@ package com.redhat.devtools.intellij.telemetry.core.service; import com.intellij.openapi.diagnostic.Logger; -import com.redhat.devtools.intellij.telemetry.core.IMessageBroker; -import com.redhat.devtools.intellij.telemetry.core.IService; -public class FeedbackService implements IService { +class FeedbackService implements IService { private static final Logger LOGGER = Logger.getInstance(FeedbackService.class); diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/FeedbackServiceFactory.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/FeedbackServiceFactory.java index 88a47c02..e1e74874 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/FeedbackServiceFactory.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/FeedbackServiceFactory.java @@ -12,14 +12,9 @@ import com.intellij.openapi.components.Service; import com.intellij.openapi.project.DumbAware; -import com.redhat.devtools.intellij.telemetry.core.IMessageBroker; -import com.redhat.devtools.intellij.telemetry.core.IService; -import com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration; -import com.redhat.devtools.intellij.telemetry.core.service.segment.SegmentBroker; -import com.redhat.devtools.intellij.telemetry.core.service.segment.SegmentConfiguration; @Service -public final class FeedbackServiceFactory implements DumbAware { +final class FeedbackServiceFactory implements DumbAware { public IService create(IMessageBroker broker) { return new FeedbackService(broker); diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/IMessageBroker.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/IMessageBroker.java similarity index 78% rename from src/main/java/com/redhat/devtools/intellij/telemetry/core/IMessageBroker.java rename to src/main/java/com/redhat/devtools/intellij/telemetry/core/service/IMessageBroker.java index cdd01009..15e4d0ef 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/IMessageBroker.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/IMessageBroker.java @@ -8,16 +8,15 @@ * Contributors: * Red Hat, Inc. - initial API and implementation ******************************************************************************/ -package com.redhat.devtools.intellij.telemetry.core; +package com.redhat.devtools.intellij.telemetry.core.service; import com.intellij.openapi.extensions.PluginDescriptor; -import com.redhat.devtools.intellij.telemetry.core.service.Event; public interface IMessageBroker { void send(Event event); void dispose(); interface IMessageBrokerFactory { - IMessageBroker create(boolean isDebug, PluginDescriptor descriptor); + IMessageBroker create(boolean isDebug, Environment environment, PluginDescriptor descriptor); } } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/IService.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/IService.java similarity index 82% rename from src/main/java/com/redhat/devtools/intellij/telemetry/core/IService.java rename to src/main/java/com/redhat/devtools/intellij/telemetry/core/service/IService.java index fe8cb531..26493be5 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/IService.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/IService.java @@ -8,9 +8,7 @@ * Contributors: * Red Hat, Inc. - initial API and implementation ******************************************************************************/ -package com.redhat.devtools.intellij.telemetry.core; - -import com.redhat.devtools.intellij.telemetry.core.service.Event; +package com.redhat.devtools.intellij.telemetry.core.service; public interface IService { void send(Event event); diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Message.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Message.java index 4f2cff42..49490179 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Message.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Message.java @@ -11,7 +11,6 @@ package com.redhat.devtools.intellij.telemetry.core.service; import com.intellij.openapi.diagnostic.Logger; -import com.redhat.devtools.intellij.telemetry.core.IService; import java.util.HashMap; import java.util.Map; diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Plugin.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Plugin.java index 982dcda1..9ed1232e 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Plugin.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/Plugin.java @@ -16,19 +16,20 @@ public class Plugin extends Application { public static final class Factory { - public Plugin create(@NotNull PluginDescriptor descriptor) { - return create(descriptor.getName(), descriptor.getVersion()); + return create(descriptor.getName(), descriptor.getVersion(), descriptor.getPluginId().getIdString()); } - public Plugin create(String name, String version) { - return new Plugin(name, version); + public Plugin create(String name, String version, String id) { + return new Plugin(name, version, id); } - } - Plugin(String name, String version) { + private final String id; + + Plugin(String name, String version, String id) { super(name, version); + this.id = id; } @Override @@ -37,4 +38,7 @@ public Plugin property(String key, String value) { return this; } + public String getId() { + return id; + } } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilder.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilder.java index 06363413..ee0963c6 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilder.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilder.java @@ -11,12 +11,13 @@ package com.redhat.devtools.intellij.telemetry.core.service; import com.intellij.ide.AppLifecycleListener; +import com.intellij.ide.plugins.cl.PluginAwareClassLoader; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.extensions.PluginDescriptor; import com.intellij.util.messages.MessageBusConnection; -import com.redhat.devtools.intellij.telemetry.core.IMessageBroker; -import com.redhat.devtools.intellij.telemetry.core.IService; import com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration; +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.EventLimits; +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.IEventLimits; import com.redhat.devtools.intellij.telemetry.core.service.Event.Type; import com.redhat.devtools.intellij.telemetry.core.service.segment.SegmentBrokerFactory; import com.redhat.devtools.intellij.telemetry.core.util.Lazy; @@ -36,13 +37,26 @@ public class TelemetryMessageBuilder { private final IService telemetryFacade; private final IService feedbackFacade; + @Deprecated(forRemoval = true) + public TelemetryMessageBuilder(ClassLoader classLoader) { + this(getDescriptor(classLoader)); + } + public TelemetryMessageBuilder(PluginDescriptor descriptor) { - this(new SegmentBrokerFactory().create(TelemetryConfiguration.getInstance().isDebug(), descriptor)); + this(createEnvironment(descriptor), descriptor); } - TelemetryMessageBuilder(IMessageBroker messageBroker) { + TelemetryMessageBuilder(Environment environment, PluginDescriptor descriptor) { + this(environment.getPlugin().getId(), + new SegmentBrokerFactory().create( + TelemetryConfiguration.getInstance().isDebug(), + environment, + descriptor)); + } + + TelemetryMessageBuilder(String pluginId, IMessageBroker messageBroker) { this( - new TelemetryServiceFacade(TelemetryConfiguration.getInstance(), messageBroker), + new TelemetryServiceFacade(TelemetryConfiguration.getInstance(), new EventLimits(pluginId), messageBroker), new FeedbackServiceFacade(messageBroker) ); } @@ -166,10 +180,12 @@ static class TelemetryServiceFacade extends Lazy implements IService { private final MessageBusConnection messageBusConnection; - protected TelemetryServiceFacade(final TelemetryConfiguration configuration, IMessageBroker broker) { - this(() -> ApplicationManager.getApplication().getService(TelemetryServiceFactory.class).create(configuration, broker), - ApplicationManager.getApplication().getMessageBus().connect() - ); + protected TelemetryServiceFacade(final TelemetryConfiguration configuration, IEventLimits limits, IMessageBroker broker) { + this(() -> ApplicationManager.getApplication().getService(TelemetryServiceFactory.class).create( + configuration, + limits, + broker), + ApplicationManager.getApplication().getMessageBus().connect()); } protected TelemetryServiceFacade(final Supplier supplier, MessageBusConnection connection) { @@ -229,4 +245,22 @@ public static class FeedbackMessage extends Message{ } } + private static Environment createEnvironment(PluginDescriptor descriptor) { + IDE ide = new IDE.Factory() + .create() + .setJavaVersion(); + return new Environment.Builder() + .ide(ide) + .plugin(descriptor) + .build(); + } + + private static PluginDescriptor getDescriptor(ClassLoader classLoader) { + if (classLoader instanceof PluginAwareClassLoader) { + return ((PluginAwareClassLoader) classLoader).getPluginDescriptor(); + } else { + return null; + } + } + } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryService.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryService.java index b11d4d4e..8f321805 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryService.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryService.java @@ -10,23 +10,22 @@ ******************************************************************************/ package com.redhat.devtools.intellij.telemetry.core.service; -import static com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration.KEY_MODE; - -import java.util.concurrent.atomic.AtomicBoolean; - import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.util.messages.MessageBusConnection; -import com.redhat.devtools.intellij.telemetry.core.IMessageBroker; -import com.redhat.devtools.intellij.telemetry.core.IService; import com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration; import com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration.ConfigurationChangedListener; import com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration.Mode; +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.IEventLimits; import com.redhat.devtools.intellij.telemetry.core.service.Event.Type; import com.redhat.devtools.intellij.telemetry.core.util.CircularBuffer; import com.redhat.devtools.intellij.telemetry.ui.TelemetryNotifications; -public class TelemetryService implements IService { +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration.KEY_MODE; + +class TelemetryService implements IService { private static final Logger LOGGER = Logger.getInstance(TelemetryService.class); @@ -34,20 +33,31 @@ public class TelemetryService implements IService { private final TelemetryNotifications notifications; private final TelemetryConfiguration configuration; + private final IEventLimits limits; protected final IMessageBroker broker; private final AtomicBoolean userQueried = new AtomicBoolean(false); private final CircularBuffer onHold = new CircularBuffer<>(BUFFER_SIZE); - public TelemetryService(final TelemetryConfiguration configuration, final IMessageBroker broker) { - this(configuration, broker, ApplicationManager.getApplication().getMessageBus().connect(), new TelemetryNotifications()); + public TelemetryService( + final TelemetryConfiguration configuration, + final IEventLimits limits, + final IMessageBroker broker) { + this(configuration, + limits, + broker, + ApplicationManager.getApplication().getMessageBus().connect(), + new TelemetryNotifications() + ); } TelemetryService( final TelemetryConfiguration configuration, + final IEventLimits limits, final IMessageBroker broker, final MessageBusConnection connection, final TelemetryNotifications notifications) { this.configuration = configuration; + this.limits = limits; this.broker = broker; this.notifications = notifications; onConfigurationChanged(connection); @@ -85,7 +95,10 @@ private void queryUserConsent() { private void doSend(Event event) { if (isEnabled()) { flushOnHold(); - broker.send(event); + if (limits.canSend(event)) { + broker.send(event); + limits.wasSent(event); + } } else if (!isConfigured()) { onHold.offer(event); } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryServiceFactory.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryServiceFactory.java index 39a8f484..ecf82af8 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryServiceFactory.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryServiceFactory.java @@ -12,13 +12,13 @@ import com.intellij.openapi.components.Service; import com.intellij.openapi.project.DumbAware; -import com.redhat.devtools.intellij.telemetry.core.IMessageBroker; import com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration; +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.IEventLimits; @Service -public final class TelemetryServiceFactory implements DumbAware { +final class TelemetryServiceFactory implements DumbAware { - public TelemetryService create(TelemetryConfiguration configuration, IMessageBroker broker) { - return new TelemetryService(configuration, broker); + public TelemetryService create(TelemetryConfiguration configuration, IEventLimits limits, IMessageBroker broker) { + return new TelemetryService(configuration, limits, broker); } } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/UserId.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/UserId.java index 6ebaba76..7bf588e8 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/UserId.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/UserId.java @@ -31,6 +31,7 @@ public class UserId { private static final Pattern UUID_REGEX = Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); private final Lazy uuid = new Lazy<>(() -> loadOrCreate(UUID_FILE)); + private final Lazy percentile = new Lazy<>(this::createPercentile); /** for testing purposes */ protected UserId() {} @@ -91,4 +92,29 @@ protected void write(String uuid, Path uuidFile) { LOGGER.warn("Could not write redhat anonymous UUID to file at " + UUID_FILE.toAbsolutePath(), e); } } + + @Override + public int hashCode() { + int hash = 0; + String uuid = get(); + for (int i = 0; i < uuid.length(); i++) { + int code = uuid.codePointAt(i); + hash = ((hash << 5) - hash) + code; + } + return hash; + } + + public float getPercentile() { + return percentile.get(); + } + + private float createPercentile() { + try { + String hash = String.valueOf(Math.abs(hashCode())); + int length = Math.min(4, hash.length()); + return Float.parseFloat(hash.substring(hash.length() - length)) / 10000; // use at most last 4 chars + } catch (NumberFormatException e) { + return 0; + } + } } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/IdentifyTraits.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/IdentifyTraits.java index 841f1d9d..c81bb475 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/IdentifyTraits.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/IdentifyTraits.java @@ -15,14 +15,14 @@ /** * Traits that the segment broker sends with am identify event. */ -public class IdentifyTraits { +class IdentifyTraits { private final String locale; private final String timezone; private final String osName; private final String osVersion; private final String osDistribution; - public IdentifyTraits(String locale, String timezone, String osName, String osVersion, String osDistribution) { + IdentifyTraits(String locale, String timezone, String osName, String osVersion, String osDistribution) { this.locale = locale; this.timezone = timezone; this.osName = osName; diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/IdentifyTraitsPersistence.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/IdentifyTraitsPersistence.java index 9a034ca9..0edbfe7b 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/IdentifyTraitsPersistence.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/IdentifyTraitsPersistence.java @@ -35,14 +35,14 @@ public class IdentifyTraitsPersistence { protected IdentifyTraitsPersistence() {} - public synchronized IdentifyTraits get() { + synchronized IdentifyTraits get() { if (identifyTraits == null) { this.identifyTraits = deserialize(load(FILE)); } return identifyTraits; } - public synchronized void set(IdentifyTraits identifyTraits) { + synchronized void set(IdentifyTraits identifyTraits) { if (Objects.equals(identifyTraits, this.identifyTraits)) { return; } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBroker.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBroker.java index bceff031..26a7cc32 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBroker.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBroker.java @@ -11,7 +11,7 @@ package com.redhat.devtools.intellij.telemetry.core.service.segment; import com.intellij.openapi.diagnostic.Logger; -import com.redhat.devtools.intellij.telemetry.core.IMessageBroker; +import com.redhat.devtools.intellij.telemetry.core.service.IMessageBroker; import com.redhat.devtools.intellij.telemetry.core.service.Application; import com.redhat.devtools.intellij.telemetry.core.service.Environment; import com.redhat.devtools.intellij.telemetry.core.service.Event; @@ -22,6 +22,7 @@ import com.segment.analytics.messages.MessageBuilder; import com.segment.analytics.messages.PageMessage; import com.segment.analytics.messages.TrackMessage; +import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.Map; @@ -52,7 +53,7 @@ public class SegmentBroker implements IMessageBroker { public static final String PROP_APP_NAME = "app_name"; public static final String PROP_APP_VERSION = "app_version"; - enum SegmentType { + private enum SegmentType { IDENTIFY { public MessageBuilder toMessage(Event event, Map context, SegmentBroker broker) { return broker.toMessage(IdentifyMessage.builder(), event, context); @@ -72,7 +73,7 @@ public MessageBuilder toMessage(Event event, Map context, Segmen public abstract MessageBuilder toMessage(Event event, Map context, SegmentBroker broker); - public static SegmentType valueOf(Event.Type eventType) { + static SegmentType valueOf(Event.Type eventType) { switch (eventType) { case USER: return IDENTIFY; @@ -90,11 +91,12 @@ public static SegmentType valueOf(Event.Type eventType) { private final Environment environment; private final Lazy analytics; - public SegmentBroker(boolean isDebug, String userId, Environment environment, ISegmentConfiguration configuration) { + SegmentBroker(boolean isDebug, String userId, Environment environment, ISegmentConfiguration configuration) { this(isDebug, userId, IdentifyTraitsPersistence.INSTANCE, environment, configuration, new AnalyticsFactory()); } - public SegmentBroker( + /* for testing purposes */ + protected SegmentBroker( boolean isDebug, String userId, IdentifyTraitsPersistence identifyTraitsPersistence, @@ -252,6 +254,7 @@ private static class AnalyticsFactory implements Function { private static final int FLUSH_INTERVAL = 10000; private static final int FLUSH_QUEUE_SIZE = 10; + @Nullable @Override public Analytics apply(String writeKey) { if (writeKey == null) { diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBrokerFactory.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBrokerFactory.java index d683d7a1..5e3fddc4 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBrokerFactory.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBrokerFactory.java @@ -1,18 +1,26 @@ +/******************************************************************************* + * Copyright (c) 2023 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ package com.redhat.devtools.intellij.telemetry.core.service.segment; import com.intellij.openapi.extensions.PluginDescriptor; -import com.redhat.devtools.intellij.telemetry.core.IMessageBroker; +import com.redhat.devtools.intellij.telemetry.core.service.IMessageBroker; import com.redhat.devtools.intellij.telemetry.core.service.Environment; -import com.redhat.devtools.intellij.telemetry.core.service.IDE; import com.redhat.devtools.intellij.telemetry.core.service.UserId; -import static com.redhat.devtools.intellij.telemetry.core.IMessageBroker.*; +import static com.redhat.devtools.intellij.telemetry.core.service.IMessageBroker.*; public class SegmentBrokerFactory implements IMessageBrokerFactory { @Override - public IMessageBroker create(boolean isDebug, PluginDescriptor descriptor) { - Environment environment = createEnvironment(descriptor); + public IMessageBroker create(boolean isDebug, Environment environment, PluginDescriptor descriptor) { SegmentConfiguration configuration = new SegmentConfiguration(descriptor.getPluginClassLoader()); return new SegmentBroker( isDebug, @@ -20,14 +28,4 @@ public IMessageBroker create(boolean isDebug, PluginDescriptor descriptor) { environment, configuration); } - - private static Environment createEnvironment(PluginDescriptor descriptor) { - IDE ide = new IDE.Factory() - .create() - .setJavaVersion(); - return new Environment.Builder() - .ide(ide) - .plugin(descriptor) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentConfiguration.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentConfiguration.java index 209a8eb7..3a452184 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentConfiguration.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentConfiguration.java @@ -29,11 +29,11 @@ public class SegmentConfiguration extends CompositeConfiguration implements ISeg private final ClasspathConfiguration consumerClasspathConfiguration; - public SegmentConfiguration(ClassLoader classLoader) { + SegmentConfiguration(ClassLoader classLoader) { this(new ClasspathConfiguration(Paths.get(SEGMENT_PROPERTIES), classLoader)); } - protected SegmentConfiguration(ClasspathConfiguration consumerClasspathConfiguration) { + SegmentConfiguration(ClasspathConfiguration consumerClasspathConfiguration) { this.consumerClasspathConfiguration = consumerClasspathConfiguration; } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/package-info.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/package-info.java new file mode 100644 index 00000000..e390bc51 --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/service/segment/package-info.java @@ -0,0 +1,4 @@ +@ApiStatus.Internal +package com.redhat.devtools.intellij.telemetry.core.service.segment; + +import org.jetbrains.annotations.ApiStatus; \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/BasicGlobPattern.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/BasicGlobPattern.java new file mode 100644 index 00000000..2e41db30 --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/BasicGlobPattern.java @@ -0,0 +1,328 @@ +/************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * License: GNU General Public License version 2 plus the Classpath exception + * + * Based on implementation at sun.nio.fs.Globs in jdk 11.0.2 + * + * Copyright (c) 2008, 2009, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.util; + +import org.apache.commons.lang.StringUtils; + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * A simple Glob Pattern that supports: + *
    + *
  • placeholders {@code ?}
  • + *
  • wildcard {@code *}
  • + *
  • brace expansions {@code {alternative1,alternative2,}}
  • + *
  • ranges {@code [1-4]}
  • + *
+ * It does not support extended (advanced-, posix-) glob expressions like alternatives {@code @(a|b) or +(a|b) etc )} + */ +public class BasicGlobPattern { + + private static final String regexMetaChars = ".^$+{[]|()"; + private static final String globMetaChars = "\\*?[{"; + + private final Pattern globPattern; + + public static BasicGlobPattern compile(String glob) { + return new Factory().create(glob); + } + + private BasicGlobPattern(Pattern globPattern) { + this.globPattern = globPattern; + } + + public boolean matches(String toMatch) { + if (StringUtils.isEmpty(toMatch)) { + return false; + } + return globPattern.matcher(toMatch).matches(); + } + + private static final class Factory { + + private static final class GlobParserContext { + + public static final char EOL = 0; + + private final String globPattern; + private final StringBuilder builder = new StringBuilder("^"); + + private boolean inGroup = false; + private boolean hasRangeStart = false; + private int index = 0; + private char lastRangeCharacter = 0; + + GlobParserContext(String globPattern) { + this.globPattern = globPattern; + } + + String getGlobPattern() { + return globPattern; + } + + int getGlobIndex() { + return index; + } + + boolean globEndReached() { + return index >= globPattern.length(); + } + + char peekGlob() { + if (index < globPattern.length()) { + return globPattern.charAt(index); + } + return EOL; + } + + char pollGlob() { + if (index < globPattern.length()) { + return globPattern.charAt(index++); + } + return EOL; + } + + void nextGlobChar() { + index++; + } + + GlobParserContext appendToRegex(String toAppend) { + builder.append(toAppend); + return this; + } + + GlobParserContext appendToRegex(char toAppend) { + builder.append(toAppend); + return this; + } + + GlobParserContext setInGroup(boolean inGroup) { + this.inGroup = inGroup; + return this; + } + + boolean isInGroup() { + return inGroup; + } + + GlobParserContext setInRange(boolean hasRangeStart) { + this.hasRangeStart = hasRangeStart; + return this; + } + + boolean isInRange() { + return hasRangeStart; + } + + void setLastRangeCharacter(char character) { + this.lastRangeCharacter = character; + } + + char getLastRangeCharacter() { + return lastRangeCharacter; + } + + String getRegex() { + return builder.toString(); + } + } + + private BasicGlobPattern create(String glob) { + Pattern globPattern = createRegex(glob); + return new BasicGlobPattern(globPattern); + } + + private Pattern createRegex(String globPattern) { + if (globPattern == null) { + return null; + } + GlobParserContext context = new GlobParserContext(globPattern); + while (!context.globEndReached()) { + char c = context.pollGlob(); + switch (c) { + case '\\': + handleEscape(context); + break; + case '/': + context.appendToRegex(c); + break; + case '[': + handleSquareOpen(context); + break; + case '{': + handleCurlyOpen(context); + break; + case '}': + handleCurlyClose(context); + break; + case ',': + handleComma(context); + break; + case '*': + handleWildcard(context); + break; + case '?': + handleQuestionMark(context); + break; + + default: + handleDefaultCharacter(c, context); + } + } + + if (context.isInGroup()) { + throw new PatternSyntaxException("Missing '}", globPattern, context.getGlobIndex() - 1); + } + + context.appendToRegex('$'); + return Pattern.compile(context.getRegex()); + } + + private void handleEscape(GlobParserContext context) { + // escape special characters + if (context.globEndReached()) { + throw new PatternSyntaxException("No character to escape", context.getGlobPattern(), context.getGlobIndex() - 1); + } + char next = context.pollGlob(); + if (isGlobMeta(next) || isRegexMeta(next)) { + context.appendToRegex('\\'); + } + context.appendToRegex(next); + } + + private void handleSquareOpen(GlobParserContext context) { + char character = '['; + // don't match name separator in class + context.appendToRegex("[[^/]&&["); + if (context.peekGlob() == '^') { + // escape the regex negation char if it appears + context.appendToRegex("\\^").nextGlobChar(); + } else { + // negation + if (context.peekGlob() == '!') { + context.appendToRegex('^').nextGlobChar(); + } + // hyphen allowed at start + if (context.peekGlob() == '-') { + context.appendToRegex('-').nextGlobChar(); + } + } + while (!context.globEndReached()) { + character = context.pollGlob(); + if (character == ']') { + break; + } + if (character == '/') { + throw new PatternSyntaxException("Explicit 'name separator' in class", context.getGlobPattern(), context.getGlobIndex() - 1); + } + // TBD: how to specify ']' in a class? + if (character == '\\' || character == '[' || + character == '&' && context.peekGlob() == '&') { + // escape '\', '[' or "&&" for regex class + context.appendToRegex('\\'); + } + context.appendToRegex(character); + + if (character == '-') { + if (!context.isInRange()) { + throw new PatternSyntaxException("Invalid range", context.getGlobPattern(), context.getGlobIndex() - 1); + } + character = context.pollGlob(); + if (character == GlobParserContext.EOL || character == ']') { + break; + } + if (character < context.getLastRangeCharacter()) { + throw new PatternSyntaxException("Invalid range", context.getGlobPattern(), context.getGlobIndex() - 3); + } + context.appendToRegex(character).setInRange(false); + } else { + context.setInRange(true).setLastRangeCharacter(character); + } + } + if (character != ']') { + throw new PatternSyntaxException("Missing ']", context.getGlobPattern(), context.getGlobIndex() - 1); + } + context.appendToRegex("]]"); + } + + private void handleCurlyOpen(GlobParserContext context) { + if (context.isInGroup()) { + throw new PatternSyntaxException("Cannot nest groups", context.getGlobPattern(), context.getGlobIndex() - 1); + } + context.setInGroup(true).appendToRegex("(?:(?:"); + } + + private void handleCurlyClose(GlobParserContext context) { + if (context.isInGroup()) { + context.appendToRegex("))").setInGroup(false); + } else { + context.appendToRegex('}'); + } + } + + private void handleComma(GlobParserContext context) { + if (context.isInGroup()) { + context.appendToRegex(")|(?:"); + } else { + context.appendToRegex(','); + } + } + + private void handleWildcard(GlobParserContext context) { + if (context.peekGlob() == '*') { + // crosses directory boundaries + context.appendToRegex(".*").nextGlobChar(); + } else { + // within directory boundary + context.appendToRegex("[^/]*"); + } + } + + private void handleQuestionMark(GlobParserContext context) { + context.appendToRegex("[^/]"); + } + + private void handleDefaultCharacter(char c, GlobParserContext context) { + if (isRegexMeta(c)) { + context.appendToRegex('\\'); + } + context.appendToRegex(c); + } + + private boolean isRegexMeta(char c) { + return regexMetaChars.indexOf(c) != -1; + } + + private boolean isGlobMeta(char c) { + return globMetaChars.indexOf(c) != -1; + } + } +} diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/CircularBuffer.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/CircularBuffer.java index 7664f671..c8d7b44d 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/CircularBuffer.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/CircularBuffer.java @@ -26,6 +26,8 @@ */ package com.redhat.devtools.intellij.telemetry.core.util; +import org.jetbrains.annotations.Nullable; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -56,6 +58,7 @@ public boolean offer(E element) { return false; } + @Nullable public E poll() { if (!isEmpty()) { E nextValue = data[readSequence % capacity]; diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/FileUtils.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/FileUtils.java index f577f590..13eab394 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/FileUtils.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/FileUtils.java @@ -10,13 +10,24 @@ ******************************************************************************/ package com.redhat.devtools.intellij.telemetry.core.util; +import org.apache.commons.lang.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; import java.io.IOException; import java.io.Writer; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; public class FileUtils { + private static final String FILE_URL_PREFIX = "file:"; + private FileUtils() { } @@ -37,9 +48,33 @@ public static void createFileAndParent(Path file) throws IOException { } } + public static Path ensureExists(@NotNull Path path) throws IOException { + FileUtils.createFileAndParent(path); + return path; + } + public static void write(String content, Path file) throws IOException { try (Writer writer = Files.newBufferedWriter(file)) { writer.append(content); } } + + public static boolean isFileUrl(String url) { + return !StringUtils.isEmpty(url) + && url.startsWith(FILE_URL_PREFIX); + } + + @Nullable + public static Path getPathForFileUrl(String url) { + if (!isFileUrl(url)) { + return null; + } + try { + URI uri = new URL(url).toURI(); + return new File(uri).toPath(); + } catch (MalformedURLException | URISyntaxException e) { + return null; + } + } + } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/LICENCE b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/LICENCE new file mode 100644 index 00000000..8b400c7a --- /dev/null +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/LICENCE @@ -0,0 +1,347 @@ +The GNU General Public License (GPL) + +Version 2, June 1991 + +Copyright (C) 1989, 1991 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble + +The licenses for most software are designed to take away your freedom to share +and change it. By contrast, the GNU General Public License is intended to +guarantee your freedom to share and change free software--to make sure the +software is free for all its users. This General Public License applies to +most of the Free Software Foundation's software and to any other program whose +authors commit to using it. (Some other Free Software Foundation software is +covered by the GNU Library General Public License instead.) You can apply it to +your programs, too. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom to +distribute copies of free software (and charge for this service if you wish), +that you receive source code or can get it if you want it, that you can change +the software or use pieces of it in new free programs; and that you know you +can do these things. + +To protect your rights, we need to make restrictions that forbid anyone to deny +you these rights or to ask you to surrender the rights. These restrictions +translate to certain responsibilities for you if you distribute copies of the +software, or if you modify it. + +For example, if you distribute copies of such a program, whether gratis or for +a fee, you must give the recipients all the rights that you have. You must +make sure that they, too, receive or can get the source code. And you must +show them these terms so they know their rights. + +We protect your rights with two steps: (1) copyright the software, and (2) +offer you this license which gives you legal permission to copy, distribute +and/or modify the software. + +Also, for each author's protection and ours, we want to make certain that +everyone understands that there is no warranty for this free software. If the +software is modified by someone else and passed on, we want its recipients to +know that what they have is not the original, so that any problems introduced +by others will not reflect on the original authors' reputations. + +Finally, any free program is threatened constantly by software patents. We +wish to avoid the danger that redistributors of a free program will +individually obtain patent licenses, in effect making the program proprietary. +To prevent this, we have made it clear that any patent must be licensed for +everyone's free use or not licensed at all. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License applies to any program or other work which contains a notice +placed by the copyright holder saying it may be distributed under the terms of +this General Public License. The "Program", below, refers to any such program +or work, and a "work based on the Program" means either the Program or any +derivative work under copyright law: that is to say, a work containing the +Program or a portion of it, either verbatim or with modifications and/or +translated into another language. (Hereinafter, translation is included +without limitation in the term "modification".) Each licensee is addressed as +"you". + +Activities other than copying, distribution and modification are not covered by +this License; they are outside its scope. The act of running the Program is +not restricted, and the output from the Program is covered only if its contents +constitute a work based on the Program (independent of having been made by +running the Program). Whether that is true depends on what the Program does. + +1. You may copy and distribute verbatim copies of the Program's source code as +you receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice and +disclaimer of warranty; keep intact all the notices that refer to this License +and to the absence of any warranty; and give any other recipients of the +Program a copy of this License along with the Program. + +You may charge a fee for the physical act of transferring a copy, and you may +at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Program or any portion of it, thus +forming a work based on the Program, and copy and distribute such modifications +or work under the terms of Section 1 above, provided that you also meet all of +these conditions: + + a) You must cause the modified files to carry prominent notices stating + that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in whole or + in part contains or is derived from the Program or any part thereof, to be + licensed as a whole at no charge to all third parties under the terms of + this License. + + c) If the modified program normally reads commands interactively when run, + you must cause it, when started running for such interactive use in the + most ordinary way, to print or display an announcement including an + appropriate copyright notice and a notice that there is no warranty (or + else, saying that you provide a warranty) and that users may redistribute + the program under these conditions, and telling the user how to view a copy + of this License. (Exception: if the Program itself is interactive but does + not normally print such an announcement, your work based on the Program is + not required to print an announcement.) + +These requirements apply to the modified work as a whole. If identifiable +sections of that work are not derived from the Program, and can be reasonably +considered independent and separate works in themselves, then this License, and +its terms, do not apply to those sections when you distribute them as separate +works. But when you distribute the same sections as part of a whole which is a +work based on the Program, the distribution of the whole must be on the terms +of this License, whose permissions for other licensees extend to the entire +whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your +rights to work written entirely by you; rather, the intent is to exercise the +right to control the distribution of derivative or collective works based on +the Program. + +In addition, mere aggregation of another work not based on the Program with the +Program (or with a work based on the Program) on a volume of a storage or +distribution medium does not bring the other work under the scope of this +License. + +3. You may copy and distribute the Program (or a work based on it, under +Section 2) in object code or executable form under the terms of Sections 1 and +2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable source + code, which must be distributed under the terms of Sections 1 and 2 above + on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three years, to + give any third party, for a charge no more than your cost of physically + performing source distribution, a complete machine-readable copy of the + corresponding source code, to be distributed under the terms of Sections 1 + and 2 above on a medium customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer to + distribute corresponding source code. (This alternative is allowed only + for noncommercial distribution and only if you received the program in + object code or executable form with such an offer, in accord with + Subsection b above.) + +The source code for a work means the preferred form of the work for making +modifications to it. For an executable work, complete source code means all +the source code for all modules it contains, plus any associated interface +definition files, plus the scripts used to control compilation and installation +of the executable. However, as a special exception, the source code +distributed need not include anything that is normally distributed (in either +source or binary form) with the major components (compiler, kernel, and so on) +of the operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the source +code from the same place counts as distribution of the source code, even though +third parties are not compelled to copy the source along with the object code. + +4. You may not copy, modify, sublicense, or distribute the Program except as +expressly provided under this License. Any attempt otherwise to copy, modify, +sublicense or distribute the Program is void, and will automatically terminate +your rights under this License. However, parties who have received copies, or +rights, from you under this License will not have their licenses terminated so +long as such parties remain in full compliance. + +5. You are not required to accept this License, since you have not signed it. +However, nothing else grants you permission to modify or distribute the Program +or its derivative works. These actions are prohibited by law if you do not +accept this License. Therefore, by modifying or distributing the Program (or +any work based on the Program), you indicate your acceptance of this License to +do so, and all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + +6. Each time you redistribute the Program (or any work based on the Program), +the recipient automatically receives a license from the original licensor to +copy, distribute or modify the Program subject to these terms and conditions. +You may not impose any further restrictions on the recipients' exercise of the +rights granted herein. You are not responsible for enforcing compliance by +third parties to this License. + +7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), conditions +are imposed on you (whether by court order, agreement or otherwise) that +contradict the conditions of this License, they do not excuse you from the +conditions of this License. If you cannot distribute so as to satisfy +simultaneously your obligations under this License and any other pertinent +obligations, then as a consequence you may not distribute the Program at all. +For example, if a patent license would not permit royalty-free redistribution +of the Program by all those who receive copies directly or indirectly through +you, then the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply and +the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or +other property right claims or to contest validity of any such claims; this +section has the sole purpose of protecting the integrity of the free software +distribution system, which is implemented by public license practices. Many +people have made generous contributions to the wide range of software +distributed through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing to +distribute software through any other system and a licensee cannot impose that +choice. + +This section is intended to make thoroughly clear what is believed to be a +consequence of the rest of this License. + +8. If the distribution and/or use of the Program is restricted in certain +countries either by patents or by copyrighted interfaces, the original +copyright holder who places the Program under this License may add an explicit +geographical distribution limitation excluding those countries, so that +distribution is permitted only in or among countries not thus excluded. In +such case, this License incorporates the limitation as if written in the body +of this License. + +9. The Free Software Foundation may publish revised and/or new versions of the +General Public License from time to time. Such new versions will be similar in +spirit to the present version, but may differ in detail to address new problems +or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any later +version", you have the option of following the terms and conditions either of +that version or of any later version published by the Free Software Foundation. +If the Program does not specify a version number of this License, you may +choose any version ever published by the Free Software Foundation. + +10. If you wish to incorporate parts of the Program into other free programs +whose distribution conditions are different, write to the author to ask for +permission. For software which is copyrighted by the Free Software Foundation, +write to the Free Software Foundation; we sometimes make exceptions for this. +Our decision will be guided by the two goals of preserving the free status of +all derivatives of our free software and of promoting the sharing and reuse of +software generally. + +NO WARRANTY + +11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR +THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE +STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE +PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, +YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR +INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA +BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER +OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively convey the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + + One line to give the program's name and a brief idea of what it does. + + Copyright (C) + + This program is free software; you can redistribute it and/or modify it + under the terms of the GNU General Public License as published by the Free + Software Foundation; either version 2 of the License, or (at your option) + any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this when it +starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author Gnomovision comes + with ABSOLUTELY NO WARRANTY; for details type 'show w'. This is free + software, and you are welcome to redistribute it under certain conditions; + type 'show c' for details. + +The hypothetical commands 'show w' and 'show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may be +called something other than 'show w' and 'show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your school, +if any, to sign a "copyright disclaimer" for the program, if necessary. Here +is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + 'Gnomovision' (which makes passes at compilers) written by James Hacker. + + signature of Ty Coon, 1 April 1989 + + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General Public +License instead of this License. + + +"CLASSPATH" EXCEPTION TO THE GPL + +Certain source files distributed by Oracle America and/or its affiliates are +subject to the following clarification and special exception to the GPL, but +only where Oracle has expressly included in the particular source file's header +the words "Oracle designates this particular file as subject to the "Classpath" +exception as provided by Oracle in the LICENSE file that accompanied this code." + + Linking this library statically or dynamically with other modules is making + a combined work based on this library. Thus, the terms and conditions of + the GNU General Public License cover the whole combination. + + As a special exception, the copyright holders of this library give you + permission to link this library with independent modules to produce an + executable, regardless of the license terms of these independent modules, + and to copy and distribute the resulting executable under terms of your + choice, provided that you also meet, for each linked independent module, + the terms and conditions of the license of that module. An independent + module is a module which is not derived from or based on this library. If + you modify this library, you may extend this exception to your version of + the library, but you are not obligated to do so. If you do not wish to do + so, delete this exception statement from your version. diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/TimeUtils.java b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/TimeUtils.java index 7097eadb..7487d8f5 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/TimeUtils.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/core/util/TimeUtils.java @@ -10,12 +10,17 @@ ******************************************************************************/ package com.redhat.devtools.intellij.telemetry.core.util; +import org.jetbrains.annotations.Nullable; + import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.Period; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -61,6 +66,7 @@ public static String toString(Duration duration) { * * @see #toString(Duration) */ + @Nullable public static Duration toDuration(String hoursMinutesSeconds) { Matcher matcher = HH_MM_SS_DURATION.matcher(hoursMinutesSeconds); if (!matcher.matches()) { @@ -75,4 +81,16 @@ public static Duration toDuration(String hoursMinutesSeconds) { return duration; } + /** + * Returns {@code true} if the given {@link LocalDate} is today. + * Returns {@code false} otherwise. + * + * @param dateTime the date/time to check whether it's today. + * + * @return true if the given date/time is today. + */ + public static boolean isToday(LocalDateTime dateTime) { + return ChronoUnit.DAYS.between(dateTime.toLocalDate(), LocalDate.now()) == 0; + } + } diff --git a/src/main/java/com/redhat/devtools/intellij/telemetry/ui/utils/NotificationGroupFactory.java b/src/main/java/com/redhat/devtools/intellij/telemetry/ui/utils/NotificationGroupFactory.java index 473240a5..81ee8b52 100644 --- a/src/main/java/com/redhat/devtools/intellij/telemetry/ui/utils/NotificationGroupFactory.java +++ b/src/main/java/com/redhat/devtools/intellij/telemetry/ui/utils/NotificationGroupFactory.java @@ -14,6 +14,7 @@ import com.intellij.notification.NotificationGroup; import com.intellij.openapi.application.ApplicationInfo; import com.intellij.openapi.diagnostic.Logger; +import org.jetbrains.annotations.Nullable; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; @@ -39,6 +40,7 @@ public class NotificationGroupFactory { private NotificationGroupFactory() {} + @Nullable public static NotificationGroup create(String displayId, NotificationDisplayType type, boolean logByDefault) { try { // < IC-2021.3 diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index cab0ab7b..74019156 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -35,8 +35,6 @@ ]]> - - com.intellij.modules.lang @@ -49,5 +47,7 @@ serviceImplementation="com.redhat.devtools.intellij.telemetry.core.service.TelemetryServiceFactory"/> + diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/TelemetryConfigurationTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/TelemetryConfigurationTest.java index 905a5136..6e98da45 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/TelemetryConfigurationTest.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/TelemetryConfigurationTest.java @@ -33,12 +33,12 @@ class TelemetryConfigurationTest { - private SaveableFileConfiguration file = configuration(new Properties(), SaveableFileConfiguration.class); - private IConfiguration defaults = mock(IConfiguration.class); - private IConfiguration overrides = mock(IConfiguration.class); - private List configurations = Arrays.asList(overrides, file, defaults); - private ConfigurationChangedListener listener = mock(ConfigurationChangedListener.class); - private TelemetryConfiguration config = new TestableTelemetryConfiguration(file, configurations, listener); + private final SaveableFileConfiguration file = configuration(new Properties(), SaveableFileConfiguration.class); + private final IConfiguration defaults = mock(IConfiguration.class); + private final IConfiguration overrides = mock(IConfiguration.class); + private final List configurations = Arrays.asList(overrides, file, defaults); + private final ConfigurationChangedListener listener = mock(ConfigurationChangedListener.class); + private final TelemetryConfiguration config = new TestableTelemetryConfiguration(file, configurations, listener); @Test void get_should_return_overridden_value() { diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventCountsTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventCountsTest.java new file mode 100644 index 00000000..1197c64b --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventCountsTest.java @@ -0,0 +1,202 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.intellij.configurationStore.JbXmlOutputter; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import com.intellij.util.xmlb.XmlSerializer; +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import org.jdom.Document; +import org.jdom.Element; +import org.jdom.JDOMException; +import org.jdom.input.SAXBuilder; +import org.xml.sax.InputSource; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.time.LocalDateTime; +import java.time.Period; +import java.time.temporal.ChronoUnit; + +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.EventCounts.Count; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * Platform Tests for the {@link EventCounts} service. + * It's extending {@link BasePlatformTestCase} which is using junit 4. + */ +public class EventCountsTest extends BasePlatformTestCase { + + private final Event event1 = new Event(Event.Type.USER, "1"); + private final Count count1 = new Count(LocalDateTime.of(2023,4,4,4,4,4), 42); + private final Event event2 = new Event(Event.Type.USER, "2"); + private final Count count2 = new Count(LocalDateTime.of(2023, 8, 8, 8, 8, 8), 84); + private final EventCounts with2Properties = new EventCounts() + .put(event1, count1) + .put(event2, count2); + + private final String serializedWith2Properties = + "\n" + + " \n" + + ""; + + + public void test_getInstance_should_return_instance() { + // given + // when + EventCounts counts = EventCounts.getInstance(); + // then + assertThat(counts).isNotNull(); + } + + public void test_should_serialize_EventCounts_with_2_properties() throws IOException { + // given + // when + Element element = XmlSerializer.serialize(with2Properties); + // then + String xml = toXML(element); + assertThat(xml).isEqualTo(serializedWith2Properties); + } + + public void test_should_deserialize_EventCounts_with_2_properties() throws IOException, JDOMException { + // given + Document document = toDocument(serializedWith2Properties); + // when + EventCounts counts = XmlSerializer.deserialize(document, EventCounts.class); + // then + assertThat(counts.counts).isEqualTo(with2Properties.counts); + } + + public void test_loadState_should_have_2_properties() { + // given + EventCounts counts = new EventCounts(); + assertThat(counts.counts.size()).isEqualTo(0); + // when + counts.loadState(with2Properties); + // then + assertThat(counts.counts).isEqualTo(with2Properties.counts); + } + + public void test_get_should_return_count() { + // given + // when + Count found = with2Properties.get(event2); + // then + assertThat(found).isEqualTo(count2); + } + + public void test_get_should_return_null_if_event_is_null() { + // given + // when + Count found = with2Properties.get(null); + // then + assertThat(found).isNull(); + } + + public void test_get_should_return_null_if_event_was_not_put_beforehand() { + // given + Event bogus = new Event(Event.Type.USER, "bogus"); + // when + Count found = with2Properties.get(bogus); + // then + assertThat(found).isNull(); + } + + public void test_put_should_create_new_count_if_it_doesnt_exist_yet() { + // given + EventCounts counts = new EventCounts(); + Count existing = counts.get(event1); + assertThat(existing).isNull(); + // when + counts.put(event1); + // then + existing = counts.get(event1); + assertThat(existing).isNotNull(); + } + + public void test_put_should_create_new_count_with_total_of_1() { + // given + EventCounts counts = new EventCounts(); + Count existing = counts.get(event1); + assertThat(existing).isNull(); + // when + counts.put(event1); + // then + existing = counts.get(event1); + assertThat(existing.getDailyTotal()).isEqualTo(1); + } + + public void test_put_should_update_existing_count_if_it_already_existed() throws InterruptedException { + // given + EventCounts counts = new EventCounts(); + counts.put(event1); + Count count = counts.get(event1); + assertThat(count).isNotNull(); + LocalDateTime previousOccurrence = count.getLastOccurrence(); + int previousTotal = count.getDailyTotal(); + Thread.sleep(1000); // wait for 1s, timestamp is in seconds only + // when + counts.put(event1); + // then + count = counts.get(event1); + assertThat(count.getLastOccurrence()).isAfter(previousOccurrence); + assertThat(count.getDailyTotal()).isEqualTo(previousTotal + 1); + } + + public void test_put_should_reset_count_to_0_existing_count_was_not_today() { + // given + EventCounts counts = new EventCounts(); + int previousTotal = 42; + LocalDateTime previousOccurrence = LocalDateTime.now().minus(Period.ofDays(1)); + Count count = new Count(previousOccurrence, previousTotal); + counts.put(event1, count); + // when + counts.put(event1); + // then + count = counts.get(event1); + assertThat(count.getLastOccurrence()).isAfter(previousOccurrence); + assertThat(count.getDailyTotal()).isEqualTo(1); + } + + public void test_put_of_different_event_should_not_affect_existing_count_for_event() { + // given + EventCounts counts = new EventCounts(); + counts.put(event1); + Count count = counts.get(event1); + assertThat(count).isNotNull(); + LocalDateTime existingLastOccurrence = count.getLastOccurrence(); + int existingTotal = count.getDailyTotal(); + // when + counts.put(event2); + // then + count = counts.get(event1); + assertThat(count.getLastOccurrence()).isEqualTo(existingLastOccurrence); + assertThat(count.getDailyTotal()).isEqualTo(existingTotal); + } + + private Document toDocument(String string) throws JDOMException, IOException { + InputSource source = new InputSource(); + source.setCharacterStream(new StringReader(string)); + return new SAXBuilder().build(source); + } + + private static String toXML(Element element) throws IOException { + StringWriter writer = new StringWriter(); + new JbXmlOutputter().output(element, writer); + return writer.toString(); + } +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventLimitsTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventLimitsTest.java new file mode 100644 index 00000000..c32f132a --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventLimitsTest.java @@ -0,0 +1,382 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.List; + +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.EventLimits.PluginLimitsFactory; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +public class EventLimitsTest { + + private static final String LOCAL = "{\n" + + " \"*\": {\n" + + " \"enabled\": \"all\",\n" + + " \"includes\": [\n" + + " {\n" + + " \"name\": \"*\"\n" + + " }\n" + + " ],\n" + + " \"excludes\": []\n" + + " }\n" + + "}"; + + private List localLimits; + + private static final String REMOTE = "{\n" + + " \"*\": {\n" + + " \"enabled\": \"error\",\n" + + " \"includes\": [\n" + + " {\n" + + " \"name\": \"startup\",\n" + + " \"dailyLimit\": 1\n" + + " },\n" + + " {\n" + + " \"name\": \"*\"\n" + + " }\n" + + " ],\n" + + " \"excludes\": [\n" + + " {\n" + + " \"name\": \"shutdown\",\n" + + " \"ratio\": \"1.0\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + + private List remoteLimits; + + private static final String EMBEDDED = "{\n" + + " \"*\": {\n" + + " \"enabled\": \"all\",\n" + + " \"refresh\": \"12h\",\n" + + " \"includes\": [],\n" + + " \"excludes\": []\n" + + " }\n" + + "}"; + + private List embeddedLimits; + + @BeforeEach + void beforeEach() throws IOException { + this.localLimits = PluginLimitsDeserialization.create(LOCAL); + this.remoteLimits = PluginLimitsDeserialization.create(REMOTE); + this.embeddedLimits = PluginLimitsDeserialization.create(EMBEDDED); + } + + @Test + public void getAllLimits_returns_null_if_deserialization_throws() throws IOException { + // given + PluginLimitsFactory factory = mock(PluginLimitsFactory.class); + doThrow(new IOException()) + .when(factory).create(any()); + LimitsConfigurations configurations = mock(LimitsConfigurations.class); + doReturn("bogus") // needs to return non-null for factory to be invoked + .when(configurations).downloadRemote(); + EventCounts counts = mock(EventCounts.class); + EventLimits limits = new EventLimits("bogus", null, factory, configurations, counts); + // when + List pluginLimits = limits.getAllLimits(); + // then + assertThat(pluginLimits).isNull(); + } + + @Test + public void getAllLimits_downloads_remote_if_local_file_has_no_modification_timestamp() { + // given + List allLimits = List.of(createDefaultPluginLimits(Integer.MAX_VALUE)); + LimitsConfigurations configurations = mock(LimitsConfigurations.class); + doReturn(null) // no modification timestamp, file does not exist + .when(configurations).getLocalLastModified(); + EventCounts counts = mock(EventCounts.class); + EventLimits limits = new EventLimits("bogus", allLimits, mock(PluginLimitsFactory.class), configurations, counts); + // when + limits.getAllLimits(); + // then + verify(configurations).downloadRemote(); + } + + @Test + public void getAllLimits_downloads_remote_if_local_file_was_modified_7h_ago_and_no_default_limits_exist() { + // given + List noDefaultLimits = Collections.emptyList(); + PluginLimitsFactory factory = mock(PluginLimitsFactory.class); + LimitsConfigurations configurations = mock(LimitsConfigurations.class); + EventCounts counts = mock(EventCounts.class); + // default refresh (without existing plugin limits) is 6h + doReturn(createFileTime(7)) // 7h ago + .when(configurations).getLocalLastModified(); + EventLimits limits = new EventLimits("bogus", noDefaultLimits, factory, configurations, counts); + // when + limits.getAllLimits(); + // then + verify(configurations).downloadRemote(); + } + + @Test + public void getAllLimits_downloads_remote_if_local_file_was_modified_7h_ago_and_default_limits_has_no_refresh() { + // given + PluginLimitsFactory factory = mock(PluginLimitsFactory.class); + LimitsConfigurations configurations = mock(LimitsConfigurations.class); + doReturn(createFileTime(7)) // 7h ago. Refresh needed, default refresh is 6h + .when(configurations).getLocalLastModified(); + EventLimits limits = new EventLimits("bogus", + null, // no configuration read yet + factory, + configurations, + mock(EventCounts.class)); + // when + limits.getAllLimits(); + // then + verify(configurations).downloadRemote(); + } + + @Test + public void getAllLimits_reads_local_config_and_does_NOT_download_remote_if_local_config_was_modified_within_specified_refresh_period() throws IOException { + // given + PluginLimitsFactory factory = mock(PluginLimitsFactory.class); + LimitsConfigurations configurations = mock(LimitsConfigurations.class); + doReturn(createFileTime(1)) // 1h ago + .when(configurations).getLocalLastModified(); + doReturn(LOCAL) // 1h ago + .when(configurations).readLocal(); + doReturn(localLimits) + .when(factory).create(LOCAL); + + EventLimits limits = new EventLimits("bogus", + null, // no configuration read yet + factory, + configurations, + mock(EventCounts.class)); + // when + List pluginLimits = limits.getAllLimits(); + // then + verify(configurations).readLocal(); + verify(configurations, never()).downloadRemote(); + verify(configurations, never()).readEmbedded(); + assertThat(pluginLimits).isEqualTo(localLimits); + } + + @Test + public void getAllLimits_returns_embedded_config_if_downloadRemote_returns_null() throws IOException { + // given + PluginLimitsFactory factory = mock(PluginLimitsFactory.class); + LimitsConfigurations configurations = mock(LimitsConfigurations.class); + doReturn(null) // no local file present + .when(configurations).getLocalLastModified(); + doReturn(null) // no remote config + .when(configurations).downloadRemote(); + doReturn(EMBEDDED) + .when(configurations).readEmbedded(); + doReturn(embeddedLimits) + .when(factory).create(EMBEDDED); + EventLimits limits = new EventLimits("bogus", + null, // no configuration read yet + factory, + configurations, + mock(EventCounts.class)); + // when + List pluginLimits = limits.getAllLimits(); + // then + assertThat(pluginLimits).isEqualTo(embeddedLimits); + } + + @Test + public void getAllLimits_downloads_remote_config_if_local_cannot_be_parsed() throws IOException { + // given + PluginLimitsFactory factory = mock(PluginLimitsFactory.class); + LimitsConfigurations configurations = createConfigurations(LocalDateTime.now()); // no refresh needed + doReturn(LOCAL) + .when(configurations).readLocal(); + doThrow(IOException.class) // parsing local file fails + .when(factory).create(LOCAL); + doReturn(REMOTE) + .when(configurations).downloadRemote(); + doReturn(remoteLimits) + .when(factory).create(REMOTE); + doReturn(EMBEDDED) + .when(configurations).readEmbedded(); + doReturn(embeddedLimits) + .when(factory).create(EMBEDDED); + EventLimits limits = new EventLimits("bogus", + null, // no configuration read yet + factory, + configurations, + mock(EventCounts.class)); + // when + List pluginLimits = limits.getAllLimits(); + // then + assertThat(pluginLimits).isEqualTo(remoteLimits); + } + + @Test + public void getAllLimits_reads_embedded_if_local_cannot_be_parsed_and_remote_is_not_downloadable() throws IOException { + // given + PluginLimitsFactory factory = mock(PluginLimitsFactory.class); + LimitsConfigurations configurations = createConfigurations(LocalDateTime.now()); // no refresh needed + doReturn(LOCAL) + .when(configurations).readLocal(); + doThrow(IOException.class) // parsing local file fails, parsing remote/embedded doesn't throw + .when(factory).create(LOCAL); + doReturn(null) + .when(configurations).downloadRemote(); + doReturn(EMBEDDED) + .when(configurations).readEmbedded(); + doReturn(embeddedLimits) + .when(factory).create(EMBEDDED); + EventLimits limits = new EventLimits("bogus", + null, // no configuration read yet + factory, + configurations, + mock(EventCounts.class)); + // when + List pluginLimits = limits.getAllLimits(); + // then + assertThat(pluginLimits).isEqualTo(embeddedLimits); + } + + @Test + public void canSend_returns_true_if_default_allows() throws IOException { + // given + LimitsConfigurations configurations = createConfigurations(LocalDateTime.now()); // local file up-to-date, no refresh + List pluginLimits = List.of(createDefaultPluginLimits(true)); + EventLimits limits = new EventLimits( + "bogus", + pluginLimits, + null, + configurations, + mock(EventCounts.class)); + Event event = new Event(Event.Type.USER, "luke"); + // when + boolean canSend = limits.canSend(event); + // then + assertThat(canSend).isTrue(); + } + + @Test + public void canSend_returns_true_if_default_cannotSend_but_pluginLimit_canSend() throws IOException { + // given + String pluginId = "jedis"; + LimitsConfigurations configurations = createConfigurations(LocalDateTime.now()); // local file up-to-date, no refresh + List pluginLimits = List.of( + createDefaultPluginLimits(Integer.MAX_VALUE, false), + createPluginLimits(pluginId, Integer.MAX_VALUE, true) + ); + EventLimits limits = new EventLimits( + pluginId, + pluginLimits, + null, + configurations, + mock(EventCounts.class)); + Event event = new Event(Event.Type.USER, "luke"); + // when + boolean canSend = limits.canSend(event); + // then + assertThat(canSend).isTrue(); + } + + @Test + public void canSend_returns_true_if_there_is_no_default_nor_pluginLimit() throws IOException { + // given + String pluginId = "jedis"; + LimitsConfigurations configurations = createConfigurations(LocalDateTime.now()); // local file up-to-date, no refresh + List pluginLimits = Collections.emptyList(); + EventLimits limits = new EventLimits( + pluginId, + pluginLimits, + null, + configurations, + mock(EventCounts.class)); + Event event = new Event(Event.Type.USER, "luke"); + // when + boolean canSend = limits.canSend(event); + // then + assertThat(canSend).isTrue(); + } + + @Test + public void wasSent_puts_event_to_eventCount() throws IOException { + // given + String pluginId = "jedis"; + LimitsConfigurations configurations = createConfigurations(LocalDateTime.now()); // local file up-to-date, no refresh + List pluginLimits = Collections.emptyList(); + EventCounts eventCounts = mock(EventCounts.class); + EventLimits limits = new EventLimits( + pluginId, + pluginLimits, + null, + configurations, + eventCounts); + Event event = new Event(Event.Type.USER, "yoda"); + // when + limits.wasSent(event); + // then + verify(eventCounts).put(event); + } + + private static FileTime createFileTime(int createdHoursAgo) { + return FileTime.from( + Instant.now().minus(createdHoursAgo, ChronoUnit.HOURS)); + } + + private static PluginLimits createDefaultPluginLimits(boolean canSend) { + return createDefaultPluginLimits(-1, canSend); // no refresh + } + + private static PluginLimits createDefaultPluginLimits(int refresh) { + return createDefaultPluginLimits(refresh, false); + } + + private static PluginLimits createDefaultPluginLimits(int refresh, boolean canSend) { + PluginLimits mock = createPluginLimits("*", refresh, canSend); + doReturn(true) + .when(mock).isDefault(); + return mock; + } + + private static PluginLimits createPluginLimits(String pluginId, int refresh, boolean canSend) { + PluginLimits mock = mock(PluginLimits.class); + doReturn(pluginId) + .when(mock).getPluginId(); + doReturn(refresh) + .when(mock).getRefresh(); + doReturn(canSend) + .when(mock).canSend(any(), anyInt()); + return mock; + } + + private static LimitsConfigurations createConfigurations(@NotNull LocalDateTime localModificationTimestamp) { + LimitsConfigurations configurations = mock(LimitsConfigurations.class); + int localCreatedHoursAgo = (int) ChronoUnit.HOURS.between(localModificationTimestamp, LocalDateTime.now()); + doReturn(createFileTime(localCreatedHoursAgo)) + .when(configurations).getLocalLastModified(); + return configurations; + } + +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventNameFilterTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventNameFilterTest.java new file mode 100644 index 00000000..ecf3b45c --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventNameFilterTest.java @@ -0,0 +1,128 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.Filter.EventNameFilter; +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import com.redhat.devtools.intellij.telemetry.core.service.UserId; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class EventNameFilterTest { + + @Test + public void isMatching_should_match_event_name() { + // given + Filter filter = new EventNameFilter("yoda", 0.42f, 42); + Event event = new Event(Event.Type.USER, "yoda"); + // when + boolean matching = filter.isMatching(event); + // then + assertThat(matching).isTrue(); + } + + @Test + public void isMatching_should_NOT_match_event_name_that_is_different() { + // given + Filter filter = new EventNameFilter("yoda", 0.42f, 42); + Event event = new Event(Event.Type.USER, "darthvader"); + // when + boolean matching = filter.isMatching(event); + // then + assertThat(matching).isFalse(); + } + + @Test + public void isMatching_should_match_event_name_when_pattern_is_wildcard() { + // given + Filter filter = new EventNameFilter("*", 0.42f, 42); + Event event = new Event(Event.Type.USER, "skywalker"); + // when + boolean matching = filter.isMatching(event); + // then + assertThat(matching).isTrue(); + } + + @Test + public void isMatching_should_match_event_name_when_pattern_has_name_with_wildcards() { + // given + Filter filter = new EventNameFilter("*walk*", 0.42f, 42); + Event event = new Event(Event.Type.USER, "skywalker"); + // when + boolean matching = filter.isMatching(event); + // then + assertThat(matching).isTrue(); + } + + @Test + public void isIncludedByRatio_returns_true_if_percentile_is_within_ratio() { + // given + Filter filter = new EventNameFilter("ignore", 0.1f, EventNameFilter.DAILY_LIMIT_UNSPECIFIED); + // when + boolean isWithin = filter.isIncludedByRatio(0.1f); + // then + assertThat(isWithin).isTrue(); + } + + @Test + public void isIncludedByRatio_returns_false_if_percentile_is_NOT_within_ratio() { + // given + Filter filter = new EventNameFilter("ignore", 0.1f, EventNameFilter.DAILY_LIMIT_UNSPECIFIED); + // when + boolean isWithin = filter.isIncludedByRatio(0.2f); + // then + assertThat(isWithin).isFalse(); + } + + @Test + public void isIncludedByRatio_returns_false_if_ratio_is_0() { + // given + Filter filter = new EventNameFilter("ignore", 0, EventNameFilter.DAILY_LIMIT_UNSPECIFIED); + // when + boolean isWithin = filter.isIncludedByRatio(0); + // then + assertThat(isWithin).isFalse(); + } + + @Test + public void isWithinDailyLimit_returns_true_if_dailyLyimit_is_unspecified() { + // given + Filter filter = new EventNameFilter("ignore", -1, EventNameFilter.DAILY_LIMIT_UNSPECIFIED); + // when + boolean isWithin = filter.isWithinDailyLimit(Integer.MAX_VALUE); + // then + assertThat(isWithin).isTrue(); + } + + @Test + public void isWithinDailyLimit_returns_true_if_dailyLimit_is_NOT_reached() { + // given + Filter filter = new EventNameFilter("ignore", -1, Integer.MAX_VALUE); + // when + boolean isWithin = filter.isWithinDailyLimit(0); + // then + assertThat(isWithin).isTrue(); + } + + @Test + public void isWithinDailyLimit_returns_false_if_dailyLimit_is_reached() { + // given + Filter filter = new EventNameFilter("ignore", -1, 1); + // when + boolean isWithin = filter.isWithinDailyLimit(2); + // then + assertThat(isWithin).isFalse(); + } + +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventPropertyFilterTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventPropertyFilterTest.java new file mode 100644 index 00000000..e57faa2c --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/EventPropertyFilterTest.java @@ -0,0 +1,72 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.Filter.EventNameFilter; +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Filter.*; +import static org.assertj.core.api.Assertions.assertThat; + +public class EventPropertyFilterTest { + + @Test + public void isMatching_should_match_any_event_with_exact_property_name_and_value() { + // given + Filter filter = new EventPropertyFilter("yoda", "jedi"); + Event event = new Event(Event.Type.USER, "there are jedis in the rebellion", Map.of( + "yoda", "jedi")); + // when + boolean matching = filter.isMatching(event); + // then + assertThat(matching).isTrue(); + } + + @Test + public void isMatching_should_match_any_event_with_exact_property_name_and_wildcard_value() { + // given + Filter filter = new EventPropertyFilter("yoda", "*jedi*"); + Event event = new Event(Event.Type.USER, "there are jedis on both sides", Map.of( + "yoda", "is a master jedi!")); + // when + boolean matching = filter.isMatching(event); + // then + assertThat(matching).isTrue(); + } + + @Test + public void isMatching_should_NOT_match_event_that_doesnt_have_given_property_name() { + // given + Filter filter = new EventPropertyFilter("yoda", "*jedi*"); + Event event = new Event(Event.Type.USER, "there are jedis on both sides", Map.of( + "darth vader", "is a master jedi!")); // key doesnt match + // when + boolean matching = filter.isMatching(event); + // then + assertThat(matching).isFalse(); + } + + @Test + public void isMatching_should_NOT_match_event_that_has_given_property_name_but_doesnt_have_property_value() { + // given + Filter filter = new EventPropertyFilter("yoda", "*jedi*"); + Event event = new Event(Event.Type.USER, "there are jedis on both sides", Map.of( + "yoda", "is stronger than the emperor")); // value doesnt match + // when + boolean matching = filter.isMatching(event); + // then + assertThat(matching).isFalse(); + } + +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/LimitsConfigurationsIntegrationTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/LimitsConfigurationsIntegrationTest.java new file mode 100644 index 00000000..848399b8 --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/LimitsConfigurationsIntegrationTest.java @@ -0,0 +1,218 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileTime; +import java.util.Objects; +import java.util.stream.Collectors; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class LimitsConfigurationsIntegrationTest { + + private Path backup = null; + + @BeforeEach + void beforeEach() throws IOException { + this.backup = backup(LimitsConfigurations.LOCAL); + } + + @AfterEach + void afterEach() { + restore(backup, LimitsConfigurations.LOCAL); + System.clearProperty(LimitsConfigurations.SYSTEM_PROP_REMOTE); + } + + @Test + public void downloadRemote_writes_remote_to_local_file() { + // given + assertThat(Files.exists(LimitsConfigurations.LOCAL)).isFalse(); + LimitsConfigurations configurations = new LimitsConfigurations(); + // when + configurations.downloadRemote(); + // then + assertThat(Files.exists(LimitsConfigurations.LOCAL)).isTrue(); + } + + @Test + public void downloadRemote_returns_content_that_is_equal_to_local_file() throws IOException { + // given + LimitsConfigurations configurations = new LimitsConfigurations(); + // when + String remote = configurations.downloadRemote(); + // then + String file = toString(LimitsConfigurations.LOCAL); + assertThat(remote).isEqualTo(file); + } + + @Test + public void downloadRemote_returns_content_of_file_provided_in_system_property() throws IOException { + // given + LimitsConfigurations configurations = new LimitsConfigurations(); + Path path = createFileContaining(Files.createTempFile(null, null), "bogus"); + String url = path.toUri().toString(); + System.setProperty(LimitsConfigurations.SYSTEM_PROP_REMOTE, url); + // when + String remote = configurations.downloadRemote(); + // then + assertThat(remote).isEqualTo("bogus"); + } + + @Test + public void downloadRemote_returns_content_of_remote_if_system_property_has_invalid_url() { + // given + LimitsConfigurations configurations = new LimitsConfigurations(); + System.setProperty(LimitsConfigurations.SYSTEM_PROP_REMOTE, "noURL"); + String expected = configurations.download(LimitsConfigurations.REMOTE); + // when + String remote = configurations.downloadRemote(); + // then + assertThat(remote).isEqualTo(expected); + } + + @Test + public void downloadRemote_returns_content_of_remote_if_system_property_points_to_missing_file() throws MalformedURLException, URISyntaxException { + // given + LimitsConfigurations configurations = new LimitsConfigurations(); + URI missingFile = Paths.get(System.getProperty("java.io.tmpdir"), "bogus").toFile().toURI(); + System.setProperty(LimitsConfigurations.SYSTEM_PROP_REMOTE, missingFile.toString()); + String expected = configurations.download(LimitsConfigurations.REMOTE); + // when + String remote = configurations.downloadRemote(); + // then + assertThat(remote).isEqualTo(expected); + } + + @Test + public void downloadRemote_returns_content_of_url_pointed_to_by_system_property() throws MalformedURLException, URISyntaxException { + // given + LimitsConfigurations configurations = new LimitsConfigurations(); + System.setProperty(LimitsConfigurations.SYSTEM_PROP_REMOTE, "https://www.redhat.com/"); + String expected = configurations.download("https://www.redhat.com/"); + // when + String remote = configurations.downloadRemote(); + // then + assertThat(remote).isEqualTo(expected); + } + + @Test + public void readLocal_returns_content_of_local_file() throws IOException { + // given + String expected = "yoda"; + createFileContaining(LimitsConfigurations.LOCAL, expected); + LimitsConfigurations configurations = new LimitsConfigurations(); + // when + String local = configurations.readLocal(); + // then + assertThat(local).isEqualTo(expected); + } + + private static Path createFileContaining(Path path, String content) throws IOException { + return Files.write(path, content.getBytes(), StandardOpenOption.CREATE); + } + + @Test + public void readLocalLastModified_returns_time_when_file_was_created() throws IOException { + // given + Files.write(LimitsConfigurations.LOCAL, "obiwan".getBytes(), StandardOpenOption.CREATE); + FileTime whenCreated = Files.getLastModifiedTime(LimitsConfigurations.LOCAL); + LimitsConfigurations configurations = new LimitsConfigurations(); + // when + FileTime whenChecked = configurations.getLocalLastModified(); + // then + assertThat(whenChecked).isEqualTo(whenCreated); + } + + @Test + public void readLocalLastModified_returns_time_when_file_was_downloaded() throws IOException { + // given + Files.write(LimitsConfigurations.LOCAL, "obiwan".getBytes(), StandardOpenOption.CREATE); + FileTime whenCreated = Files.getLastModifiedTime(LimitsConfigurations.LOCAL); + LimitsConfigurations configurations = new LimitsConfigurations(); + configurations.downloadRemote(); + FileTime whenDownloaded = Files.getLastModifiedTime(LimitsConfigurations.LOCAL); + // when + FileTime whenChecked = configurations.getLocalLastModified(); + // then + assertThat(whenCreated.compareTo(whenChecked) < 0).isTrue(); + assertThat(whenChecked).isEqualTo(whenDownloaded); + } + + @Test + public void readLocalLastModified_returns_null_if_local_file_does_not_exist() { + // given + LimitsConfigurations configurations = new LimitsConfigurations(); + // when + FileTime whenChecked = configurations.getLocalLastModified(); + // then + assertThat(whenChecked).isNull(); + } + + @Test + public void readEmbedded_returns_content_of_embedded_file() throws IOException { + // given + BufferedReader reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull( + LimitsConfigurationsIntegrationTest.class.getResourceAsStream(LimitsConfigurations.EMBEDDED)))); + String expected = reader.lines().collect(Collectors.joining()); + LimitsConfigurations configurations = new LimitsConfigurations(); + // when + String local = configurations.readEmbedded(); + // then + assertThat(local).isEqualTo(expected); + } + + private String toString(Path path) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(Files.newInputStream(path))); + return reader.lines().collect(Collectors.joining()); + } + + private Path backup(Path toBackup) throws IOException { + Path backup = Files.createTempFile(toBackup.getFileName().toString(), null); + boolean moved = safeMove(toBackup, backup); + assertThat(Files.exists(toBackup)).isFalse(); + if (moved) { + return backup; + } else { + return null; + } + } + + private void restore(Path backup, Path destination) { + if (backup == null) { + return; + } + safeMove(backup, destination); + } + + private boolean safeMove(Path source, Path destination) { + try { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + return true; + } catch (IOException e) { + return false; + } + } +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Mocks.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Mocks.java new file mode 100644 index 00000000..cd5e15aa --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/Mocks.java @@ -0,0 +1,109 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import com.redhat.devtools.intellij.telemetry.core.service.UserId; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Filter.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class Mocks { + + public static Event event() { + return event(new HashMap<>()); + } + + public static Event event(Map properties) { + return new Event(null, null, properties); + } + + public static UserId userId(float percentile) { + UserId userId = mock(UserId.class); + doReturn(percentile) + .when(userId).getPercentile(); + return userId; + } + + public static PluginLimits pluginLimitsWithIncludesExcludes(List includes, List excludes) { + return pluginLimitsWithIncludesExcludesWithPercentile( + includes, + excludes, + userId(0)); + } + + public static PluginLimits pluginLimitsWithIncludesExcludesWithPercentile(List includes, List excludes, UserId userId) { + return new PluginLimits( + "jedis", + Enabled.ALL, // ignore + -1, // ignore + 1f, // ratio 100% + includes, + excludes, + userId); + } + + public static EventNameFilter eventNameFilter(final boolean isMatching, boolean isIncludedRatio, boolean isExcludedRatio) { + return eventNameFilter(isMatching, isIncludedRatio, isExcludedRatio, Integer.MAX_VALUE); // no daily limit + } + + public static EventNameFilter eventNameFilter(final boolean isMatching, boolean isIncludedRatio, boolean isExcludedRatio, int dailyLimit) { + return new EventNameFilter( null, -1f, dailyLimit) { + @Override + public boolean isMatching(Event event) { + return isMatching; + } + + @Override + public boolean isExcludedByRatio(float percentile) { + return isExcludedRatio; + } + + @Override + public boolean isIncludedByRatio(float percentile) { + return isIncludedRatio; + } + }; + } + + public static EventNameFilter eventNameFilterFakeWithRatio(float ratio) { + return new EventNameFilter( null, ratio, Integer.MAX_VALUE) { + @Override + public boolean isMatching(Event event) { + return true; + } + }; + } + + public static EventNameFilter eventNameWithDailyLimit(int dailyLimit) { + return new EventNameFilter( null, 1, dailyLimit) { + @Override + public boolean isMatching(Event event) { + return true; + } + }; + } + + public static EventPropertyFilter eventProperty() { + return new EventPropertyFilter( null, null) { + @Override + public boolean isMatching(Event event) { + return true; + } + }; + } + +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsDeserializationTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsDeserializationTest.java new file mode 100644 index 00000000..0e42cdcd --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsDeserializationTest.java @@ -0,0 +1,395 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PluginLimitsDeserializationTest { + + @Test + public void get_should_return_3_limits_if_3_plugins_are_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {}," + + " \"yoda\": {},"+ + " \"obiwan\": {} "+ + "}"; + // when + List limits = PluginLimitsDeserialization.create(config); + // then + assertThat(limits).hasSize(3); + } + + @Test + public void getEnabled_should_return_ALL_if_no_value_present() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + Enabled enabled = limit.getEnabled(); + // then + assertThat(enabled).isEqualTo(Enabled.ALL); + } + + @Test + public void getEnabled_should_return_ALL_if_unknown_value_present() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {" + + " \"enabled\" : \"bogus\"" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + Enabled enabled = limit.getEnabled(); + // then + assertThat(enabled).isEqualTo(Enabled.ALL); + } + + @Test + public void getEnabled_should_return_ERROR_if_error_is_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {" + + " \"enabled\" : \"error\"" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + Enabled enabled = limit.getEnabled(); + // then + assertThat(enabled).isEqualTo(Enabled.ERROR); + } + + @Test + public void getRefresh_should_return_negative_refresh_if_no_refresh_is_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {}" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + int refresh = limit.getRefresh(); + // then + assertThat(refresh).isNegative(); + } + + @Test + public void getRefresh_should_return_negative_refresh_if_non_numeric_refresh_is_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"refresh\": \"bogus\"\n" + + " }\n" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + int refresh = limit.getRefresh(); + // then + assertThat(refresh).isNegative(); + } + + @Test + public void getRefresh_should_return_numeric_portion_of_value_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"refresh\": \"12h\"\n" + + " }\n" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + int refresh = limit.getRefresh(); + // then + assertThat(refresh).isEqualTo(12); + } + + @Test + public void getRefresh_should_return_numeric_value_specified_as_refresh_value() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"refresh\": \"42\"\n" + + " }\n" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + int refresh = limit.getRefresh(); + // then + assertThat(refresh).isEqualTo(42); + } + + @Test + public void getRatio_should_return_float_value_specified_as_ratio_value() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"ratio\": \"0.42\"\n" + + " }\n" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + float ratio = limit.getRatio(); + // then + assertThat(ratio).isEqualTo(0.42f); + } + + @Test + public void getRatio_should_return_1_if_no_ratio_is_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " }\n" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + float ratio = limit.getRatio(); + // then + assertThat(ratio).isEqualTo(1f); + } + + @Test + public void getIncludes_should_return_no_filters_if_no_includes_are_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {}" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + List includes = limit.getIncludes(); + // then + assertThat(includes).isEmpty(); + } + + @Test + public void getIncludes_should_return_no_filters_if_bogus_includes_are_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"includes\": [\"bogus\"]" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); + // when + List includes = limit.getIncludes(); + // then + assertThat(includes).isEmpty(); + } + + @Test + public void getIncludes_should_return_3_filters() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {},\n" + + " \"jedis\": {\n" + + " \"includes\": [\n" + + " {\n" + + " \"name\" : \"yoda\"\n" + + " },\n" + + " {\n" + + " \"name\" : \"obiwan\"\n" + + " },\n" + + " {\n" + + " \"name\" : \"*\"\n" + + " }\n" + + " ]\n" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(1); // jedis + // when + List includes = limit.getIncludes(); + // then + assertThat(includes).hasSize(3); + } + + @Test + public void getIncludes_should_return_1_event_name_filter() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"includes\": [\n" + + " {\n" + + " \"name\" : \"yoda\"\n" + + " }\n" + + " ]\n" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); // * + // when + List includes = limit.getIncludes(); + // then + assertThat(includes).are( + new Condition<>((filter) -> filter instanceof Filter.EventNameFilter, "is EventNameFilter")); + } + + @Test + public void getIncludes_should_return_event_name_filter_with_negative_daily_limit_if_daily_limit_is_not_numeric() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"includes\": [\n" + + " {\n" + + " \"name\" : \"yoda\",\n" + + " \"dailyLimit\" : \"bogus\"\n" + // not numeric + " }\n" + + " ]\n" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); // * + List includes = limit.getIncludes(); + assertThat(includes).hasSize(1); + Filter filter = includes.get(0); + assertThat(filter).isExactlyInstanceOf(Filter.EventNameFilter.class); + Filter.EventNameFilter nameFilter = (Filter.EventNameFilter) filter; + // when + int dailyLimit = nameFilter.getDailyLimit(); + // then + assertThat(dailyLimit).isEqualTo(-1); + } + + @Test + public void getIncludes_should_return_event_name_filter_with_negative_daily_limit_if_daily_limit_is_not_specified() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"includes\": [\n" + + " {\n" + + " \"name\" : \"yoda\"\n" + // dailyLimit missing + " }\n" + + " ]\n" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); // * + List includes = limit.getIncludes(); + assertThat(includes).hasSize(1); + Filter filter = includes.get(0); + assertThat(filter).isExactlyInstanceOf(Filter.EventNameFilter.class); + Filter.EventNameFilter nameFilter = (Filter.EventNameFilter) filter; + // when + int dailyLimit = nameFilter.getDailyLimit(); + // then + assertThat(dailyLimit).isEqualTo(-1); + } + + @Test + public void getIncludes_should_return_1_event_name_filter_with_ratio_and_daily_limit() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"includes\": [\n" + + " {\n" + + " \"name\" : \"yoda\",\n" + + " \"ratio\" : \"0.544\",\n" + + " \"dailyLimit\" : \"42\"\n" + + " }\n" + + " ]\n" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); // * + List includes = limit.getIncludes(); + assertThat(includes).hasSize(1); + Filter filter = includes.get(0); + assertThat(filter).isExactlyInstanceOf(Filter.EventNameFilter.class); + Filter.EventNameFilter nameFilter = (Filter.EventNameFilter) filter; + // when + float ratio = nameFilter.getRatio(); + int dailyLimit = nameFilter.getDailyLimit(); + // then + assertThat(ratio).isEqualTo(0.544f); + assertThat(dailyLimit).isEqualTo(42); + } + + @Test + public void getIncludes_should_return_1_event_property_filter() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"includes\": [\n" + + " {\n" + + " \"property\" : \"yoda\",\n" + + " \"value\" : \"jedi\"\n" + + " }\n" + + " ]\n" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); // * + // when + List includes = limit.getIncludes(); + // then + assertThat(includes).are(new Condition<>( + (filter) -> filter instanceof Filter.EventPropertyFilter, "is EventPropertyFilter")); + } + + @Test + public void getIncludes_should_NOT_have_property_value_filter_if_value_is_missing() throws JsonProcessingException { + // given + String config = + "{\n" + + " \"*\": {\n" + + " \"includes\": [\n" + + " {\n" + + " \"property\" : \"yoda\"\n" + + " }\n" + + " ]\n" + + " }" + + "}"; + List limits = PluginLimitsDeserialization.create(config); + PluginLimits limit = limits.get(0); // * + // when + List includes = limit.getIncludes(); + // then + assertThat(includes).isEmpty(); + } +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsRatioTests.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsRatioTests.java new file mode 100644 index 00000000..dcb606ea --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsRatioTests.java @@ -0,0 +1,112 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.event; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.eventNameFilterFakeWithRatio; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.pluginLimitsWithIncludesExcludesWithPercentile; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.userId; +import static org.assertj.core.api.Assertions.assertThat; + +public class PluginLimitsRatioTests { + + @ParameterizedTest + @MethodSource("canSend_for_include_ratio_and_percentile") + public void canSend_for_given_ratio_in_limits_and_user_percentile(float limitsRatio, float percentile, boolean shouldSend) { + // given + PluginLimits limits = new PluginLimits( + "jedis", + Enabled.ALL, // ignore + -1, // ignore + limitsRatio, + Collections.emptyList(), + Collections.emptyList(), + userId(percentile)); + Event event = event(); + // when + boolean canSend = limits.canSend(event, 0); + // then + assertThat(canSend).isEqualTo(shouldSend); + } + + @ParameterizedTest + @MethodSource("canSend_for_include_ratio_and_percentile") + public void canSend_for_given_ratio_in_include_filter_and_user_percentile(float filterRatio, float percentile, boolean shouldSend) { + // given + PluginLimits limits = new PluginLimits( + "jedis", + Enabled.ALL, // ignore + -1, // ignore + 1f, // ratio 100% + List.of( + eventNameFilterFakeWithRatio(filterRatio) + ), + Collections.emptyList(), + userId(percentile)); + Event event = event(); + // when + boolean canSend = limits.canSend(event,0); + // then + assertThat(canSend).isEqualTo(shouldSend); + } + + private static Stream canSend_for_include_ratio_and_percentile() { + return Stream.of( + Arguments.of(0f, 0f, false), // ratio: 0, percentile: .2 -> false + Arguments.of(0f, .2f, false), // ratio: 0, percentile: .2 -> false + Arguments.of(.1f, .1f, true), // ratio: .1, percentile: .1 -> true + Arguments.of(.5f, .4f, true), // ratio: .5, percentile: .4 -> true + Arguments.of(.5f, .5f, true), // ratio: .5, percentile: .5 -> true + Arguments.of(.5f, .6f, false), // ratio: .5, percentile: .6 -> false + Arguments.of(1f, .6f, true), // ratio: 1, percentile: .6 -> true + Arguments.of(1f, 1f, true) // ratio: 1, percentile: 1 -> true + ); + } + + @ParameterizedTest + @MethodSource("canSend_for_exclude_ratio_and_percentile") + public void canSend_for_given_ratio_in_exclude_filter_and_user_percentile(float filterRatio, float percentile, boolean shouldSend) { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + Collections.emptyList(), + List.of( + eventNameFilterFakeWithRatio(filterRatio) + ), + userId(percentile)); + Event event = event(); + // when + boolean canSend = limits.canSend(event,0); + // then + assertThat(canSend).isEqualTo(shouldSend); + } + + private static Stream canSend_for_exclude_ratio_and_percentile() { + return Stream.of( + Arguments.of(0f, 0f, true), // exclude ratio: 0, percentile: .2 -> true + Arguments.of(0f, .2f, true), // exclude ratio: 0, percentile: .2 -> true + Arguments.of(.1f, .1f, true), // exclude ratio: .1, percentile: .1 -> true + Arguments.of(.5f, .4f, true), // exclude ratio: .5, percentile: .4 -> true + Arguments.of(.5f, .5f, true), // exclude ratio: .5, percentile: .5 -> true + Arguments.of(.5f, .6f, false), // exclude ratio: .5, percentile: .6 -> false + Arguments.of(1f, .6f, false), // exclude ratio: 1, percentile: .6 -> false + Arguments.of(1f, 1f, false) // exclude ratio: 1, percentile: 1 -> false + ); + } +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsTest.java new file mode 100644 index 00000000..e589baec --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/limits/PluginLimitsTest.java @@ -0,0 +1,398 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.configuration.limits; + +import com.redhat.devtools.intellij.telemetry.core.service.Event; +import com.redhat.devtools.intellij.telemetry.core.service.UserId; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.event; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.eventNameFilter; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.eventNameWithDailyLimit; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.eventProperty; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.pluginLimitsWithIncludesExcludes; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.pluginLimitsWithIncludesExcludesWithPercentile; +import static com.redhat.devtools.intellij.telemetry.core.configuration.limits.Mocks.userId; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +public class PluginLimitsTest { + + @Test + public void canSend_should_return_false_if_enabled_is_OFF() { + // given + PluginLimits limits = new PluginLimits( + "yoda", + Enabled.OFF, + -1, // ignore + 1f, // ignore + Collections.emptyList(), + Collections.emptyList(), + null); // no ratio check + // when + boolean canSend = limits.canSend(null, Integer.MAX_VALUE); + // then + assertThat(canSend).isFalse(); + } + + @Test + public void canSend_should_return_false_if_enabled_is_null() { + // given + PluginLimits limits = new PluginLimits( + "yoda", + null, // null enabled + -1, // ignore + 1f, // ignore + Collections.emptyList(), + Collections.emptyList(), + null); // no ratio check; + // when + boolean canSend = limits.canSend(null, Integer.MAX_VALUE); + // then + assertThat(canSend).isFalse(); + } + + @Test + public void canSend_should_return_true_if_enabled_is_CRASH_and_event_has_error() { + // given + PluginLimits limits = new PluginLimits( + "yoda", + Enabled.CRASH, // only crash + -1, // ignore + 1f, // ignore + Collections.emptyList(), + Collections.emptyList(), + null); // no ratio check + Event error = event( + Map.of("error", "anakin turned to the dark side")); // error + // when + boolean canSend = limits.canSend(error, 0); + // then + assertThat(canSend).isTrue(); + } + + @Test + public void canSend_should_return_false_if_enabled_is_CRASH_and_event_has_no_error() { + // given + PluginLimits limits = new PluginLimits( + "yoda", + Enabled.CRASH, // only crash + -1, // ignore + 1f, // ignore + Collections.emptyList(), + Collections.emptyList(), + null); // no ratio check + Event error = event(); // no error + // when + boolean canSend = limits.canSend(error, 0); + // then + assertThat(canSend).isFalse(); + } + + @Test + public void canSend_should_return_true_if_enabled_is_ERROR_and_event_has_error() { + // given + PluginLimits limits = new PluginLimits( + "yoda", + Enabled.ERROR, // only errors + -1, // ignore + 1f, // ignore + Collections.emptyList(), + Collections.emptyList(), + null); // no ratio check + Event error = event( + Map.of("error", "anakin turned to the dark side")); // error + // when + boolean canSend = limits.canSend(error, 0); + // then + assertThat(canSend).isTrue(); + } + + @Test + public void canSend_should_return_false_if_enabled_is_ERROR_and_event_has_no_error() { + // given + PluginLimits limits = new PluginLimits( + "yoda", + Enabled.ERROR, // only errors + -1, // ignore + 1f, // ignore + Collections.emptyList(), + Collections.emptyList(), + null); // no ratio check + Event event = event(); // no error + // when + boolean canSend = limits.canSend(event, 0); + // then + assertThat(canSend).isFalse(); + } + + @Test + public void canSend_should_return_true_if_event_is_matched_by_inclusion_filter_with_ratio() { + // given + UserId userId = userId(1f); + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + List.of( + // matching & ratio + eventNameFilter(true,true, false) + ), + Collections.emptyList(), + userId); + Event event = event(); // no error + // when + boolean canSend = limits.canSend(event, 0); + // then + assertThat(canSend).isTrue(); + } + + @Test + public void canSend_should_return_false_if_event_is_matched_by_exclusion_filter_with_ratio() { + // given + UserId userId = userId(1f); + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + Collections.emptyList(), + List.of( + eventNameFilter(true,false, true) + ), + userId); + Event event = event(); + // when + boolean canSend = limits.canSend(event, 0); + // then + assertThat(canSend).isFalse(); + } + + @Test + public void canSend_should_return_false_if_event_is_matched_by_inclusion_and_exclusion_filter() { + // given + UserId userId = userId(1f); + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + List.of( + eventNameFilter(true,true, false) + ), + List.of( + eventNameFilter(true,false, true) + ), + userId); + Event event = event(); + // when + boolean canSend = limits.canSend(event, 0); + // then + assertThat(canSend).isFalse(); + } + + @Test + public void canSend_returns_true_if_dailyLimit_is_not_reached() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludes( + List.of(eventNameWithDailyLimit(Integer.MAX_VALUE)), + Collections.emptyList()); + Event event = event(); + // when + boolean canSend = limits.canSend(event,0); + // then + assertThat(canSend).isEqualTo(true); + } + + @Test + public void canSend_returns_false_if_dailyLimit_is_reached() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludes( + List.of(eventNameWithDailyLimit(1)), + Collections.emptyList()); + Event event = event(); + // when + boolean canSend = limits.canSend(event,1); + // then + assertThat(canSend).isEqualTo(false); + } + + @Test + public void canSend_returns_true_for_property_filter_event_though_dailyLimit_is_reached() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludes( + List.of(eventProperty()), + Collections.emptyList()); + Event event = event(); + // when + boolean canSend = limits.canSend(event,Integer.MAX_VALUE); + // then + assertThat(canSend).isEqualTo(true); + } + + @Test + public void isIncluded_should_return_true_if_there_is_no_include_filter() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + Collections.emptyList(), + Collections.emptyList(), + mock(UserId.class)); + Event event = event(); + // when + boolean isIncluded = limits.isIncluded(event, 0); + // then + assertThat(isIncluded).isTrue(); + } + + @Test + public void isIncluded_should_return_true_if_there_is_no_matching_include_filter() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + List.of( + // is NOT matching + eventNameFilter(false,true, false) + ), + Collections.emptyList(), + mock(UserId.class)); + Event event = event(); + // when + boolean isIncluded = limits.isIncluded(event, 0); + // then + assertThat(isIncluded).isTrue(); + } + + @Test + public void isIncluded_should_return_true_if_event_is_matching_filter_and_is_included() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + List.of( + // is matching & is excluded in ratio + eventNameFilter(true,true, false) + ), + Collections.emptyList(), + mock(UserId.class)); + Event event = event(); + // when + boolean isIncluded = limits.isIncluded(event, 0); + // then + assertThat(isIncluded).isTrue(); + } + + @Test + public void isIncluded_should_return_false_if_event_is_matching_filter_but_isnt_included() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + List.of( + // is matching & is NOT included in ratio + eventNameFilter(true,false, false) + ), + Collections.emptyList(), + mock(UserId.class)); + Event event = event(); + // when + boolean isIncluded = limits.isIncluded(event, 0); + // then + assertThat(isIncluded).isFalse(); + } + + @Test + public void isIncluded_should_return_true_if_event_is_within_daily_limit() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + List.of( + // is matching & is NOT included in ratio + eventNameFilter(true,true, false, 1) // max 1 daily + ), + Collections.emptyList(), + mock(UserId.class)); + Event event = event(); + // when + boolean isIncluded = limits.isIncluded(event, 0); // 0 events so far + // then + assertThat(isIncluded).isTrue(); + } + + @Test + public void isIncluded_should_return_false_if_daily_limit_reached_already() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + List.of( + // is matching & is NOT included in ratio + eventNameFilter(true,true, false, 1) // max 1 daily + ), + Collections.emptyList(), + mock(UserId.class)); + Event event = event(); + // when + boolean isIncluded = limits.isIncluded(event, 1); // 1 event so far, no room left + // then + assertThat(isIncluded).isFalse(); + } + + @Test + public void isExcluded_should_return_false_if_there_is_no_filter() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + Collections.emptyList(), + Collections.emptyList(), + mock(UserId.class)); + Event event = event(); + // when + boolean isExcluded = limits.isExcluded(event); + // then + assertThat(isExcluded).isFalse(); + } + + @Test + public void isExcluded_should_return_false_if_there_is_no_matching_filter() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + List.of( + // is NOT matching + eventNameFilter(false,true, true) + ), + Collections.emptyList(), + mock(UserId.class)); + Event event = event(); + // when + boolean isExcluded = limits.isExcluded(event); + // then + assertThat(isExcluded).isFalse(); + } + + @Test + public void isExcluded_should_return_true_if_event_is_matching_filter_and_is_excluded_in_ratio() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + Collections.emptyList(), + List.of( + // is matching & is excluded in ratio + eventNameFilter(true,true, true) + ), + mock(UserId.class)); + Event event = event(); + // when + boolean isExcluded = limits.isExcluded(event); + // then + assertThat(isExcluded).isTrue(); + } + + @Test + public void isExcluded_should_return_false_if_event_is_matching_filter_and_is_NOT_excluded_in_ratio() { + // given + PluginLimits limits = pluginLimitsWithIncludesExcludesWithPercentile( + Collections.emptyList(), + List.of( + // is matching & is NOT excluded in ratio + eventNameFilter(true,true, false) + ), + mock(UserId.class)); + Event event = event(); + // when + boolean isExcluded = limits.isExcluded(event); + // then + assertThat(isExcluded).isFalse(); + } + +} diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/EnvironmentTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/EnvironmentTest.java index 5cc4a265..ca778d06 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/EnvironmentTest.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/EnvironmentTest.java @@ -284,12 +284,12 @@ void equals_should_be_false_if_ide_javaversion_is_different() { @Test void equals_should_be_false_if_plugin_name_is_different() { // given - Plugin jediPlugin = new Plugin.Factory().create("jedi", "42"); + Plugin jediPlugin = new Plugin.Factory().create("jedi", "42", "starwars.jedi.id"); Environment jediEnv = envBuilder .ide(ide) .plugin(jediPlugin) .build(); - Plugin sithPlugin = new Plugin.Factory().create("sith", "42"); + Plugin sithPlugin = new Plugin.Factory().create("sith", "42","starwars.jedi.id"); Environment sithEnv = envBuilder .ide(ide) .plugin(sithPlugin) @@ -303,12 +303,12 @@ void equals_should_be_false_if_plugin_name_is_different() { @Test void equals_should_be_false_if_plugin_version_is_different() { // given - Plugin jedi42Plugin = new Plugin.Factory().create("jedi", "42"); + Plugin jedi42Plugin = new Plugin.Factory().create("jedi", "42", "starwars.jedi.id"); Environment jedi42Env = envBuilder .ide(ide) .plugin(jedi42Plugin) .build(); - Plugin jedi84Plugin = new Plugin.Factory().create("jedi", "84"); + Plugin jedi84Plugin = new Plugin.Factory().create("jedi", "84", "starwars.jedi.id"); Environment jedi84Env = envBuilder .ide(ide) .plugin(jedi84Plugin) diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/Fakes.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/Fakes.java index 4cb1c1e5..be52b026 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/Fakes.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/Fakes.java @@ -37,7 +37,7 @@ public static Environment environment( .timezone(timezone) .country(country) .platform(new Platform(platform_name, platform_distribution, platform_version)) - .plugin(new Plugin.Factory().create(extensionName, extensionVersion)) + .plugin(new Plugin.Factory().create(extensionName, extensionVersion, "bogusId")) .build(); } diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilderIntegrationTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilderIntegrationTest.java index 83b02ecf..eb9a642a 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilderIntegrationTest.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilderIntegrationTest.java @@ -11,8 +11,8 @@ package com.redhat.devtools.intellij.telemetry.core.service; import com.intellij.util.messages.MessageBusConnection; -import com.redhat.devtools.intellij.telemetry.core.IService; import com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration; +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.EventLimits; import com.redhat.devtools.intellij.telemetry.core.service.segment.ISegmentConfiguration; import com.redhat.devtools.intellij.telemetry.core.service.segment.IdentifyTraitsPersistence; import com.redhat.devtools.intellij.telemetry.core.service.segment.SegmentBroker; @@ -39,7 +39,7 @@ class TelemetryMessageBuilderIntegrationTest { private static final String EXTENSION_NAME = "com.redhat.devtools.intellij.telemetry"; - private static final String EXTENSION_VERSION = "1.0.0.44"; + private static final String EXTENSION_VERSION = "1.2.0-SNAPSHOT"; private static final String APPLICATION_VERSION = "1.0.0"; private static final String APPLICATION_NAME = TelemetryMessageBuilderIntegrationTest.class.getSimpleName(); private static final String PLATFORM_NAME = "smurfOS"; @@ -71,17 +71,19 @@ void before() { LOCALE, TIMEZONE, COUNTRY); + EventLimits limits = new EventLimits("*"); SegmentBroker broker = new SegmentBroker( false, UserId.INSTANCE.get(), IdentifyTraitsPersistence.INSTANCE, environment, segmentConfiguration, - key -> analytics); + key -> analytics) {}; TelemetryConfiguration telemetryConfiguration = telemetryConfiguration(TelemetryConfiguration.Mode.DEBUG); TelemetryService telemetryService = new TelemetryService( telemetryConfiguration, + limits, broker, mock(MessageBusConnection.class), mock(TelemetryNotifications.class)); diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilderTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilderTest.java index 805cfe25..977eeb36 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilderTest.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryMessageBuilderTest.java @@ -12,7 +12,6 @@ import com.intellij.ide.AppLifecycleListener; import com.intellij.util.messages.MessageBusConnection; -import com.redhat.devtools.intellij.telemetry.core.IService; import com.redhat.devtools.intellij.telemetry.core.service.TelemetryMessageBuilder.FeedbackServiceFacade; import com.redhat.devtools.intellij.telemetry.core.util.AnonymizeUtils; import com.redhat.devtools.intellij.telemetry.core.util.TimeUtils; diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryServiceTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryServiceTest.java index 93d38132..517a30fd 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryServiceTest.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/TelemetryServiceTest.java @@ -11,8 +11,8 @@ package com.redhat.devtools.intellij.telemetry.core.service; import com.intellij.util.messages.MessageBusConnection; -import com.redhat.devtools.intellij.telemetry.core.IService; import com.redhat.devtools.intellij.telemetry.core.configuration.TelemetryConfiguration; +import com.redhat.devtools.intellij.telemetry.core.configuration.limits.IEventLimits; import com.redhat.devtools.intellij.telemetry.core.service.segment.SegmentBroker; import com.redhat.devtools.intellij.telemetry.ui.TelemetryNotifications; import org.junit.jupiter.api.BeforeEach; @@ -34,24 +34,27 @@ class TelemetryServiceTest { + private IEventLimits limits; private SegmentBroker broker; private MessageBusConnection bus; + private TelemetryConfiguration configuration; private IService service; private Event event; private TelemetryNotifications notifications; @BeforeEach void before() { + this.limits = createEventLimits(); this.broker = createSegmentBroker(); this.bus = createMessageBusConnection(); this.notifications = createTelemetryNotifications(); - TelemetryConfiguration configuration = telemetryConfiguration(true, true); - this.service = new TelemetryService(configuration, broker, bus, notifications); + this.configuration = telemetryConfiguration(true, true); + this.service = new TelemetryService(configuration, limits, broker, bus, notifications); this.event = new Event(null, "Testing Telemetry", null); } @Test - void send_should_send_if_is_enabled() { + void send_should_send_if_is_enabled_and_limits_allow_it() { // given // when service.send(event); @@ -59,11 +62,35 @@ void send_should_send_if_is_enabled() { verify(broker, atLeastOnce()).send(any(Event.class)); } + @Test + void send_should_NOT_send_if_is_enabled_but_limits_DONT_allow_it() { + // given + doReturn(false) + .when(limits).canSend(any()); + // when + service.send(event); + // then + verify(broker, never()).send(any(Event.class)); + } + + @Test + void send_should_NOT_send_if_is_NOT_enabled_but_limits_allow_it() { + // given + doReturn(false) + .when(configuration).isEnabled(); + doReturn(true) + .when(limits).canSend(any()); + // when + service.send(event); + // then + verify(broker, never()).send(any(Event.class)); + } + @Test void send_should_NOT_send_if_is_NOT_configured() { // given TelemetryConfiguration configuration = telemetryConfiguration(false, false); - TelemetryService service = new TelemetryService(configuration, broker, bus, notifications); + TelemetryService service = new TelemetryService(configuration, limits, broker, bus, notifications); // when service.send(event); // then @@ -74,7 +101,7 @@ void send_should_NOT_send_if_is_NOT_configured() { void send_should_send_all_events_once_it_gets_enabled() { // given TelemetryConfiguration configuration = telemetryConfiguration(false, false); - TelemetryService service = new TelemetryService(configuration, broker, bus, notifications); + TelemetryService service = new TelemetryService(configuration, limits, broker, bus, notifications); // when config is disabled service.send(event); service.send(event); @@ -105,7 +132,7 @@ void send_should_send_userinfo() { void send_should_query_user_consent_once() { // given TelemetryConfiguration configuration = telemetryConfiguration(true, false); - TelemetryService service = new TelemetryService(configuration, broker, bus, notifications); + TelemetryService service = new TelemetryService(configuration, limits, broker, bus, notifications); // when service.send(event); service.send(event); @@ -123,6 +150,46 @@ void send_should_NOT_query_user_consent_if_configured() { verify(notifications, never()).queryUserConsent(); } + @Test + void send_should_send_if_limits_allow_it() { + // given + doReturn(true) + .when(limits).canSend(event); + // when + service.send(event); + // then + verify(broker).send(event); + } + + @Test + void send_should_notify_limits_that_event_was_sent() { + // given + doReturn(true) + .when(limits).canSend(event); + // when + service.send(event); + // then + verify(limits).wasSent(event); + } + + @Test + void send_should_NOT_send_if_limits_DONT_allow_it() { + // given + doReturn(false) + .when(limits).canSend(event); + // when + service.send(event); + // then + verify(broker, never()).send(event); + } + + private IEventLimits createEventLimits() { + IEventLimits mock = mock(IEventLimits.class); + doReturn(true) + .when(mock).canSend(any()); + return mock; + } + private SegmentBroker createSegmentBroker() { return mock(SegmentBroker.class); } diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/UserIdTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/UserIdTest.java index 38a28a9e..64da21b7 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/UserIdTest.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/UserIdTest.java @@ -11,8 +11,12 @@ package com.redhat.devtools.intellij.telemetry.core.service; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.nio.file.Path; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -62,6 +66,78 @@ void get_should_write_if_file_exists_but_is_invalid() { assertThat(user.written).isTrue(); } + @ParameterizedTest + @MethodSource("percentile_for_hashCode") + public void getPercentile_should_return_value_for_hashCode(int hashCode, float expectedPercentile) { + // given + UserId userId = new FixedHashCodeUserId(hashCode); + // when + float percentile = userId.getPercentile(); + // then + assertThat(percentile).isEqualTo(expectedPercentile); + } + + @ParameterizedTest + @MethodSource("hashCode_for_uuid") + public void hashCode_should_return_value_for_uuid(String uuid, int expectedHashCode) { + // given + UserId userId = new TestableUserId(true, uuid); + // when + int hashCode = userId.hashCode(); + // then + assertThat(hashCode).isEqualTo(expectedHashCode); + } + + public static Stream hashCode_for_uuid() { + return Stream.of( + Arguments.of("6c4698ed-85f3-4448-9b0f-10897b8b4178", 349419899), // uuid -> hashCode + Arguments.of("870c8e59-9299-437f-a4dd-5bd331352ec7", -2018427608), + Arguments.of("c020f453-6811-4545-a3aa-3c5cc17d6fe8", -252979871), + Arguments.of("db3f9e5e-2dd5-4d81-aac8-aa75333c105c", 1140739481), + Arguments.of("8abd3beb-c930-46a0-b244-7f1c6f9857da", 82715988), + Arguments.of("d839a99f-6afc-4309-bcb7-5d1e78eb0241", 1829289193), + Arguments.of("08f87a61-077a-4cb3-b9f9-4e5751d4dc96", 1602451551), + Arguments.of("72f09a0e-1fa6-46d1-8322-48ac0ffa4252", -633581890), + Arguments.of("c1d68afc-a39e-4b89-bb95-e9d7684efe7c", -1103007680), + Arguments.of("a52ec11d-35bf-4579-88fd-72de5c6a0467", 158094785), + Arguments.of("d136d7b4-518a-43b6-bb1d-1dafd9e1e52b", 2110423401), + Arguments.of("ddf95114-333d-41e0-b1ba-d84bc6293634", 1889783579), + Arguments.of("fb833841-75de-435e-98d2-ab0988712340", 1464118621), + Arguments.of("71f327fa-e8ed-4fdc-92d9-5de6a1f47229", -367676488), + Arguments.of("82b4c9f4-73e3-4e4a-b243-dc4aff91b9f6", 224204832), + Arguments.of("16d122ec-9122-4392-a90f-71504ef40c6f", 1020229945), + Arguments.of("570447c7-168e-4d3d-be40-e5559dd4f86b", -690069930), + Arguments.of("cc4ee6ef-6862-4468-ac51-a64f237f84f5", -1247454805), + Arguments.of("b2ee8320-4dff-44a1-87e2-ca9daa9e24ed", -1381801037), + Arguments.of("4e97382d-6042-4001-889d-ecc0cb4e8862", -1911346601) + ); + } + + public static Stream percentile_for_hashCode() { + return Stream.of( + Arguments.of(349419899, 0.9899f), // hashCode -> percentile + Arguments.of(-2018427608, 0.7608f), + Arguments.of(-252979871, 0.9871f), + Arguments.of(1140739481, 0.9481f), + Arguments.of(82715988, 0.5988f), + Arguments.of(1829289193, 0.9193f), + Arguments.of(1602451551, 0.1551f), + Arguments.of(-633581890, 0.189f), + Arguments.of(-1103007680, 0.768f), + Arguments.of(158094785, 0.4785f), + Arguments.of(2110423401, 0.3401f), + Arguments.of(1889783579, 0.3579f), + Arguments.of(1464118621, 0.8621f), + Arguments.of(-367676488, 0.6488f), + Arguments.of(224204832, 0.4832f), + Arguments.of(1020229945, 0.9945f), + Arguments.of(-690069930, 0.993f), + Arguments.of(-1247454805, 0.4805f), + Arguments.of(-1381801037, 0.1037f), + Arguments.of(-1911346601, 0.6601f) + ); + } + private static class TestableUserId extends UserId { private final boolean exists; @@ -91,4 +167,17 @@ protected void write(String uuid, Path uuidFile) { } } + private static class FixedHashCodeUserId extends UserId { + + private final int hashCode; + + public FixedHashCodeUserId(int hashCode) { + this.hashCode = hashCode; + } + + @Override + public int hashCode() { + return hashCode; + } + } } diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBrokerTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBrokerTest.java index c78793b8..90f45ed0 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBrokerTest.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentBrokerTest.java @@ -10,7 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.intellij.telemetry.core.service.segment; -import com.redhat.devtools.intellij.telemetry.core.IMessageBroker; +import com.redhat.devtools.intellij.telemetry.core.service.IMessageBroker; import com.redhat.devtools.intellij.telemetry.core.service.Environment; import com.redhat.devtools.intellij.telemetry.core.service.Event; import com.segment.analytics.Analytics; diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/SegmentConfigurationTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentConfigurationTest.java similarity index 92% rename from src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/SegmentConfigurationTest.java rename to src/test/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentConfigurationTest.java index d5ed6bc8..5916ea74 100644 --- a/src/test/java/com/redhat/devtools/intellij/telemetry/core/configuration/SegmentConfigurationTest.java +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/service/segment/SegmentConfigurationTest.java @@ -8,9 +8,8 @@ * Contributors: * Red Hat, Inc. - initial API and implementation ******************************************************************************/ -package com.redhat.devtools.intellij.telemetry.core.configuration; +package com.redhat.devtools.intellij.telemetry.core.service.segment; -import com.redhat.devtools.intellij.telemetry.core.service.segment.SegmentConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/redhat/devtools/intellij/telemetry/core/util/BasicGlobPatternTest.java b/src/test/java/com/redhat/devtools/intellij/telemetry/core/util/BasicGlobPatternTest.java new file mode 100644 index 00000000..24dac031 --- /dev/null +++ b/src/test/java/com/redhat/devtools/intellij/telemetry/core/util/BasicGlobPatternTest.java @@ -0,0 +1,140 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.intellij.telemetry.core.util; + +import org.junit.jupiter.api.Test; + +import java.util.regex.PatternSyntaxException; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class BasicGlobPatternTest { + + @Test + public void compile_should_throw_when_range_is_invalid() { + // given, when, then + assertThrows(PatternSyntaxException.class, () -> BasicGlobPattern.compile("[5-1] jedi")); + } + + @Test + public void compile_should_throw_when_brace_expansions_are_nested() { + // given, when, then + assertThrows(PatternSyntaxException.class, () -> BasicGlobPattern.compile("{{yoda,obiwan}")); + } + + @Test + public void compile_should_throw_when_brace_expansions_are_not_closed() { + // given, when, then + assertThrows(PatternSyntaxException.class, () -> BasicGlobPattern.compile("{yoda,obiwan")); + } + + @Test + public void machtes_should_match_expression_that_starts_and_ends_with_wildcard() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("*yoda*"); + // when, then + assertThat(glob.matches("master yoda is a jedi master")).isTrue(); + assertThat(glob.matches("yoda")).isTrue(); // * matches no character, too + assertThat(glob.matches("master yoda")).isTrue(); // * matches no character, too + assertThat(glob.matches("master obiwan is a jedi master, too")).isFalse(); + } + + @Test + public void machtes_should_match_expression_that_starts_with_wildcard() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("*yoda"); + // when, then + assertThat(glob.matches("master yoda")).isTrue(); + assertThat(glob.matches("yoda")).isTrue(); + assertThat(glob.matches("master obiwan")).isFalse(); + } + + @Test + public void machtes_should_match_expression_that_has_a_wildcard() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("y*da"); + // when, then + assertThat(glob.matches("yoda")).isTrue(); + assertThat(glob.matches("yooooda")).isTrue(); + } + + @Test + public void machtes_should_match_expression_that_starts_and_ends_with_placeholder() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("?this is yoda?"); + // when, then + assertThat(glob.matches("!this is yoda!")).isTrue(); + assertThat(glob.matches("!!this is yoda!")).isFalse(); + assertThat(glob.matches("this is yoda!")).isFalse(); + } + + @Test + public void machtes_should_match_expression_that_has_placeholders() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("y??a"); + // when, then + assertThat(glob.matches("yoda")).isTrue(); + assertThat(glob.matches("yiza")).isTrue(); + assertThat(glob.matches("yoooda")).isFalse(); + } + + @Test + public void machtes_should_match_expression_with_brace_expansions() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("{yoda,obiwan,skywalker} is a jedi"); + // when, then + assertThat(glob.matches("yoda is a jedi")).isTrue(); + assertThat(glob.matches("obiwan is a jedi")).isTrue(); + assertThat(glob.matches("skywalker is a jedi")).isTrue(); + assertThat(glob.matches("darthvader is a jedi")).isFalse(); + } + + @Test + public void machtes_should_match_empty_brace_expansion() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("{yoda,darth,} the jedi"); + // when, then + assertThat(glob.matches("yoda the jedi")).isTrue(); + assertThat(glob.matches(" the jedi")).isTrue(); // empty alternative + } + + @Test + public void machtes_should_match_expression_with_a_range() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("jedi [0-4]"); + // when, then + assertThat(glob.matches("jedi 0")).isTrue(); + assertThat(glob.matches("jedi 1")).isTrue(); + assertThat(glob.matches("jedi 4")).isTrue(); + assertThat(glob.matches("jedi 5")).isFalse(); + } + + @Test + public void machtes_should_match_expression_with_alternatives() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("jedi [abc]"); + // when, then + assertThat(glob.matches("jedi a")).isTrue(); + assertThat(glob.matches("jedi b")).isTrue(); + assertThat(glob.matches("jedi c")).isTrue(); + assertThat(glob.matches("jedi d")).isFalse(); + } + + @Test + public void machtes_should_match_parenthesis_and_pipe_as_normal_characters() { + // given + BasicGlobPattern glob = BasicGlobPattern.compile("jedi(s|42)"); + // when, then + assertThat(glob.matches("jedi(s|42)")).isTrue(); + assertThat(glob.matches("jedi(s)")).isFalse(); + } +}