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/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/io/snyk/plugin/services/download/CliDownloaderService.kt b/src/main/kotlin/io/snyk/plugin/services/download/CliDownloaderService.kt index 5cbdc47f2..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 { 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..67ee44c0a --- /dev/null +++ b/src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt @@ -0,0 +1,13 @@ +package snyk.common.lsp.analytics + +data class AnalyticsEvent( + val interactionType: String, + val category: List, + val status: String = "success", + val targetId: String = "pkg:filesystem/scrubbed", + 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/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()) } } } 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