Skip to content

Commit

Permalink
chore: send telemetry when there is a failure connecting to a cluster…
Browse files Browse the repository at this point in the history
… INTELLIJ-14 (#10)
  • Loading branch information
kmruiz authored Jun 26, 2024
1 parent 9ff3396 commit 4955171
Show file tree
Hide file tree
Showing 13 changed files with 521 additions and 111 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ replay_pid*
/.gradle
/build
/.idea
/**/.idea
/**/build

# This file is generated at compile time.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ MongoDB plugin for IntelliJ IDEA.
## [Unreleased]

### Added
* [INTELLIJ-14](https://jira.mongodb.org/browse/INTELLIJ-14): Send telemetry when a connection to a MongoDB Cluster fails.
* [INTELLIJ-13](https://jira.mongodb.org/browse/INTELLIJ-13): Send telemetry when successfully connected to a MongoDB Cluster.
* [INTELLIJ-12](https://jira.mongodb.org/browse/INTELLIJ-12): Notify users about telemetry, and allow them to disable it.
* [INTELLIJ-11](https://jira.mongodb.org/browse/INTELLIJ-11): Flush pending analytics events before closing the IDE.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ package com.mongodb.jbplugin.observability
*
* @property publicName Name of the field in Segment.
*/
internal enum class TelemetryProperty(val publicName: String) {
internal enum class TelemetryProperty(
val publicName: String,
) {
IS_ATLAS("is_atlas"),
IS_LOCAL_ATLAS("is_local_atlas"),
IS_LOCALHOST("is_localhost"),
Expand All @@ -21,6 +23,8 @@ internal enum class TelemetryProperty(val publicName: String) {
NON_GENUINE_SERVER_NAME("non_genuine_server_name"),
SERVER_OS_FAMILY("server_os_family"),
VERSION("version"),
ERROR_CODE("error_code"),
ERROR_NAME("error_name"),
;
}

Expand Down Expand Up @@ -82,4 +86,47 @@ internal sealed class TelemetryEvent(
TelemetryProperty.VERSION to (version ?: ""),
),
)

/**
* Represents the event that is emitted when the there is an error
* during the connection to a MongoDB Cluster.
*
* @param isAtlas
* @param isLocalhost
* @param isEnterprise
* @param isGenuine
* @param nonGenuineServerName
* @param serverOsFamily
* @param version
* @param isLocalAtlas
* @param errorCode
* @param errorName
*/
class ConnectionError(
errorCode: String,
errorName: String,
isAtlas: Boolean?,
isLocalAtlas: Boolean?,
isLocalhost: Boolean?,
isEnterprise: Boolean?,
isGenuine: Boolean?,
nonGenuineServerName: String?,
serverOsFamily: String?,
version: String?,
) : TelemetryEvent(
name = "connection-error",
properties =
mapOf(
TelemetryProperty.IS_ATLAS to (isAtlas ?: ""),
TelemetryProperty.IS_LOCAL_ATLAS to (isLocalAtlas ?: ""),
TelemetryProperty.IS_LOCALHOST to (isLocalhost ?: ""),
TelemetryProperty.IS_ENTERPRISE to (isEnterprise ?: ""),
TelemetryProperty.IS_GENUINE to (isGenuine ?: ""),
TelemetryProperty.NON_GENUINE_SERVER_NAME to (nonGenuineServerName ?: ""),
TelemetryProperty.SERVER_OS_FAMILY to (serverOsFamily ?: ""),
TelemetryProperty.VERSION to (version ?: ""),
TelemetryProperty.ERROR_CODE to errorCode,
TelemetryProperty.ERROR_NAME to errorName,
),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.mongodb.jbplugin.observability.probe

import com.intellij.database.dataSource.DatabaseConnection
import com.intellij.database.dataSource.DatabaseConnectionManager
import com.intellij.database.dataSource.DatabaseConnectionPoint
import com.intellij.execution.rmi.RemoteObject
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.mongodb.jbplugin.accessadapter.datagrip.DataGripBasedReadModelProvider
import com.mongodb.jbplugin.accessadapter.datagrip.adapter.isMongoDbDataSource
import com.mongodb.jbplugin.accessadapter.slice.BuildInfo
import com.mongodb.jbplugin.observability.LogMessage
import com.mongodb.jbplugin.observability.TelemetryEvent
import com.mongodb.jbplugin.observability.TelemetryService
import java.sql.SQLException

private val logger: Logger = logger<NewConnectionActivatedProbe>()

/** This probe is emitted when a connection error happens in DataGrip. It connects
* directly into DataGrip extension points, so it shouldn't be instantiated directly.
*/
class ConnectionFailureProbe : DatabaseConnectionManager.Listener {
override fun connectionChanged(
connection: DatabaseConnection,
added: Boolean,
) {
}

override fun connectionFailed(
project: Project,
connectionPoint: DatabaseConnectionPoint,
th: Throwable,
) {
if (!connectionPoint.dataSource.isMongoDbDataSource()) {
return
}

val exception = if (th is SQLException) th.cause else th

val application = ApplicationManager.getApplication()
val logMessage = application.getService(LogMessage::class.java)
val telemetryService = application.getService(TelemetryService::class.java)

val readModelProvider = project.getService(DataGripBasedReadModelProvider::class.java)
val dataSource = connectionPoint.dataSource
val serverInfo = readModelProvider.slice(dataSource, BuildInfo.Slice)
val telemetryEvent =
if (exception is RemoteObject.ForeignException) {
connectionFailureEvent(extractMongoDbExceptionCode(exception), exception.originalClassName!!,
serverInfo)
} else {
connectionFailureEvent("<unk>", th.javaClass.simpleName, serverInfo)
}

telemetryService.sendEvent(telemetryEvent)
logger.warn(
logMessage
.message("Failure connecting to cluster")
.put("isMongoDBException", exception is RemoteObject.ForeignException)
.put("exceptionMessage", th.message ?: "<no-message>")
.put("stackTrace", th.stackTrace.joinToString())
.mergeTelemetryEventProperties(telemetryEvent)
.build(),
)
}

private fun extractMongoDbExceptionCode(exception: RemoteObject.ForeignException): String {
val originalCause = exception.cause?.message
val errorCode =
originalCause?.let {
Regex("""\d+""").find(originalCause)?.value ?: "<unk>"
} ?: "-1"
return errorCode
}

private fun connectionFailureEvent(
errorCode: String,
errorName: String,
serverInfo: BuildInfo,
) = TelemetryEvent.ConnectionError(
errorCode = errorCode,
errorName = errorName,
isAtlas = serverInfo.isAtlas,
isLocalhost = serverInfo.isLocalhost,
isEnterprise = serverInfo.isEnterprise,
isGenuine = serverInfo.isGenuineMongoDb,
nonGenuineServerName = serverInfo.nonGenuineVariant,
serverOsFamily = serverInfo.buildEnvironment["target_os"],
isLocalAtlas = serverInfo.isLocalAtlas,
version = serverInfo.version,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.logger
import com.mongodb.jbplugin.accessadapter.datagrip.DataGripBasedReadModelProvider
import com.mongodb.jbplugin.accessadapter.datagrip.adapter.isMongoDbDataSource
import com.mongodb.jbplugin.accessadapter.slice.BuildInfo
import com.mongodb.jbplugin.observability.LogMessage
import com.mongodb.jbplugin.observability.TelemetryEvent
Expand Down Expand Up @@ -41,6 +42,11 @@ class NewConnectionActivatedProbe : DatabaseSessionStateListener {

val readModelProvider = session.project.getService(DataGripBasedReadModelProvider::class.java)
val dataSource = session.connectionPoint.dataSource

if (!dataSource.isMongoDbDataSource()) {
return
}

val serverInfo = readModelProvider.slice(dataSource, BuildInfo.Slice)

val newConnectionEvent =
Expand All @@ -58,7 +64,8 @@ class NewConnectionActivatedProbe : DatabaseSessionStateListener {
telemetryService.sendEvent(newConnectionEvent)

logger.info(
logMessage.message("New connection activated")
logMessage
.message("New connection activated")
.mergeTelemetryEventProperties(newConnectionEvent)
.build(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@
topic="com.intellij.database.console.session.DatabaseSessionStateListener"
activeInHeadlessMode="true"
activeInTestMode="true"/>
<listener class="com.mongodb.jbplugin.observability.probe.ConnectionFailureProbe"
topic="com.intellij.database.dataSource.DatabaseConnectionManager$Listener"
activeInHeadlessMode="true"
activeInTestMode="true"/>
</projectListeners>
</idea-plugin>
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import com.mongodb.jbplugin.accessadapter.datagrip.DataGripBasedReadModelProvide
import org.bson.Document
import org.bson.conversions.Bson
import org.junit.jupiter.api.extension.*
import org.mockito.Mockito.`when`
import org.mockito.kotlin.mock
import org.testcontainers.containers.DockerComposeContainer
import org.testcontainers.containers.MongoDBContainer
import org.testcontainers.lifecycle.Startable
Expand Down Expand Up @@ -137,6 +139,7 @@ internal class DirectMongoDbDriver(
val uri: String,
val client: MongoClient,
) : MongoDbDriver {
override val connected = true
val gson = Gson()

override suspend fun connectionString(): ConnectionString = ConnectionString(uri)
Expand Down Expand Up @@ -218,3 +221,24 @@ fun Project.withMockedMongoDbConnection(url: MongoDbServerUrl): Project {
}
return withMockedService(readModelProvider)
}

/**
* Allows to mock a MongoDB connection at the project level that is not connected.
*
* @param url
* @return
*/
suspend fun Project.withMockedUnconnectedMongoDbConnection(url: MongoDbServerUrl): Project {
val driver = mock<MongoDbDriver>()
`when`(driver.connected).thenReturn(false)
`when`(driver.connectionString()).thenReturn(ConnectionString(url.value))

val readModelProvider =
DataGripBasedReadModelProvider(
this,
).apply {
driverFactory = { _, _ -> driver }
}

return withMockedService(readModelProvider)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.mongodb.jbplugin.observability.probe

import com.intellij.database.dataSource.DatabaseConnectionPoint
import com.intellij.database.dataSource.LocalDataSource
import com.intellij.openapi.application.Application
import com.intellij.openapi.project.Project
import com.intellij.psi.util.CachedValuesManager
import com.intellij.util.CachedValuesManagerImpl
import com.mongodb.jbplugin.fixtures.*
import com.mongodb.jbplugin.fixtures.mockLogMessage
import com.mongodb.jbplugin.observability.TelemetryProperty
import com.mongodb.jbplugin.observability.TelemetryService
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.mockito.kotlin.argThat
import org.mockito.kotlin.verify

import java.rmi.server.RemoteObject
import java.sql.SQLException

import kotlinx.coroutines.runBlocking

@IntegrationTest
class ConnectionFailureProbeTest {
@Test
fun `should infer the relevant info from the connection string`(
application: Application,
project: Project,
) = runBlocking {
val telemetryService = mock<TelemetryService>()
val logMessage = mockLogMessage()
val connectionPoint = mock<DatabaseConnectionPoint>()
val dataSource = mock<LocalDataSource>()

`when`(connectionPoint.dataSource).thenReturn(dataSource)
application.withMockedService(telemetryService)
application.withMockedService(logMessage)

project.withMockedUnconnectedMongoDbConnection(MongoDbServerUrl("mongodb://localhost"))
val probe = ConnectionFailureProbe()

project.withMockedService<Project, CachedValuesManager>(CachedValuesManagerImpl(project))
probe.connectionFailed(project, connectionPoint, Throwable())

verify(telemetryService).sendEvent(
argThat { event ->
event.properties[TelemetryProperty.IS_ATLAS] == false &&
event.properties[TelemetryProperty.IS_LOCAL_ATLAS] == false &&
event.properties[TelemetryProperty.IS_LOCALHOST] == true &&
event.properties[TelemetryProperty.IS_ENTERPRISE] == false &&
event.properties[TelemetryProperty.IS_GENUINE] == true &&
event.properties[TelemetryProperty.VERSION] == "<unknown>" &&
event.properties[TelemetryProperty.ERROR_CODE] == "<unk>" &&
event.properties[TelemetryProperty.ERROR_NAME] == "Throwable"
},
)
}

@Test
fun `should infer the relevant connection error from the exception message`(
application: Application,
project: Project,
) = runBlocking {
val telemetryService = mock<TelemetryService>()
val logMessage = mockLogMessage()
val connectionPoint = mock<DatabaseConnectionPoint>()
val dataSource = mock<LocalDataSource>()

`when`(connectionPoint.dataSource).thenReturn(dataSource)
application.withMockedService(telemetryService)
application.withMockedService(logMessage)

project.withMockedUnconnectedMongoDbConnection(MongoDbServerUrl("mongodb://localhost"))
val probe = ConnectionFailureProbe()

project.withMockedService<Project, CachedValuesManager>(CachedValuesManagerImpl(project))

val innerException = Exception(
"com.mongodb.MongoCommandException: Command failed with error 18 (AuthenticationFailed):"
)
val remoteWrapper =
com.intellij.execution.rmi.RemoteObject.ForeignException(
"Error in the driver",
"com.mongodb.MongoCommandException",
)
remoteWrapper.initCause(innerException)
val sqlException = SQLException(remoteWrapper)

probe.connectionFailed(project, connectionPoint, sqlException)

verify(telemetryService).sendEvent(
argThat { event ->
event.properties[TelemetryProperty.IS_ATLAS] == false &&
event.properties[TelemetryProperty.ERROR_CODE] == "18" &&
event.properties[TelemetryProperty.ERROR_NAME] == "com.mongodb.MongoCommandException"
},
)
}
}
Loading

0 comments on commit 4955171

Please sign in to comment.