From 5e193c833b91ccd623fcdea2dee0c33046e42968 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Fri, 25 Oct 2024 15:49:08 +0200 Subject: [PATCH 1/4] feat: add plugin installed event --- .../io/snyk/plugin/SnykPostStartupActivity.kt | 13 ---- .../plugin/analytics/AnalyticsScanListener.kt | 14 +++-- .../snyk/plugin/analytics/AnalyticsSender.kt | 63 +++++++++++++++++++ .../SnykApplicationSettingsStateService.kt | 5 +- .../snyk/common/lsp/LanguageServerWrapper.kt | 13 ++-- .../lsp/analytics/AbstractAnalyticsEvent.kt | 3 + .../common/lsp/analytics/AnalyticsEvent.kt | 15 +++++ .../{commands => analytics}/ScanDoneEvent.kt | 4 +- .../common/lsp/LanguageServerWrapperTest.kt | 2 +- 9 files changed, 104 insertions(+), 28 deletions(-) create mode 100644 src/main/kotlin/io/snyk/plugin/analytics/AnalyticsSender.kt create mode 100644 src/main/kotlin/snyk/common/lsp/analytics/AbstractAnalyticsEvent.kt create mode 100644 src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt rename src/main/kotlin/snyk/common/lsp/{commands => analytics}/ScanDoneEvent.kt (97%) diff --git a/src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt b/src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt index 9b6437833..26f1045f6 100644 --- a/src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt +++ b/src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt @@ -1,8 +1,5 @@ package io.snyk.plugin -import com.intellij.ide.plugins.IdeaPluginDescriptor -import com.intellij.ide.plugins.PluginInstaller -import com.intellij.ide.plugins.PluginStateListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.extensions.ExtensionPointName @@ -36,8 +33,6 @@ class SnykPostStartupActivity : ProjectActivity { @Suppress("TooGenericExceptionCaught") override suspend fun execute(project: Project) { - PluginInstaller.addStateListener(UninstallListener()) - if (!listenersActivated) { val messageBusConnection = ApplicationManager.getApplication().messageBus.connect() // TODO: add subscription for language server messages @@ -87,11 +82,3 @@ class SnykPostStartupActivity : ProjectActivity { } } } - -private class UninstallListener : PluginStateListener { - @Suppress("EmptyFunctionBlock") - override fun install(descriptor: IdeaPluginDescriptor) { - } - - override fun uninstall(descriptor: IdeaPluginDescriptor) {} -} diff --git a/src/main/kotlin/io/snyk/plugin/analytics/AnalyticsScanListener.kt b/src/main/kotlin/io/snyk/plugin/analytics/AnalyticsScanListener.kt index 8f500adb4..d3f5be408 100644 --- a/src/main/kotlin/io/snyk/plugin/analytics/AnalyticsScanListener.kt +++ b/src/main/kotlin/io/snyk/plugin/analytics/AnalyticsScanListener.kt @@ -3,12 +3,12 @@ package io.snyk.plugin.analytics import com.intellij.openapi.components.Service import com.intellij.openapi.project.Project import io.snyk.plugin.events.SnykScanListener +import io.snyk.plugin.pluginSettings import io.snyk.plugin.toVirtualFile import snyk.common.SnykError -import snyk.common.lsp.LanguageServerWrapper -import snyk.common.lsp.commands.ScanDoneEvent +import snyk.common.lsp.analytics.AnalyticsEvent +import snyk.common.lsp.analytics.ScanDoneEvent import snyk.container.ContainerResult -import snyk.iac.IacResult @Service(Service.Level.PROJECT) class AnalyticsScanListener(val project: Project) { @@ -53,7 +53,7 @@ class AnalyticsScanListener(val project: Project) { containerResult.mediumSeveritiesCount(), containerResult.lowSeveritiesCount() ) - LanguageServerWrapper.getInstance().sendReportAnalyticsCommand(scanDoneEvent) + AnalyticsSender.getInstance().logEvent(scanDoneEvent) } override fun scanningContainerError(snykError: SnykError) { @@ -66,5 +66,11 @@ class AnalyticsScanListener(val project: Project) { SnykScanListener.SNYK_SCAN_TOPIC, snykScanListener, ) + if (!pluginSettings().pluginInstalledSent) { + val event = AnalyticsEvent("plugin installed", listOf("install")) + AnalyticsSender.getInstance().logEvent(event) { + pluginSettings().pluginInstalledSent = true + } + } } } diff --git a/src/main/kotlin/io/snyk/plugin/analytics/AnalyticsSender.kt b/src/main/kotlin/io/snyk/plugin/analytics/AnalyticsSender.kt new file mode 100644 index 000000000..0f7c67d40 --- /dev/null +++ b/src/main/kotlin/io/snyk/plugin/analytics/AnalyticsSender.kt @@ -0,0 +1,63 @@ +package io.snyk.plugin.analytics + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import io.snyk.plugin.ui.toolwindow.SnykPluginDisposable +import org.jetbrains.concurrency.runAsync +import snyk.common.lsp.LanguageServerWrapper +import snyk.common.lsp.analytics.AbstractAnalyticsEvent +import java.util.LinkedList +import java.util.concurrent.ConcurrentLinkedQueue + +class AnalyticsSender : Disposable { + private var disposed: Boolean = false + + // left = event, right = callback function + private val eventQueue = ConcurrentLinkedQueue Unit>>() + + init { + Disposer.register(SnykPluginDisposable.getInstance(), this) + start() + } + + private fun start() { + runAsync { + val lsw = LanguageServerWrapper.getInstance() + while (!disposed) { + if (eventQueue.isEmpty() || lsw.notAuthenticated()) { + Thread.sleep(1000) + continue + } + val copyForSending = LinkedList(eventQueue) + for (event in copyForSending) { + try { + lsw.sendReportAnalyticsCommand(event.first) + event.second() + } catch (e: Exception) { + lsw.logger.warn("unexpected exception while sending analytics") + } finally { + eventQueue.remove(event) + } + } + } + } + } + + fun logEvent(event: AbstractAnalyticsEvent, callback: () -> Unit = {}) = eventQueue.add(Pair(event, callback)) + + companion object { + private var instance: AnalyticsSender? = null + + @JvmStatic + fun getInstance(): AnalyticsSender { + if (instance == null) { + instance = AnalyticsSender() + } + return instance as AnalyticsSender + } + } + + override fun dispose() { + this.disposed = true + } +} diff --git a/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt b/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt index 98547d86f..684c7a70d 100644 --- a/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt +++ b/src/main/kotlin/io/snyk/plugin/services/SnykApplicationSettingsStateService.kt @@ -26,7 +26,10 @@ import java.util.UUID storages = [Storage("snyk.settings.xml", roamingType = RoamingType.DISABLED)], ) class SnykApplicationSettingsStateService : PersistentStateComponent { - val requiredLsProtocolVersion = 16 + // events + var pluginInstalledSent: Boolean = false + + val requiredLsProtocolVersion = 17 var useTokenAuthentication = false var currentLSProtocolVersion: Int? = 0 diff --git a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt index 5881abed5..ddb4c4928 100644 --- a/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt +++ b/src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt @@ -47,6 +47,7 @@ import org.eclipse.lsp4j.services.LanguageServer import org.jetbrains.concurrency.runAsync import snyk.common.EnvironmentHelper import snyk.common.getEndpointUrl +import snyk.common.lsp.analytics.AbstractAnalyticsEvent import snyk.common.lsp.commands.COMMAND_CODE_FIX_DIFFS import snyk.common.lsp.commands.COMMAND_CODE_SUBMIT_FIX_FEEDBACK import snyk.common.lsp.commands.COMMAND_COPY_AUTH_LINK @@ -59,7 +60,6 @@ import snyk.common.lsp.commands.COMMAND_LOGOUT import snyk.common.lsp.commands.COMMAND_REPORT_ANALYTICS import snyk.common.lsp.commands.COMMAND_WORKSPACE_FOLDER_SCAN import snyk.common.lsp.commands.SNYK_GENERATE_ISSUE_DESCRIPTION -import snyk.common.lsp.commands.ScanDoneEvent import snyk.common.lsp.progress.ProgressManager import snyk.common.lsp.settings.LanguageServerSettings import snyk.common.lsp.settings.SeverityFilter @@ -348,10 +348,10 @@ class LanguageServerWrapper( return isInitialized } - fun sendReportAnalyticsCommand(scanDoneEvent: ScanDoneEvent) { + fun sendReportAnalyticsCommand(event: AbstractAnalyticsEvent) { if (notAuthenticated()) return try { - val eventString = gson.toJson(scanDoneEvent) + val eventString = gson.toJson(event) val param = ExecuteCommandParams() param.command = COMMAND_REPORT_ANALYTICS param.arguments = listOf(eventString) @@ -488,8 +488,7 @@ class LanguageServerWrapper( } fun getAuthenticatedUser(): String? { - if (pluginSettings().token.isNullOrBlank()) return null - if (!ensureLanguageServerInitialized()) return null + if (notAuthenticated()) return null if (!this.authenticatedUser.isNullOrEmpty()) return authenticatedUser!!["username"] val cmd = ExecuteCommandParams(COMMAND_GET_ACTIVE_USER, emptyList()) @@ -541,7 +540,7 @@ class LanguageServerWrapper( } fun generateIssueDescription(issue: ScanIssue): String? { - if (!ensureLanguageServerInitialized()) return null + if (notAuthenticated()) return null val key = issue.additionalData.key if (key.isBlank()) throw RuntimeException("Issue ID is required") val generateIssueCommand = ExecuteCommandParams(SNYK_GENERATE_ISSUE_DESCRIPTION, listOf(key)) @@ -624,7 +623,7 @@ class LanguageServerWrapper( } } - private fun notAuthenticated() = !ensureLanguageServerInitialized() || pluginSettings().token.isNullOrBlank() + fun notAuthenticated() = !ensureLanguageServerInitialized() || pluginSettings().token.isNullOrBlank() private fun ensureLanguageServerProtocolVersion(project: Project) { diff --git a/src/main/kotlin/snyk/common/lsp/analytics/AbstractAnalyticsEvent.kt b/src/main/kotlin/snyk/common/lsp/analytics/AbstractAnalyticsEvent.kt new file mode 100644 index 000000000..35b741ffa --- /dev/null +++ b/src/main/kotlin/snyk/common/lsp/analytics/AbstractAnalyticsEvent.kt @@ -0,0 +1,3 @@ +package snyk.common.lsp.analytics + +interface AbstractAnalyticsEvent diff --git a/src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt b/src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt new file mode 100644 index 000000000..d517e3706 --- /dev/null +++ b/src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt @@ -0,0 +1,15 @@ +package snyk.common.lsp.analytics + +import io.snyk.plugin.getPluginPath + +data class AnalyticsEvent( + val interactionType: String, + val category: List, + val status: String = "success", + val targetId: String = "pkg:file/${getPluginPath()}", + val timestampMs: Long = System.currentTimeMillis(), + val durationMs: Long = 0, + val results: Map = emptyMap(), + val errors: List = emptyList(), + val extension: Map = emptyMap(), +) : AbstractAnalyticsEvent diff --git a/src/main/kotlin/snyk/common/lsp/commands/ScanDoneEvent.kt b/src/main/kotlin/snyk/common/lsp/analytics/ScanDoneEvent.kt similarity index 97% rename from src/main/kotlin/snyk/common/lsp/commands/ScanDoneEvent.kt rename to src/main/kotlin/snyk/common/lsp/analytics/ScanDoneEvent.kt index 52a8bda44..93b796889 100644 --- a/src/main/kotlin/snyk/common/lsp/commands/ScanDoneEvent.kt +++ b/src/main/kotlin/snyk/common/lsp/analytics/ScanDoneEvent.kt @@ -1,4 +1,4 @@ -package snyk.common.lsp.commands +package snyk.common.lsp.analytics import com.google.gson.annotations.SerializedName import io.snyk.plugin.getArch @@ -9,7 +9,7 @@ import java.time.ZonedDateTime data class ScanDoneEvent( @SerializedName("data") val data: Data -) { +) : AbstractAnalyticsEvent { data class Data( @SerializedName("type") val type: String = "analytics", @SerializedName("attributes") val attributes: Attributes diff --git a/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt b/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt index b7021e662..b8aacc044 100644 --- a/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt +++ b/src/test/kotlin/snyk/common/lsp/LanguageServerWrapperTest.kt @@ -27,7 +27,7 @@ import org.eclipse.lsp4j.services.LanguageServer import org.junit.After import org.junit.Before import org.junit.Test -import snyk.common.lsp.commands.ScanDoneEvent +import snyk.common.lsp.analytics.ScanDoneEvent import snyk.common.lsp.settings.FolderConfigSettings import snyk.pluginInfo import snyk.trust.WorkspaceTrustService From ccde93eb179d78e2613616a22b568dbcfdafe284 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Mon, 28 Oct 2024 16:51:21 +0100 Subject: [PATCH 2/4] chore: don't log actual filesystem path --- src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt b/src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt index d517e3706..67ee44c0a 100644 --- a/src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt +++ b/src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt @@ -1,12 +1,10 @@ package snyk.common.lsp.analytics -import io.snyk.plugin.getPluginPath - data class AnalyticsEvent( val interactionType: String, val category: List, val status: String = "success", - val targetId: String = "pkg:file/${getPluginPath()}", + val targetId: String = "pkg:filesystem/scrubbed", val timestampMs: Long = System.currentTimeMillis(), val durationMs: Long = 0, val results: Map = emptyMap(), From 160059226213bb90580bc2e5eb1682aec83d1253 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Mon, 4 Nov 2024 09:32:10 +0100 Subject: [PATCH 3/4] chore: add changelog, minor visibility change to private methods --- CHANGELOG.md | 1 + .../io/snyk/plugin/services/download/CliDownloaderService.kt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b549726..b1598d412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [2.11.0] ### Changed - If $/snyk.hasAuthenticated transmits an API URL, this is saved in the settings. +- Add "plugin installed" analytics event (sent after authentication) - Added a description of custom endpoints to settings dialog. ## [2.10.0] diff --git a/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt b/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt index 5cbdc47f2..36e6f3fe9 100644 --- a/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt +++ b/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt @@ -129,13 +129,13 @@ class SnykCliDownloaderService { return pluginSettings().currentLSProtocolVersion == pluginSettings().requiredLsProtocolVersion } - fun isFourDaysPassedSinceLastCheck(): Boolean { + private fun isFourDaysPassedSinceLastCheck(): Boolean { val previousDate = pluginSettings().getLastCheckDate() ?: return true return ChronoUnit.DAYS.between(previousDate, LocalDate.now()) >= NUMBER_OF_DAYS_BETWEEN_RELEASE_CHECK } - fun isNewVersionAvailable(currentCliVersion: String?, newCliVersion: String?): Boolean { + private fun isNewVersionAvailable(currentCliVersion: String?, newCliVersion: String?): Boolean { val cliVersionsNullOrEmpty = currentCliVersion == null || newCliVersion == null || currentCliVersion.isEmpty() || newCliVersion.isEmpty() From 40b9c024d35a8a339b55782a6dbb9bae5301e4a4 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Mon, 4 Nov 2024 09:58:42 +0100 Subject: [PATCH 4/4] fix: test and visibility --- .../plugin/services/download/CliDownloaderService.kt | 5 +++-- .../plugin/analytics/AnalyticsScanListenerTest.kt | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt b/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt index 36e6f3fe9..6bc7160fa 100644 --- a/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt +++ b/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt @@ -16,6 +16,7 @@ import java.time.LocalDate import java.time.temporal.ChronoUnit import java.util.Date +@Suppress("MemberVisibilityCanBePrivate") @Service class SnykCliDownloaderService { @@ -129,13 +130,13 @@ class SnykCliDownloaderService { return pluginSettings().currentLSProtocolVersion == pluginSettings().requiredLsProtocolVersion } - private fun isFourDaysPassedSinceLastCheck(): Boolean { + fun isFourDaysPassedSinceLastCheck(): Boolean { val previousDate = pluginSettings().getLastCheckDate() ?: return true return ChronoUnit.DAYS.between(previousDate, LocalDate.now()) >= NUMBER_OF_DAYS_BETWEEN_RELEASE_CHECK } - private fun isNewVersionAvailable(currentCliVersion: String?, newCliVersion: String?): Boolean { + fun isNewVersionAvailable(currentCliVersion: String?, newCliVersion: String?): Boolean { val cliVersionsNullOrEmpty = currentCliVersion == null || newCliVersion == null || currentCliVersion.isEmpty() || newCliVersion.isEmpty() diff --git a/src/test/kotlin/io/snyk/plugin/analytics/AnalyticsScanListenerTest.kt b/src/test/kotlin/io/snyk/plugin/analytics/AnalyticsScanListenerTest.kt index 9949883c3..45049f2d8 100644 --- a/src/test/kotlin/io/snyk/plugin/analytics/AnalyticsScanListenerTest.kt +++ b/src/test/kotlin/io/snyk/plugin/analytics/AnalyticsScanListenerTest.kt @@ -1,5 +1,7 @@ package io.snyk.plugin.analytics +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import io.mockk.every @@ -14,6 +16,7 @@ import io.snyk.plugin.getOS import io.snyk.plugin.pluginSettings import io.snyk.plugin.services.SnykApplicationSettingsStateService import io.snyk.plugin.toVirtualFile +import io.snyk.plugin.ui.toolwindow.SnykPluginDisposable import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import org.junit.After @@ -29,16 +32,22 @@ class AnalyticsScanListenerTest { private val projectMock: Project = mockk() private val settings = SnykApplicationSettingsStateService() private val languageServerWrapper: LanguageServerWrapper = mockk() + private val applicationMock: Application = mockk(relaxed = true) @Before fun setUp() { unmockkAll() + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns applicationMock + every { applicationMock.getService(SnykPluginDisposable::class.java) } returns mockk(relaxed = true) + mockkStatic("io.snyk.plugin.UtilsKt") every { pluginSettings() } returns settings mockkObject(LanguageServerWrapper.Companion) every { LanguageServerWrapper.getInstance() } returns languageServerWrapper + every { languageServerWrapper.notAuthenticated() } returns false justRun { languageServerWrapper.sendReportAnalyticsCommand(any()) } mockkStatic("snyk.PluginInformationKt") @@ -104,6 +113,6 @@ class AnalyticsScanListenerTest { fun `testScanListener scanningContainerFinished should call language server to report analytics`() { cut.snykScanListener.scanningContainerFinished(mockk(relaxed = true)) - verify { languageServerWrapper.sendReportAnalyticsCommand(any()) } + verify(timeout = 3000) { languageServerWrapper.sendReportAnalyticsCommand(any()) } } }