diff --git a/backlog/tasks/task-208 - Harden-analytics-tracking-for-offline-fire-and-forget-behavior.md b/backlog/tasks/task-208 - Harden-analytics-tracking-for-offline-fire-and-forget-behavior.md new file mode 100644 index 00000000..136e42a6 --- /dev/null +++ b/backlog/tasks/task-208 - Harden-analytics-tracking-for-offline-fire-and-forget-behavior.md @@ -0,0 +1,66 @@ +--- +id: TASK-208 +title: Harden analytics tracking for offline fire-and-forget behavior +status: Done +assignee: + - codex +created_date: '2026-04-13 12:25' +updated_date: '2026-04-13 12:44' +labels: + - analytics + - stability +dependencies: [] +references: + - src/main/java/com/devoxx/genie/service/analytics/AnalyticsService.java + - src/test/java/com/devoxx/genie/service/analytics/AnalyticsServiceTest.java + - src/main/java/com/devoxx/genie/service/prompt/PromptExecutionService.java + - src/main/java/com/devoxx/genie/ui/window/DevoxxGenieToolWindowContent.java +priority: high +--- + +## Description + + +Ensure anonymous usage analytics can never crash or interrupt the IntelliJ plugin when the user is offline, the analytics endpoint is unreachable, or analytics setup fails. Tracking must remain non-critical, fire-and-forget behavior from every public analytics entry point. + + +## Acceptance Criteria + +- [x] #1 Public analytics tracking methods never throw to callers when state access, payload creation, scheduling, URI creation, or network delivery fails +- [x] #2 Analytics HTTP delivery remains asynchronous in production and never blocks the EDT or prompt/model-selection flow +- [x] #3 Offline, DNS, timeout, and non-2xx endpoint failures are swallowed and logged only at debug level +- [x] #4 Regression tests cover silent failure before scheduling and during network delivery + + +## Implementation Plan + + +1. Harden `AnalyticsService` public tracking entry points so analytics preconditions, state lookup, payload construction, endpoint parsing, client creation, and dispatch failures cannot propagate to callers. +2. Use asynchronous `HttpClient.sendAsync` for production delivery so analytics remains fire-and-forget without occupying IntelliJ pooled threads while offline or timing out. +3. Keep test-only synchronous injection for deterministic existing tests, and add async test coverage for failed delivery. +4. Update analytics call sites where needed so service lookup/tracking remains non-critical. +5. Run the focused analytics test class and update acceptance criteria based on verified behavior. + + +## Implementation Notes + + +Implemented analytics hardening in the plugin: production delivery now uses HttpClient.sendAsync, public tracking methods catch pre-send failures, and call sites use safe static entry points so service lookup/tracking remains non-critical. Added regression coverage for state lookup failure, invalid endpoint URI, synchronous network failure, and async network failure. Verified with `./gradlew -q test --tests com.devoxx.genie.service.analytics.AnalyticsServiceTest`. + +Added explicit regression coverage for non-2xx analytics responses remaining silent, and reran `./gradlew -q test --tests com.devoxx.genie.service.analytics.AnalyticsServiceTest` successfully. + + +## Final Summary + + +Summary: +- Hardened `AnalyticsService` so analytics precondition checks, state lookup, endpoint parsing, request dispatch, and HTTP failures are swallowed and logged at debug level instead of propagating to plugin callers. +- Switched production analytics delivery from IntelliJ pooled-thread blocking sends to `HttpClient.sendAsync`, keeping prompt/model-selection flows fire-and-forget when offline or when the endpoint is unreachable. +- Updated prompt execution and model-selection call sites to use safe analytics entry points. +- Added regression tests for state lookup failure, invalid endpoint URI, synchronous network failure, async network failure, and non-2xx endpoint responses. + +Tests: +- `./gradlew -q test --tests com.devoxx.genie.service.analytics.AnalyticsServiceTest` + +PR: https://github.com/devoxx/DevoxxGenieIDEAPlugin/pull/1007 + diff --git a/src/main/java/com/devoxx/genie/service/analytics/AnalyticsService.java b/src/main/java/com/devoxx/genie/service/analytics/AnalyticsService.java index 1081a4f1..5c76e5ed 100644 --- a/src/main/java/com/devoxx/genie/service/analytics/AnalyticsService.java +++ b/src/main/java/com/devoxx/genie/service/analytics/AnalyticsService.java @@ -16,6 +16,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.concurrent.CompletionException; import java.util.concurrent.ThreadLocalRandom; /** @@ -28,8 +29,8 @@ * text, response text, conversation history, file content, file paths, project names, API keys, * or user identity is ever sent. * - *
Calls are fire-and-forget on the application thread pool and never block the EDT. Failures - * are logged at debug level and never surfaced to the user. + *
Calls are fire-and-forget through {@link HttpClient#sendAsync(HttpRequest, HttpResponse.BodyHandler)}
+ * and never block the EDT. Failures are logged at debug level and never surfaced to the user.
*/
@Slf4j
@Service(Service.Level.APP)
@@ -53,12 +54,36 @@ public static AnalyticsService getInstance() {
return ApplicationManager.getApplication().getService(AnalyticsService.class);
}
+ public static void trackPromptExecutedSafely(@Nullable String providerId, @Nullable String modelName) {
+ try {
+ getInstance().trackPromptExecuted(providerId, modelName);
+ } catch (Exception e) {
+ logAnalyticsFailure("Analytics tracking skipped", e);
+ }
+ }
+
+ public static void trackModelSelectedSafely(@Nullable String providerId, @Nullable String modelName) {
+ try {
+ getInstance().trackModelSelected(providerId, modelName);
+ } catch (Exception e) {
+ logAnalyticsFailure("Analytics tracking skipped", e);
+ }
+ }
+
public void trackPromptExecuted(@Nullable String providerId, @Nullable String modelName) {
- send(EVENT_PROMPT_EXECUTED, providerId, modelName);
+ sendSafely(EVENT_PROMPT_EXECUTED, providerId, modelName);
}
public void trackModelSelected(@Nullable String providerId, @Nullable String modelName) {
- send(EVENT_MODEL_SELECTED, providerId, modelName);
+ sendSafely(EVENT_MODEL_SELECTED, providerId, modelName);
+ }
+
+ private void sendSafely(@NotNull String eventName, @Nullable String providerId, @Nullable String modelName) {
+ try {
+ send(eventName, providerId, modelName);
+ } catch (Exception e) {
+ logAnalyticsFailure("Analytics tracking skipped", e);
+ }
}
private void send(@NotNull String eventName, @Nullable String providerId, @Nullable String modelName) {
@@ -86,29 +111,65 @@ private void send(@NotNull String eventName, @Nullable String providerId, @Nulla
String payload = buildPayload(clientId, eventName, providerId, modelName);
if (synchronousForTest) {
- postSilently(endpoint, payload);
+ postBlockingSilently(endpoint, payload);
} else {
- ApplicationManager.getApplication().executeOnPooledThread(() -> postSilently(endpoint, payload));
+ postAsyncSilently(endpoint, payload);
}
}
- private void postSilently(@NotNull String endpoint, @NotNull String payload) {
+ private void postAsyncSilently(@NotNull String endpoint, @NotNull String payload) {
try {
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(endpoint))
- .timeout(Duration.ofSeconds(5))
- .header("Content-Type", "application/json")
- .POST(HttpRequest.BodyPublishers.ofString(payload))
- .build();
+ HttpRequest request = buildRequest(endpoint, payload);
+ client().sendAsync(request, HttpResponse.BodyHandlers.discarding())
+ .thenAccept(this::logUnexpectedStatus)
+ .exceptionally(e -> {
+ logAnalyticsFailure("Analytics post failed", unwrapCompletionException(e));
+ return null;
+ });
+ } catch (Exception e) {
+ logAnalyticsFailure("Analytics post failed", e);
+ }
+ }
+
+ private void postBlockingSilently(@NotNull String endpoint, @NotNull String payload) {
+ try {
+ HttpRequest request = buildRequest(endpoint, payload);
HttpResponse