diff --git a/bundles/com.espressif.idf.core/META-INF/MANIFEST.MF b/bundles/com.espressif.idf.core/META-INF/MANIFEST.MF index 4ed616a4c..7d74b3415 100644 --- a/bundles/com.espressif.idf.core/META-INF/MANIFEST.MF +++ b/bundles/com.espressif.idf.core/META-INF/MANIFEST.MF @@ -35,6 +35,7 @@ Export-Package: com.espressif.idf.core, com.espressif.idf.core.configparser.vo, com.espressif.idf.core.logging, com.espressif.idf.core.resources, + com.espressif.idf.core.telemetry, com.espressif.idf.core.toolchain, com.espressif.idf.core.tools, com.espressif.idf.core.tools.eimjson, diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/IDFCorePreferenceConstants.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/IDFCorePreferenceConstants.java index d9d7c9e6a..677652985 100644 --- a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/IDFCorePreferenceConstants.java +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/IDFCorePreferenceConstants.java @@ -26,6 +26,8 @@ public class IDFCorePreferenceConstants public static final String HIDE_ERRORS_IDF_COMPONENTS = "hideErrorsOnIdfDerivedFiles"; //$NON-NLS-1$ public static final String AUTOMATE_CLANGD_FORMAT_FILE = "automateClangFormatFileCreation"; //$NON-NLS-1$ public static final String EIM_IDF_JSON_PATH = "eimIdfJsonPath"; //$NON-NLS-1$ + public static final String TELEMETRY_ENABLED = "telemetryEnabled"; //$NON-NLS-1$ + public static final boolean TELEMETRY_ENABLED_DEFAULT = true; public static final boolean AUTOMATE_CLANGD_FORMAT_FILE_DEFAULT = true; public static final boolean CMAKE_CCACHE_DEFAULT_STATUS = true; public static final boolean AUTOMATE_BUILD_HINTS_DEFAULT_STATUS = true; diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryConnection.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryConnection.java new file mode 100644 index 000000000..98f5f2b8b --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryConnection.java @@ -0,0 +1,101 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.telemetry; + +import java.net.URI; +import java.util.Locale; +import java.util.Optional; + +/** + * Azure Application Insights connection string, holding the instrumentation key and the regional ingestion endpoint. + * + * @author Kondal Kolipaka + * + */ +public final class TelemetryConnection +{ + private static final String INSTRUMENTATION_KEY = "instrumentationkey"; //$NON-NLS-1$ + private static final String INGESTION_ENDPOINT = "ingestionendpoint"; //$NON-NLS-1$ + private static final String DEFAULT_INGESTION_ENDPOINT = "https://dc.services.visualstudio.com/"; //$NON-NLS-1$ + private static final String TRACK_PATH = "v2/track"; //$NON-NLS-1$ + + private final String instrumentationKey; + private final String ingestionEndpoint; + + private TelemetryConnection(String instrumentationKey, String ingestionEndpoint) + { + this.instrumentationKey = instrumentationKey; + this.ingestionEndpoint = ingestionEndpoint; + } + + /** + * Parses an Application Insights connection string of the form + * InstrumentationKey=<key>;IngestionEndpoint=<url>. Unknown fields are ignored and the + * ingestion endpoint falls back to the global one when absent. + * + * @param connectionString connection string to parse, may be null + * @return the parsed connection or an empty optional when no instrumentation key is present + */ + public static Optional parse(String connectionString) + { + if (connectionString == null || connectionString.isBlank()) + { + return Optional.empty(); + } + + String key = null; + String endpoint = DEFAULT_INGESTION_ENDPOINT; + for (String field : connectionString.split(";")) //$NON-NLS-1$ + { + int separator = field.indexOf('='); + if (separator <= 0) + { + continue; + } + String name = field.substring(0, separator).trim().toLowerCase(Locale.ENGLISH); + String value = field.substring(separator + 1).trim(); + if (value.isEmpty()) + { + continue; + } + if (INSTRUMENTATION_KEY.equals(name)) + { + key = value; + } + else if (INGESTION_ENDPOINT.equals(name)) + { + endpoint = value; + } + } + + if (key == null) + { + return Optional.empty(); + } + if (!endpoint.endsWith("/")) //$NON-NLS-1$ + { + endpoint = endpoint + "/"; //$NON-NLS-1$ + } + return Optional.of(new TelemetryConnection(key, endpoint)); + } + + public String getInstrumentationKey() + { + return instrumentationKey; + } + + public String getIngestionEndpoint() + { + return ingestionEndpoint; + } + + /** + * @return endpoint accepting the telemetry envelopes + */ + public URI getTrackUri() + { + return URI.create(ingestionEndpoint + TRACK_PATH); + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryEnvelope.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryEnvelope.java new file mode 100644 index 000000000..32c1fdaf8 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryEnvelope.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.telemetry; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * Builds the JSON payload accepted by the Application Insights ingestion endpoint. + * + * @author Kondal Kolipaka + * + */ +public final class TelemetryEnvelope +{ + private static final String ENVELOPE_NAME = "Microsoft.ApplicationInsights.Event"; //$NON-NLS-1$ + private static final String EVENT_BASE_TYPE = "EventData"; //$NON-NLS-1$ + private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC); //$NON-NLS-1$ + + private static final Gson GSON = new Gson(); + + private TelemetryEnvelope() + { + } + + /** + * @param instrumentationKey Application Insights instrumentation key + * @param eventName name of the custom event, for instance espressif-ide/session + * @param tags Application Insights context tags, such as ai.user.id + * @param properties custom string properties reported along with the event + * @param timestamp time the event occurred + * @return the serialized envelope + */ + public static String build(String instrumentationKey, String eventName, Map tags, + Map properties, Instant timestamp) + { + JsonObject baseData = new JsonObject(); + baseData.addProperty("ver", 2); //$NON-NLS-1$ + baseData.addProperty("name", eventName); //$NON-NLS-1$ + baseData.add("properties", toJsonObject(properties)); //$NON-NLS-1$ + + JsonObject data = new JsonObject(); + data.addProperty("baseType", EVENT_BASE_TYPE); //$NON-NLS-1$ + data.add("baseData", baseData); //$NON-NLS-1$ + + JsonObject envelope = new JsonObject(); + envelope.addProperty("name", ENVELOPE_NAME); //$NON-NLS-1$ + envelope.addProperty("time", TIMESTAMP_FORMAT.format(timestamp)); //$NON-NLS-1$ + envelope.addProperty("iKey", instrumentationKey); //$NON-NLS-1$ + envelope.add("tags", toJsonObject(tags)); //$NON-NLS-1$ + envelope.add("data", data); //$NON-NLS-1$ + + return GSON.toJson(envelope); + } + + private static JsonObject toJsonObject(Map values) + { + JsonObject object = new JsonObject(); + if (values != null) + { + values.forEach((key, value) -> { + if (key != null && value != null) + { + object.addProperty(key, value); + } + }); + } + return object; + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryPreferences.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryPreferences.java new file mode 100644 index 000000000..58f1915fd --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryPreferences.java @@ -0,0 +1,184 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.telemetry; + +import java.util.UUID; + +import org.eclipse.core.runtime.preferences.ConfigurationScope; +import org.eclipse.core.runtime.preferences.DefaultScope; +import org.eclipse.core.runtime.preferences.IEclipsePreferences; +import org.eclipse.core.runtime.preferences.InstanceScope; +import org.osgi.service.prefs.BackingStoreException; + +import com.espressif.idf.core.IDFCorePlugin; +import com.espressif.idf.core.IDFCorePreferenceConstants; +import com.espressif.idf.core.logging.Logger; + +/** + * Stores the opt-out flag and the anonymous state needed to count installations and updates. + *

