diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt index 0f322e0c68d..3b8ba1932c4 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt @@ -82,6 +82,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SEND_ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.STOP_CHAT_RESPONSE import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SendChatPromptRequest import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.StopResponseMessage +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.TELEMETRY_EVENT import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString import software.aws.toolkits.jetbrains.services.amazonq.util.command @@ -439,6 +440,9 @@ class BrowserConnector( ShowSettingsUtil.getInstance().showSettingsDialog(browser.project, CodeWhispererConfigurable::class.java) } } + TELEMETRY_EVENT -> { + handleChat(AmazonQChatServer.telemetryEvent, node) + } } } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQChatServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQChatServer.kt index e65ae09d0a5..c1be2d75130 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQChatServer.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQChatServer.kt @@ -43,6 +43,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.PROMP import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.PromptInputOptionChangeParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SEND_CHAT_COMMAND_PROMPT import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SourceLinkClickParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.TELEMETRY_EVENT import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.TabBarActionParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.TabEventParams import kotlin.reflect.KProperty @@ -201,4 +202,9 @@ object AmazonQChatServer : JsonRpcMethodProvider { CHAT_CREATE_PROMPT, CreatePromptParams::class.java ) + + val telemetryEvent = JsonRpcNotification( + TELEMETRY_EVENT, + Any::class.java + ) } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt index 3aa72464a82..b67e3d0cb76 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt @@ -46,7 +46,9 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowS import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogResult import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.TelemetryParsingUtil import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings import software.aws.toolkits.resources.message import java.io.File @@ -60,8 +62,39 @@ import java.util.concurrent.TimeUnit * Concrete implementation of [AmazonQLanguageClient] to handle messages sent from server */ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageClient { + + private fun handleTelemetryMap(telemetryMap: Map<*, *>) { + try { + val name = telemetryMap["name"] as? String ?: return + + @Suppress("UNCHECKED_CAST") + val data = telemetryMap["data"] as? Map ?: return + + TelemetryService.getInstance().record(project) { + datum(name) { + unit(TelemetryParsingUtil.parseMetricUnit(telemetryMap["unit"])) + value(telemetryMap["value"] as? Double ?: 1.0) + passive(telemetryMap["passive"] as? Boolean ?: false) + + telemetryMap["result"]?.let { result -> + metadata("result", result.toString()) + } + + data.forEach { (key, value) -> + metadata(key, value.toString()) + } + } + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to process telemetry event: $telemetryMap" } + } + } + override fun telemetryEvent(`object`: Any) { - println(`object`) + when (`object`) { + is Map<*, *> -> handleTelemetryMap(`object`) + else -> LOG.warn { "Unexpected telemetry event: $`object`" } + } } override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/FlareChatCommands.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/FlareChatCommands.kt index 6df372cdc90..8725c8a64d9 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/FlareChatCommands.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/FlareChatCommands.kt @@ -41,3 +41,4 @@ const val PROMPT_INPUT_OPTIONS_CHANGE = "aws/chat/promptInputOptionChange" const val SEND_CHAT_COMMAND_PROMPT = "aws/chat/sendChatPrompt" const val SHOW_SAVE_FILE_DIALOG_REQUEST_METHOD = "aws/showSaveFileDialog" const val STOP_CHAT_RESPONSE = "stopChatResponse" +const val TELEMETRY_EVENT = "telemetry/event" diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/TelemetryParsingUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/TelemetryParsingUtil.kt new file mode 100644 index 00000000000..321012b3cc1 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/TelemetryParsingUtil.kt @@ -0,0 +1,16 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import software.amazon.awssdk.services.toolkittelemetry.model.MetricUnit + +object TelemetryParsingUtil { + + fun parseMetricUnit(value: Any?): MetricUnit = + when (value) { + is String -> MetricUnit.fromValue(value) ?: MetricUnit.NONE + is MetricUnit -> value + else -> MetricUnit.NONE + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt index 245969411e7..b77dacb06a1 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt @@ -5,6 +5,8 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.ToNumberPolicy import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service @@ -15,12 +17,18 @@ import com.intellij.testFramework.replaceService import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.verify import migration.software.aws.toolkits.jetbrains.settings.AwsSettings import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.entry import org.eclipse.lsp4j.ConfigurationItem import org.eclipse.lsp4j.ConfigurationParams import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import software.amazon.awssdk.services.toolkittelemetry.model.MetricUnit +import software.aws.toolkits.core.telemetry.DefaultMetricEvent +import software.aws.toolkits.core.telemetry.MetricEvent import software.aws.toolkits.core.utils.test.aString import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager @@ -29,6 +37,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credential import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings.Companion.CONTEXT_INDEX_SIZE import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings.Companion.CONTEXT_INDEX_THREADS @@ -40,6 +49,264 @@ class AmazonQLanguageClientImplTest { private val project: Project = mockk(relaxed = true) private val sut = AmazonQLanguageClientImpl(project) + @Test + fun `telemetryEvent handles basic event with name and data`() { + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val builderCaptor = slot Unit>() + every { telemetryService.record(project, capture(builderCaptor)) } returns Unit + + val event = mapOf( + "name" to "test_event", + "data" to mapOf( + "key1" to "value1", + "key2" to 42 + ) + ) + + sut.telemetryEvent(event) + + val builder = DefaultMetricEvent.builder() + builderCaptor.captured.invoke(builder) + + val metricEvent = builder.build() + val datum = metricEvent.data.first() + + assertThat(datum.name).isEqualTo("test_event") + assertThat(datum.metadata).contains( + entry("key1", "value1"), + entry("key2", "42") + ) + } + + @Test + fun `telemetryEvent handles event with result field`() { + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val builderCaptor = slot Unit>() + every { telemetryService.record(project, capture(builderCaptor)) } returns Unit + + val event = mapOf( + "name" to "test_event", + "result" to "success", + "data" to mapOf( + "key1" to "value1" + ) + ) + + sut.telemetryEvent(event) + + val builder = DefaultMetricEvent.builder() + builderCaptor.captured.invoke(builder) + + val metricEvent = builder.build() + val datum = metricEvent.data.first() + + assertThat(datum.name).isEqualTo("test_event") + assertThat(datum.metadata).contains( + entry("key1", "value1"), + entry("result", "success") + ) + } + + @Test + fun `telemetryEvent uses custom unit value when provided`() { + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val builderCaptor = slot Unit>() + every { telemetryService.record(project, capture(builderCaptor)) } returns Unit + + val event = mapOf( + "name" to "test_event", + "unit" to "Bytes", + "data" to mapOf( + "key1" to "value1" + ) + ) + + sut.telemetryEvent(event) + + val builder = DefaultMetricEvent.builder() + builderCaptor.captured.invoke(builder) + + val metricEvent = builder.build() + val datum = metricEvent.data.first() + + assertThat(datum.unit).isEqualTo(MetricUnit.BYTES) + assertThat(datum.metadata).contains(entry("key1", "value1")) + } + + @Test + fun `telemetryEvent uses custom value when provided`() { + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val builderCaptor = slot Unit>() + every { telemetryService.record(project, capture(builderCaptor)) } returns Unit + + val event = mapOf( + "name" to "test_event", + "value" to 2.5, + "data" to mapOf( + "key1" to "value1" + ) + ) + + sut.telemetryEvent(event) + + val builder = DefaultMetricEvent.builder() + builderCaptor.captured.invoke(builder) + + val metricEvent = builder.build() + val datum = metricEvent.data.first() + + assertThat(datum.value).isEqualTo(2.5) + assertThat(datum.metadata).contains(entry("key1", "value1")) + } + + @Test + fun `telemetryEvent uses custom passive value when provided`() { + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val builderCaptor = slot Unit>() + every { telemetryService.record(project, capture(builderCaptor)) } returns Unit + + val event = mapOf( + "name" to "test_event", + "passive" to true, + "data" to mapOf( + "key1" to "value1" + ) + ) + + sut.telemetryEvent(event) + + val builder = DefaultMetricEvent.builder() + builderCaptor.captured.invoke(builder) + + val metricEvent = builder.build() + val datum = metricEvent.data.first() + + assertThat(datum.passive).isTrue() + assertThat(datum.metadata).contains(entry("key1", "value1")) + } + + @Test + fun `telemetryEvent ignores event without name`() { + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val event = mapOf( + "data" to mapOf( + "key1" to "value1" + ) + ) + + sut.telemetryEvent(event) + + verify(exactly = 0) { + telemetryService.record(project, any()) + } + } + + @Test + fun `telemetryEvent ignores event without data`() { + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val event = mapOf( + "name" to "test_event" + ) + + sut.telemetryEvent(event) + + verify(exactly = 0) { + telemetryService.record(project, any()) + } + } + + @Test + fun `telemetryEvent uses default values when not provided`() { + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val builderCaptor = slot Unit>() + every { telemetryService.record(project, capture(builderCaptor)) } returns Unit + + val event = mapOf( + "name" to "test_event", + "data" to mapOf( + "key1" to "value1" + ) + ) + + sut.telemetryEvent(event) + + val builder = DefaultMetricEvent.builder() + builderCaptor.captured.invoke(builder) + + val metricEvent = builder.build() + val datum = metricEvent.data.first() + + assertThat(datum.unit).isEqualTo(MetricUnit.NONE) + assertThat(datum.value).isEqualTo(1.0) + assertThat(datum.passive).isFalse() + assertThat(datum.metadata).contains(entry("key1", "value1")) + } + + @Test + fun `test GSON deserialization behavior for telemetryEvent`() { + val gson = GsonBuilder() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create() + + val jsonString = """ + { + "name": "test_event", + "value": 3.0, + "passive": true, + "unit": "Milliseconds", + "data": { + "key1": "value1" + } + } + """.trimIndent() + + val result = gson.fromJson(jsonString, Map::class.java) + + val telemetryService = mockk(relaxed = true) + mockkObject(TelemetryService) + every { TelemetryService.getInstance() } returns telemetryService + + val builderCaptor = slot Unit>() + every { telemetryService.record(project, capture(builderCaptor)) } returns Unit + + sut.telemetryEvent(result) + + val builder = DefaultMetricEvent.builder() + builderCaptor.captured.invoke(builder) + + val metricEvent = builder.build() + val datum = metricEvent.data.first() + + assertThat(datum.passive).isTrue() + assertThat(datum.unit).isEqualTo(MetricUnit.MILLISECONDS) + assertThat(datum.value).isEqualTo(3.0) + assertThat(datum.metadata).contains(entry("key1", "value1")) + } + @Test fun `getConnectionMetadata returns connection metadata with start URL for bearer token connection`() { val mockConnectionManager = mockk()