diff --git a/vscode/esbuild.js b/vscode/esbuild.js index 80873aae..61db24a9 100644 --- a/vscode/esbuild.js +++ b/vscode/esbuild.js @@ -57,6 +57,9 @@ const createTelemetryConfig = () => { baseUrl: null, baseEndpoint: "/vscode/java/sendTelemetry", version: "/v1" + }, + metadata: { + consentSchemaVersion: "v1" } } @@ -73,6 +76,9 @@ const createTelemetryConfig = () => { baseUrl: process.env.TELEMETRY_API_BASE_URL, baseEndpoint: process.env.TELEMETRY_API_ENDPOINT, version: process.env.TELEMETRY_API_VERSION + }, + metadata: { + consentSchemaVersion: process.env.CONSENT_SCHEMA_VERSION } }); diff --git a/vscode/package.json b/vscode/package.json index 230b8d41..0a9a7e7e 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -245,7 +245,10 @@ "jdk.telemetry.enabled": { "type": "boolean", "description": "%jdk.configuration.telemetry.enabled.description%", - "default": false + "default": false, + "tags": [ + "telemetry" + ] } } }, diff --git a/vscode/src/telemetry/config.ts b/vscode/src/telemetry/config.ts index b087baec..f18a171f 100644 --- a/vscode/src/telemetry/config.ts +++ b/vscode/src/telemetry/config.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RetryConfig, TelemetryApi } from "./types"; +import { RetryConfig, TelemetryApi, TelemetryConfigMetadata } from "./types"; import * as path from 'path'; import * as fs from 'fs'; import { LOGGER } from "../logger"; @@ -24,6 +24,7 @@ export class TelemetryConfiguration { private static instance: TelemetryConfiguration; private retryConfig!: RetryConfig; private apiConfig!: TelemetryApi; + private metadata!: TelemetryConfigMetadata; public constructor() { this.initialize(); @@ -54,6 +55,11 @@ export class TelemetryConfiguration { baseEndpoint: config.telemetryApi.baseEndpoint, version: config.telemetryApi.version }); + + this.metadata = Object.freeze({ + consentSchemaVersion: config.metadata.consentSchemaVersion + }); + } catch (error: any) { LOGGER.error("Error occurred while setting up telemetry config"); LOGGER.error(error.message); @@ -68,4 +74,7 @@ export class TelemetryConfiguration { return this.apiConfig; } + public getTelemetryConfigMetadata(): Readonly { + return this.metadata; + } } \ No newline at end of file diff --git a/vscode/src/telemetry/constants.ts b/vscode/src/telemetry/constants.ts new file mode 100644 index 00000000..f21dbf05 --- /dev/null +++ b/vscode/src/telemetry/constants.ts @@ -0,0 +1,18 @@ +/* + Copyright (c) 2024-2025, Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +export const TELEMETRY_CONSENT_VERSION_SCHEMA_KEY = "telemetryConsentSchemaVersion"; +export const TELEMETRY_CONSENT_RESPONSE_TIME_KEY = "telemetryConsentResponseTime"; +export const TELEMETRY_SETTING_VALUE_KEY = "telemetrySettingValue"; \ No newline at end of file diff --git a/vscode/src/telemetry/events/baseEvent.ts b/vscode/src/telemetry/events/baseEvent.ts index 87a2087e..224dd2ab 100644 --- a/vscode/src/telemetry/events/baseEvent.ts +++ b/vscode/src/telemetry/events/baseEvent.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -42,7 +42,7 @@ export abstract class BaseEvent { get getPayload(): T & BaseEventPayload { return this._payload; } - + get getData(): T { return this._data; } @@ -58,8 +58,8 @@ export abstract class BaseEvent { protected addEventToCache = (): void => { const dataString = JSON.stringify(this.getData); const calculatedHashVal = getHashCode(dataString); - const isAdded = cacheService.put(this.NAME, calculatedHashVal); - - LOGGER.debug(`${this.NAME} added in cache ${isAdded ? "Successfully" : "Unsucessfully"}`); + cacheService.put(this.NAME, calculatedHashVal).then((isAdded: boolean) => { + LOGGER.debug(`${this.NAME} added in cache ${isAdded ? "Successfully" : "Unsucessfully"}`); + }); } } \ No newline at end of file diff --git a/vscode/src/telemetry/events/close.ts b/vscode/src/telemetry/events/close.ts index 3e9d394f..0f5abc30 100644 --- a/vscode/src/telemetry/events/close.ts +++ b/vscode/src/telemetry/events/close.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/telemetry/events/jdkDownload.ts b/vscode/src/telemetry/events/jdkDownload.ts index f227b127..b6eb1585 100644 --- a/vscode/src/telemetry/events/jdkDownload.ts +++ b/vscode/src/telemetry/events/jdkDownload.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/telemetry/events/jdkFeature.ts b/vscode/src/telemetry/events/jdkFeature.ts index 24c8e976..9d892261 100644 --- a/vscode/src/telemetry/events/jdkFeature.ts +++ b/vscode/src/telemetry/events/jdkFeature.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/telemetry/events/start.ts b/vscode/src/telemetry/events/start.ts index 4ae74ff5..9334064a 100644 --- a/vscode/src/telemetry/events/start.ts +++ b/vscode/src/telemetry/events/start.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/telemetry/events/workspaceChange.ts b/vscode/src/telemetry/events/workspaceChange.ts index 7d577606..23f3b431 100644 --- a/vscode/src/telemetry/events/workspaceChange.ts +++ b/vscode/src/telemetry/events/workspaceChange.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/telemetry/impl/AnonymousIdManager.ts b/vscode/src/telemetry/impl/AnonymousIdManager.ts index d1186d64..092475cf 100644 --- a/vscode/src/telemetry/impl/AnonymousIdManager.ts +++ b/vscode/src/telemetry/impl/AnonymousIdManager.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/telemetry/impl/cacheServiceImpl.ts b/vscode/src/telemetry/impl/cacheServiceImpl.ts index e7aaf6a0..83746870 100644 --- a/vscode/src/telemetry/impl/cacheServiceImpl.ts +++ b/vscode/src/telemetry/impl/cacheServiceImpl.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -28,10 +28,11 @@ class CacheServiceImpl implements CacheService { } } - public put = (key: string, value: string): boolean => { + public put = async (key: string, value: string): Promise => { try { const vscGlobalState = globalState.getExtensionContextInfo().getVscGlobalState(); - vscGlobalState.update(key, value); + await vscGlobalState.update(key, value); + LOGGER.debug(`Updating key: ${key} to ${value}`); return true; } catch (err) { LOGGER.error(`Error while storing ${key} in cache: ${(err as Error).message}`); diff --git a/vscode/src/telemetry/impl/enviromentDetails.ts b/vscode/src/telemetry/impl/enviromentDetails.ts index 2b6d4277..c14d2677 100644 --- a/vscode/src/telemetry/impl/enviromentDetails.ts +++ b/vscode/src/telemetry/impl/enviromentDetails.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/telemetry/impl/postTelemetry.ts b/vscode/src/telemetry/impl/postTelemetry.ts index 1385a917..2fbe8b54 100644 --- a/vscode/src/telemetry/impl/postTelemetry.ts +++ b/vscode/src/telemetry/impl/postTelemetry.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/telemetry/impl/telemetryEventQueue.ts b/vscode/src/telemetry/impl/telemetryEventQueue.ts index af5fd7d6..5a6e44f5 100644 --- a/vscode/src/telemetry/impl/telemetryEventQueue.ts +++ b/vscode/src/telemetry/impl/telemetryEventQueue.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13,9 +13,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { LOGGER } from "../../logger"; import { BaseEvent } from "../events/baseEvent"; -export class TelemetryEventQueue { +export class TelemetryEventQueue { private events: BaseEvent[] = []; public enqueue = (e: BaseEvent): void => { @@ -35,4 +36,26 @@ export class TelemetryEventQueue { this.events = []; return queue; } + + public adjustQueueSize = (maxNumOfEventsToRetain: number) => { + const excess = this.size() - maxNumOfEventsToRetain; + + if (excess > 0) { + LOGGER.debug('Decreasing size of the queue as max capacity reached'); + + const seen = new Set(); + const deduplicated = []; + + for (let i = 0; i < excess; i++) { + const event = this.events[i]; + if (!seen.has(event.NAME)) { + deduplicated.push(event); + seen.add(event.NAME); + } + } + + this.events = [...deduplicated, ...this.events.slice(excess)]; + } + } + } \ No newline at end of file diff --git a/vscode/src/telemetry/impl/telemetryPrefs.ts b/vscode/src/telemetry/impl/telemetryPrefs.ts index c7213f06..620a6778 100644 --- a/vscode/src/telemetry/impl/telemetryPrefs.ts +++ b/vscode/src/telemetry/impl/telemetryPrefs.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13,52 +13,159 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ConfigurationChangeEvent, env, workspace } from "vscode"; +import { ConfigurationChangeEvent, env, workspace, Disposable } from "vscode"; import { getConfigurationValue, inspectConfiguration, updateConfigurationValue } from "../../configurations/handlers"; import { configKeys } from "../../configurations/configuration"; import { appendPrefixToCommand } from "../../utils"; +import { ExtensionContextInfo } from "../../extensionContextInfo"; +import { TelemetryPreference } from "../types"; +import { cacheService } from "./cacheServiceImpl"; +import { TELEMETRY_CONSENT_RESPONSE_TIME_KEY, TELEMETRY_CONSENT_VERSION_SCHEMA_KEY, TELEMETRY_SETTING_VALUE_KEY } from "../constants"; +import { TelemetryConfiguration } from "../config"; +import { LOGGER } from "../../logger"; -export class TelemetryPrefs { - public isExtTelemetryEnabled: boolean; +export class TelemetrySettings { + private isTelemetryEnabled: boolean; + private extensionPrefs: ExtensionTelemetryPreference; + private vscodePrefs: VscodeTelemetryPreference; + + constructor( + extensionContext: ExtensionContextInfo, + private onTelemetryEnableCallback: () => void, + private onTelemetryDisableCallback: () => void, + private triggerPopup: () => void) { + + this.extensionPrefs = new ExtensionTelemetryPreference(); + this.vscodePrefs = new VscodeTelemetryPreference(); + extensionContext.pushSubscription(this.extensionPrefs.onChangeTelemetrySetting(this.onChangeTelemetrySettingCallback)); + extensionContext.pushSubscription(this.vscodePrefs.onChangeTelemetrySetting(this.onChangeTelemetrySettingCallback)); + + this.isTelemetryEnabled = this.checkTelemetryStatus(); + this.syncTelemetrySettingGlobalState(); + } + + private checkTelemetryStatus = (): boolean => this.extensionPrefs.getIsTelemetryEnabled() && this.vscodePrefs.getIsTelemetryEnabled(); + + private onChangeTelemetrySettingCallback = () => { + const newTelemetryStatus = this.checkTelemetryStatus(); + if (newTelemetryStatus !== this.isTelemetryEnabled) { + this.isTelemetryEnabled = newTelemetryStatus; + this.updateGlobalStates(); + + if (newTelemetryStatus) { + this.onTelemetryEnableCallback(); + } else { + this.onTelemetryDisableCallback(); + } + } else if (this.vscodePrefs.getIsTelemetryEnabled() + && !this.extensionPrefs.isTelemetrySettingSet() + && !cacheService.get(TELEMETRY_CONSENT_RESPONSE_TIME_KEY)) { + this.triggerPopup(); + } + } + + public getIsTelemetryEnabled = (): boolean => this.isTelemetryEnabled; + + public isConsentPopupToBeTriggered = (): boolean => { + const isExtensionSettingSet = this.extensionPrefs.isTelemetrySettingSet(); + const isVscodeSettingEnabled = this.vscodePrefs.getIsTelemetryEnabled(); + + return !isExtensionSettingSet && isVscodeSettingEnabled; + } + + public updateTelemetrySetting = (value: boolean | undefined): void => { + this.extensionPrefs.updateTelemetryConfig(value); + } + + private syncTelemetrySettingGlobalState (): void { + const cachedSettingValue = cacheService.get(TELEMETRY_SETTING_VALUE_KEY); + const cachedConsentSchemaVersion = cacheService.get(TELEMETRY_CONSENT_VERSION_SCHEMA_KEY); + + if (this.isTelemetryEnabled.toString() !== cachedSettingValue) { + this.updateGlobalStates(); + } + this.checkConsentVersionSchemaGlobalState(cachedConsentSchemaVersion); + } + + private updateGlobalStates(): void { + cacheService.put(TELEMETRY_CONSENT_RESPONSE_TIME_KEY, Date.now().toString()); + cacheService.put(TELEMETRY_CONSENT_VERSION_SCHEMA_KEY, TelemetryConfiguration.getInstance().getTelemetryConfigMetadata()?.consentSchemaVersion); + cacheService.put(TELEMETRY_SETTING_VALUE_KEY, this.isTelemetryEnabled.toString()); + } + + private checkConsentVersionSchemaGlobalState(consentSchemaVersion: string | undefined): void { + if (this.extensionPrefs.isTelemetrySettingSet()) { + const currentExtConsentSchemaVersion = TelemetryConfiguration.getInstance().getTelemetryConfigMetadata()?.consentSchemaVersion; + + if (consentSchemaVersion !== currentExtConsentSchemaVersion) { + LOGGER.debug("Removing telemetry config from user settings due to consent schema version change"); + this.isTelemetryEnabled = false; + this.updateTelemetrySetting(undefined); + } + } + } +} + +class ExtensionTelemetryPreference implements TelemetryPreference { + private isTelemetryEnabled: boolean | undefined; + private readonly CONFIG = appendPrefixToCommand(configKeys.telemetryEnabled); constructor() { - this.isExtTelemetryEnabled = this.checkTelemetryStatus(); + this.isTelemetryEnabled = getConfigurationValue(configKeys.telemetryEnabled, false); } - private checkTelemetryStatus = (): boolean => { - return getConfigurationValue(configKeys.telemetryEnabled, false); + public getIsTelemetryEnabled = (): boolean => this.isTelemetryEnabled === undefined ? false : this.isTelemetryEnabled; + + public onChangeTelemetrySetting = (callback: () => void): Disposable => workspace.onDidChangeConfiguration((e: ConfigurationChangeEvent) => { + if (e.affectsConfiguration(this.CONFIG)) { + this.isTelemetryEnabled = getConfigurationValue(configKeys.telemetryEnabled, false); + callback(); + } + }); + + public updateTelemetryConfig = (value: boolean | undefined): void => { + this.isTelemetryEnabled = value; + updateConfigurationValue(configKeys.telemetryEnabled, value, true); } - private configPref = (configCommand: string): boolean => { - const config = inspectConfiguration(configCommand); + public isTelemetrySettingSet = (): boolean => { + if (this.isTelemetryEnabled === undefined) return false; + const config = inspectConfiguration(this.CONFIG); return ( - config?.workspaceFolderValue !== undefined || - config?.workspaceFolderLanguageValue !== undefined || - config?.workspaceValue !== undefined || - config?.workspaceLanguageValue !== undefined || config?.globalValue !== undefined || config?.globalLanguageValue !== undefined ); } +} - public isExtTelemetryConfigured = (): boolean => { - return this.configPref(appendPrefixToCommand(configKeys.telemetryEnabled)); - } +class VscodeTelemetryPreference implements TelemetryPreference { + private isTelemetryEnabled: boolean; - public updateTelemetryEnabledConfig = (value: boolean): void => { - this.isExtTelemetryEnabled = value; - updateConfigurationValue(configKeys.telemetryEnabled, value, true); + constructor() { + this.isTelemetryEnabled = env.isTelemetryEnabled; } - public didUserDisableVscodeTelemetry = (): boolean => { - return !env.isTelemetryEnabled; - } + public getIsTelemetryEnabled = (): boolean => this.isTelemetryEnabled; - public onDidChangeTelemetryEnabled = () => workspace.onDidChangeConfiguration( - (e: ConfigurationChangeEvent) => { - if (e.affectsConfiguration(appendPrefixToCommand(configKeys.telemetryEnabled))) { - this.isExtTelemetryEnabled = this.checkTelemetryStatus(); - } - } - ); -} \ No newline at end of file + public onChangeTelemetrySetting = (callback: () => void): Disposable => env.onDidChangeTelemetryEnabled((newSetting: boolean) => { + this.isTelemetryEnabled = newSetting; + callback(); + }); +} + +// Test cases: +// 1. User accepts consent and VSCode telemetry is set to 'all'. Output: enabled telemetry +// 2. User accepts consent and VSCode telemetry is not set to 'all'. Output: disabled telemetry +// 3. User rejects consent and VSCode telemetry is set to 'all'. Output: disabled telemetry +// 4. User rejects consent and VSCode telemetry is not set to 'all'. Output: disabled telemetry +// 5. User changes from accept to reject consent and VSCode telemetry is set to 'all'. Output: disabled telemetry +// 6. User changes from accept to reject consent and VSCode telemetry is not set to 'all'. Output: disabled telemetry +// 7. User changes from reject to accept consent and VSCode telemetry is set to 'all'. Output: enabled telemetry +// 8. User changes from reject to accept consent and VSCode telemetry is not set to 'all'. Output: disabled telemetry +// 9. User accepts consent and VSCode telemetry is changed from 'all' to 'error'. Output: disabled telemetry +// 10. User accepts consent and VSCode telemetry is changed from 'error' to 'all'. Output: enabled telemetry +// 11. When consent schema version updated, pop up should trigger again. +// 12. When consent schema version updated, pop up should trigger again, if closed without selecting any value and again reloading the screen, it should pop-up again.: Disabled telemetry in settings +// 13. When consent schema version updated, pop up should trigger again, if selected yes and again reloading the screen, it shouldn't pop-up again. Output: Enabled telemetry in settings +// 14. When consent schema version updated, pop up should trigger again, if selected no and again reloading the screen, it shouldn't pop-up again. Output: Disabled telemetry in settings +// 15. When VSCode setting is changed from reject to accept, our pop-up should come. diff --git a/vscode/src/telemetry/impl/telemetryReporterImpl.ts b/vscode/src/telemetry/impl/telemetryReporterImpl.ts index ac782971..7478acda 100644 --- a/vscode/src/telemetry/impl/telemetryReporterImpl.ts +++ b/vscode/src/telemetry/impl/telemetryReporterImpl.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,8 +27,9 @@ import { PostTelemetry, TelemetryPostResponse } from "./postTelemetry"; export class TelemetryReporterImpl implements TelemetryReporter { private activationTime: number = getCurrentUTCDateInSeconds(); - private disableReporter: boolean = false; private postTelemetry: PostTelemetry = new PostTelemetry(); + private onCloseEventState: { status: boolean, numOfRetries: number } = { status: false, numOfRetries: 0 }; + private readonly MAX_RETRY_ON_CLOSE = 5; constructor( private queue: TelemetryEventQueue, @@ -38,15 +39,17 @@ export class TelemetryReporterImpl implements TelemetryReporter { } public startEvent = (): void => { + this.setOnCloseEventState(); + this.retryManager.startTimer(); + const extensionStartEvent = ExtensionStartEvent.builder(); - if(extensionStartEvent != null){ + if (extensionStartEvent != null) { this.addEventToQueue(extensionStartEvent); LOGGER.debug(`Start event enqueued: ${extensionStartEvent.getPayload}`); - } + } } public closeEvent = (): void => { - const extensionCloseEvent = ExtensionCloseEvent.builder(this.activationTime); this.addEventToQueue(extensionCloseEvent); @@ -55,18 +58,53 @@ export class TelemetryReporterImpl implements TelemetryReporter { } public addEventToQueue = (event: BaseEvent): void => { - if (!this.disableReporter) { - this.queue.enqueue(event); - if (this.retryManager.isQueueOverflow(this.queue.size())) { - LOGGER.debug(`Send triggered to queue size overflow`); - this.sendEvents(); + this.setOnCloseEventState(event); + + this.queue.enqueue(event); + if (this.retryManager.isQueueOverflow(this.queue.size())) { + LOGGER.debug(`Send triggered to queue size overflow`); + const numOfEventsToBeRetained = this.retryManager.getNumberOfEventsToBeRetained(); + this.sendEvents(); + if (numOfEventsToBeRetained !== -1) { + this.queue.adjustQueueSize(numOfEventsToBeRetained); } } } + private setOnCloseEventState = (event?: BaseEvent) => { + if (event?.NAME === ExtensionCloseEvent.NAME) { + this.onCloseEventState = { + status: true, + numOfRetries: 0 + }; + } else { + this.onCloseEventState = { + status: false, + numOfRetries: 0 + }; + } + } + + private increaseRetryCountOrDisableRetry = () => { + if (!this.onCloseEventState.status) return; + + const queueEmpty = this.queue.size() === 0; + const retriesExceeded = this.onCloseEventState.numOfRetries >= this.MAX_RETRY_ON_CLOSE; + + if (queueEmpty || retriesExceeded) { + LOGGER.debug(`Telemetry disabled state: ${queueEmpty ? 'Queue is empty' : 'Max retries reached'}, clearing timer`); + this.retryManager.clearTimer(); + this.queue.flush(); + this.setOnCloseEventState(); + } else { + LOGGER.debug("Telemetry disabled state: Increasing retry count"); + this.onCloseEventState.numOfRetries++; + } + }; + private sendEvents = async (): Promise => { try { - if(!this.queue.size()){ + if (!this.queue.size()) { LOGGER.debug(`Queue is empty nothing to send`); return; } @@ -81,28 +119,31 @@ export class TelemetryReporterImpl implements TelemetryReporter { LOGGER.debug(`Number of events successfully sent: ${response.success.length}`); LOGGER.debug(`Number of events failed to send: ${response.failures.length}`); - this.handlePostTelemetryResponse(response); + const isAllEventsSuccess = this.handlePostTelemetryResponse(response); + + this.retryManager.startTimer(isAllEventsSuccess); - this.retryManager.startTimer(); + this.increaseRetryCountOrDisableRetry(); } catch (err: any) { - this.disableReporter = true; LOGGER.debug(`Error while sending telemetry: ${isError(err) ? err.message : err}`); } } - + private transformEvents = (events: BaseEvent[]): BaseEvent[] => { const jdkFeatureEvents = events.filter(event => event.NAME === JdkFeatureEvent.NAME); const concatedEvents = JdkFeatureEvent.concatEvents(jdkFeatureEvents); const removedJdkFeatureEvents = events.filter(event => event.NAME !== JdkFeatureEvent.NAME); - + return [...removedJdkFeatureEvents, ...concatedEvents]; } - private handlePostTelemetryResponse = (response: TelemetryPostResponse) => { + private handlePostTelemetryResponse = (response: TelemetryPostResponse): boolean => { const eventsToBeEnqueued = this.retryManager.eventsToBeEnqueuedAgain(response); this.queue.concatQueue(eventsToBeEnqueued); LOGGER.debug(`Number of failed events enqueuing again: ${eventsToBeEnqueued.length}`); + + return eventsToBeEnqueued.length === 0; } } \ No newline at end of file diff --git a/vscode/src/telemetry/impl/telemetryRetry.ts b/vscode/src/telemetry/impl/telemetryRetry.ts index 0ee6302b..8580a796 100644 --- a/vscode/src/telemetry/impl/telemetryRetry.ts +++ b/vscode/src/telemetry/impl/telemetryRetry.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -33,7 +33,7 @@ export class TelemetryRetry { this.callbackHandler = callbackHandler; } - public startTimer = (): void => { + public startTimer = (resetParameters: boolean = true): void => { if (!this.callbackHandler) { LOGGER.debug("Callback handler is not set for telemetry retry mechanism"); return; @@ -41,22 +41,28 @@ export class TelemetryRetry { if (this.timeout) { LOGGER.debug("Overriding current timeout"); } + if(resetParameters){ + this.resetTimerParameters(); + this.resetQueueCapacity(); + } this.timeout = setInterval(this.callbackHandler, this.timePeriod); } private resetTimerParameters = () => { + LOGGER.debug("Resetting time period to default"); + this.numOfAttemptsWhenTimerHits = 1; this.timePeriod = this.TELEMETRY_RETRY_CONFIG.baseTimer; this.clearTimer(); } private increaseTimePeriod = (): void => { - if (this.numOfAttemptsWhenTimerHits <= this.TELEMETRY_RETRY_CONFIG.maxRetries) { + if (this.numOfAttemptsWhenTimerHits < this.TELEMETRY_RETRY_CONFIG.maxRetries) { this.timePeriod = this.calculateDelay(); this.numOfAttemptsWhenTimerHits++; return; } - throw new Error("Number of retries exceeded"); + LOGGER.debug("Keeping timer same as max capactiy reached"); } public clearTimer = (): void => { @@ -82,10 +88,16 @@ export class TelemetryRetry { this.queueCapacity = this.TELEMETRY_RETRY_CONFIG.baseCapacity * Math.pow(this.TELEMETRY_RETRY_CONFIG.backoffFactor, this.numOfAttemptsWhenQueueIsFull); } - throw new Error("Number of retries exceeded"); + LOGGER.debug("Keeping queue capacity same as max capacity reached"); } + public getNumberOfEventsToBeRetained = (): number => + this.numOfAttemptsWhenQueueIsFull >= this.TELEMETRY_RETRY_CONFIG.maxRetries ? + this.queueCapacity/2 : -1; + private resetQueueCapacity = (): void => { + LOGGER.debug("Resetting queue capacity to default"); + this.queueCapacity = this.TELEMETRY_RETRY_CONFIG.baseCapacity; this.numOfAttemptsWhenQueueIsFull = 1; this.triggeredDueToQueueOverflow = false; @@ -108,10 +120,7 @@ export class TelemetryRetry { res.event.onSuccessPostEventCallback(); }); - if (eventResponses.failures.length === 0) { - this.resetQueueCapacity(); - this.resetTimerParameters(); - } else { + if (eventResponses.failures.length) { const eventsToBeEnqueuedAgain: BaseEvent[] = []; eventResponses.failures.forEach((eventRes) => { if (this.isEventRetryable(eventRes.statusCode)) @@ -119,6 +128,7 @@ export class TelemetryRetry { }); if (eventsToBeEnqueuedAgain.length) { + this.triggeredDueToQueueOverflow ? this.increaseQueueCapacity() : this.increaseTimePeriod(); diff --git a/vscode/src/telemetry/telemetry.ts b/vscode/src/telemetry/telemetry.ts index 4e61d656..fc55d4c8 100644 --- a/vscode/src/telemetry/telemetry.ts +++ b/vscode/src/telemetry/telemetry.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -43,7 +43,7 @@ export namespace Telemetry { } const enqueueEvent = (cbFunction: (reporter: TelemetryReporter) => void) => { - if (telemetryManager.isExtTelemetryEnabled() && getIsTelemetryFeatureAvailable()) { + if (telemetryManager.isTelemetryEnabled() && getIsTelemetryFeatureAvailable()) { const reporter = telemetryManager.getReporter(); if (reporter) { cbFunction(reporter); diff --git a/vscode/src/telemetry/telemetryManager.ts b/vscode/src/telemetry/telemetryManager.ts index eb1c40eb..076801cb 100644 --- a/vscode/src/telemetry/telemetryManager.ts +++ b/vscode/src/telemetry/telemetryManager.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ limitations under the License. */ import { window } from "vscode"; -import { TelemetryPrefs } from "./impl/telemetryPrefs"; +import { TelemetrySettings } from "./impl/telemetryPrefs"; import { TelemetryEventQueue } from "./impl/telemetryEventQueue"; import { TelemetryReporterImpl } from "./impl/telemetryReporterImpl"; import { TelemetryReporter } from "./types"; @@ -25,24 +25,27 @@ import { TelemetryRetry } from "./impl/telemetryRetry"; export class TelemetryManager { private extensionContextInfo: ExtensionContextInfo; - private settings: TelemetryPrefs = new TelemetryPrefs(); + private settings: TelemetrySettings; private reporter?: TelemetryReporter; private telemetryRetryManager: TelemetryRetry = new TelemetryRetry() constructor(extensionContextInfo: ExtensionContextInfo) { this.extensionContextInfo = extensionContextInfo; + this.settings = new TelemetrySettings(extensionContextInfo, + this.onTelemetryEnable, + this.onTelemetryDisable, + this.openTelemetryDialog); } - public isExtTelemetryEnabled = (): boolean => { - return this.settings.isExtTelemetryEnabled; + public isTelemetryEnabled = (): boolean => { + return this.settings.getIsTelemetryEnabled(); } public initializeReporter = (): void => { const queue = new TelemetryEventQueue(); - this.extensionContextInfo.pushSubscription(this.settings.onDidChangeTelemetryEnabled()); this.reporter = new TelemetryReporterImpl(queue, this.telemetryRetryManager); - this.openTelemetryDialog(); + this.isTelemetryEnabled() ? this.onTelemetryEnable() : this.openTelemetryDialog(); } public getReporter = (): TelemetryReporter | null => { @@ -50,7 +53,7 @@ export class TelemetryManager { } private openTelemetryDialog = async () => { - if (!this.settings.isExtTelemetryConfigured() && !this.settings.didUserDisableVscodeTelemetry()) { + if (this.settings?.isConsentPopupToBeTriggered()) { LOGGER.log('Telemetry not enabled yet'); const yesLabel = l10n.value("jdk.downloader.message.confirmation.yes"); @@ -62,14 +65,17 @@ export class TelemetryManager { return; } - this.settings.updateTelemetryEnabledConfig(enable === yesLabel); - if (enable === yesLabel) { - LOGGER.log("Telemetry is now enabled"); - } - } - if (this.settings.isExtTelemetryEnabled) { - this.telemetryRetryManager.startTimer(); - this.reporter?.startEvent(); + this.settings.updateTelemetrySetting(enable === yesLabel); } } + + private onTelemetryEnable = () => { + LOGGER.log("Telemetry is now enabled"); + this.reporter?.startEvent(); + } + + private onTelemetryDisable = () => { + LOGGER.log("Telemetry is now disabled"); + this.reporter?.closeEvent(); + } }; \ No newline at end of file diff --git a/vscode/src/telemetry/types.ts b/vscode/src/telemetry/types.ts index 30a663f2..bb273d28 100644 --- a/vscode/src/telemetry/types.ts +++ b/vscode/src/telemetry/types.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ limitations under the License. */ import { BaseEvent } from "./events/baseEvent"; +import { Disposable } from "vscode"; export interface TelemetryReporter { startEvent(): void; @@ -26,7 +27,7 @@ export interface TelemetryReporter { export interface CacheService { get(key: string): string | undefined; - put(key: string, value: string): boolean; + put(key: string, value: string): Promise; } export interface TelemetryEventQueue { @@ -48,4 +49,15 @@ export interface TelemetryApi { baseUrl: string | null; baseEndpoint: string; version: string; -} \ No newline at end of file +} + +export interface TelemetryConfigMetadata { + consentSchemaVersion: string; +} + +export interface TelemetryPreference { + getIsTelemetryEnabled(): boolean; + onChangeTelemetrySetting(cb: () => void): Disposable; + updateTelemetryConfig?(value: boolean): void; + isTelemetrySettingSet?: () => boolean; +} diff --git a/vscode/src/telemetry/utils.ts b/vscode/src/telemetry/utils.ts index 7b692568..fcf1a0f6 100644 --- a/vscode/src/telemetry/utils.ts +++ b/vscode/src/telemetry/utils.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024, Oracle and/or its affiliates. + Copyright (c) 2024-2025, Oracle and/or its affiliates. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/vscode/src/test/unit/telemetry/cacheService.unit.test.ts b/vscode/src/test/unit/telemetry/cacheService.unit.test.ts new file mode 100644 index 00000000..8ab10d74 --- /dev/null +++ b/vscode/src/test/unit/telemetry/cacheService.unit.test.ts @@ -0,0 +1,86 @@ +/* + Copyright (c) 2025, Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { expect } from "chai"; +import * as sinon from "sinon"; +import { globalState } from "../../../globalState"; +import { LOGGER } from "../../../logger"; +import { describe, it, beforeEach, afterEach } from "mocha"; +import { cacheService } from "../../../telemetry/impl/cacheServiceImpl"; + +describe("CacheServiceImpl", () => { + let getStub: sinon.SinonStub; + let updateStub: sinon.SinonStub; + let loggerErrorStub: sinon.SinonStub; + let loggerDebugStub: sinon.SinonStub; + + const fakeState = { + get: (key: string) => `value-${key}`, + update: async (key: string, value: string) => {}, + }; + + beforeEach(() => { + getStub = sinon.stub(fakeState, "get").callThrough(); + updateStub = sinon.stub(fakeState, "update").resolves(); + + sinon.stub(globalState, "getExtensionContextInfo").returns({ + getVscGlobalState: () => fakeState, + } as any); + + loggerErrorStub = sinon.stub(LOGGER, "error"); + loggerDebugStub = sinon.stub(LOGGER, "debug"); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("get", () => { + it("should return the cached value for a key", () => { + const key = "example"; + const value = cacheService.get(key); + expect(value).to.equal(`value-${key}`); + expect(getStub.calledOnceWith(key)).to.be.true; + }); + + it("should log and return undefined on error", () => { + getStub.throws(new Error("key not found error")); + + const result = cacheService.get("notPresent"); + expect(result).to.be.undefined; + expect(loggerErrorStub.calledOnce).to.be.true; + }); + }); + + describe("put", () => { + it("should store the value and return true", async () => { + const key = "example"; + const value = "example-value" + const result = await cacheService.put(key, value); + expect(result).to.be.true; + expect(updateStub.calledOnceWith(key, value)).to.be.true; + expect(loggerDebugStub.calledOnce).to.be.true; + }); + + it("should log and return false on error", async () => { + updateStub.rejects(new Error("Error while storing key")); + + const result = await cacheService.put("badKey", "value"); + expect(result).to.be.false; + expect(loggerErrorStub.calledOnce).to.be.true; + }); + }); +}); diff --git a/vscode/src/test/unit/telemetry/queue.unit.test.ts b/vscode/src/test/unit/telemetry/queue.unit.test.ts new file mode 100644 index 00000000..8e195ae5 --- /dev/null +++ b/vscode/src/test/unit/telemetry/queue.unit.test.ts @@ -0,0 +1,184 @@ +/* + Copyright (c) 2025, Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { TelemetryEventQueue } from '../../../telemetry/impl/telemetryEventQueue'; +import { BaseEvent } from '../../../telemetry/events/baseEvent'; +import { LOGGER } from '../../../logger'; +import { describe, it, beforeEach, afterEach } from 'mocha'; + +describe('TelemetryEventQueue', () => { + let queue: TelemetryEventQueue; + let loggerStub: sinon.SinonStub; + + class MockEvent extends BaseEvent { + public static readonly NAME = "mock"; + public static readonly ENDPOINT = "/mock"; + + + constructor(name?: string, data?: any) { + super(name || MockEvent.NAME, MockEvent.ENDPOINT, data || {}); + } + } + + beforeEach(() => { + queue = new TelemetryEventQueue(); + + loggerStub = sinon.stub(LOGGER, 'debug'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('enqueue', () => { + it('should add an event to the queue', () => { + const event = new MockEvent(); + queue.enqueue(event); + expect(queue.size()).to.equal(1); + }); + + it('should add multiple events in order', () => { + const event1 = new MockEvent('event1'); + const event2 = new MockEvent('event2'); + + queue.enqueue(event1); + queue.enqueue(event2); + + const firstEvent = queue.dequeue(); + expect(firstEvent).to.equal(event1); + expect(queue.size()).to.equal(1); + }); + }); + + describe('dequeue', () => { + it('should remove and return the first event from the queue', () => { + const event = new MockEvent(); + queue.enqueue(event); + + const dequeuedEvent = queue.dequeue(); + expect(dequeuedEvent).to.equal(event); + expect(queue.size()).to.equal(0); + }); + + it('should return undefined if queue is empty', () => { + const dequeuedEvent = queue.dequeue(); + expect(dequeuedEvent).to.be.undefined; + }); + }); + + describe('concatQueue', () => { + it('should append events to the end of the queue by default', () => { + const event1 = new MockEvent('event1'); + const event2 = new MockEvent('event2'); + const event3 = new MockEvent('event3'); + + queue.enqueue(event1); + queue.concatQueue([event2, event3]); + + expect(queue.size()).to.equal(3); + expect(queue.dequeue()).to.equal(event1); + expect(queue.dequeue()).to.equal(event2); + expect(queue.dequeue()).to.equal(event3); + }); + + it('should prepend events to the start of the queue when mergeAtStarting is true', () => { + const event1 = new MockEvent('event1'); + const event2 = new MockEvent('event2'); + const event3 = new MockEvent('event3'); + + queue.enqueue(event1); + queue.concatQueue([event2, event3], true); + + expect(queue.size()).to.equal(3); + expect(queue.dequeue()).to.equal(event2); + expect(queue.dequeue()).to.equal(event3); + expect(queue.dequeue()).to.equal(event1); + }); + }); + + describe('size', () => { + it('should return the number of events in the queue', () => { + expect(queue.size()).to.equal(0); + + queue.enqueue(new MockEvent('event1')); + expect(queue.size()).to.equal(1); + + queue.enqueue(new MockEvent('event2')); + expect(queue.size()).to.equal(2); + + queue.dequeue(); + expect(queue.size()).to.equal(1); + }); + }); + + describe('flush', () => { + it('should return all events and empty the queue', () => { + const event1 = new MockEvent('event1'); + const event2 = new MockEvent('event2'); + + queue.enqueue(event1); + queue.enqueue(event2); + + const flushedEvents = queue.flush(); + + expect(flushedEvents).to.deep.equal([event1, event2]); + expect(queue.size()).to.equal(0); + }); + }); + + describe('decreaseSizeOnMaxOverflow', () => { + it('should do nothing if queue size is below the max', () => { + const event1 = new MockEvent('event1'); + const event2 = new MockEvent('event2'); + + queue.enqueue(event1); + queue.enqueue(event2); + + queue.adjustQueueSize(5); + + expect(queue.size()).to.equal(2); + expect(loggerStub.called).to.be.false; + }); + + it('should log and deduplicate events when queue exceeds max size', () => { + const event1 = new MockEvent('event1'); + const event2 = new MockEvent('event2'); + const event3 = new MockEvent('event1'); + const event4 = new MockEvent('event3'); + const event5 = new MockEvent('event4'); + const event6 = new MockEvent('event5'); + + queue.enqueue(event1); + queue.enqueue(event2); + queue.enqueue(event3); + queue.enqueue(event4); + queue.enqueue(event5); + queue.enqueue(event6); + + queue.adjustQueueSize(3); + + expect(queue.size()).to.equal(5); + expect(loggerStub.calledOnce).to.be.true; + + const remainingEvents = queue.flush(); + const eventNames = remainingEvents.map(e => e.NAME); + expect(eventNames).to.deep.equal(['event1', 'event2', 'event3', 'event4', 'event5']); + }); + }); + +}); \ No newline at end of file