+ * The anonymous identifier and the report timestamps live in the configuration scope so that all workspaces of one + * installation are counted as a single user. + * + * @author Kondal Kolipaka + * + */ +public final class TelemetryPreferences +{ + /** System property to disable telemetry, for instance -Didf.telemetry=false. */ + public static final String TELEMETRY_SYSTEM_PROPERTY = "idf.telemetry"; //$NON-NLS-1$ + + /** Environment variable to disable telemetry, for instance IDF_TELEMETRY=0. */ + public static final String TELEMETRY_ENV_VARIABLE = "IDF_TELEMETRY"; //$NON-NLS-1$ + + private static final String INSTALL_ID = "telemetryInstallId"; //$NON-NLS-1$ + private static final String LAST_SESSION_REPORT = "telemetryLastSessionReport"; //$NON-NLS-1$ + private static final String LAST_REPORTED_VERSION = "telemetryLastReportedVersion"; //$NON-NLS-1$ + private static final String NOTICE_SHOWN = "telemetryNoticeShown"; //$NON-NLS-1$ + + private TelemetryPreferences() + { + } + + /** + * @return false when the user opted out through the preference page, the system property or the + * environment variable + */ + public static boolean isEnabled() + { + return !isDisabledByOverride() && isEnabledByPreference(); + } + + /** + * Tells whether reporting is switched off outside of the preference page, which is how shared and automated + * installations opt out. The override can only disable reporting, so that it can never overrule a user who opted + * out through the preference page. + * + * @return true when the system property or the environment variable disables reporting + */ + public static boolean isDisabledByOverride() + { + String property = System.getProperty(TELEMETRY_SYSTEM_PROPERTY); + if (property == null || property.isBlank()) + { + property = System.getenv(TELEMETRY_ENV_VARIABLE); + } + return property != null && isDisabledValue(property.trim()); + } + + /** + * @return the stored opt-out, which is off as soon as any scope switched reporting off + */ + public static boolean isEnabledByPreference() + { + boolean defaultValue = DefaultScope.INSTANCE.getNode(IDFCorePlugin.PLUGIN_ID).getBoolean( + IDFCorePreferenceConstants.TELEMETRY_ENABLED, + IDFCorePreferenceConstants.TELEMETRY_ENABLED_DEFAULT); + return ConfigurationScope.INSTANCE.getNode(IDFCorePlugin.PLUGIN_ID) + .getBoolean(IDFCorePreferenceConstants.TELEMETRY_ENABLED, defaultValue) + && InstanceScope.INSTANCE.getNode(IDFCorePlugin.PLUGIN_ID) + .getBoolean(IDFCorePreferenceConstants.TELEMETRY_ENABLED, defaultValue); + } + + /** + * Stores the opt-out for the whole installation, so that opting out in one workspace also applies to the others. + * + * @param enabled true to report usage statistics + */ + public static void setEnabled(boolean enabled) + { + for (IEclipsePreferences node : new IEclipsePreferences[] { + ConfigurationScope.INSTANCE.getNode(IDFCorePlugin.PLUGIN_ID), + InstanceScope.INSTANCE.getNode(IDFCorePlugin.PLUGIN_ID) }) + { + node.putBoolean(IDFCorePreferenceConstants.TELEMETRY_ENABLED, enabled); + flush(node); + } + } + + private static boolean isDisabledValue(String value) + { + return "false".equalsIgnoreCase(value) || "0".equals(value) || "no".equalsIgnoreCase(value) //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + || "off".equalsIgnoreCase(value) || "disabled".equalsIgnoreCase(value); //$NON-NLS-1$ //$NON-NLS-2$ + } + + /** + * @return true once the installation told the user that usage statistics are reported + */ + public static boolean isNoticeShown() + { + return getStateNode().getBoolean(NOTICE_SHOWN, false); + } + + public static void setNoticeShown() + { + IEclipsePreferences node = getStateNode(); + node.putBoolean(NOTICE_SHOWN, true); + flush(node); + } + + /** + * @return a random identifier created on first use, which is not derived from any machine or user attribute + */ + public static String getInstallId() + { + IEclipsePreferences node = getStateNode(); + String installId = node.get(INSTALL_ID, null); + if (installId == null || installId.isBlank()) + { + installId = UUID.randomUUID().toString(); + node.put(INSTALL_ID, installId); + flush(node); + } + return installId; + } + + public static long getLastSessionReport() + { + return getStateNode().getLong(LAST_SESSION_REPORT, 0L); + } + + public static void setLastSessionReport(long timestamp) + { + IEclipsePreferences node = getStateNode(); + node.putLong(LAST_SESSION_REPORT, timestamp); + flush(node); + } + + public static String getLastReportedVersion() + { + return getStateNode().get(LAST_REPORTED_VERSION, ""); //$NON-NLS-1$ + } + + public static void setLastReportedVersion(String version) + { + IEclipsePreferences node = getStateNode(); + node.put(LAST_REPORTED_VERSION, version); + flush(node); + } + + private static IEclipsePreferences getStateNode() + { + IEclipsePreferences node = ConfigurationScope.INSTANCE.getNode(IDFCorePlugin.PLUGIN_ID); + if (node == null) + { + node = InstanceScope.INSTANCE.getNode(IDFCorePlugin.PLUGIN_ID); + } + return node; + } + + private static void flush(IEclipsePreferences node) + { + try + { + node.flush(); + } + catch (BackingStoreException e) + { + // A read-only configuration area only means the state is recomputed on the next start + Logger.log(e, true); + } + } +} diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryService.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryService.java new file mode 100644 index 000000000..5cd8f5ef1 --- /dev/null +++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/telemetry/TelemetryService.java @@ -0,0 +1,342 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.telemetry; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.eclipse.core.runtime.IProduct; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Platform; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.jobs.Job; +import org.osgi.framework.Bundle; + +import com.espressif.idf.core.IDFCorePlugin; +import com.espressif.idf.core.logging.Logger; + +/** + * Reports anonymous usage events to Azure Application Insights so that installations, updates and active users of + * Espressif-IDE can be counted. + *

+ * Every report is best effort: it runs in a background job, never blocks the workbench and silently gives up when the + * endpoint cannot be reached. Nothing is sent when the user opted out. + * + * @author Kondal Kolipaka + * + */ +public final class TelemetryService +{ + /** Prefix identifying events coming from Espressif-IDE and the IDF Eclipse plugin. */ + public static final String EVENT_PREFIX = "espressif-ide/"; //$NON-NLS-1$ + + /** Fired the first time an installation reports, before any other event. */ + public static final String EVENT_INSTALL = EVENT_PREFIX + "install"; //$NON-NLS-1$ + + /** Fired when the installed Espressif plugin version changed since the previous start. */ + public static final String EVENT_UPDATE = EVENT_PREFIX + "update"; //$NON-NLS-1$ + + /** Fired at most once a day to count active users. */ + public static final String EVENT_SESSION = EVENT_PREFIX + "session"; //$NON-NLS-1$ + + static final long SESSION_INTERVAL_MS = TimeUnit.HOURS.toMillis(24); + + private static final String CONNECTION_STRING_PROPERTY = "idf.telemetry.connectionString"; //$NON-NLS-1$ + private static final String CONNECTION_STRING_ENV = "APPLICATIONINSIGHTS_CONNECTION_STRING"; //$NON-NLS-1$ + private static final String DEFAULT_CONNECTION_STRING = "InstrumentationKey=0cc48b26-38e9-453d-81fe-15849ac04c36;IngestionEndpoint=https://southeastasia-1.in.applicationinsights.azure.com/"; //$NON-NLS-1$ + + private static final String ESPRESSIF_IDE_PRODUCT_ID = "com.espressif.idf.branding.idf"; //$NON-NLS-1$ + private static final String BRANDING_BUNDLE_ID = "com.espressif.idf.branding"; //$NON-NLS-1$ + private static final String PLATFORM_BUNDLE_ID = "org.eclipse.platform"; //$NON-NLS-1$ + private static final String DISTRIBUTION_IDE = "espressif-ide"; //$NON-NLS-1$ + private static final String DISTRIBUTION_PLUGIN = "eclipse-plugin"; //$NON-NLS-1$ + + private static final Duration TIMEOUT = Duration.ofSeconds(15); + private static final String UNKNOWN = "unknown"; //$NON-NLS-1$ + + private static final TelemetryService INSTANCE = new TelemetryService(); + + private final String sessionId = UUID.randomUUID().toString(); + + private HttpClient httpClient; + + private TelemetryService() + { + } + + public static TelemetryService getInstance() + { + return INSTANCE; + } + + public boolean isEnabled() + { + return TelemetryPreferences.isEnabled() && getConnection().isPresent(); + } + + /** + * Reports the installation, the update and the daily session events for the running installation. Intended to be + * called once per workbench start. + */ + public void reportSessionStart() + { + if (!isEnabled()) + { + return; + } + schedule(this::reportStartupEvents); + } + + /** + * Sends the startup events one after the other and remembers what was reported only once the endpoint accepted it, + * so that an installation started without a network connection is still counted on a later start. + */ + private void reportStartupEvents() + { + try + { + String version = getPluginVersion(); + String lastVersion = TelemetryPreferences.getLastReportedVersion(); + if (lastVersion.isBlank()) + { + if (send(EVENT_INSTALL, Map.of())) + { + TelemetryPreferences.setLastReportedVersion(version); + } + } + else if (!lastVersion.equals(version) + && send(EVENT_UPDATE, Map.of("previousVersion", lastVersion))) //$NON-NLS-1$ + { + TelemetryPreferences.setLastReportedVersion(version); + } + + long now = System.currentTimeMillis(); + if (shouldReportSession(TelemetryPreferences.getLastSessionReport(), now) && send(EVENT_SESSION, Map.of())) + { + TelemetryPreferences.setLastSessionReport(now); + } + } + catch (Exception e) + { + Logger.log(e, true); + } + } + + /** + * @param lastReport time of the previous session report, in milliseconds since the epoch + * @param now current time, in milliseconds since the epoch + * @return true when a session event is due + */ + public static boolean shouldReportSession(long lastReport, long now) + { + if (lastReport <= 0 || lastReport > now) + { + return true; + } + return now - lastReport >= SESSION_INTERVAL_MS; + } + + /** + * Queues an anonymous event. The call returns immediately and the event is dropped when telemetry is disabled. + * + * @param eventName name of the event + * @param properties additional string properties, which must not contain personal or project specific data + */ + public void sendEvent(String eventName, Map properties) + { + if (!isEnabled()) + { + return; + } + schedule(() -> send(eventName, properties)); + } + + private void schedule(Runnable reporter) + { + Job job = new Job("Espressif-IDE usage report") //$NON-NLS-1$ + { + @Override + protected IStatus run(IProgressMonitor monitor) + { + reporter.run(); + return Status.OK_STATUS; + } + }; + job.setSystem(true); + job.setPriority(Job.DECORATE); + job.schedule(); + } + + /** + * @return true when the endpoint accepted the event + */ + private boolean send(String eventName, Map properties) + { + Optional connection = getConnection(); + if (connection.isEmpty()) + { + return false; + } + + try + { + Map eventProperties = new HashMap<>(getCommonProperties()); + if (properties != null) + { + eventProperties.putAll(properties); + } + + TelemetryConnection telemetryConnection = connection.get(); + String payload = TelemetryEnvelope.build(telemetryConnection.getInstrumentationKey(), eventName, getTags(), + eventProperties, Instant.now()); + HttpRequest request = HttpRequest.newBuilder(telemetryConnection.getTrackUri()).timeout(TIMEOUT) + .header("Content-Type", "application/json") //$NON-NLS-1$ //$NON-NLS-2$ + .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)).build(); + HttpResponse response = getHttpClient().send(request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() >= 300) + { + Logger.log("Usage report rejected with status " + response.statusCode(), true); //$NON-NLS-1$ + return false; + } + return true; + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + catch (Exception e) + { + // Telemetry must never surface as an error to the user + Logger.log(e, true); + } + return false; + } + + private synchronized HttpClient getHttpClient() + { + if (httpClient == null) + { + httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); + } + return httpClient; + } + + private Map getTags() + { + Map tags = new LinkedHashMap<>(); + tags.put("ai.user.id", TelemetryPreferences.getInstallId()); //$NON-NLS-1$ + tags.put("ai.session.id", sessionId); //$NON-NLS-1$ + tags.put("ai.application.ver", getPluginVersion()); //$NON-NLS-1$ + tags.put("ai.device.osVersion", //$NON-NLS-1$ + getSystemProperty("os.name") + ' ' + getSystemProperty("os.version")); //$NON-NLS-1$ //$NON-NLS-2$ + tags.put("ai.internal.sdkVersion", "espressif-ide:" + getPluginVersion()); //$NON-NLS-1$ //$NON-NLS-2$ + return tags; + } + + private Map getCommonProperties() + { + Map properties = new LinkedHashMap<>(); + properties.put("pluginVersion", getPluginVersion()); //$NON-NLS-1$ + properties.put("ideVersion", getIdeVersion()); //$NON-NLS-1$ + properties.put("eclipseVersion", getBundleVersion(PLATFORM_BUNDLE_ID)); //$NON-NLS-1$ + properties.put("productId", getProductId()); //$NON-NLS-1$ + properties.put("distribution", getDistribution()); //$NON-NLS-1$ + properties.put("os", getSystemProperty("os.name")); //$NON-NLS-1$ //$NON-NLS-2$ + properties.put("osVersion", getSystemProperty("os.version")); //$NON-NLS-1$ //$NON-NLS-2$ + properties.put("arch", getSystemProperty("os.arch")); //$NON-NLS-1$ //$NON-NLS-2$ + properties.put("javaVersion", getSystemProperty("java.version")); //$NON-NLS-1$ //$NON-NLS-2$ + return properties; + } + + private String getDistribution() + { + IProduct product = Platform.getProduct(); + if (product != null && ESPRESSIF_IDE_PRODUCT_ID.equals(product.getId())) + { + return DISTRIBUTION_IDE; + } + return DISTRIBUTION_PLUGIN; + } + + private String getProductId() + { + IProduct product = Platform.getProduct(); + return product != null && product.getId() != null ? product.getId() : UNKNOWN; + } + + /** + * @param symbolicName bundle to look up + * @return the installed version, or {@value #UNKNOWN} when the bundle is not part of the installation + */ + private String getBundleVersion(String symbolicName) + { + Bundle bundle = Platform.getBundle(symbolicName); + return bundle != null ? bundle.getVersion().toString() : UNKNOWN; + } + + /** + * Returns the Espressif release version, which is the same value for the standalone IDE and for the plugin + * installed into a plain Eclipse. The product version is only a fallback, because for update site users it + * reports the version of their Eclipse package rather than the Espressif release. + * + * @return the Espressif release version, for instance 4.4.0.202608051100 + */ + private String getIdeVersion() + { + String brandingVersion = getBundleVersion(BRANDING_BUNDLE_ID); + if (!UNKNOWN.equals(brandingVersion)) + { + return brandingVersion; + } + + IProduct product = Platform.getProduct(); + if (product != null) + { + Bundle definingBundle = product.getDefiningBundle(); + if (definingBundle != null) + { + return definingBundle.getVersion().toString(); + } + } + return getPluginVersion(); + } + + private String getPluginVersion() + { + Bundle bundle = IDFCorePlugin.getPlugin() != null ? IDFCorePlugin.getPlugin().getBundle() : null; + return bundle != null ? bundle.getVersion().toString() : UNKNOWN; + } + + private String getSystemProperty(String key) + { + String value = System.getProperty(key); + return value != null ? value : UNKNOWN; + } + + private Optional getConnection() + { + String connectionString = System.getProperty(CONNECTION_STRING_PROPERTY); + if (connectionString == null || connectionString.isBlank()) + { + connectionString = System.getenv(CONNECTION_STRING_ENV); + } + if (connectionString == null || connectionString.isBlank()) + { + connectionString = DEFAULT_CONNECTION_STRING; + } + return TelemetryConnection.parse(connectionString); + } +} diff --git a/bundles/com.espressif.idf.ui/META-INF/MANIFEST.MF b/bundles/com.espressif.idf.ui/META-INF/MANIFEST.MF index 0e212f1db..209f090f3 100644 --- a/bundles/com.espressif.idf.ui/META-INF/MANIFEST.MF +++ b/bundles/com.espressif.idf.ui/META-INF/MANIFEST.MF @@ -32,6 +32,7 @@ Require-Bundle: org.eclipse.core.runtime, org.eclipse.tools.templates.ui, org.eclipse.ui.intro, org.eclipse.jface, + org.eclipse.jface.notifications, org.eclipse.terminal.view.ui;bundle-version="[1.0.0,2.0.0)", org.eclipse.terminal.view.core;bundle-version="[1.0.0,2.0.0)", org.eclipse.terminal.connector.process;bundle-version="[1.0.0,2.0.0)", diff --git a/bundles/com.espressif.idf.ui/plugin.xml b/bundles/com.espressif.idf.ui/plugin.xml index 5969fa63a..693973a41 100644 --- a/bundles/com.espressif.idf.ui/plugin.xml +++ b/bundles/com.espressif.idf.ui/plugin.xml @@ -657,6 +657,8 @@ + + * + */ +public class TelemetryNotice +{ + private static final String LEARN_MORE_HREF = "learnMore"; //$NON-NLS-1$ + private static final String DISABLE_HREF = "disable"; //$NON-NLS-1$ + private static final String DOCUMENTATION_URL = "https://docs.espressif.com/projects/espressif-ide/en/latest/telemetry.html"; //$NON-NLS-1$ + + private static final long CLOSE_DELAY_MS = TimeUnit.SECONDS.toMillis(30); + private static final int WIDTH_HINT = 320; + + private TelemetryNotice() + { + } + + /** + * Shows the notice when this installation never showed it and usage statistics are actually reported. Safe to call + * from any thread. + */ + public static void showIfNeeded() + { + if (TelemetryPreferences.isNoticeShown() || !TelemetryService.getInstance().isEnabled() + || !PlatformUI.isWorkbenchRunning()) + { + return; + } + + Display display = PlatformUI.getWorkbench().getDisplay(); + if (display.isDisposed()) + { + return; + } + TelemetryPreferences.setNoticeShown(); + display.asyncExec(() -> open(display)); + } + + private static void open(Display display) + { + if (display.isDisposed()) + { + return; + } + NotificationPopup.forDisplay(display).title(Messages.TelemetryNotice_Title, true) + .content(TelemetryNotice::createContent).delay(CLOSE_DELAY_MS).open(); + } + + private static Control createContent(Composite parent) + { + Link link = new Link(parent, SWT.WRAP); + link.setText(Messages.TelemetryNotice_Message); + GridData layoutData = new GridData(SWT.FILL, SWT.TOP, true, true); + layoutData.widthHint = WIDTH_HINT; + link.setLayoutData(layoutData); + link.addSelectionListener(new SelectionAdapter() + { + @Override + public void widgetSelected(SelectionEvent event) + { + handleSelection(event.text, link); + } + }); + return link; + } + + private static void handleSelection(String href, Link link) + { + if (DISABLE_HREF.equals(href)) + { + TelemetryPreferences.setEnabled(false); + } + else if (LEARN_MORE_HREF.equals(href)) + { + openDocumentation(); + } + link.getShell().close(); + } + + private static void openDocumentation() + { + try + { + PlatformUI.getWorkbench().getBrowserSupport().getExternalBrowser() + .openURL(URI.create(DOCUMENTATION_URL).toURL()); + } + catch (Exception e) + { + Logger.log(e); + } + } +} diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/TelemetryStartup.java b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/TelemetryStartup.java new file mode 100644 index 000000000..8edff6ecf --- /dev/null +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/TelemetryStartup.java @@ -0,0 +1,25 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.ui; + +import org.eclipse.ui.IStartup; + +import com.espressif.idf.core.telemetry.TelemetryService; + +/** + * Reports the anonymous installation, update and session events once the workbench is up. + * + * @author Kondal Kolipaka + * + */ +public class TelemetryStartup implements IStartup +{ + @Override + public void earlyStartup() + { + TelemetryNotice.showIfNeeded(); + TelemetryService.getInstance().reportSessionStart(); + } +} diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/messages.properties b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/messages.properties index 1ab067fc1..a4425b7b3 100644 --- a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/messages.properties +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/messages.properties @@ -1,3 +1,5 @@ LaunchBarListener_TargetChanged_Msg=Current target for the project {0} has changed from {1} to {2} in the launchbar, would you like to delete the "build" folder for the project? LaunchBarListener_TargetChanged_Title=IDF Launch Target Changed LaunchBarListener_TargetDontMatch_Msg=The selected target {0} doesn''t match the target {1} for the JTAG flashing in {2}. Do you want to change the selected board in the configuration? The sdkconfig and the "build" folder will be cleared +TelemetryNotice_Title=Anonymous usage statistics +TelemetryNotice_Message=Espressif-IDE reports anonymous usage statistics so that we know how many installations are active and how quickly releases are adopted. No project, source code or personal data is collected. Learn more or turn it off. diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/messages_zh.properties b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/messages_zh.properties new file mode 100644 index 000000000..b54579356 --- /dev/null +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/messages_zh.properties @@ -0,0 +1,2 @@ +TelemetryNotice_Title=\u533f\u540d\u4f7f\u7528\u7edf\u8ba1\u4fe1\u606f +TelemetryNotice_Message=Espressif-IDE \u4f1a\u4e0a\u62a5\u533f\u540d\u4f7f\u7528\u7edf\u8ba1\u4fe1\u606f\uff0c\u4ee5\u4fbf\u6211\u4eec\u4e86\u89e3\u6709\u591a\u5c11\u5b89\u88c5\u5904\u4e8e\u6d3b\u8dc3\u72b6\u6001\u4ee5\u53ca\u65b0\u7248\u672c\u7684\u66f4\u65b0\u901f\u5ea6\u3002\u4e0d\u4f1a\u6536\u96c6\u4efb\u4f55\u9879\u76ee\u3001\u6e90\u4ee3\u7801\u6216\u4e2a\u4eba\u6570\u636e\u3002\u4e86\u89e3\u66f4\u591a\u6216\u5173\u95ed\u4e0a\u62a5\u3002 diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/EspresssifPreferencesPage.java b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/EspresssifPreferencesPage.java index dbc288cac..9e5ee5878 100644 --- a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/EspresssifPreferencesPage.java +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/EspresssifPreferencesPage.java @@ -26,6 +26,7 @@ import com.espressif.idf.core.IDFCorePlugin; import com.espressif.idf.core.IDFCorePreferenceConstants; import com.espressif.idf.core.logging.Logger; +import com.espressif.idf.core.telemetry.TelemetryPreferences; import com.espressif.idf.core.tools.EimConstants; import com.espressif.idf.core.tools.watcher.EimJsonWatchService; @@ -49,6 +50,7 @@ public class EspresssifPreferencesPage extends PreferencePage implements IWorkbe private Combo pythonWheelCombo; private Button automateClangdFormatCreationBtn; private Text eimIdfJsonPathText; + private Button telemetryBtn; public EspresssifPreferencesPage() { @@ -88,9 +90,31 @@ protected Control createContents(Composite parent) addClangdSettings(mainComposite); addEimSettings(mainComposite); + + addPrivacySettings(mainComposite); return mainComposite; } + private void addPrivacySettings(Composite mainComposite) + { + Group privacyGroup = new Group(mainComposite, SWT.SHADOW_ETCHED_IN); + privacyGroup.setText(Messages.EspresssifPreferencesPage_PrivacyGroupName); + privacyGroup.setLayout(new GridLayout(1, false)); + privacyGroup.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); + + telemetryBtn = new Button(privacyGroup, SWT.CHECK); + telemetryBtn.setText(Messages.EspresssifPreferencesPage_TelemetryBtn); + telemetryBtn.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + telemetryBtn.setToolTipText(Messages.EspresssifPreferencesPage_TelemetryTooltip); + telemetryBtn.setSelection(TelemetryPreferences.isEnabledByPreference()); + + Label telemetryDescription = new Label(privacyGroup, SWT.WRAP); + telemetryDescription.setText(Messages.EspresssifPreferencesPage_TelemetryDescription); + GridData descriptionData = new GridData(SWT.FILL, SWT.CENTER, true, false); + descriptionData.widthHint = convertWidthInCharsToPixels(80); + telemetryDescription.setLayoutData(descriptionData); + } + private void addEimSettings(Composite mainComposite) { Group eimGroup = new Group(mainComposite, SWT.SHADOW_ETCHED_IN); @@ -250,6 +274,8 @@ public boolean performOk() getPreferenceStore().setValue(IDFCorePreferenceConstants.AUTOMATE_CLANGD_FORMAT_FILE, automateClangdFormatCreationBtn.getSelection()); + TelemetryPreferences.setEnabled(telemetryBtn.getSelection()); + String eimIdf = eimIdfJsonPathText.getText().trim(); if (!eimIdf.isEmpty() && !Paths.get(eimIdf).getFileName().toString().equals(EimConstants.EIM_JSON)) @@ -289,6 +315,7 @@ protected void performDefaults() .setSelection(getPreferenceStore().getBoolean(IDFCorePreferenceConstants.AUTOMATE_CLANGD_FORMAT_FILE)); eimIdfJsonPathText .setText(getPreferenceStore().getDefaultString(IDFCorePreferenceConstants.EIM_IDF_JSON_PATH)); + telemetryBtn.setSelection(getPreferenceStore().getDefaultBoolean(IDFCorePreferenceConstants.TELEMETRY_ENABLED)); gitAssetsCombo.setText(gitAssetsCombo.getItem(0)); pythonWheelCombo.setText(pythonWheelCombo.getItem(0)); } @@ -307,5 +334,7 @@ private void initializeDefaults() getPreferenceStore().setDefault(IDFCorePreferenceConstants.AUTOMATE_CLANGD_FORMAT_FILE, IDFCorePreferenceConstants.AUTOMATE_CLANGD_FORMAT_FILE_DEFAULT); getPreferenceStore().setDefault(IDFCorePreferenceConstants.EIM_IDF_JSON_PATH, ""); //$NON-NLS-1$ + getPreferenceStore().setDefault(IDFCorePreferenceConstants.TELEMETRY_ENABLED, + IDFCorePreferenceConstants.TELEMETRY_ENABLED_DEFAULT); } } diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/Messages.java b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/Messages.java index da73a4fc9..f846cfbab 100644 --- a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/Messages.java +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/Messages.java @@ -17,8 +17,12 @@ public class Messages extends NLS public static String EspresssifPreferencesPage_EimIdfJsonPathInvalid; public static String EspresssifPreferencesPage_EnableCCache; public static String EspresssifPreferencesPage_IDFSpecificPrefs; + public static String EspresssifPreferencesPage_PrivacyGroupName; public static String EspresssifPreferencesPage_SearchHintsCheckBtn; public static String EspresssifPreferencesPage_SearchHintsTooltip; + public static String EspresssifPreferencesPage_TelemetryBtn; + public static String EspresssifPreferencesPage_TelemetryDescription; + public static String EspresssifPreferencesPage_TelemetryTooltip; public static String GDBServerTimeoutPage_TimeoutField; public static String SerialMonitorPage_Field_NumberOfLines; public static String SerialMonitorPage_Field_NumberOfCharsInLine; diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/messages.properties b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/messages.properties index 93b735625..a8dd68594 100644 --- a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/messages.properties +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/messages.properties @@ -12,6 +12,10 @@ EspresssifPreferencesPage_EnableCCache=Enable Ccache EspresssifPreferencesPage_IDFSpecificPrefs=ESP-IDF Specific Preferences. EspresssifPreferencesPage_SearchHintsCheckBtn=Search hints for build errors (may affect build performance) EspresssifPreferencesPage_SearchHintsTooltip=If enabled, a Build Hints view will automatically open after a build +EspresssifPreferencesPage_PrivacyGroupName=Privacy +EspresssifPreferencesPage_TelemetryBtn=Send anonymous usage statistics to Espressif +EspresssifPreferencesPage_TelemetryTooltip=Reports an anonymous installation identifier, the IDE and plugin version, the operating system and the Java version +EspresssifPreferencesPage_TelemetryDescription=Espressif uses these statistics to count installations, updates and active users. No project, source code or personal data is collected. EspresssifPreferencesPage_HideErrprOnIdfComponentsBtn=Hide errors on derived ESP-IDF component files EspresssifPreferencesPage_HideErrprOnIdfComponentsToolTip=If enabled the errors in the derived files from ESP-IDF will not be shown when a derived file is opened in the editor GDBServerTimeoutPage_TimeoutField=GDB server launch timeout(s) diff --git a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/messages_zh.properties b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/messages_zh.properties index 3238d4195..702f69f21 100644 --- a/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/messages_zh.properties +++ b/bundles/com.espressif.idf.ui/src/com/espressif/idf/ui/preferences/messages_zh.properties @@ -13,3 +13,8 @@ EspresssifPreferencesPage_EimIdfJsonPathTooltip=\u7559\u7a7a\u5219\u4f7f\u7528\u EspresssifPreferencesPage_EimIdfJsonPathBrowse=\u6d4f\u89c8\u2026 EspresssifPreferencesPage_EimIdfJsonPathInvalid=\u6587\u4ef6\u540d\u5fc5\u987b\u4e3a eim_idf.json +EspresssifPreferencesPage_PrivacyGroupName=\u9690\u79c1 +EspresssifPreferencesPage_TelemetryBtn=\u5411\u4e50\u946b\u53d1\u9001\u533f\u540d\u4f7f\u7528\u7edf\u8ba1\u4fe1\u606f +EspresssifPreferencesPage_TelemetryTooltip=\u4e0a\u62a5\u533f\u540d\u5b89\u88c5\u6807\u8bc6\u3001IDE \u548c\u63d2\u4ef6\u7248\u672c\u3001\u64cd\u4f5c\u7cfb\u7edf\u4ee5\u53ca Java \u7248\u672c +EspresssifPreferencesPage_TelemetryDescription=\u4e50\u946b\u4f7f\u7528\u8fd9\u4e9b\u7edf\u8ba1\u4fe1\u606f\u6765\u7edf\u8ba1\u5b89\u88c5\u91cf\u3001\u66f4\u65b0\u91cf\u548c\u6d3b\u8dc3\u7528\u6237\u3002\u4e0d\u4f1a\u6536\u96c6\u4efb\u4f55\u9879\u76ee\u3001\u6e90\u4ee3\u7801\u6216\u4e2a\u4eba\u6570\u636e\u3002 + diff --git a/docs/en/index.rst b/docs/en/index.rst index 4e9050c5c..feab361ce 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -56,4 +56,5 @@ Contents Additional IDE Features Troubleshooting FAQs + Telemetry Downloads diff --git a/docs/en/telemetry.rst b/docs/en/telemetry.rst new file mode 100644 index 000000000..8b9ff98e4 --- /dev/null +++ b/docs/en/telemetry.rst @@ -0,0 +1,41 @@ +.. _telemetry: + +Telemetry +========= + +:link_to_translation:`zh_CN:[中文]` + +Espressif-IDE collects anonymous usage statistics to understand how many installations are active and how quickly new releases are adopted. These numbers help prioritize features, plan releases and decide which platforms to support. + +What is collected +----------------- + +At most once a day, the IDE sends a small event containing: + +- a random installation identifier, generated on first start and not derived from any machine, network or user attribute +- the Espressif-IDE and IDF plugin versions +- the Eclipse platform version and the identifier of the running product +- whether the plugin runs inside Espressif-IDE or in a plain Eclipse installation +- the operating system name, version and architecture +- the Java version + +An additional event is sent when an installation reports for the first time and when the plugin version changes, so that installations and updates can be counted. + +What is not collected +--------------------- + +No personal data is collected. Project names, file paths, source code, serial ports, chip serial numbers and credentials are never reported. Your IP address is not part of the report, although the receiving service sees the address the report is sent from and derives an approximate location from it. + +Disable telemetry reporting +--------------------------- + +Usage statistics are enabled by default. The first start shows a notification that explains the reporting and offers to switch it off. Reporting can be turned off at any later time as well, and the setting applies to the whole installation rather than to a single workspace: + +- Go to ``Window`` > ``Preferences`` > ``Espressif``. +- In the ``Privacy`` group, deselect ``Send anonymous usage statistics to Espressif``. +- Click ``Apply and Close``. + +Reporting can also be disabled without opening the IDE, which is useful for shared or automated installations: + +- Start the IDE with the ``-Didf.telemetry=false`` virtual machine argument, for example by adding it to ``eclipse.ini`` after the ``-vmargs`` line. +- Or set the environment variable ``IDF_TELEMETRY=0`` before starting the IDE. diff --git a/docs/zh_CN/index.rst b/docs/zh_CN/index.rst index 43ef60c7a..d0a97c15b 100644 --- a/docs/zh_CN/index.rst +++ b/docs/zh_CN/index.rst @@ -56,4 +56,5 @@ Espressif-IDE 是基于 `Eclipse CDT `_ 的集 其他 IDE 功能 故障排查 常见问题 + 遥测数据 下载 diff --git a/docs/zh_CN/telemetry.rst b/docs/zh_CN/telemetry.rst new file mode 100644 index 000000000..bf8fcf31d --- /dev/null +++ b/docs/zh_CN/telemetry.rst @@ -0,0 +1,41 @@ +.. _telemetry: + +遥测数据 +======== + +:link_to_translation:`en:[English]` + +Espressif-IDE 会收集匿名使用统计信息,用于了解有多少安装处于活跃状态,以及新版本的更新速度。这些数据有助于确定功能优先级、规划版本发布并决定需要支持的平台。 + +收集的内容 +---------- + +IDE 每天最多发送一次事件,其中包含: + +- 随机生成的安装标识符,在首次启动时创建,不基于任何机器、网络或用户信息 +- Espressif-IDE 与 IDF 插件的版本 +- Eclipse 平台版本以及当前运行产品的标识符 +- 插件运行在 Espressif-IDE 中还是普通的 Eclipse 安装中 +- 操作系统名称、版本和架构 +- Java 版本 + +此外,当某个安装首次上报以及插件版本发生变化时,还会各发送一次事件,以便统计安装量和更新量。 + +不收集的内容 +------------ + +不会收集任何个人数据。项目名称、文件路径、源代码、串口、芯片序列号和凭据均不会上报。上报内容中不包含 IP 地址,但接收服务能够看到发送请求的地址,并据此推断出大致的地理位置。 + +关闭遥测上报 +------------ + +使用统计默认开启。首次启动时会显示通知,说明上报内容并提供关闭入口。之后也可随时关闭,该设置对整个安装生效,而不仅限于单个工作空间: + +- 依次选择 ``Window`` > ``Preferences`` > ``Espressif``。 +- 在 ``Privacy`` 分组中取消勾选 ``Send anonymous usage statistics to Espressif``。 +- 点击 ``Apply and Close``。 + +也可以在不打开 IDE 的情况下关闭上报,这对共享安装或自动化环境很有用: + +- 使用 ``-Didf.telemetry=false`` 虚拟机参数启动 IDE,例如将其添加到 ``eclipse.ini`` 中 ``-vmargs`` 之后。 +- 或在启动 IDE 前设置环境变量 ``IDF_TELEMETRY=0``。 diff --git a/tests/com.espressif.idf.core.test/META-INF/MANIFEST.MF b/tests/com.espressif.idf.core.test/META-INF/MANIFEST.MF index 1fa42c69a..d9222922d 100644 --- a/tests/com.espressif.idf.core.test/META-INF/MANIFEST.MF +++ b/tests/com.espressif.idf.core.test/META-INF/MANIFEST.MF @@ -11,7 +11,8 @@ Require-Bundle: com.espressif.idf.core;bundle-version="1.0.1", org.eclipse.jface, junit-jupiter-api;bundle-version="5.14.1", junit-jupiter-params;bundle-version="5.14.1", - org.mockito.mockito-core + org.mockito.mockito-core, + com.google.gson Bundle-ClassPath: ., lib/commons-collections4-4.4.jar, lib/commons-io-2.9.0.jar, diff --git a/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetryConnectionTest.java b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetryConnectionTest.java new file mode 100644 index 000000000..21fb156a2 --- /dev/null +++ b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetryConnectionTest.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.telemetry.test; + +import java.util.Optional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.espressif.idf.core.telemetry.TelemetryConnection; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class TelemetryConnectionTest +{ + @Test + void test_parses_key_and_regional_endpoint() + { + Optional connection = TelemetryConnection + .parse("InstrumentationKey=abc-123;IngestionEndpoint=https://eastasia-0.in.applicationinsights.azure.com/"); + + Assertions.assertTrue(connection.isPresent()); + Assertions.assertEquals("abc-123", connection.get().getInstrumentationKey()); + Assertions.assertEquals("https://eastasia-0.in.applicationinsights.azure.com/v2/track", + connection.get().getTrackUri().toString()); + } + + @Test + void test_falls_back_to_global_endpoint_when_not_specified() + { + Optional connection = TelemetryConnection.parse("InstrumentationKey=abc-123"); + + Assertions.assertTrue(connection.isPresent()); + Assertions.assertEquals("https://dc.services.visualstudio.com/v2/track", + connection.get().getTrackUri().toString()); + } + + @Test + void test_appends_missing_trailing_slash_to_endpoint() + { + Optional connection = TelemetryConnection + .parse("InstrumentationKey=abc-123;IngestionEndpoint=https://example.invalid"); + + Assertions.assertTrue(connection.isPresent()); + Assertions.assertEquals("https://example.invalid/v2/track", connection.get().getTrackUri().toString()); + } + + @Test + void test_ignores_unknown_fields_and_is_case_insensitive() + { + Optional connection = TelemetryConnection + .parse("LiveEndpoint=https://live.invalid/;instrumentationkey=abc-123;ApplicationId=xyz"); + + Assertions.assertTrue(connection.isPresent()); + Assertions.assertEquals("abc-123", connection.get().getInstrumentationKey()); + } + + @ParameterizedTest(name = "connection string ''{0}'' is rejected") + @ValueSource(strings = { "", " ", "IngestionEndpoint=https://example.invalid/", "InstrumentationKey=", + "garbage" }) + void test_rejects_connection_string_without_key(String connectionString) + { + Assertions.assertTrue(TelemetryConnection.parse(connectionString).isEmpty()); + } + + @Test + void test_rejects_null_connection_string() + { + Assertions.assertTrue(TelemetryConnection.parse(null).isEmpty()); + } +} diff --git a/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetryEnvelopeTest.java b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetryEnvelopeTest.java new file mode 100644 index 000000000..3f5d33a3d --- /dev/null +++ b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetryEnvelopeTest.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.telemetry.test; + +import java.time.Instant; +import java.util.Map; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import com.espressif.idf.core.telemetry.TelemetryEnvelope; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class TelemetryEnvelopeTest +{ + private static final Instant TIMESTAMP = Instant.parse("2026-08-05T10:15:30.500Z"); + + @Test + void test_builds_application_insights_event_envelope() + { + String payload = TelemetryEnvelope.build("abc-123", "espressif-ide/session", Map.of("ai.user.id", "install-1"), + Map.of("os", "Mac OS X"), TIMESTAMP); + + JsonObject envelope = JsonParser.parseString(payload).getAsJsonObject(); + Assertions.assertEquals("Microsoft.ApplicationInsights.Event", envelope.get("name").getAsString()); + Assertions.assertEquals("abc-123", envelope.get("iKey").getAsString()); + Assertions.assertEquals("2026-08-05T10:15:30.500Z", envelope.get("time").getAsString()); + Assertions.assertEquals("install-1", + envelope.getAsJsonObject("tags").get("ai.user.id").getAsString()); + + JsonObject data = envelope.getAsJsonObject("data"); + Assertions.assertEquals("EventData", data.get("baseType").getAsString()); + + JsonObject baseData = data.getAsJsonObject("baseData"); + Assertions.assertEquals("espressif-ide/session", baseData.get("name").getAsString()); + Assertions.assertEquals(2, baseData.get("ver").getAsInt()); + Assertions.assertEquals("Mac OS X", baseData.getAsJsonObject("properties").get("os").getAsString()); + } + + @Test + void test_builds_envelope_without_tags_and_properties() + { + String payload = TelemetryEnvelope.build("abc-123", "espressif-ide/install", null, null, TIMESTAMP); + + JsonObject envelope = JsonParser.parseString(payload).getAsJsonObject(); + Assertions.assertTrue(envelope.getAsJsonObject("tags").isEmpty()); + Assertions.assertTrue(envelope.getAsJsonObject("data").getAsJsonObject("baseData").getAsJsonObject("properties") + .isEmpty()); + } +} diff --git a/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetrySessionIntervalTest.java b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetrySessionIntervalTest.java new file mode 100644 index 000000000..a3538deef --- /dev/null +++ b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/telemetry/test/TelemetrySessionIntervalTest.java @@ -0,0 +1,47 @@ +/******************************************************************************* + * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved. + * Use is subject to license terms. + *******************************************************************************/ +package com.espressif.idf.core.telemetry.test; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import com.espressif.idf.core.telemetry.TelemetryService; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class TelemetrySessionIntervalTest +{ + private static final long NOW = TimeUnit.DAYS.toMillis(20000); + private static final long ONE_DAY = TimeUnit.HOURS.toMillis(24); + + @Test + void test_reports_when_never_reported_before() + { + Assertions.assertTrue(TelemetryService.shouldReportSession(0L, NOW)); + } + + @Test + void test_reports_once_a_day() + { + Assertions.assertTrue(TelemetryService.shouldReportSession(NOW - ONE_DAY, NOW)); + Assertions.assertTrue(TelemetryService.shouldReportSession(NOW - ONE_DAY - 1, NOW)); + } + + @Test + void test_skips_report_within_the_same_day() + { + Assertions.assertFalse(TelemetryService.shouldReportSession(NOW - TimeUnit.HOURS.toMillis(23), NOW)); + Assertions.assertFalse(TelemetryService.shouldReportSession(NOW, NOW)); + } + + @Test + void test_reports_when_the_clock_moved_backwards() + { + Assertions.assertTrue(TelemetryService.shouldReportSession(NOW + ONE_DAY, NOW)); + } +}