From 672f95f0d0587eeb8f6b9cccaf991c7f4f5a5739 Mon Sep 17 00:00:00 2001 From: Achal Talati Date: Thu, 10 Jul 2025 17:48:50 +0530 Subject: [PATCH 1/3] Added ability to transform telemetry events and fixed possible race condition while sending JDK downloader event addressed review comments --- vscode/src/telemetry/events/baseEvent.ts | 10 +- vscode/src/telemetry/events/jdkFeature.ts | 4 +- vscode/src/telemetry/events/start.ts | 4 +- .../src/telemetry/events/workspaceChange.ts | 4 +- vscode/src/telemetry/utils.ts | 71 +++++++ .../unit/telemetry/baseEvent.unit.test.ts | 200 ++++++++++++++++++ vscode/src/utils.ts | 2 + vscode/src/webviews/jdkDownloader/action.ts | 23 +- 8 files changed, 302 insertions(+), 16 deletions(-) create mode 100644 vscode/src/test/unit/telemetry/baseEvent.unit.test.ts diff --git a/vscode/src/telemetry/events/baseEvent.ts b/vscode/src/telemetry/events/baseEvent.ts index 224dd2a..53e6a1d 100644 --- a/vscode/src/telemetry/events/baseEvent.ts +++ b/vscode/src/telemetry/events/baseEvent.ts @@ -16,7 +16,7 @@ import { LOGGER } from "../../logger"; import { AnonymousIdManager } from "../impl/AnonymousIdManager"; import { cacheService } from "../impl/cacheServiceImpl"; -import { getHashCode } from "../utils"; +import { getHashCode, getValuesToBeTransformed, transformValue } from "../utils"; export interface BaseEventPayload { vsCodeId: string; @@ -26,6 +26,7 @@ export interface BaseEventPayload { export abstract class BaseEvent { protected _payload: T & BaseEventPayload; protected _data: T + private static readonly blockedValues = getValuesToBeTransformed(); constructor(public readonly NAME: string, public readonly ENDPOINT: string, @@ -47,6 +48,13 @@ export abstract class BaseEvent { return this._data; } + protected static transformEvent = (propertiesToTransform: string[], payload: Record): any => { + const replacedValue = "_REM_"; + + return transformValue(null, this.blockedValues, propertiesToTransform, replacedValue, payload); + }; + + public onSuccessPostEventCallback = async (): Promise => { LOGGER.debug(`${this.NAME} sent successfully`); } diff --git a/vscode/src/telemetry/events/jdkFeature.ts b/vscode/src/telemetry/events/jdkFeature.ts index 9d89226..94d403c 100644 --- a/vscode/src/telemetry/events/jdkFeature.ts +++ b/vscode/src/telemetry/events/jdkFeature.ts @@ -25,9 +25,11 @@ export interface JdkFeatureEventData { export class JdkFeatureEvent extends BaseEvent { public static readonly NAME = "jdkFeature"; public static readonly ENDPOINT = "/jdkFeature"; + private static readonly propertiesToTransform = ['javaVersion']; constructor(payload: JdkFeatureEventData) { - super(JdkFeatureEvent.NAME, JdkFeatureEvent.ENDPOINT, payload); + const updatedPayload: JdkFeatureEventData = BaseEvent.transformEvent(JdkFeatureEvent.propertiesToTransform, payload); + super(JdkFeatureEvent.NAME, JdkFeatureEvent.ENDPOINT, updatedPayload); } public static concatEvents(events:JdkFeatureEvent[]): JdkFeatureEvent[] { diff --git a/vscode/src/telemetry/events/start.ts b/vscode/src/telemetry/events/start.ts index 9334064..71f6f8b 100644 --- a/vscode/src/telemetry/events/start.ts +++ b/vscode/src/telemetry/events/start.ts @@ -53,9 +53,11 @@ export interface StartEventData { export class ExtensionStartEvent extends BaseEvent { public static readonly NAME = "startup"; public static readonly ENDPOINT = "/start"; + private static readonly propertiesToTransform = ['osVersion']; constructor(payload: StartEventData) { - super(ExtensionStartEvent.NAME, ExtensionStartEvent.ENDPOINT, payload); + const updatedPayload: StartEventData = BaseEvent.transformEvent(ExtensionStartEvent.propertiesToTransform, payload); + super(ExtensionStartEvent.NAME, ExtensionStartEvent.ENDPOINT, updatedPayload); } onSuccessPostEventCallback = async (): Promise => { diff --git a/vscode/src/telemetry/events/workspaceChange.ts b/vscode/src/telemetry/events/workspaceChange.ts index 23f3b43..d924f11 100644 --- a/vscode/src/telemetry/events/workspaceChange.ts +++ b/vscode/src/telemetry/events/workspaceChange.ts @@ -37,9 +37,11 @@ let workspaceChangeEventTimeout: NodeJS.Timeout | null = null; export class WorkspaceChangeEvent extends BaseEvent { public static readonly NAME = "workspaceChange"; public static readonly ENDPOINT = "/workspaceChange"; + private static readonly propertiesToTransform = ['javaVersion']; constructor(payload: WorkspaceChangeData) { - super(WorkspaceChangeEvent.NAME, WorkspaceChangeEvent.ENDPOINT, payload); + const updatedPayload: WorkspaceChangeData = BaseEvent.transformEvent(WorkspaceChangeEvent.propertiesToTransform, payload); + super(WorkspaceChangeEvent.NAME, WorkspaceChangeEvent.ENDPOINT, updatedPayload); } public onSuccessPostEventCallback = async (): Promise => { diff --git a/vscode/src/telemetry/utils.ts b/vscode/src/telemetry/utils.ts index fcf1a0f..5c9ab42 100644 --- a/vscode/src/telemetry/utils.ts +++ b/vscode/src/telemetry/utils.ts @@ -15,6 +15,8 @@ */ import * as crypto from 'crypto'; import { Uri, workspace } from 'vscode'; +import * as os from 'os'; +import { isObject, isString } from '../utils'; export const getCurrentUTCDateInSeconds = () => { const date = Date.now(); @@ -70,3 +72,72 @@ const getUri = (pathOrUri: Uri | string): Uri => { } return Uri.file(pathOrUri); } + +export const getValuesToBeTransformed = (): string[] => { + const MIN_BLOCKED_VAL_LENGTH = 3; + const IGNORED_VALUES = ['java', 'vscode', 'user', 'oracle']; + + const blockedValues: (string | undefined)[] = [ + process.env?.SUDO_USER, + process.env?.C9_USER, + process.env?.LOGNAME, + process.env?.USER, + process.env?.LNAME, + process.env?.USERNAME, + process.env?.HOSTNAME, + process.env?.COMPUTERNAME, + process.env?.NAME, + os.userInfo().username].map(el => el?.trim()); + + return Array.from(new Set(blockedValues.filter((el): el is string => el !== undefined && + el.length >= MIN_BLOCKED_VAL_LENGTH && + !IGNORED_VALUES.includes(el) + ))); +} + + +export const isPrimitiveTransformationNotRequired = (value: any) => ( + value === null || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'undefined' || + typeof value === 'symbol' || + typeof value === 'bigint' +); + + +export const transformValue = (key: string | null, blockedValues: string[], propertiesToTransform: string[], replacedValue: string, value: any): any => { + if (isPrimitiveTransformationNotRequired(value)) { + return value; + } + + if (isString(value)) { + if (!value.trim().length) return value; + + let updatedValue = value; + if (key == null || !propertiesToTransform.includes(key)) return value; + blockedValues.forEach(valueToBeReplaced => + updatedValue = replaceAllOccurrences(updatedValue, valueToBeReplaced, replacedValue) + ); + + return updatedValue; + } + + if (Array.isArray(value)) { + return value.map(el => transformValue(key, blockedValues, propertiesToTransform, replacedValue, el)); + } + + if (isObject(value)) { + const result: any = {}; + for (const [k, val] of Object.entries(value)) { + result[k] = transformValue(k, blockedValues, propertiesToTransform, replacedValue, val); + } + return result; + } + return value; +}; + +export const replaceAllOccurrences = (str: string, valueString: string, replaceString: string) => { + if(!valueString.trim().length) return str; + return str.split(valueString).join(replaceString); +} \ No newline at end of file diff --git a/vscode/src/test/unit/telemetry/baseEvent.unit.test.ts b/vscode/src/test/unit/telemetry/baseEvent.unit.test.ts new file mode 100644 index 0000000..78773a1 --- /dev/null +++ b/vscode/src/test/unit/telemetry/baseEvent.unit.test.ts @@ -0,0 +1,200 @@ +import { expect } from "chai"; +import { describe, it, beforeEach, afterEach } from "mocha"; +import { transformValue, replaceAllOccurrences, getValuesToBeTransformed } from "../../../telemetry/utils"; +import * as sinon from 'sinon'; +import * as os from 'os'; + +const replacedValue = "_REM_"; +const propertiesToTransform = ["message", "b", "c", "d"]; + +describe("transformValue()", () => { + it("replaces top-level string if key is in propertiesToTransform", () => { + const result = transformValue("message", ["john"], propertiesToTransform, replacedValue, "hello john"); + expect(result).to.equal(`hello ${replacedValue}`); + }); + + it("does not replace if key is not in propertiesToTransform", () => { + const result = transformValue("greeting", ["john"], propertiesToTransform, replacedValue, "hello john"); + expect(result).to.equal("hello john"); + }); + + it("replaces nested name values for matching keys only", () => { + const input = { + b: "john is here", + c: ["john", { d: "also john" }] + }; + + const result = transformValue("a", ["john"], propertiesToTransform, replacedValue, input); + + expect(result.b).to.equal(`${replacedValue} is here`); + expect(result.c[0]).to.equal(`${replacedValue}`); + expect(result.c[1].d).to.equal(`also ${replacedValue}`); + }); + + it("handles keys with deep nesting", () => { + const blocked = ["john", "doe"]; + const data = { + level1: { + message: "john was here", + test: "john was here", + b: ["hello john", "doe speaks", "java"], + nestedArray: [ + { + b: "john and doe", + other: "keep this" + }, + { + b: "john is safe", + d: ["john doe", { d: "doe and john" }] + } + ] + }, + c: [ + { + d: "test john", + random: "doe" + }, + { + a: { + b: "john" + }, + b: "john and jane", + d: null + } + ], + extra: "nothing here" + }; + + const result = transformValue(null, blocked, propertiesToTransform, replacedValue, data); + + expect(result.level1.message).to.equal(`${replacedValue} was here`); + expect(result.level1.test).to.equal("john was here"); + expect(result.level1.b).to.deep.equal([`hello ${replacedValue}`, `${replacedValue} speaks`, "java"]); + expect(result.level1.nestedArray[0].b).to.equal(`${replacedValue} and ${replacedValue}`); + expect(result.level1.nestedArray[0].other).to.equal("keep this"); + + expect(result.level1.nestedArray[1].b).to.equal(`${replacedValue} is safe`); + expect(result.level1.nestedArray[1].d[0]).to.equal(`${replacedValue} ${replacedValue}`); + expect(result.level1.nestedArray[1].d[1].d).to.equal(`${replacedValue} and ${replacedValue}`); + + expect(result.c[0].d).to.equal(`test ${replacedValue}`); + expect(result.c[0].random).to.equal("doe"); + + expect(result.c[1].a.b).to.equal(`${replacedValue}`); + expect(result.c[1].b).to.equal(`${replacedValue} and jane`); + expect(result.c[1].d).to.equal(null); + + expect(result.extra).to.equal("nothing here"); + }); + + it("does not transform primitive values", () => { + const data = { + count: 5, + flag: true, + nothing: null + }; + const result = transformValue(null, ["john"], propertiesToTransform, replacedValue, data); + + expect(result.count).to.equal(5); + expect(result.flag).to.equal(true); + expect(result.nothing).to.equal(null); + }); + + it("returns original string if no blocked values", () => { + const result = transformValue("message", [], propertiesToTransform, replacedValue, "hello john"); + expect(result).to.equal("hello john"); + }); +}); + +describe("replaceAllOccurrences()", () => { + it("replaces all non-overlapping occurrences", () => { + const result = replaceAllOccurrences("john is john", "john", "_REM_"); + expect(result).to.equal("_REM_ is _REM_"); + }); + + it("does not change string if valueString not found", () => { + const result = replaceAllOccurrences("hello world", "john", "_REM_"); + expect(result).to.equal("hello world"); + }); + + it("replaces multiple adjacent matches", () => { + const result = replaceAllOccurrences("johnjohnjohn", "john", "_REM_"); + expect(result).to.equal("_REM__REM__REM_"); + }); + + it("returns original string if valueString is empty", () => { + const result = replaceAllOccurrences("john is here", "", "_REM_"); + expect(result).to.equal("john is here"); + }); + + it("can replace special characters (treated literally)", () => { + const result = replaceAllOccurrences("price is $5.00 and $5.00", "$5.00", "_REM_"); + expect(result).to.equal("price is _REM_ and _REM_"); + }); + + it("is case-sensitive by default", () => { + const result = replaceAllOccurrences("John john JOHN", "john", "_REM_"); + expect(result).to.equal("John _REM_ JOHN"); + }); + + it("handles overlapping cases (only first per loop)", () => { + const result = replaceAllOccurrences("aaa", "aa", "_X_"); + expect(result).to.equal("_X_a"); + }); +}); + +describe("transformValue() with getValuesToBeTransformed()", () => { + let stub: sinon.SinonStub; + + beforeEach(() => { + stub = sinon.stub(os, "userInfo").returns({ username: "john" } as os.UserInfo); + }); + + afterEach(() => { + stub.restore(); + }); + + it("transforms string using values from getValuesToBeTransformed", () => { + const blocked = getValuesToBeTransformed(); + + const result = transformValue("message", blocked, propertiesToTransform, replacedValue, "hello john"); + + expect(result).to.equal(`hello ${replacedValue}`); + }); + + it("does not transform if key is not in propertiesToTransform", () => { + const blocked = getValuesToBeTransformed(); + + const result = transformValue("greeting", blocked, propertiesToTransform, replacedValue, "hello john"); + + expect(result).to.equal("hello john"); + }); + + it("filters ignored and short env values correctly", () => { + const testEnv = { + SUDO_USER: "ja", + C9_USER: "java", + LOGNAME: "john", + USER: "oracle", + LNAME: "ab", + USERNAME: "johndoe", + HOSTNAME: undefined, + COMPUTERNAME: "user", + NAME: "vscode" + }; + + const stubbed = sinon.stub(process, "env").value(testEnv as any); + stub.restore(); + + const values = getValuesToBeTransformed(); + + expect(values).to.include("john"); + expect(values).to.include("johndoe"); + expect(values).to.not.include("java"); + expect(values).to.not.include("user"); + expect(values).to.not.include("ab"); + expect(values).to.not.include("vscode"); + + stubbed.restore(); + }); +}); \ No newline at end of file diff --git a/vscode/src/utils.ts b/vscode/src/utils.ts index 9ebd334..31059f7 100644 --- a/vscode/src/utils.ts +++ b/vscode/src/utils.ts @@ -287,6 +287,8 @@ export function isError(obj: unknown): obj is Error { return obj instanceof Error; } +export const isObject = (value: any) => value !== null && typeof value === 'object' && !Array.isArray(value); + export async function initializeRunConfiguration(): Promise { if (vscode.workspace.name || vscode.workspace.workspaceFile) { const java = await vscode.workspace.findFiles('**/*.java', '**/node_modules/**', 1); diff --git a/vscode/src/webviews/jdkDownloader/action.ts b/vscode/src/webviews/jdkDownloader/action.ts index ecd9943..bf99152 100644 --- a/vscode/src/webviews/jdkDownloader/action.ts +++ b/vscode/src/webviews/jdkDownloader/action.ts @@ -203,6 +203,17 @@ export class JdkDownloaderAction { throw new Error(checksumMatchFailedLabel); } LOGGER.log(`Checksum match successful`); + const currentTime = getCurrentUTCDateInSeconds(); + const downloadTelemetryEvent: JdkDownloadEventData = { + vendor: this.jdkType, + version: this.jdkVersion!, + os: this.osType!, + arch: this.machineArch!, + timeTaken: Math.min(currentTime - this.startTimer!) + }; + + const event: JdkDownloadEvent = new JdkDownloadEvent(downloadTelemetryEvent); + Telemetry.sendTelemetry(event); } private checksumMatch = async (): Promise => { @@ -276,18 +287,6 @@ export class JdkDownloaderAction { } private installationCleanup = (tempDirPath: string, newDirPath: string) => { - const currentTime = getCurrentUTCDateInSeconds(); - const downloadTelemetryEvent: JdkDownloadEventData = { - vendor: this.jdkType, - version: this.jdkVersion!, - os: this.osType!, - arch: this.machineArch!, - timeTaken: Math.min(currentTime - this.startTimer!) - }; - - const event: JdkDownloadEvent = new JdkDownloadEvent(downloadTelemetryEvent); - Telemetry.sendTelemetry(event); - fs.unlink(this.downloadFilePath!, async (err) => { if (err) { LOGGER.error(`Error while installation cleanup: ${err.message}`); From 6afb9d8f435e72e021f6278781e384c5fff0ffdb Mon Sep 17 00:00:00 2001 From: Achal Talati Date: Tue, 22 Jul 2025 23:17:01 +0530 Subject: [PATCH 2/3] updated cacheService interface and some other minor changes --- vscode/src/telemetry/events/baseEvent.ts | 4 +- vscode/src/telemetry/events/start.ts | 4 +- .../src/telemetry/events/workspaceChange.ts | 26 ++- .../telemetry/impl/cache/BaseCacheValue.ts | 29 +++ vscode/src/telemetry/impl/cache/index.ts | 23 +++ .../impl/cache/projectCacheService.ts | 83 ++++++++ .../telemetry/impl/cache/projectCacheValue.ts | 34 ++++ .../simpleCacheService.ts} | 22 +-- vscode/src/telemetry/impl/cache/utils.ts | 49 +++++ vscode/src/telemetry/impl/telemetryPrefs.ts | 14 +- vscode/src/telemetry/types.ts | 8 +- .../unit/telemetry/baseEvent.unit.test.ts | 16 ++ .../unit/telemetry/cacheService.unit.test.ts | 134 ++++++------- .../projectCacheService.unit.test.ts | 136 +++++++++++++ .../telemetry/simpleCacheService.unit.test.ts | 79 ++++++++ .../workspaceChange.event.unit.test.ts | 178 ++++++++++++++++++ 16 files changed, 749 insertions(+), 90 deletions(-) create mode 100644 vscode/src/telemetry/impl/cache/BaseCacheValue.ts create mode 100644 vscode/src/telemetry/impl/cache/index.ts create mode 100644 vscode/src/telemetry/impl/cache/projectCacheService.ts create mode 100644 vscode/src/telemetry/impl/cache/projectCacheValue.ts rename vscode/src/telemetry/impl/{cacheServiceImpl.ts => cache/simpleCacheService.ts} (72%) create mode 100644 vscode/src/telemetry/impl/cache/utils.ts create mode 100644 vscode/src/test/unit/telemetry/projectCacheService.unit.test.ts create mode 100644 vscode/src/test/unit/telemetry/simpleCacheService.unit.test.ts create mode 100644 vscode/src/test/unit/telemetry/workspaceChange.event.unit.test.ts diff --git a/vscode/src/telemetry/events/baseEvent.ts b/vscode/src/telemetry/events/baseEvent.ts index 53e6a1d..18dcc9c 100644 --- a/vscode/src/telemetry/events/baseEvent.ts +++ b/vscode/src/telemetry/events/baseEvent.ts @@ -15,7 +15,7 @@ */ import { LOGGER } from "../../logger"; import { AnonymousIdManager } from "../impl/AnonymousIdManager"; -import { cacheService } from "../impl/cacheServiceImpl"; +import { cacheServiceIndex } from "../impl/cache"; import { getHashCode, getValuesToBeTransformed, transformValue } from "../utils"; export interface BaseEventPayload { @@ -66,7 +66,7 @@ export abstract class BaseEvent { protected addEventToCache = (): void => { const dataString = JSON.stringify(this.getData); const calculatedHashVal = getHashCode(dataString); - cacheService.put(this.NAME, calculatedHashVal).then((isAdded: boolean) => { + cacheServiceIndex.simpleCache.put(this.NAME, calculatedHashVal).then((isAdded: boolean) => { LOGGER.debug(`${this.NAME} added in cache ${isAdded ? "Successfully" : "Unsucessfully"}`); }); } diff --git a/vscode/src/telemetry/events/start.ts b/vscode/src/telemetry/events/start.ts index 71f6f8b..87610a8 100644 --- a/vscode/src/telemetry/events/start.ts +++ b/vscode/src/telemetry/events/start.ts @@ -15,7 +15,7 @@ */ import { globalState } from "../../globalState"; import { LOGGER } from "../../logger"; -import { cacheService } from "../impl/cacheServiceImpl"; +import { cacheServiceIndex } from "../impl/cache"; import { getEnvironmentInfo } from "../impl/enviromentDetails"; import { getHashCode } from "../utils"; import { BaseEvent } from "./baseEvent"; @@ -67,7 +67,7 @@ export class ExtensionStartEvent extends BaseEvent { public static builder = (): ExtensionStartEvent | null => { const startEventData = getEnvironmentInfo(globalState.getExtensionContextInfo()); - const cachedValue: string | undefined = cacheService.get(this.NAME); + const cachedValue: string | undefined = cacheServiceIndex.simpleCache.get(this.NAME); const envString = JSON.stringify(startEventData); const newValue = getHashCode(envString); diff --git a/vscode/src/telemetry/events/workspaceChange.ts b/vscode/src/telemetry/events/workspaceChange.ts index d924f11..6e459d9 100644 --- a/vscode/src/telemetry/events/workspaceChange.ts +++ b/vscode/src/telemetry/events/workspaceChange.ts @@ -13,9 +13,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { randomUUID } from "crypto"; import { LOGGER } from "../../logger"; import { Telemetry } from "../telemetry"; import { BaseEvent } from "./baseEvent"; +import { cacheServiceIndex } from "../impl/cache"; +import { ProjectCacheValue } from "../impl/cache/projectCacheValue"; interface ProjectInfo { id: string; @@ -40,9 +43,30 @@ export class WorkspaceChangeEvent extends BaseEvent { private static readonly propertiesToTransform = ['javaVersion']; constructor(payload: WorkspaceChangeData) { - const updatedPayload: WorkspaceChangeData = BaseEvent.transformEvent(WorkspaceChangeEvent.propertiesToTransform, payload); + const updatedPayload: WorkspaceChangeData = WorkspaceChangeEvent.transformPayload(payload); super(WorkspaceChangeEvent.NAME, WorkspaceChangeEvent.ENDPOINT, updatedPayload); } + + private static transformPayload = (payload: WorkspaceChangeData) => { + const transformedPayload: WorkspaceChangeData = BaseEvent.transformEvent(WorkspaceChangeEvent.propertiesToTransform, payload); + return WorkspaceChangeEvent.updateProjectId(transformedPayload) + } + + private static updateProjectId = (payload: WorkspaceChangeData) => { + const updatedProjectInfo = payload.projectInfo.map(project => { + const existingId = cacheServiceIndex.projectCache.get(project.id); + const uniqueId = existingId ?? randomUUID(); + + if (!existingId) { + // Cannot be awaited because the caller is constructor and it cannot be a async call + cacheServiceIndex.projectCache.put(project.id, new ProjectCacheValue(uniqueId)); + } + + return { ...project, id: uniqueId }; + }); + + return { ...payload, projectInfo: updatedProjectInfo }; + } public onSuccessPostEventCallback = async (): Promise => { LOGGER.debug(`WorkspaceChange event sent successfully`); diff --git a/vscode/src/telemetry/impl/cache/BaseCacheValue.ts b/vscode/src/telemetry/impl/cache/BaseCacheValue.ts new file mode 100644 index 0000000..a198357 --- /dev/null +++ b/vscode/src/telemetry/impl/cache/BaseCacheValue.ts @@ -0,0 +1,29 @@ +/* + 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. +*/ + +export type CacheValueObj = { + type: string; + payload: T; + lastUsed: number; +} + +export abstract class BaseCacheValue { + public readonly lastUsed: number; + + constructor(public readonly type: string, public readonly payload: T, lastUsed?: number) { + this.lastUsed = lastUsed ?? Date.now(); + } +} diff --git a/vscode/src/telemetry/impl/cache/index.ts b/vscode/src/telemetry/impl/cache/index.ts new file mode 100644 index 0000000..29bccad --- /dev/null +++ b/vscode/src/telemetry/impl/cache/index.ts @@ -0,0 +1,23 @@ +/* + 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 { ProjectCacheService } from "./projectCacheService"; +import { SimpleCacheService } from "./simpleCacheService"; + +export namespace cacheServiceIndex { + export const simpleCache = new SimpleCacheService(); + export const projectCache = new ProjectCacheService(); +} \ No newline at end of file diff --git a/vscode/src/telemetry/impl/cache/projectCacheService.ts b/vscode/src/telemetry/impl/cache/projectCacheService.ts new file mode 100644 index 0000000..87a9bad --- /dev/null +++ b/vscode/src/telemetry/impl/cache/projectCacheService.ts @@ -0,0 +1,83 @@ +/* + 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. +*/ +import { CacheService } from "../../types"; +import { LOGGER } from "../../../logger"; +import { globalState } from "../../../globalState"; +import { isError } from "../../../utils"; +import { removeEntriesOnOverflow } from "./utils"; +import { ProjectCacheValue } from "./projectCacheValue"; + +export class ProjectCacheService implements CacheService { + readonly MAX_KEYS_SIZE: number = 5000; + private removingKeys: boolean = false; + + public get = (key: string) => { + try { + const updatedKey = this.getUpdatedKey(key); + const vscGlobalState = globalState.getExtensionContextInfo().getVscGlobalState(); + + const value = vscGlobalState.get(updatedKey); + if (value) { + this.put(updatedKey, ProjectCacheValue.fromObject({ ...value, lastUsed: Date.now() })); + } + + return value?.payload; + } catch (err) { + LOGGER.error(`Error while retrieving ${key} from cache: ${(err as Error).message}`); + return undefined; + } + } + + public put = async (key: string, value: ProjectCacheValue) => { + try { + const updatedKey = this.getUpdatedKey(key); + const vscGlobalState = globalState.getExtensionContextInfo().getVscGlobalState(); + + await vscGlobalState.update(updatedKey, value); + if (vscGlobalState.keys().length > this.MAX_KEYS_SIZE) { + this.removeOnOverflow(); + } + LOGGER.debug(`Updating key: ${key} to ${value}`); + + return true; + } catch (err) { + LOGGER.error(`Error while storing ${key} in cache: ${(err as Error).message}`); + return false; + } + } + + public removeOnOverflow = async () => { + try { + if (this.removingKeys) { + LOGGER.log("Ignoring removing keys request, since it is already in progress"); + return; + } + this.removingKeys = true; + + const vscGlobalState = globalState.getExtensionContextInfo().getVscGlobalState(); + const comparator = (a: ProjectCacheValue, b: ProjectCacheValue) => (a.lastUsed - b.lastUsed); + + await removeEntriesOnOverflow(vscGlobalState, ProjectCacheValue.type, comparator); + } catch (error) { + LOGGER.error("Some error occurred while removing keys " + (isError(error) ? error.message : error)); + } finally { + this.removingKeys = false; + } + } + + // for unit tests needs to be public + public getUpdatedKey = (key: string) => `${ProjectCacheValue.type}.${key}`; +} \ No newline at end of file diff --git a/vscode/src/telemetry/impl/cache/projectCacheValue.ts b/vscode/src/telemetry/impl/cache/projectCacheValue.ts new file mode 100644 index 0000000..ee15a3b --- /dev/null +++ b/vscode/src/telemetry/impl/cache/projectCacheValue.ts @@ -0,0 +1,34 @@ +/* + 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 { BaseCacheValue, CacheValueObj } from "./BaseCacheValue"; + +export class ProjectCacheValue extends BaseCacheValue { + public static readonly type = "prjId"; + + constructor(payload: string, lastUsed?: number){ + super(ProjectCacheValue.type, payload, lastUsed); + } + + public static fromObject(obj: CacheValueObj): ProjectCacheValue { + if (obj.type !== ProjectCacheValue.type) { + throw new Error(`Invalid object type for ProjectCacheEntry: received ${obj.type}`); + } + const entry = new ProjectCacheValue(obj.payload, obj.lastUsed); + + return entry; + } +} \ No newline at end of file diff --git a/vscode/src/telemetry/impl/cacheServiceImpl.ts b/vscode/src/telemetry/impl/cache/simpleCacheService.ts similarity index 72% rename from vscode/src/telemetry/impl/cacheServiceImpl.ts rename to vscode/src/telemetry/impl/cache/simpleCacheService.ts index 8374687..7e5fb67 100644 --- a/vscode/src/telemetry/impl/cacheServiceImpl.ts +++ b/vscode/src/telemetry/impl/cache/simpleCacheService.ts @@ -1,5 +1,5 @@ /* - Copyright (c) 2024-2025, Oracle and/or its affiliates. + 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. @@ -13,26 +13,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { CacheService } from "../types"; -import { LOGGER } from "../../logger"; -import { globalState } from "../../globalState"; -class CacheServiceImpl implements CacheService { - public get = (key: string): string | undefined => { +import { globalState } from "../../../globalState"; +import { LOGGER } from "../../../logger"; +import { CacheService } from "../../types"; + +export class SimpleCacheService implements CacheService { + get(key: string) { try { const vscGlobalState = globalState.getExtensionContextInfo().getVscGlobalState(); - return vscGlobalState.get(key); + return vscGlobalState.get(key); } catch (err) { LOGGER.error(`Error while retrieving ${key} from cache: ${(err as Error).message}`); return undefined; } } - - public put = async (key: string, value: string): Promise => { + + async put(key: string, value: string) { try { const vscGlobalState = globalState.getExtensionContextInfo().getVscGlobalState(); 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}`); @@ -40,5 +42,3 @@ class CacheServiceImpl implements CacheService { } } } - -export const cacheService = new CacheServiceImpl(); \ No newline at end of file diff --git a/vscode/src/telemetry/impl/cache/utils.ts b/vscode/src/telemetry/impl/cache/utils.ts new file mode 100644 index 0000000..433044c --- /dev/null +++ b/vscode/src/telemetry/impl/cache/utils.ts @@ -0,0 +1,49 @@ +/* + 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 { Memento } from "vscode"; +import { LOGGER } from "../../../logger"; +import { BaseCacheValue } from "./BaseCacheValue"; + +export const removeEntriesOnOverflow = async (globalCache: Memento & { + setKeysForSync(keys: readonly string[]): void; +}, + type: string, + comparator: (a: BaseCacheValue, b: BaseCacheValue) => number +) => { + const allKeys = globalCache.keys(); + + const entries: (BaseCacheValue & { key: string })[] = []; + + for (const key of allKeys) { + const value = globalCache.get>(key); + if (value?.type === type) { + entries.push({ key, ...value }); + } + } + + const half = Math.floor(entries.length / 2); + entries.sort(comparator); + const toEvict = entries.slice(0, half); + LOGGER.debug(toEvict.toString()); + const toEvictPromises: Promise[] = []; + for (const entry of toEvict) { + toEvictPromises.push(Promise.resolve(globalCache.update(entry.key, undefined))); + } + await Promise.allSettled(toEvictPromises); + + LOGGER.debug(`Evicted ${toEvict.length} least-used cache keys due to overflow.`); +} \ No newline at end of file diff --git a/vscode/src/telemetry/impl/telemetryPrefs.ts b/vscode/src/telemetry/impl/telemetryPrefs.ts index 620a677..452abda 100644 --- a/vscode/src/telemetry/impl/telemetryPrefs.ts +++ b/vscode/src/telemetry/impl/telemetryPrefs.ts @@ -19,10 +19,10 @@ 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"; +import { cacheServiceIndex } from "./cache"; export class TelemetrySettings { private isTelemetryEnabled: boolean; @@ -59,7 +59,7 @@ export class TelemetrySettings { } } else if (this.vscodePrefs.getIsTelemetryEnabled() && !this.extensionPrefs.isTelemetrySettingSet() - && !cacheService.get(TELEMETRY_CONSENT_RESPONSE_TIME_KEY)) { + && !cacheServiceIndex.simpleCache.get(TELEMETRY_CONSENT_RESPONSE_TIME_KEY)) { this.triggerPopup(); } } @@ -78,8 +78,8 @@ export class TelemetrySettings { } private syncTelemetrySettingGlobalState (): void { - const cachedSettingValue = cacheService.get(TELEMETRY_SETTING_VALUE_KEY); - const cachedConsentSchemaVersion = cacheService.get(TELEMETRY_CONSENT_VERSION_SCHEMA_KEY); + const cachedSettingValue = cacheServiceIndex.simpleCache.get(TELEMETRY_SETTING_VALUE_KEY); + const cachedConsentSchemaVersion = cacheServiceIndex.simpleCache.get(TELEMETRY_CONSENT_VERSION_SCHEMA_KEY); if (this.isTelemetryEnabled.toString() !== cachedSettingValue) { this.updateGlobalStates(); @@ -88,9 +88,9 @@ export class TelemetrySettings { } 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()); + cacheServiceIndex.simpleCache.put(TELEMETRY_CONSENT_RESPONSE_TIME_KEY, Date.now().toString()); + cacheServiceIndex.simpleCache.put(TELEMETRY_CONSENT_VERSION_SCHEMA_KEY, TelemetryConfiguration.getInstance().getTelemetryConfigMetadata()?.consentSchemaVersion); + cacheServiceIndex.simpleCache.put(TELEMETRY_SETTING_VALUE_KEY, this.isTelemetryEnabled.toString()); } private checkConsentVersionSchemaGlobalState(consentSchemaVersion: string | undefined): void { diff --git a/vscode/src/telemetry/types.ts b/vscode/src/telemetry/types.ts index bb273d2..10f7710 100644 --- a/vscode/src/telemetry/types.ts +++ b/vscode/src/telemetry/types.ts @@ -24,10 +24,12 @@ export interface TelemetryReporter { closeEvent(): void; } -export interface CacheService { - get(key: string): string | undefined; +export interface CacheService { + get(key: string): U | undefined; - put(key: string, value: string): Promise; + put(key: string, value: T): Promise; + + removeOnOverflow?(): void; } export interface TelemetryEventQueue { diff --git a/vscode/src/test/unit/telemetry/baseEvent.unit.test.ts b/vscode/src/test/unit/telemetry/baseEvent.unit.test.ts index 78773a1..092dfdd 100644 --- a/vscode/src/test/unit/telemetry/baseEvent.unit.test.ts +++ b/vscode/src/test/unit/telemetry/baseEvent.unit.test.ts @@ -1,3 +1,19 @@ +/* + 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 { describe, it, beforeEach, afterEach } from "mocha"; import { transformValue, replaceAllOccurrences, getValuesToBeTransformed } from "../../../telemetry/utils"; diff --git a/vscode/src/test/unit/telemetry/cacheService.unit.test.ts b/vscode/src/test/unit/telemetry/cacheService.unit.test.ts index 8ab10d7..85935eb 100644 --- a/vscode/src/test/unit/telemetry/cacheService.unit.test.ts +++ b/vscode/src/test/unit/telemetry/cacheService.unit.test.ts @@ -16,71 +16,77 @@ 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"; +import { BaseCacheValue } from "../../../telemetry/impl/cache/BaseCacheValue"; +import { removeEntriesOnOverflow } from "../../../telemetry/impl/cache/utils"; + +class TestCacheValue extends BaseCacheValue {} + +describe("removeEntriesOnOverflow", () => { + let globalCache: any; + let setKeysForSyncStub: sinon.SinonStub; + + beforeEach(() => { + let store: Record> = { + "a": new TestCacheValue("test", "foo", 10), + "b": new TestCacheValue("test", "bar", 20), + "c": new TestCacheValue("nonTarget", "foo", 30), + "d": new TestCacheValue("test", "bar", 5), + "e": new TestCacheValue("test", "foo", 2) + }; + setKeysForSyncStub = sinon.stub(); + globalCache = { + keys: () => Object.keys(store), + get: (k: string) => store[k], + update: sinon.stub().callsFake((k: string, v: any) => { + if (v === undefined) { + delete store[k]; + } else { + store[k] = v; + } + return Promise.resolve(); + }), + setKeysForSync: sinon.stub() + }; + }); -describe("CacheServiceImpl", () => { - let getStub: sinon.SinonStub; - let updateStub: sinon.SinonStub; - let loggerErrorStub: sinon.SinonStub; - let loggerDebugStub: sinon.SinonStub; + afterEach(() => { + sinon.restore(); + }); - const fakeState = { - get: (key: string) => `value-${key}`, - update: async (key: string, value: string) => {}, - }; + it("should evict half of the matching cache entries by comparator", async () => { + const comparator = (a: BaseCacheValue, b: BaseCacheValue) => + a.lastUsed - b.lastUsed; + + await removeEntriesOnOverflow(globalCache, "test", comparator); + + expect(globalCache.keys()).to.have.members(["a", "b", "c"]); + expect(globalCache.get("b")).to.be.an.instanceof(BaseCacheValue); + expect(globalCache.get("c")).to.be.an.instanceof(BaseCacheValue); + }); + + it("should do nothing if there are not enough entries to evict", async () => { + sinon.stub(globalCache, "keys").returns(["a", "c"]); + const comparator = (a: BaseCacheValue, b: BaseCacheValue) => b.lastUsed - a.lastUsed; + + await removeEntriesOnOverflow(globalCache, "test", comparator); + + expect(globalCache.keys()).to.include("a"); + expect(globalCache.keys()).to.include("c"); + }); + + it("should not attempt to evict entries of a different type", async () => { + const comparator = (a: BaseCacheValue, b: BaseCacheValue) => a.lastUsed - b.lastUsed; + + await removeEntriesOnOverflow(globalCache, "otherType", comparator); + + expect(globalCache.keys()).to.have.members(["a", "b", "c", "d", "e"]); + }); + + it("should handle when no entries match the type", async () => { + const comparator = (a: BaseCacheValue, b: BaseCacheValue) => 0; + await removeEntriesOnOverflow(globalCache, "absentType", comparator); - 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; - }); - }); -}); + expect(globalCache.keys()).to.have.members(["a", "b", "c", "d", "e"]); + }); +}); \ No newline at end of file diff --git a/vscode/src/test/unit/telemetry/projectCacheService.unit.test.ts b/vscode/src/test/unit/telemetry/projectCacheService.unit.test.ts new file mode 100644 index 0000000..9f033e4 --- /dev/null +++ b/vscode/src/test/unit/telemetry/projectCacheService.unit.test.ts @@ -0,0 +1,136 @@ +/* + 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 { ProjectCacheService } from "../../../telemetry/impl/cache/projectCacheService"; +import { ProjectCacheValue } from "../../../telemetry/impl/cache/projectCacheValue"; +import { globalState } from "../../../globalState"; +import * as utilsModule from "../../../telemetry/impl/cache/utils"; +import { describe, it, beforeEach, afterEach } from "mocha"; + +describe("ProjectCacheService", () => { + let vscGlobalStateMock: { + get: sinon.SinonStub; + update: sinon.SinonStub; + keys: sinon.SinonStub; + }; + let getVscGlobalStateStub: sinon.SinonStub; + let getExtensionContextInfoStub: sinon.SinonStub; + + let projectCacheService: ProjectCacheService; + + beforeEach(() => { + vscGlobalStateMock = { + get: sinon.stub(), + update: sinon.stub().resolves(), + keys: sinon.stub() + }; + + getVscGlobalStateStub = sinon.stub().returns(vscGlobalStateMock); + getExtensionContextInfoStub = sinon.stub().returns({ getVscGlobalState: getVscGlobalStateStub }); + + sinon.stub(globalState, "getExtensionContextInfo").callsFake(getExtensionContextInfoStub); + sinon.stub(utilsModule, "removeEntriesOnOverflow").resolves(); + + projectCacheService = new ProjectCacheService(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("get", () => { + it("should return undefined if key is not present", () => { + vscGlobalStateMock.get.returns(undefined); + const result = projectCacheService.get("foo"); + expect(result).to.be.undefined; + }); + + it("should return payload if key is found and update lastUsed", () => { + const obj = new ProjectCacheValue("payloadTest", Date.now()); + vscGlobalStateMock.get.returns(obj); + const putSpy = sinon.spy(projectCacheService, "put"); + + const result = projectCacheService.get("bar"); + expect(result).to.equal("payloadTest"); + expect(putSpy.calledOnce).to.be.true; + }); + + it("should return undefined if an error is thrown", () => { + vscGlobalStateMock.get.throws(new Error("Test Error")); + const result = projectCacheService.get("baz"); + expect(result).to.be.undefined; + }); + }); + + describe("put", () => { + it("should store value and return true", async () => { + vscGlobalStateMock.keys.returns([]); + const val = new ProjectCacheValue("myProject"); + const res = await projectCacheService.put("someKey", val); + + expect(res).to.be.true; + expect(vscGlobalStateMock.update.calledOnce).to.be.true; + }); + + it("should trigger removeOnOverflow if over MAX_KEYS_SIZE", async () => { + const val = new ProjectCacheValue("v"); + vscGlobalStateMock.keys.returns(new Array(projectCacheService.MAX_KEYS_SIZE + 1)); + const removeOnOverflowSpy = sinon.spy(projectCacheService, "removeOnOverflow"); + + await projectCacheService.put("someKey", val); + + expect(removeOnOverflowSpy.calledOnce).to.be.true; + }); + + it("should return false on exception", async () => { + vscGlobalStateMock.update.rejects(new Error("Test Put Error")); + const val = new ProjectCacheValue("v"); + const result = await projectCacheService.put("k", val); + expect(result).to.be.false; + }); + }); + + describe("removeOnOverflow", () => { + it("should do nothing if already removing keys", async () => { + projectCacheService["removingKeys"] = true; + await projectCacheService.removeOnOverflow(); + expect((utilsModule.removeEntriesOnOverflow as sinon.SinonStub).notCalled).to.be.true; + }); + + it("should call removeEntriesOnOverflow when not already running", async () => { + projectCacheService["removingKeys"] = false; + await projectCacheService.removeOnOverflow(); + + expect((utilsModule.removeEntriesOnOverflow as sinon.SinonStub).calledOnce).to.be.true; + }); + + it("should recover removingKeys even if error occurs", async () => { + (utilsModule.removeEntriesOnOverflow as sinon.SinonStub).rejects(new Error("Overflow Error")); + await projectCacheService.removeOnOverflow(); + expect(projectCacheService["removingKeys"]).to.be.false; + }); + }); + + describe("getUpdatedKey", () => { + it("should prefix the key with ProjectCacheValue.type", () => { + const key = "foo"; + const updatedKey = projectCacheService.getUpdatedKey(key); + expect(updatedKey).to.equal(`${ProjectCacheValue.type}.foo`); + }); + }); +}); \ No newline at end of file diff --git a/vscode/src/test/unit/telemetry/simpleCacheService.unit.test.ts b/vscode/src/test/unit/telemetry/simpleCacheService.unit.test.ts new file mode 100644 index 0000000..1bbc8a6 --- /dev/null +++ b/vscode/src/test/unit/telemetry/simpleCacheService.unit.test.ts @@ -0,0 +1,79 @@ +/* + 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 { describe, it, beforeEach, afterEach } from "mocha"; +import * as globalStateModule from "../../../globalState"; +import { SimpleCacheService } from "../../../telemetry/impl/cache/simpleCacheService"; + +describe("SimpleCacheService", () => { + let vscGlobalStateMock: { + get: sinon.SinonStub; + update: sinon.SinonStub; + }; + let getVscGlobalStateStub: sinon.SinonStub; + let getExtensionContextInfoStub: sinon.SinonStub; + let simpleCacheService: SimpleCacheService; + + beforeEach(() => { + vscGlobalStateMock = { + get: sinon.stub(), + update: sinon.stub().resolves() + }; + getVscGlobalStateStub = sinon.stub().returns(vscGlobalStateMock); + getExtensionContextInfoStub = sinon.stub().returns({ getVscGlobalState: getVscGlobalStateStub }); + + sinon.stub(globalStateModule.globalState, "getExtensionContextInfo").callsFake(getExtensionContextInfoStub); + + simpleCacheService = new SimpleCacheService(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("get", () => { + it("should return the cached value if present", () => { + vscGlobalStateMock.get.withArgs("foo").returns("bar"); + expect(simpleCacheService.get("foo")).to.equal("bar"); + }); + + it("should return undefined if the value is missing", () => { + vscGlobalStateMock.get.withArgs("foo").returns(undefined); + expect(simpleCacheService.get("foo")).to.be.undefined; + }); + + it("should return undefined if an error is thrown", () => { + vscGlobalStateMock.get.throws(new Error("failed")); + expect(simpleCacheService.get("foo")).to.be.undefined; + }); + }); + + describe("put", () => { + it("should return true if update succeeds", async () => { + const res = await simpleCacheService.put("someKey", "someValue"); + expect(res).to.be.true; + expect(vscGlobalStateMock.update.calledOnceWith("someKey", "someValue")).to.be.true; + }); + + it("should return false if update throws an error", async () => { + vscGlobalStateMock.update.rejects(new Error("fail")); + const result = await simpleCacheService.put("a", "b"); + expect(result).to.be.false; + }); + }); +}); \ No newline at end of file diff --git a/vscode/src/test/unit/telemetry/workspaceChange.event.unit.test.ts b/vscode/src/test/unit/telemetry/workspaceChange.event.unit.test.ts new file mode 100644 index 0000000..44954e1 --- /dev/null +++ b/vscode/src/test/unit/telemetry/workspaceChange.event.unit.test.ts @@ -0,0 +1,178 @@ +/* + 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 { describe, it, beforeEach, afterEach } from 'mocha'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as crypto from 'crypto'; +import { WorkspaceChangeData, WorkspaceChangeEvent } from '../../../telemetry/events/workspaceChange'; +import { cacheServiceIndex } from '../../../telemetry/impl/cache'; +import { ProjectCacheValue } from '../../../telemetry/impl/cache/projectCacheValue'; + +describe('WorkspaceChangeEvent', () => { + let cacheServiceGetStub: sinon.SinonStub; + let cacheServicePutStub: sinon.SinonStub; + let randomUUIDStub: sinon.SinonStub; + + beforeEach(() => { + cacheServiceGetStub = sinon.stub(cacheServiceIndex.projectCache, 'get'); + cacheServicePutStub = sinon.stub(cacheServiceIndex.projectCache, 'put'); + randomUUIDStub = sinon.stub(crypto, 'randomUUID'); + }); + + afterEach(() => { + sinon.restore(); + }); + + const buildProject = (id: string, overrides: Partial = {}) => ({ + id, + buildTool: 'maven', + javaVersion: '11', + isOpenedWithProblems: false, + isPreviewEnabled: true, + ...overrides, + }); + + const buildEvent = (project: any): WorkspaceChangeData => ({ + projectInfo: project, + numProjects: 1, + lspInitTimeTaken: 100, + projInitTimeTaken: 200, + }); + + const assertProject = (actual: any, expected: any) => { + expect(actual.id).to.equal(expected.id); + expect(actual.buildTool).to.equal(expected.buildTool); + expect(actual.javaVersion).to.equal(expected.javaVersion); + expect(actual.isOpenedWithProblems).to.equal(expected.isOpenedWithProblems); + expect(actual.isPreviewEnabled).to.equal(expected.isPreviewEnabled); + }; + + describe('updateProjectId', () => { + it('uses cached ID when present', () => { + const cachedId = 'cached-uuid'; + const oldId = 'old-id'; + const project = buildProject(oldId); + const payload = buildEvent([project]); + + cacheServiceGetStub.withArgs(oldId).returns(cachedId); + + const event = new WorkspaceChangeEvent(payload); + const result = event.getPayload; + + expect(result.projectInfo[0].id).to.equal(cachedId); + sinon.assert.notCalled(cacheServicePutStub); + sinon.assert.notCalled(randomUUIDStub); + }); + + it('generates UUID and caches it when not in cache', () => { + const uuid = 'new-uuid'; + const oldId = 'old-id'; + const project = buildProject(oldId); + const payload = buildEvent([project]); + + cacheServiceGetStub.withArgs(oldId).returns(undefined); + randomUUIDStub.returns(uuid); + + const event = new WorkspaceChangeEvent(payload); + const result = event.getPayload; + + expect(result.projectInfo[0].id).to.equal(uuid); + sinon.assert.calledWith(cacheServicePutStub, oldId, new ProjectCacheValue(uuid)); + assertProject(result.projectInfo[0], { ...project, id: uuid }); + }); + + it('handles multiple projects with mixed cache states', () => { + const projects = [ + buildProject('project-1'), + buildProject('project-2'), + buildProject('project-3'), + ]; + const originalProjects = JSON.parse(JSON.stringify(projects)); + const payload = buildEvent(projects); + + const cached = 'cached-uuid-1'; + const uuid2 = 'new-uuid-2'; + const uuid3 = 'new-uuid-3'; + + cacheServiceGetStub.withArgs('project-1').returns(cached); + cacheServiceGetStub.withArgs('project-2').returns(undefined); + cacheServiceGetStub.withArgs('project-3').returns(undefined); + randomUUIDStub.onFirstCall().returns(uuid2).onSecondCall().returns(uuid3); + + const event = new WorkspaceChangeEvent(payload); + const result = event.getPayload; + + const expected = [ + { ...originalProjects[0], id: cached }, + { ...originalProjects[1], id: uuid2 }, + { ...originalProjects[2], id: uuid3 }, + ]; + + expected.forEach((exp, i) => assertProject(result.projectInfo[i], exp)); + }); + + it('handles empty projectInfo array', () => { + const payload: WorkspaceChangeData = { + projectInfo: [], + numProjects: 0, + lspInitTimeTaken: 50, + projInitTimeTaken: 75, + }; + + const event = new WorkspaceChangeEvent(payload); + const result = event.getPayload; + + expect(result.projectInfo).to.be.empty; + }); + + it('preserves all other payload properties', () => { + const uuid = 'preserve-uuid'; + const project = buildProject('preserve-id'); + const payload: WorkspaceChangeData = { + projectInfo: [project], + numProjects: 1, + lspInitTimeTaken: 123, + projInitTimeTaken: 456, + }; + + cacheServiceGetStub.withArgs('preserve-id').returns(undefined); + randomUUIDStub.returns(uuid); + + const event = new WorkspaceChangeEvent(payload); + const result = event.getPayload; + + expect(result.numProjects).to.equal(1); + expect(result.lspInitTimeTaken).to.equal(123); + expect(result.projInitTimeTaken).to.equal(456); + assertProject(result.projectInfo[0], { ...project, id: uuid }); + }); + + it('handles null/undefined cache responses gracefully', () => { + const uuid = 'null-fallback'; + const project = buildProject('null-id'); + const payload = buildEvent([project]); + + cacheServiceGetStub.withArgs('null-id').returns(null); + randomUUIDStub.returns(uuid); + + const event = new WorkspaceChangeEvent(payload); + const result = event.getPayload; + + expect(result.projectInfo[0].id).to.equal(uuid); + }); + }); +}); From 3efe9108039a3bbcc05204502a46f1e6579be638 Mon Sep 17 00:00:00 2001 From: Siddharth Srinivasan Date: Fri, 11 Jul 2025 13:23:58 +0530 Subject: [PATCH 3/3] Updated README to refer to the Telemetry policy Included a section in the README to introduce Telemetry and refer the user to the detailed policy in vscode/TELEMETRY.md --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index e97e8c7..6184f74 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,20 @@ If an action has been applied to the Maven `pom.xml` file through the extension, 1. Check the `pom.xml` file for any duplicated tags. 2. If duplicated tags are found, remove the extra tags and attempt to compile again. +## Telemetry + +You may choose to enable the Oracle Java extension for Visual Studio Code (\"*JVSCE*\") to collect and send anonymous technical data commonly known as \"*telemetry data*\" to Oracle to help improve the Java platform. +- No personal information nor source code is collected. +- You may refer to the data collection and privacy policy for JVSCE at [TELEMETRY.md](vscode/TELEMETRY.md). +- No information is sent prior to you enabling Telemetry. + +### Enabling/Disabling Telemetry +If you wish to enable or disable the collection and transmission of the telemetry data, you may do so in the following ways. +1. Notification pop-up request to enable. + - Appears at the time of activation of the extension, when you have not made a choice for this setting. +2. The Java extension setting: [`jdk.telemetry.enabled`](vscode://settings/jdk.telemetry.enabled) +3. *(On Microsoft Visual Studio Code)* The global VS Code setting: [`telemetry.telemetryLevel`](vscode://settings/telemetry.telemetryLevel) must be set to `all` for enabling JVSCE telemetry. + ## Contributing This project welcomes contributions from the community. Before submitting a pull request, please [review our contribution guide](./CONTRIBUTING.md)