Skip to content

Commit b8fded6

Browse files
SLCORE-1783 Allow clients to record telemetry events for IDE Labs
1 parent 3931277 commit b8fded6

File tree

18 files changed

+422
-17
lines changed

18 files changed

+422
-17
lines changed

API_CHANGES.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
# 10.37
22

33
## Breaking Changes
4+
5+
* Add initial IDE Labs parameters to `org.sonarsource.sonarlint.core.rpc.protocol.backend.initialize.InitializeParams`. Deprecated constructor uses default parameters.
6+
7+
## Deprecation
8+
49
* Deprecate 4-parameter constructor and remove deprecation of 2-parameter one of `org.sonarsource.sonarlint.core.rpc.protocol.backend.config.binding.DidUpdateBindingParams`. Move back to an old constructor as not all IDEs were able to provide all data to a new one.
510
* Remove Deprecation from `org.sonarsource.sonarlint.core.rpc.protocol.backend.telemetry.TelemetryRpcService#addedManualBindings` method. It should be used again for manual binding events instead of parametrized `didUpdateBinding`.
11+
12+
## New features
13+
14+
* Introduce 3 new methods to `org.sonarsource.sonarlint.core.rpc.protocol.backend.telemetry.TelemetryRpcService` to record Ide Labs telemetry: `toggleIdeLabsEnablement`, `externalLinkClicked`, `feedbackLinkClicked`.
615
* Introduce a new `org.sonarsource.sonarlint.core.rpc.protocol.backend.telemetry.TelemetryRpcService.acceptedBindingSuggestion`. It should be used to for bindings created based on suggestions and pass `org.sonarsource.sonarlint.core.rpc.protocol.backend.config.binding.BindingSuggestionOrigin` instead of parametrized `didUpdateBinding`.
716

817
# 10.36
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* SonarLint Core - Implementation
3+
* Copyright (C) 2016-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonarsource.sonarlint.core.event;
21+
22+
public record JoinIdeLabsEvent() {
23+
}

backend/core/src/main/java/org/sonarsource/sonarlint/core/labs/IdeLabsService.java

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,34 @@
2020
package org.sonarsource.sonarlint.core.labs;
2121

2222
import com.google.gson.Gson;
23+
import org.sonarsource.sonarlint.core.event.JoinIdeLabsEvent;
2324
import org.sonarsource.sonarlint.core.rpc.protocol.backend.labs.JoinIdeLabsProgramResponse;
25+
import org.springframework.context.ApplicationEventPublisher;
2426

