Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add plugin installed event [IDE-736] #632

Merged
merged 4 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 0 additions & 13 deletions src/main/kotlin/io/snyk/plugin/SnykPostStartupActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -87,11 +82,3 @@ class SnykPostStartupActivity : ProjectActivity {
}
}
}

private class UninstallListener : PluginStateListener {
@Suppress("EmptyFunctionBlock")
override fun install(descriptor: IdeaPluginDescriptor) {
}

override fun uninstall(descriptor: IdeaPluginDescriptor) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
}
}
}
63 changes: 63 additions & 0 deletions src/main/kotlin/io/snyk/plugin/analytics/AnalyticsSender.kt
Original file line number Diff line number Diff line change
@@ -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<Pair<AbstractAnalyticsEvent, () -> 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import java.util.UUID
storages = [Storage("snyk.settings.xml", roamingType = RoamingType.DISABLED)],
)
class SnykApplicationSettingsStateService : PersistentStateComponent<SnykApplicationSettingsStateService> {
val requiredLsProtocolVersion = 16
// events
var pluginInstalledSent: Boolean = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will plugin developers working towards prod send a plugin installed event every time we start debug sessions? Will this be an issue for reporting?

Copy link
Collaborator Author

@bastiandoetsch bastiandoetsch Nov 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not be an issue, as these versions would have a snapshot version of the integrationName. Any report would of cause need to filter snapshot or dev versions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue would only occur in our org anyways.


val requiredLsProtocolVersion = 17

var useTokenAuthentication = false
var currentLSProtocolVersion: Int? = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import java.time.LocalDate
import java.time.temporal.ChronoUnit
import java.util.Date

@Suppress("MemberVisibilityCanBePrivate")
@Service
class SnykCliDownloaderService {

Expand Down
13 changes: 6 additions & 7 deletions src/main/kotlin/snyk/common/lsp/LanguageServerWrapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -624,7 +623,7 @@ class LanguageServerWrapper(
}
}

private fun notAuthenticated() = !ensureLanguageServerInitialized() || pluginSettings().token.isNullOrBlank()
fun notAuthenticated() = !ensureLanguageServerInitialized() || pluginSettings().token.isNullOrBlank()


private fun ensureLanguageServerProtocolVersion(project: Project) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package snyk.common.lsp.analytics

interface AbstractAnalyticsEvent
13 changes: 13 additions & 0 deletions src/main/kotlin/snyk/common/lsp/analytics/AnalyticsEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package snyk.common.lsp.analytics

data class AnalyticsEvent(
val interactionType: String,
val category: List<String>,
val status: String = "success",
val targetId: String = "pkg:filesystem/scrubbed",
val timestampMs: Long = System.currentTimeMillis(),
val durationMs: Long = 0,
val results: Map<String, Any> = emptyMap(),
val errors: List<Any> = emptyList(),
val extension: Map<String, Any> = emptyMap(),
) : AbstractAnalyticsEvent
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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()) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading