diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e105f53..e7314f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ MongoDB plugin for IntelliJ IDEA. ## [Unreleased] ### Added +* [INTELLIJ-11](https://jira.mongodb.org/browse/INTELLIJ-11): Flush pending analytics events before closing the IDE. ### Changed diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivity.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivity.kt index 030e29c5..bec0c1fd 100644 --- a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivity.kt +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivity.kt @@ -1,5 +1,6 @@ package com.mongodb.jbplugin +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.startup.StartupActivity @@ -15,7 +16,7 @@ import kotlinx.coroutines.launch class ActivatePluginPostStartupActivity(private val cs: CoroutineScope) : StartupActivity, DumbAware { override fun runActivity(project: Project) { cs.launch { - val pluginActivated = project.getService(PluginActivatedProbe::class.java) + val pluginActivated = ApplicationManager.getApplication().getService(PluginActivatedProbe::class.java) pluginActivated.pluginActivated() } } diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/LogMessage.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/LogMessage.kt index 5eb13e73..ebbf286e 100644 --- a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/LogMessage.kt +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/LogMessage.kt @@ -12,8 +12,8 @@ package com.mongodb.jbplugin.observability import com.google.gson.Gson import com.google.gson.GsonBuilder +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service -import com.intellij.openapi.project.Project /** * @param gson @@ -33,25 +33,26 @@ internal class LogMessageBuilder(private val gson: Gson, message: String) { /** * This class will be injected in probes to build log messages. Usually like: * ```kt - * @Service(Service.Level.PROJECT) - * class MyProbe(private val project: Project) { + * @Service + * class MyProbe { * ... * fun somethingProbed() { - * val logMessage = project.getService(LogMessage::class.java) + * val logMessage = ApplicationManager.getApplication().getService(LogMessage::class.java) * log.info(logMessage.message("My message").put("someOtherProp", 25).build()) * } * ... * } * ``` * - * @param project */ -@Service(Service.Level.PROJECT) -internal class LogMessage(private val project: Project) { +@Service +internal class LogMessage { private val gson = GsonBuilder().generateNonExecutableJson().disableJdkUnsafe().create() fun message(key: String): LogMessageBuilder { - val runtimeInformationService = project.getService(RuntimeInformationService::class.java) + val runtimeInformationService = ApplicationManager.getApplication().getService( +RuntimeInformationService::class.java +) val runtimeInformation = runtimeInformationService.get() return LogMessageBuilder(gson, key) diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/TelemetryService.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/TelemetryService.kt index 9c3dcf8d..b6b5ba62 100644 --- a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/TelemetryService.kt +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/TelemetryService.kt @@ -1,26 +1,41 @@ package com.mongodb.jbplugin.observability +import com.intellij.ide.AppLifecycleListener +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service -import com.intellij.openapi.project.Project +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.diagnostic.logger import com.mongodb.jbplugin.meta.BuildInformation import com.segment.analytics.Analytics import com.segment.analytics.messages.IdentifyMessage import com.segment.analytics.messages.TrackMessage +private val logger: Logger = logger() + /** * This telemetry service is used to send events to Segment. Should be used within * probes, no directly. That is why it's marked as internal. - * - * @param project */ -@Service(Service.Level.PROJECT) -internal class TelemetryService(private val project: Project) { +@Service +internal class TelemetryService : AppLifecycleListener { internal var analytics: Analytics = Analytics .builder(BuildInformation.segmentApiKey) .build() + init { + ApplicationManager.getApplication() + .messageBus + .connect() + .subscribe( + AppLifecycleListener.TOPIC, + this + ) + } + fun sendEvent(event: TelemetryEvent) { - val runtimeInformationService = project.getService(RuntimeInformationService::class.java) + val runtimeInformationService = ApplicationManager.getApplication().getService( + RuntimeInformationService::class.java + ) val runtimeInfo = runtimeInformationService.get() val message = when (event) { @@ -35,4 +50,16 @@ internal class TelemetryService(private val project: Project) { analytics.enqueue(message) } + + override fun appWillBeClosed(isRestart: Boolean) { + val logMessage = ApplicationManager.getApplication().getService(LogMessage::class.java) + logger.info( + logMessage.message("Flushing Segment analytics because the IDE is closing.") + .put("isRestart", isRestart) + .build() + ) + + analytics.flush() + analytics.shutdown() + } } diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/probe/PluginActivatedProbe.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/probe/PluginActivatedProbe.kt index cc1672b7..f3f77ddb 100644 --- a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/probe/PluginActivatedProbe.kt +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/probe/PluginActivatedProbe.kt @@ -1,9 +1,9 @@ package com.mongodb.jbplugin.observability.probe +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.logger -import com.intellij.openapi.project.Project import com.mongodb.jbplugin.observability.LogMessage import com.mongodb.jbplugin.observability.TelemetryEvent import com.mongodb.jbplugin.observability.TelemetryService @@ -12,14 +12,13 @@ private val logger: Logger = logger() /** * This probe is emitted when the plugin is activated (started). - * - * @param project Project where the plugin is set up */ -@Service(Service.Level.PROJECT) -class PluginActivatedProbe(private val project: Project) { +@Service +class PluginActivatedProbe { fun pluginActivated() { - val telemetry = project.getService(TelemetryService::class.java) - val logMessage = project.getService(LogMessage::class.java) + val application = ApplicationManager.getApplication() + val telemetry = application.getService(TelemetryService::class.java) + val logMessage = application.getService(LogMessage::class.java) telemetry.sendEvent(TelemetryEvent.PluginActivated) diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivityTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivityTest.kt index 7c1a8f26..e959afc4 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivityTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivityTest.kt @@ -1,7 +1,10 @@ package com.mongodb.jbplugin +import com.intellij.openapi.application.Application +import com.intellij.openapi.project.Project +import com.mongodb.jbplugin.fixtures.IntegrationTest import com.mongodb.jbplugin.fixtures.eventually -import com.mongodb.jbplugin.fixtures.mockProject +import com.mongodb.jbplugin.fixtures.withMockedService import com.mongodb.jbplugin.observability.probe.PluginActivatedProbe import org.junit.jupiter.api.Test import org.mockito.Mockito.mock @@ -11,11 +14,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +@IntegrationTest class ActivatePluginPostStartupActivityTest { @Test - fun `emits a plugin activated probe`() = runBlocking { + fun `emits a plugin activated probe`(application: Application, project: Project) = runBlocking { val pluginActivatedProbe = mock() - val project = mockProject(pluginActivatedProbe = pluginActivatedProbe) + application.withMockedService(pluginActivatedProbe) + val listener = ActivatePluginPostStartupActivity(CoroutineScope(Dispatchers.Default)) listener.runActivity(project) diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt new file mode 100644 index 00000000..4f3eefb9 --- /dev/null +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt @@ -0,0 +1,153 @@ +/** + * Extension for tests that depend on an Application. + */ + +package com.mongodb.jbplugin.fixtures + +import com.google.gson.Gson +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.ComponentManager +import com.intellij.openapi.project.Project +import com.intellij.util.messages.ListenerDescriptor +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusOwner +import com.intellij.util.messages.impl.RootBus +import com.mongodb.jbplugin.observability.LogMessage +import com.mongodb.jbplugin.observability.LogMessageBuilder +import com.mongodb.jbplugin.observability.RuntimeInformation +import com.mongodb.jbplugin.observability.RuntimeInformationService +import org.junit.jupiter.api.extension.* +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.kotlin.any + +/** + * Annotation to be used within the test. It provides, as a parameter of a test, + * either a mocked setup Application or Project. + * + * @see com.mongodb.jbplugin.observability.LogMessageTest + */ +@ExtendWith(IntegrationTestExtension::class) +annotation class IntegrationTest + +/** + * Extension class, should not be used directly. + */ +private class IntegrationTestExtension : BeforeTestExecutionCallback, + AfterTestExecutionCallback, + ParameterResolver { + private lateinit var application: Application + private lateinit var messageBus: MessageBus + private lateinit var project: Project + + override fun beforeTestExecution(context: ExtensionContext?) { + application = mock() + project = mock() + + messageBus = RootBus(mock().apply { + `when`(this.isDisposed()).thenReturn(false) + `when`(this.isParentLazyListenersIgnored()).thenReturn(false) + `when`(this.createListener(any())).then { + val descriptor = it.arguments[0] as ListenerDescriptor + Class.forName(descriptor.listenerClassName).getConstructor().newInstance() + } + }) + + `when`(application.getMessageBus()).thenReturn(messageBus) + `when`(project.getMessageBus()).thenReturn(messageBus) + + ApplicationManager.setApplication(application) {} + } + + override fun afterTestExecution(context: ExtensionContext?) { + } + + override fun supportsParameter(parameterContext: ParameterContext?, extensionContext: ExtensionContext?): Boolean = + parameterContext?.parameter?.type?.run { + equals(Application::class.java) || equals(Project::class.java) + } ?: false + + override fun resolveParameter(parameterContext: ParameterContext?, extensionContext: ExtensionContext?): Any = + when (parameterContext?.parameter?.type) { + Application::class.java -> application + Project::class.java -> project + else -> TODO() + } +} + +/** + * Convenience function in application or project for tests. It mocks the implementation of a single service + * with whatever implementation is passed as a parameter. For example: + * + * ```kt + * application.withMockedService(mockRuntimeInformationService()) + * project.withMockedService(mockRuntimeInformationService()) + * ``` + * + * @param serviceImpl + * @return itself so it can be chained + */ +inline fun S.withMockedService(serviceImpl: T): S { + `when`(getService(T::class.java)).thenReturn(serviceImpl) + return this +} + +/** + * Generates a mock runtime information service, useful for testing. If you need + * to create your own. You'll likely will build first an information service and + * then inject it into a mock project, something like this: + * + * ```kt + * val myInfoService = mockRuntimeInformationService(userId = "hey") + * project.withMockedService(myInfoService) + * ``` + * + * @param userId + * @param osName + * @param arch + * @param jvmVendor + * @param jvmVersion + * @param buildVersion + * @param applicationName + * @return A new mocked RuntimeInformationService + */ +internal fun mockRuntimeInformationService( + userId: String = "123456", + osName: String = "Winux OSX", + arch: String = "x128", + jvmVendor: String = "Obelisk", + jvmVersion: String = "42", + buildVersion: String = "2024.2", + applicationName: String = "Cool IDE" +) = mock().also { service -> + `when`(service.get()).thenReturn( + RuntimeInformation( + userId = userId, + osName = osName, + arch = arch, + jvmVendor = jvmVendor, + jvmVersion = jvmVersion, + buildVersion = buildVersion, + applicationName = applicationName + ) + ) +} + +/** + * Generates a mock log message service. + * You'll likely will build first a log message service and + * then inject it into a mock project, something like this: + * + * ```kt + * val myLogMessage = mockLogMessage() + * project.withMockedService(myLogMessage) + * ``` + * + * @return A new mocked LogMessage + */ +internal fun mockLogMessage() = mock().also { logMessage -> + `when`(logMessage.message(any())).then { message -> + LogMessageBuilder(Gson(), message.arguments[0].toString()) + } +} \ No newline at end of file diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/ProjectExtensions.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/ProjectExtensions.kt deleted file mode 100644 index f7aa3b80..00000000 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/ProjectExtensions.kt +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Functions to simplify testing that depends on a project. - */ - -package com.mongodb.jbplugin.fixtures - -import com.google.gson.Gson -import com.intellij.openapi.project.Project -import com.mongodb.jbplugin.observability.* -import com.mongodb.jbplugin.observability.probe.PluginActivatedProbe -import org.mockito.Mockito.`when` -import org.mockito.kotlin.any -import org.mockito.kotlin.mock - -/** - * Creates a mock project with dependencies injected. - * - * All parameters are optional, so you can pass a custom mock to any of them to - * verify. - * - * @param telemetryService - * @param pluginActivatedProbe - * @param logMessage - * @param runtimeInformationService - * @return A mock project to be used in dependency injection. - */ -internal fun mockProject( - telemetryService: TelemetryService = mock(), - runtimeInformationService: RuntimeInformationService = mockRuntimeInformationService(), - pluginActivatedProbe: PluginActivatedProbe = mock(), - logMessage: LogMessage = mockLogMessage(), -): Project { - val project = mock() - `when`(project.getService(TelemetryService::class.java)).thenReturn(telemetryService) - `when`(project.getService(RuntimeInformationService::class.java)).thenReturn(runtimeInformationService) - `when`(project.getService(PluginActivatedProbe::class.java)).thenReturn(pluginActivatedProbe) - `when`(project.getService(LogMessage::class.java)).thenReturn(logMessage) - return project -} - -/** - * Generates a mock runtime information service, useful for testing. If you need - * to create your own. You'll likely will build first an information service and - * then inject it into a mock project, something like this: - * - * ```kt - * val myInfoService = mockRuntimeInformationService(userId = "hey") - * val myProject = mockProject(runtimeInformationService = myInfoService) - * ``` - * - * @param userId - * @param osName - * @param arch - * @param jvmVendor - * @param jvmVersion - * @param buildVersion - * @param applicationName - * @return A new mocked RuntimeInformationService - */ -internal fun mockRuntimeInformationService( - userId: String = "123456", - osName: String = "Winux OSX", - arch: String = "x128", - jvmVendor: String = "Obelisk", - jvmVersion: String = "42", - buildVersion: String = "2024.2", - applicationName: String = "Cool IDE" -): RuntimeInformationService = mock().also { service -> - `when`(service.get()).thenReturn( - RuntimeInformation( - userId = userId, - osName = osName, - arch = arch, - jvmVendor = jvmVendor, - jvmVersion = jvmVersion, - buildVersion = buildVersion, - applicationName = applicationName - ) - ) -} - -/** - * Generates a mock log message service. - * You'll likely will build first a log message service and - * then inject it into a mock project, something like this: - * - * ```kt - * val myLogMessage = mockLogMessage() - * val myProject = mockProject(logMessage = myLogMessage) - * ``` - * - * @return A new mocked LogMessage - */ -internal fun mockLogMessage(): LogMessage = mock().also { logMessage -> - `when`(logMessage.message(any())).then { message -> - LogMessageBuilder(Gson(), message.arguments[0].toString()) - } -} diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/LogMessageTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/LogMessageTest.kt index 9d8f7f18..54dc18c8 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/LogMessageTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/LogMessageTest.kt @@ -1,24 +1,32 @@ package com.mongodb.jbplugin.observability import com.google.gson.Gson -import com.mongodb.jbplugin.fixtures.mockProject +import com.intellij.openapi.application.Application +import com.mongodb.jbplugin.fixtures.IntegrationTest +import com.mongodb.jbplugin.fixtures.mockRuntimeInformationService +import com.mongodb.jbplugin.fixtures.withMockedService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test -open class LogMessageTest { +@IntegrationTest +class LogMessageTest { private val gson: Gson = Gson() @Test - fun `should serialize a log message to json`() { - val message = LogMessage(mockProject()).message("My Message").build() + fun `should serialize a log message to json`(application: Application) { + application.withMockedService(mockRuntimeInformationService()) + + val message = LogMessage().message("My Message").build() val parsedMessage = gson.fromJson>(message, Map::class.java) assertEquals("My Message", parsedMessage["message"]) } @Test - fun `should serialize a log message to json with additional fields`() { - val message = LogMessage(mockProject()) + fun `should serialize a log message to json with additional fields`(application: Application) { + application.withMockedService(mockRuntimeInformationService()) + + val message = LogMessage() .message("My Message") .put("jetbrainsId", "someId") .build() diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/TelemetryServiceTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/TelemetryServiceTest.kt index 3ec082ca..7dc84a5e 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/TelemetryServiceTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/TelemetryServiceTest.kt @@ -1,27 +1,28 @@ package com.mongodb.jbplugin.observability -import com.mongodb.jbplugin.fixtures.mockProject +import com.intellij.ide.AppLifecycleListener +import com.intellij.openapi.application.Application +import com.mongodb.jbplugin.fixtures.IntegrationTest +import com.mongodb.jbplugin.fixtures.mockLogMessage import com.mongodb.jbplugin.fixtures.mockRuntimeInformationService +import com.mongodb.jbplugin.fixtures.withMockedService import com.segment.analytics.Analytics import org.junit.jupiter.api.Test import org.mockito.kotlin.argThat import org.mockito.kotlin.mock import org.mockito.kotlin.verify +@IntegrationTest internal class TelemetryServiceTest { @Test - fun `sends an identify event when a PluginActivated event is sent`() { - val mockRuntimeInfo = mockRuntimeInformationService(userId = "654321") - val service = TelemetryService( - mockProject( - runtimeInformationService = mockRuntimeInfo - ) - ).apply { + fun `sends an identify event when a PluginActivated event is sent`(application: Application) { + application.withMockedService(mockRuntimeInformationService(userId = "654321")) + + val service = TelemetryService().apply { analytics = mock() } service.sendEvent(TelemetryEvent.PluginActivated) - verify(service.analytics).enqueue( argThat { build().let { @@ -33,13 +34,10 @@ internal class TelemetryServiceTest { } @Test - fun `sends a new connection event as a tracking event`() { - val mockRuntimeInfo = mockRuntimeInformationService(userId = "654321") - val service = TelemetryService( - mockProject( - runtimeInformationService = mockRuntimeInfo - ) - ).apply { + fun `sends a new connection event as a tracking event`(application: Application) { + application.withMockedService(mockRuntimeInformationService(userId = "654321")) + + val service = TelemetryService().apply { analytics = mock() } @@ -64,4 +62,20 @@ internal class TelemetryServiceTest { } ) } + + @Test + fun `flushes and shutdowns the segment client when the ide is closing`(application: Application) { + application.withMockedService(mockRuntimeInformationService(userId = "654321")) + application.withMockedService(mockLogMessage()) + + val service = TelemetryService().apply { + analytics = mock() + } + + val publisher = application.messageBus.syncPublisher(AppLifecycleListener.TOPIC) + publisher.appWillBeClosed(true) + + verify(service.analytics).flush() + verify(service.analytics).shutdown() + } } diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/probe/PluginActivatedProbeTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/probe/PluginActivatedProbeTest.kt index bbc4837e..40018e92 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/probe/PluginActivatedProbeTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/probe/PluginActivatedProbeTest.kt @@ -1,17 +1,25 @@ package com.mongodb.jbplugin.observability.probe -import com.mongodb.jbplugin.fixtures.mockProject +import com.intellij.openapi.application.Application +import com.mongodb.jbplugin.fixtures.IntegrationTest +import com.mongodb.jbplugin.fixtures.mockLogMessage +import com.mongodb.jbplugin.fixtures.withMockedService import com.mongodb.jbplugin.observability.TelemetryEvent import com.mongodb.jbplugin.observability.TelemetryService import org.junit.jupiter.api.Test import org.mockito.kotlin.mock import org.mockito.kotlin.verify +@IntegrationTest internal class PluginActivatedProbeTest { @Test - fun `should send a PluginActivated event`() { + fun `should send a PluginActivated event`(application: Application) { val telemetryService = mock() - val probe = PluginActivatedProbe(mockProject(telemetryService = telemetryService)) + + application.withMockedService(telemetryService) + .withMockedService(mockLogMessage()) + + val probe = PluginActivatedProbe() probe.pluginActivated() verify(telemetryService).sendEvent(TelemetryEvent.PluginActivated)