2527
public class IdeLabsService {
28+
2629
private final IdeLabsHttpClient labsHttpClient;
30+
private final ApplicationEventPublisher eventPublisher;
2731
private final Gson gson = new Gson();
2832

29-
public IdeLabsService(IdeLabsHttpClient labsHttpClient) {
33+
public IdeLabsService(IdeLabsHttpClient labsHttpClient, ApplicationEventPublisher eventPublisher) {
3034
this.labsHttpClient = labsHttpClient;
35+
this.eventPublisher = eventPublisher;
3136
}
3237

3338
public JoinIdeLabsProgramResponse joinIdeLabsProgram(String email, String ideName) {
3439
try (var response = labsHttpClient.join(email, ideName)) {
3540
if (!response.isSuccessful()) {
3641
return new JoinIdeLabsProgramResponse(false, "An unexpected error occurred. Server responded with status code: " + response.code());
37-
}
42+
}
3843

39-
var responseBody = response.bodyAsString();
40-
if (gson.fromJson(responseBody, IdeLabsSubscriptionResponseBody.class).validEmail()) {
41-
return new JoinIdeLabsProgramResponse(true, null);
42-
}
44+
var responseBody = gson.fromJson(response.bodyAsString(), IdeLabsSubscriptionResponseBody.class);
45+
if (!responseBody.validEmail()) {
46+
return new JoinIdeLabsProgramResponse(false, "The provided email address is not valid. Please enter a valid email address.");
47+
}
4348

44-
return new JoinIdeLabsProgramResponse(false, "The provided email address is not valid. Please enter a valid email address.");
49+
eventPublisher.publishEvent(new JoinIdeLabsEvent());
50+
return new JoinIdeLabsProgramResponse(true, null);
4551
} catch (Exception e) {
4652
return new JoinIdeLabsProgramResponse(false, "An unexpected error occurred: " + e.getMessage());
4753
}

backend/core/src/main/java/org/sonarsource/sonarlint/core/telemetry/TelemetryService.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.sonarsource.sonarlint.core.commons.log.SonarLintLogger;
3636
import org.sonarsource.sonarlint.core.commons.util.FailSafeExecutors;
3737
import org.sonarsource.sonarlint.core.event.FixSuggestionReceivedEvent;
38+
import org.sonarsource.sonarlint.core.event.JoinIdeLabsEvent;
3839
import org.sonarsource.sonarlint.core.event.LocalOnlyIssueStatusChangedEvent;
3940
import org.sonarsource.sonarlint.core.event.MatchingSessionEndedEvent;
4041
import org.sonarsource.sonarlint.core.event.ServerIssueStatusChangedEvent;
@@ -86,6 +87,8 @@ private void initTelemetryAndScheduleUpload(InitializeParams initializeParams) {
8687
updateTelemetry(localStorage -> {
8788
localStorage.setInitialNewCodeFocus(initializeParams.isFocusOnNewCode());
8889
localStorage.setInitialAutomaticAnalysisEnablement(initializeParams.isAutomaticAnalysisEnabled());
90+
localStorage.setLabsJoined(initializeParams.hasJoinedIdeLabs());
91+
localStorage.setLabsEnabled(initializeParams.hasEnabledIdeLabs());
8992
});
9093
var initialDelay = Integer.parseInt(System.getProperty("sonarlint.internal.telemetry.initialDelay", "1"));
9194
scheduledExecutor.scheduleWithFixedDelay(this::upload, initialDelay, TELEMETRY_UPLOAD_DELAY, MINUTES);
@@ -318,6 +321,18 @@ public void mcpServerConfigurationRequested() {
318321
updateTelemetry(TelemetryLocalStorage::incrementMcpServerConfigurationRequestedCount);
319322
}
320323

324+
public void toggleIdeLabsEnablement(boolean newValue) {
325+
updateTelemetry(storage -> storage.setLabsEnabled(newValue));
326+
}
327+
328+
public void ideLabsLinkClicked(String linkId) {
329+
updateTelemetry(storage -> storage.ideLabsLinkClicked(linkId));
330+
}
331+
332+
public void ideLabsFeedbackLinkClicked(String featureId) {
333+
updateTelemetry(storage -> storage.ideLabsFeedbackLinkClicked(featureId));
334+
}
335+
321336
@EventListener
322337
public void onMatchingSessionEnded(MatchingSessionEndedEvent event) {
323338
updateTelemetry(telemetryLocalStorage -> {
@@ -371,6 +386,12 @@ public void onAutomaticAnalysisSettingChanged(AutomaticAnalysisSettingChangedEve
371386
automaticAnalysisSettingToggled();
372387
}
373388

389+
@EventListener
390+
public void onJoiningIdeLabs(JoinIdeLabsEvent event) {
391+
updateTelemetry(storage -> storage.setLabsJoined(true));
392+
updateTelemetry(storage -> storage.setLabsEnabled(true));
393+
}
394+
374395
@PreDestroy
375396
public void close() {
376397
if ((!MoreExecutors.shutdownAndAwaitTermination(scheduledExecutor, 1, TimeUnit.SECONDS)) && (InternalDebug.isEnabled())) {

backend/rpc-impl/src/main/java/org/sonarsource/sonarlint/core/rpc/impl/TelemetryRpcServiceDelegate.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@
3030
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.DevNotificationsClickedParams;
3131
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.FindingsFilteredParams;
3232
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.FixSuggestionResolvedParams;
33+
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.IdeLabsExternalLinkClickedParams;
34+
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.IdeLabsFeedbackLinkClickedParams;
3335
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.HelpAndFeedbackClickedParams;
3436
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.McpTransportModeUsedParams;
37+
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.ToggleIdeLabsEnablementParams;
3538
import org.sonarsource.sonarlint.core.rpc.protocol.client.telemetry.ToolCalledParams;
3639
import org.sonarsource.sonarlint.core.telemetry.TelemetryService;
3740

@@ -175,4 +178,19 @@ public void dependencyRiskInvestigatedLocally() {
175178
public void findingsFiltered(FindingsFilteredParams params) {
176179
notify(() -> getBean(TelemetryService.class).findingsFiltered(params.getFilterType()));
177180
}
181+
182+
@Override
183+
public void toggleIdeLabsEnablement(ToggleIdeLabsEnablementParams params) {
184+
notify(() -> getBean(TelemetryService.class).toggleIdeLabsEnablement(params.getNewValue()));
185+
}
186+
187+
@Override
188+
public void ideLabsExternalLinkClicked(IdeLabsExternalLinkClickedParams params) {
189+
notify(() -> getBean(TelemetryService.class).ideLabsLinkClicked(params.getLinkId()));
190+
}
191+
192+
@Override
193+
public void ideLabsFeedbackLinkClicked(IdeLabsFeedbackLinkClickedParams params) {
194+
notify(() -> getBean(TelemetryService.class).ideLabsFeedbackLinkClicked(params.getFeatureId()));
195+
}
178196
}

backend/telemetry/src/main/java/org/sonarsource/sonarlint/core/telemetry/TelemetryLocalStorage.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ public class TelemetryLocalStorage {
100100
private boolean isMcpIntegrationEnabled;
101101
@Nullable
102102
private McpTransportMode mcpTransportModeUsed;
103+
private boolean labsEnabled;
104+
private boolean labsJoined;
105+
private final Map<String, Integer> labsLinkClickedCount;
106+
private final Map<String, Integer> labsFeedbackLinkClickedCount;
103107

104108
TelemetryLocalStorage() {
105109
enabled = true;
@@ -117,6 +121,8 @@ public class TelemetryLocalStorage {
117121
fixSuggestionResolved = new LinkedHashMap<>();
118122
issuesUuidAiFixableSeen = new HashSet<>();
119123
calledToolsByName = new HashMap<>();
124+
labsLinkClickedCount = new HashMap<>();
125+
labsFeedbackLinkClickedCount = new HashMap<>();
120126
}
121127

122128
public Collection<String> getRaisedIssuesRules() {
@@ -267,6 +273,8 @@ void clearAfterPing() {
267273
mcpServerConfigurationRequestedCount = 0;
268274
isMcpIntegrationEnabled = false;
269275
mcpTransportModeUsed = null;
276+
labsLinkClickedCount.clear();
277+
labsFeedbackLinkClickedCount.clear();
270278
}
271279

272280
public long numUseDays() {
@@ -725,4 +733,36 @@ public void incrementMcpServerConfigurationRequestedCount() {
725733
public int getMcpServerConfigurationRequestedCount() {
726734
return mcpServerConfigurationRequestedCount;
727735
}
736+
737+
public Map<String, Integer> getLabsFeedbackLinkClickedCount() {
738+
return labsFeedbackLinkClickedCount;
739+
}
740+
741+
public Map<String, Integer> getLabsLinkClickedCount() {
742+
return labsLinkClickedCount;
743+
}
744+
745+
public boolean isLabsJoined() {
746+
return labsJoined;
747+
}
748+
749+
public boolean isLabsEnabled() {
750+
return labsEnabled;
751+
}
752+
753+
public void setLabsJoined(boolean labsJoined) {
754+
this.labsJoined = labsJoined;
755+
}
756+
757+
public void setLabsEnabled(boolean labsEnabled) {
758+
this.labsEnabled = labsEnabled;
759+
}
760+
761+
public void ideLabsLinkClicked(String linkId) {
762+
this.labsLinkClickedCount.merge(linkId, 1, Integer::sum);
763+
}
764+
765+
public void ideLabsFeedbackLinkClicked(String featureId) {
766+
this.labsFeedbackLinkClickedCount.merge(featureId, 1, Integer::sum);
767+
}
728768
}

backend/telemetry/src/main/java/org/sonarsource/sonarlint/core/telemetry/measures/payload/TelemetryMeasuresBuilder.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.ArrayList;
2424
import java.util.List;
2525
import java.util.Locale;
26+
import java.util.Map;
2627
import java.util.UUID;
2728
import org.sonarsource.sonarlint.core.telemetry.TelemetryLiveAttributes;
2829
import org.sonarsource.sonarlint.core.telemetry.TelemetryLocalStorage;
@@ -34,6 +35,9 @@
3435

3536
public class TelemetryMeasuresBuilder {
3637

38+
private static final String LINK_CLICKED_BASE_NAME = "link_clicked_count_";
39+
private static final String FEEDBACK_CLICKED_BASE_NAME = "feedback_link_clicked_count_";
40+
3741
private final String platform;
3842
private final String product;
3943
private final TelemetryLocalStorage storage;
@@ -77,6 +81,8 @@ public TelemetryMeasuresPayload build() {
7781

7882
addMCPMeasures(values);
7983

84+
addLabsMeasures(values);
85+
8086
return new TelemetryMeasuresPayload(UUID.randomUUID().toString(), platform, storage.installTime(), product, TelemetryMeasuresDimension.INSTALLATION, values);
8187
}
8288

@@ -221,4 +227,21 @@ private void addMCPMeasures(List<TelemetryMeasuresValue> values) {
221227
}
222228
}
223229

230+
private void addLabsMeasures(ArrayList<TelemetryMeasuresValue> values) {
231+
values.add(new TelemetryMeasuresValue("ide_labs.joined", String.valueOf(storage.isLabsJoined()), BOOLEAN, DAILY));
232+
values.add(new TelemetryMeasuresValue("ide_labs.enabled", String.valueOf(storage.isLabsEnabled()), BOOLEAN, DAILY));
233+
addAll(storage.getLabsLinkClickedCount(), LINK_CLICKED_BASE_NAME, values);
234+
addAll(storage.getLabsFeedbackLinkClickedCount(), FEEDBACK_CLICKED_BASE_NAME, values);
235+
}
236+
237+
private static void addAll(Map<String, Integer> clickCounts, String baseName, List<TelemetryMeasuresValue> values) {
238+
clickCounts.entrySet().stream()
239+
.filter(entry -> entry.getValue() > 0)
240+
.map(entry -> new TelemetryMeasuresValue(
241+
"ide_labs." + baseName + entry.getKey(),
242+
String.valueOf(entry.getValue()),
243+
INTEGER,
244+
DAILY))
245+
.forEach(values::add);
246+
}
224247
}

backend/telemetry/src/test/java/org/sonarsource/sonarlint/core/telemetry/TelemetryHttpClientTests.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ private void assertTelemetryUploaded(boolean isDebugEnabled) {
123123
telemetryLocalStorage.incrementFlightRecorderSessionsCount();
124124
telemetryLocalStorage.setMcpIntegrationEnabled(true);
125125
telemetryLocalStorage.setMcpTransportModeUsed(McpTransportMode.STDIO);
126+
telemetryLocalStorage.setLabsJoined(true);
127+
telemetryLocalStorage.setLabsEnabled(true);
128+
telemetryLocalStorage.ideLabsLinkClicked("changed_file_analysis_doc");
129+
telemetryLocalStorage.ideLabsLinkClicked("privacy_policy");
130+
telemetryLocalStorage.ideLabsLinkClicked("privacy_policy");
131+
telemetryLocalStorage.ideLabsFeedbackLinkClicked("connected_mode");
132+
telemetryLocalStorage.ideLabsFeedbackLinkClicked("manage_dependency_risk");
133+
telemetryLocalStorage.ideLabsFeedbackLinkClicked("manage_dependency_risk");
126134
spy.upload(telemetryLocalStorage, getTelemetryLiveAttributesDto());
127135

128136
telemetryMock.verify(postRequestedFor(urlEqualTo("/"))
@@ -148,7 +156,13 @@ private void assertTelemetryUploaded(boolean isDebugEnabled) {
148156
{"key":"findings_filtered.severity","value":"1","type":"integer","granularity":"daily"},
149157
{"key":"flight_recorder.sessions_count","value":"1","type":"integer","granularity":"daily"},
150158
{"key":"mcp.integration_enabled","value":"true","type":"boolean","granularity":"daily"},
151-
{"key":"mcp.transport_mode","value":"STDIO","type":"string","granularity":"daily"}
159+
{"key":"mcp.transport_mode","value":"STDIO","type":"string","granularity":"daily"},
160+
{"key":"ide_labs.joined","value":"true","type":"boolean","granularity":"daily"},
161+
{"key":"ide_labs.enabled","value":"true","type":"boolean","granularity":"daily"},
162+
{"key":"ide_labs.link_clicked_count_changed_file_analysis_doc","value":"1","type":"integer","granularity":"daily"},
163+
{"key":"ide_labs.link_clicked_count_privacy_policy","value":"2","type":"integer","granularity":"daily"},
164+
{"key":"ide_labs.feedback_link_clicked_count_connected_mode","value":"1","type":"integer","granularity":"daily"},
165+
{"key":"ide_labs.feedback_link_clicked_count_manage_dependency_risk","value":"2","type":"integer","granularity":"daily"}
152166
]}
153167
""", PLATFORM),
154168
true, true)));

backend/telemetry/src/test/java/org/sonarsource/sonarlint/core/telemetry/TelemetryLocalStorageTests.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.time.LocalDateTime;
2424
import java.time.OffsetDateTime;
2525
import java.time.temporal.ChronoUnit;
26+
import java.util.Map;
2627
import java.util.UUID;
2728
import org.assertj.core.api.Condition;
2829
import org.junit.jupiter.api.Test;
@@ -362,4 +363,65 @@ void should_find_mcp_transport_mode_used() {
362363
data.setMcpTransportModeUsed(McpTransportMode.HTTP);
363364
assertThat(data.getMcpTransportModeUsed()).isEqualTo(McpTransportMode.HTTP);
364365
}
366+
367+
@Test
368+
void should_set_labs_joined() {
369+
var data = new TelemetryLocalStorage();
370+
assertThat(data.isLabsJoined()).isFalse();
371+
data.setLabsJoined(true);
372+
assertThat(data.isLabsJoined()).isTrue();
373+
}
374+
375+
@Test
376+
void should_set_labs_enabled() {
377+
var data = new TelemetryLocalStorage();
378+
assertThat(data.isLabsEnabled()).isFalse();
379+
data.setLabsEnabled(true);
380+
assertThat(data.isLabsEnabled()).isTrue();
381+
}
382+
383+
@Test
384+
void should_increment_link_clicked_count_for_each_link_separately() {
385+
var data = new TelemetryLocalStorage();
386+
assertThat(data.getLabsLinkClickedCount()).isEmpty();
387+
388+
data.ideLabsLinkClicked("1");
389+
data.ideLabsLinkClicked("2");
390+
data.ideLabsLinkClicked("2");
391+
392+
assertThat(data.getLabsLinkClickedCount())
393+
.isEqualTo(Map.of(
394+
"1", 1,
395+
"2", 2));
396+
}
397+
398+
@Test
399+
void should_increment_feedback_link_clicked_count_for_each_link_separately() {
400+
var data = new TelemetryLocalStorage();
401+
assertThat(data.getLabsFeedbackLinkClickedCount()).isEmpty();
402+
403+
data.ideLabsFeedbackLinkClicked("1");
404+
data.ideLabsFeedbackLinkClicked("2");
405+
data.ideLabsFeedbackLinkClicked("2");
406+
407+
assertThat(data.getLabsFeedbackLinkClickedCount())
408+
.isEqualTo(Map.of(
409+
"1", 1,
410+
"2", 2));
411+
}
412+
413+
@Test
414+
void should_reset_link_clicked_data_after_ping() {
415+
var data = new TelemetryLocalStorage();
416+
417+
data.ideLabsLinkClicked("1");
418+
data.ideLabsLinkClicked("2");
419+
data.ideLabsFeedbackLinkClicked("1");
420+
data.ideLabsFeedbackLinkClicked("2");
421+
422+
data.clearAfterPing();
423+
424+
assertThat(data.getLabsLinkClickedCount()).isEmpty();
425+
assertThat(data.getLabsFeedbackLinkClickedCount()).isEmpty();
426+
}
365427
}

0 commit comments

Comments
 (0)