diff --git a/.gitignore b/.gitignore index 643416e..671e79f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ build/ .DS_STORE +.vscode/ .idea/ *.iml /nbcode/l10n/locale_ja/release/ /nbcode/l10n/locale_zh_CN/release/ /nbcode/l10n/locale_ja/nbproject/private/ /nbcode/l10n/locale_zh_CN/nbproject/private/ +telemetryConfig.json diff --git a/build.xml b/build.xml index bd9f67d..b344df7 100644 --- a/build.xml +++ b/build.xml @@ -68,6 +68,7 @@ patches/nbjavac-not-required.diff patches/l10n-licence.diff patches/dev-dependency-licenses.diff + patches/nb-telemetry.diff patches/change-method-parameters-refactoring-qualified-names.diff diff --git a/nbcode/nbproject/project.properties b/nbcode/nbproject/project.properties index 864aa45..f3e0906 100644 --- a/nbcode/nbproject/project.properties +++ b/nbcode/nbproject/project.properties @@ -27,5 +27,7 @@ auxiliary.org-netbeans-modules-apisupport-installer.os-solaris=false auxiliary.org-netbeans-modules-apisupport-installer.os-windows=false auxiliary.org-netbeans-spi-editor-hints-projects.perProjectHintSettingsFile=nbproject/cfg_hints.xml modules=\ - ${project.org.netbeans.modules.nbcode.integration} + ${project.org.netbeans.modules.nbcode.integration} :\ + ${project.org.netbeans.modules.nbcode.java.lsp.server.telemetry} project.org.netbeans.modules.nbcode.integration=integration +project.org.netbeans.modules.nbcode.java.lsp.server.telemetry=telemetry diff --git a/nbcode/telemetry/build.xml b/nbcode/telemetry/build.xml new file mode 100644 index 0000000..339fecb --- /dev/null +++ b/nbcode/telemetry/build.xml @@ -0,0 +1,23 @@ + + + + Builds, tests, and runs the project org.netbeans.modules.nbcode.java.lsp.server.telemetry. + + + + + diff --git a/nbcode/telemetry/manifest.mf b/nbcode/telemetry/manifest.mf new file mode 100644 index 0000000..61a299d --- /dev/null +++ b/nbcode/telemetry/manifest.mf @@ -0,0 +1,6 @@ +Manifest-Version: 1.0 +AutoUpdate-Show-In-Client: false +OpenIDE-Module: org.netbeans.modules.nbcode.java.lsp.server.telemetry +OpenIDE-Module-Implementation-Version: 1 +OpenIDE-Module-Localizing-Bundle: org/netbeans/modules/nbcode/java/lsp/server/telemetry/Bundle.properties + diff --git a/nbcode/telemetry/nbproject/build-impl.xml b/nbcode/telemetry/nbproject/build-impl.xml new file mode 100644 index 0000000..208c413 --- /dev/null +++ b/nbcode/telemetry/nbproject/build-impl.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + You must set 'suite.dir' to point to your containing module suite + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nbcode/telemetry/nbproject/genfiles.properties b/nbcode/telemetry/nbproject/genfiles.properties new file mode 100644 index 0000000..4f8a333 --- /dev/null +++ b/nbcode/telemetry/nbproject/genfiles.properties @@ -0,0 +1,23 @@ +# +# 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. +# +build.xml.data.CRC32=bcbc94fb +build.xml.script.CRC32=f4d83a2b +build.xml.stylesheet.CRC32=15ca8a54@2.97 +# This file is used by a NetBeans-based IDE to track changes in generated files such as build-impl.xml. +# Do not edit this file. You may delete it but then the IDE will never regenerate such files for you. +nbproject/build-impl.xml.data.CRC32=decee057 +nbproject/build-impl.xml.script.CRC32=547e2c6a +nbproject/build-impl.xml.stylesheet.CRC32=49aa68b0@2.97 diff --git a/nbcode/telemetry/nbproject/project.properties b/nbcode/telemetry/nbproject/project.properties new file mode 100644 index 0000000..787b525 --- /dev/null +++ b/nbcode/telemetry/nbproject/project.properties @@ -0,0 +1,30 @@ +# +# 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. +# +javac.source=1.8 +requires.nb.javac=true +javac.compilerargs=-Xlint -Xlint:-serial +test.run.args=--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.resources=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + +test.unit.lib.cp= +test.unit.run.cp.extra= +license.file=../../LICENSE.txt +nbm.homepage=https://github.com/oracle/javavscode/ +nbplatform.default.netbeans.dest.dir=${suite.dir}/../netbeans/nbbuild/netbeans +nbplatform.default.harness.dir=${nbplatform.default.netbeans.dest.dir}/harness +spec.version.base=1.0 diff --git a/nbcode/telemetry/nbproject/project.xml b/nbcode/telemetry/nbproject/project.xml new file mode 100644 index 0000000..7a2c069 --- /dev/null +++ b/nbcode/telemetry/nbproject/project.xml @@ -0,0 +1,136 @@ + + + + org.netbeans.modules.apisupport.project + + + org.netbeans.modules.nbcode.java.lsp.server.telemetry + + + + org.netbeans.api.lsp + + + + 1 + 1.28 + + + + org.netbeans.libs.javacapi + + + + + + + + org.netbeans.modules.editor.mimelookup + + + + 1 + 1.65 + + + + org.netbeans.modules.java.lsp.server + + + + 2 + + + + + org.netbeans.modules.java.platform + + + + 1 + 1.67 + + + + org.netbeans.modules.java.project + + + + 1 + 1.97 + + + + org.netbeans.modules.java.source.base + + + + 2.68.0.6.4.3.8.1 + + + + org.netbeans.modules.projectapi + + + + 1 + 1.96 + + + + org.openide.filesystems + + + + 9.38 + + + + org.openide.util + + + + 9.33 + + + + org.openide.util.lookup + + + + 8.59 + + + + + + unit + + org.netbeans.libs.junit4 + + + + org.netbeans.modules.nbjunit + + + + + + + + + diff --git a/nbcode/telemetry/nbproject/suite.properties b/nbcode/telemetry/nbproject/suite.properties new file mode 100644 index 0000000..0b44bb9 --- /dev/null +++ b/nbcode/telemetry/nbproject/suite.properties @@ -0,0 +1,16 @@ +# +# 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. +# +suite.dir=${basedir}/.. diff --git a/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/Bundle.properties b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/Bundle.properties new file mode 100644 index 0000000..7c1563f --- /dev/null +++ b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/Bundle.properties @@ -0,0 +1,17 @@ +# +# 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. +# +OpenIDE-Module-Display-Category=Java +OpenIDE-Module-Name=Java LSP Server - Telemetry diff --git a/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLangFeatures.java b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLangFeatures.java new file mode 100644 index 0000000..7595b60 --- /dev/null +++ b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLangFeatures.java @@ -0,0 +1,91 @@ +/* + * 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. + */ +package org.netbeans.modules.nbcode.java.lsp.server.telemetry; + +import com.sun.tools.javac.code.Source; +import com.sun.tools.javac.resources.CompilerProperties; +import com.sun.tools.javac.util.JCDiagnostic; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +class JavaLangFeatures { + + public static boolean isDiagnosticForUnsupportedFeatures(String diagnosticCode) { + return Singleton.javacParentDiagnosticKeys.contains(diagnosticCode); + } + + public static String getFeatureName(String featureCode) { + Source.Feature feature = Singleton.fragmentCodeToFeature.get(featureCode); + if (feature == null && featureCode.startsWith(Singleton.javacFragmentCodePrefix)) { + feature = Singleton.fragmentCodeToFeature.get(featureCode.substring(Singleton.javacFragmentCodePrefix.length())); + } + return feature == null ? featureCode : feature.name(); + } + + private static class Singleton { + + private static final Map fragmentCodeToFeature; + private static final Set javacParentDiagnosticKeys; + private static final String javacFragmentCodePrefix; + + static { + Map featureFragments = new HashMap<>(); + Set parentDiagnosticKeys = new HashSet<>(); + String prefix = "compiler.misc."; + try { + final JCDiagnostic.Fragment fragment = CompilerProperties.Fragments.FeatureNotSupportedInSource((JCDiagnostic) null, null, null); + final String fragmentKey = fragment.key(); + final String fragmentCode = fragment.getCode(); + if (fragmentKey.startsWith(fragmentCode)) { + prefix = fragmentKey.substring(fragmentCode.length()); + } + + parentDiagnosticKeys.add(fragmentKey); + parentDiagnosticKeys.add(CompilerProperties.Fragments.FeatureNotSupportedInSourcePlural((JCDiagnostic) null, null, null).key()); + parentDiagnosticKeys.add(CompilerProperties.Errors.FeatureNotSupportedInSource((JCDiagnostic) null, null, null).key()); + parentDiagnosticKeys.add(CompilerProperties.Errors.FeatureNotSupportedInSourcePlural((JCDiagnostic) null, null, null).key()); + + parentDiagnosticKeys.add(CompilerProperties.Errors.PreviewFeatureDisabled((JCDiagnostic) null).key()); + parentDiagnosticKeys.add(CompilerProperties.Errors.PreviewFeatureDisabledPlural((JCDiagnostic) null).key()); + parentDiagnosticKeys.add(CompilerProperties.Warnings.PreviewFeatureUse((JCDiagnostic) null).key()); + parentDiagnosticKeys.add(CompilerProperties.Warnings.PreviewFeatureUsePlural((JCDiagnostic) null).key()); + + parentDiagnosticKeys.add(CompilerProperties.Errors.IsPreview(null).key()); + parentDiagnosticKeys.add(CompilerProperties.Warnings.IsPreview(null).key()); + parentDiagnosticKeys.add(CompilerProperties.Warnings.IsPreviewReflective(null).key()); + + for (Source.Feature f : Source.Feature.values()) { + try { + featureFragments.put(f.nameFragment().getCode(), f); + } catch (AssertionError | NullPointerException e) { + // In case no error message code has been registered; for example: LOCAL_VARIABLE_TYPE_INFERENCE + featureFragments.put(f.name(), f); + } + } + } catch (VirtualMachineError e) { + throw e; + } catch (Throwable ignore) { + } + javacFragmentCodePrefix = prefix; + javacParentDiagnosticKeys = parentDiagnosticKeys.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(parentDiagnosticKeys); + fragmentCodeToFeature = featureFragments.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(featureFragments); + } + } + +} diff --git a/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLangFeaturesTelemetryProvider.java b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLangFeaturesTelemetryProvider.java new file mode 100644 index 0000000..8a0043e --- /dev/null +++ b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLangFeaturesTelemetryProvider.java @@ -0,0 +1,86 @@ +/* + * 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. + */ +package org.netbeans.modules.nbcode.java.lsp.server.telemetry; + +import java.lang.ref.WeakReference; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import org.netbeans.api.editor.mimelookup.MimeRegistration; +import org.netbeans.api.lsp.Diagnostic; +import org.netbeans.modules.java.lsp.server.protocol.LspServerTelemetryManager; +import org.netbeans.spi.lsp.ErrorProvider; +import org.openide.util.NbPreferences; +import org.openide.util.RequestProcessor; +import org.openide.util.RequestProcessor.Task; + +@MimeRegistration(mimeType="text/x-java", service=ErrorProvider.class) +public class JavaLangFeaturesTelemetryProvider implements ErrorProvider { + + static final String PREFERENCES_NODE = "jdk.telemetry"; + static final String PREFERENCES_KEY_DEBOUNCE_TIME = "java-lang-features-debounce"; + static final String PREFERENCES_KEY_CACHE_EXPIRY = "java-lang-features-cache-expiry"; + + static RequestProcessor getRequestProcessor() { + return RPSingleton.instance; + } + + private static int getRequestDebounceTime() { + return RPSingleton.DEBOUNCE_TIME; + } + + private static final class RPSingleton { + private static final RequestProcessor instance = new RequestProcessor(JavaLangFeaturesTelemetryProvider.class.getName(), 10, true, false); + private static final int DEBOUNCE_TIME = Math.max(0, NbPreferences.forModule(JavaLangFeaturesTelemetryProvider.class).node(PREFERENCES_NODE).getInt(PREFERENCES_KEY_DEBOUNCE_TIME, 1000)); // 1 sec + } + + private static final ConcurrentHashMap> sourceAnalysisTasks = new ConcurrentHashMap<>(); + + @Override + public List computeErrors(Context context) { + if (context.errorKind() == ErrorProvider.Kind.HINTS && LspServerTelemetryManager.getInstance().isTelemetryEnabled()) { + final SourceInfo sourceInfo = SourceInfo.getSourceObject(context); + if (sourceInfo.source != null) { + scheduleTask(sourceInfo, JavaLanguageFeaturesEmitter::new); + } + } + return Collections.emptyList(); + } + + private WeakReference scheduleTask(SourceInfo sourceInfo, Function runner) { + String sourceFileName = sourceInfo.getSourceName(); + return sourceAnalysisTasks.compute(sourceFileName, (file, existingTaskRef) -> { + Task existingTask = existingTaskRef == null ? null : existingTaskRef.get(); + final Task task; + final WeakReference taskRef; + if (existingTask == null) { + task = getRequestProcessor().create(runner.apply(sourceInfo)); + taskRef = new WeakReference<>(existingTask); + task.addTaskListener(t -> sourceAnalysisTasks.remove(sourceFileName, taskRef)); + } else { + task = existingTask; + taskRef = existingTaskRef; + } + task.schedule(getRequestDebounceTime()); + return taskRef; + }); + } + + static ConcurrentHashMap> getSourceAnalysisTasks() { + return sourceAnalysisTasks; + } +} diff --git a/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLanguageFeaturesEmitter.java b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLanguageFeaturesEmitter.java new file mode 100644 index 0000000..dfc7022 --- /dev/null +++ b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLanguageFeaturesEmitter.java @@ -0,0 +1,105 @@ +/* + * 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. + */ +package org.netbeans.modules.nbcode.java.lsp.server.telemetry; + +import com.sun.source.util.JavacTask; +import com.sun.tools.javac.api.ClientCodeWrapper.DiagnosticSourceUnwrapper; +import com.sun.tools.javac.code.Symbol; +import com.sun.tools.javac.util.JCDiagnostic; +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.tools.DiagnosticListener; +import javax.tools.JavaFileObject; +import javax.tools.ToolProvider; +import org.eclipse.lsp4j.services.LanguageClient; +import org.netbeans.modules.java.lsp.server.protocol.LspServerTelemetryManager; + +class JavaLanguageFeaturesEmitter implements Runnable { + private static final Logger LOG = Logger.getLogger(JavaLanguageFeaturesEmitter.class.getName()); + + private final SourceInfo sourceInfo; + + JavaLanguageFeaturesEmitter(SourceInfo sourceInfo) { + this.sourceInfo = sourceInfo; + } + + @Override + public void run() { + Set featuresUsed = checkJavaFeatures(); + if (!featuresUsed.isEmpty() && SourceFeatureCache.add(sourceInfo.getProjectName(), featuresUsed)) { + final LanguageClient client = sourceInfo.getLanguageClient(); + final SourceFeatureCache.SourceFeatureCacheEntry cached = SourceFeatureCache.get(sourceInfo.getProjectName()); + final boolean previewEnabled = cached == null ? sourceInfo.getPreviewEnabled() : cached.isPreviewEnabled(sourceInfo); + final JdkFeatureEvent event = new JdkFeatureEvent.Builder() + .setJavaVersion(sourceInfo.getJavaVersion()) + .setIsPreviewEnabled(previewEnabled) + .setNames(featuresUsed) + .build(); + if (client == null) { + LspServerTelemetryManager.getInstance().sendTelemetry(event); + } else { + LspServerTelemetryManager.getInstance().sendTelemetry(client, event); + } + } + } + + Set checkJavaFeatures() { + Set featuresUsed = new HashSet<>(); + DiagnosticListener dl = d -> { + //this is not an API, requires access to internals: + addNewJavaFeaturesUsed(featuresUsed, + d instanceof DiagnosticSourceUnwrapper ? ((DiagnosticSourceUnwrapper) d).d + : d instanceof JCDiagnostic ? (JCDiagnostic) d + : null); + }; + JavacTask task; + try { + task = (JavacTask) ToolProvider.getSystemJavaCompiler().getTask(null, null, dl, List.of("--source", "8", "--source-path", sourceInfo.getSourcesPath()), null, List.of(sourceInfo.source)); + task.analyze(); + } catch (IOException e) { + LOG.log(Level.FINE, "IO error while scanning Java Language features: {0}", e); + } catch (IllegalArgumentException e) { + LOG.log(Level.CONFIG, "Invalid parsing parameters for scanning Java Language features: {0}", e); + } catch (RuntimeException ignored) { + } + return featuresUsed; + } + + void addNewJavaFeaturesUsed(Set featuresUsed, JCDiagnostic jcDiag) { + if (jcDiag == null) + return; + if (JavaLangFeatures.isDiagnosticForUnsupportedFeatures(jcDiag.getCode())) { + if (jcDiag.getArgs().length > 0) { + if (jcDiag.getArgs()[0] instanceof JCDiagnostic) { + featuresUsed.add(JavaLangFeatures.getFeatureName(((JCDiagnostic) jcDiag.getArgs()[0]).getCode())); + } else if (jcDiag.getArgs()[0] instanceof JCDiagnostic.DiagnosticInfo) { + featuresUsed.add(JavaLangFeatures.getFeatureName(((JCDiagnostic.DiagnosticInfo) jcDiag.getArgs()[0]).getCode())); + } else if (jcDiag.getArgs()[0] instanceof Symbol) { + featuresUsed.add(JavaLangFeatures.getFeatureName(((Symbol) jcDiag.getArgs()[0]).getSimpleName().toString())); + } + } + return; + } + for (Object arg : jcDiag.getArgs()) { + if (arg instanceof JCDiagnostic) + addNewJavaFeaturesUsed(featuresUsed, (JCDiagnostic) arg); + } + } +} diff --git a/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JdkFeatureEvent.java b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JdkFeatureEvent.java new file mode 100644 index 0000000..a24ac31 --- /dev/null +++ b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JdkFeatureEvent.java @@ -0,0 +1,102 @@ +/* + * 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. + */ +package org.netbeans.modules.nbcode.java.lsp.server.telemetry; + +import java.util.Set; +import org.eclipse.lsp4j.MessageType; +import org.netbeans.modules.java.lsp.server.protocol.TelemetryEvent; + +public class JdkFeatureEvent extends TelemetryEvent { + + public static final String JDK_FEATURES_EVT = "jdkFeature"; + + public JdkFeatureEvent(String jsonString) { + super(MessageType.Info.toString(), JDK_FEATURES_EVT, jsonString); + } + + public JdkFeatureEvent(JdkFeatures features) { + super(MessageType.Info.toString(), JDK_FEATURES_EVT, features); + } + + public static class JdkFeatures { + private String javaVersion; + private Boolean isPreviewEnabled; + private Set names; + private Set jeps; + + public String getJavaVersion() { + return javaVersion; + } + + public void setJavaVersion(String javaVersion) { + this.javaVersion = javaVersion; + } + + public Boolean getIsPreviewEnabled() { + return isPreviewEnabled; + } + + public void setIsPreviewEnabled(Boolean isPreviewEnabled) { + this.isPreviewEnabled = isPreviewEnabled; + } + + public Set getNames() { + return names; + } + + public void setNames(Set names) { + this.names = names; + } + + public Set getJeps() { + return jeps; + } + + public void setJeps(Set jeps) { + this.jeps = jeps; + } + + } + + public static class Builder { + private final JdkFeatures properties = new JdkFeatures(); + + public Builder setJavaVersion(String javaVersion) { + properties.setJavaVersion(javaVersion); + return this; + } + + public Builder setNames(Set names) { + properties.setNames(names); + return this; + } + + public Builder setJeps(Set jeps) { + properties.setJeps(jeps); + return this; + } + + public Builder setIsPreviewEnabled(boolean previewEnabled) { + properties.setIsPreviewEnabled(previewEnabled); + return this; + } + + public JdkFeatureEvent build() { + return new JdkFeatureEvent(properties); + } + } + +} diff --git a/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/SourceFeatureCache.java b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/SourceFeatureCache.java new file mode 100644 index 0000000..6b1c5b2 --- /dev/null +++ b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/SourceFeatureCache.java @@ -0,0 +1,119 @@ +/* + * 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. + */ +package org.netbeans.modules.nbcode.java.lsp.server.telemetry; + +import java.util.Collections; +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import org.openide.util.NbPreferences; +import org.openide.util.RequestProcessor.Task; + +class SourceFeatureCache { + + static class SourceFeatureCacheEntry { + + private final long timestamp; + private final Set featuresUsed; + private final AtomicReference previewEnabled; + + public SourceFeatureCacheEntry(long timestamp, Set featuresUsed) { + this(timestamp, featuresUsed, null); + } + + protected SourceFeatureCacheEntry(long timestamp, Set featuresUsed, SourceFeatureCacheEntry copy) { + this.timestamp = timestamp; + this.featuresUsed = featuresUsed == null ? Collections.emptySet() : featuresUsed; + this.previewEnabled = new AtomicReference<>(copy == null ? null : copy.previewEnabled.get()); + } + + public long getTimestamp() { + return timestamp; + } + + public Set getFeaturesUsed() { + return featuresUsed; + } + + public boolean isPreviewEnabled(SourceInfo sourceInfo) { + Boolean value = previewEnabled.get(); + if (value == null) { + value = sourceInfo.getPreviewEnabled(); + if (!previewEnabled.compareAndSet(null, value)) + value = previewEnabled.get(); + } + return value; + } + } + + private static class Singleton { + private static final int CACHE_EXPIRY = Math.max(0, NbPreferences.forModule(JavaLangFeaturesTelemetryProvider.class).node(JavaLangFeaturesTelemetryProvider.PREFERENCES_NODE).getInt(JavaLangFeaturesTelemetryProvider.PREFERENCES_KEY_CACHE_EXPIRY, 12 * 3_600_000)); // 12 hours + private static final ConcurrentHashMap cachedSourceFeatures = new ConcurrentHashMap<>(); + } + + static ConcurrentHashMap getCachedSourceFeatures() { + return Singleton.cachedSourceFeatures; + } + + public static SourceFeatureCacheEntry get(String sourceName) { + return Singleton.cachedSourceFeatures.get(sourceName); + } + + public static boolean add(String sourceName, Set features) { + final Set newFeatures = Collections.unmodifiableSet(features); + final SourceFeatureCacheEntry entry = getCachedSourceFeatures().compute(sourceName, + (name, cache) -> { + if (cache != null && cache.getFeaturesUsed().containsAll(newFeatures)) return cache; + if (cache != null) features.addAll(cache.getFeaturesUsed()); + return new SourceFeatureCacheEntry(System.currentTimeMillis(), newFeatures, cache); + } + ); + + boolean added = newFeatures == entry.getFeaturesUsed(); + if (!added) { + SourceFeatureCacheCleaner.delay(); + } + return added; + } + + private static class SourceFeatureCacheCleaner implements Runnable { + + private static final int CLEANER_DELAY = Math.max(10_000, Singleton.CACHE_EXPIRY / 10); // 10 times/expiry period; min. 10secs. + private static final Task cacheCleaner = JavaLangFeaturesTelemetryProvider.getRequestProcessor().create(new SourceFeatureCacheCleaner(), true); + + static { + cacheCleaner.schedule(CLEANER_DELAY); + } + + static void delay() { + cacheCleaner.schedule(CLEANER_DELAY); + } + + @Override + public void run() { + final long cleanBeforeTime = System.currentTimeMillis() - Singleton.CACHE_EXPIRY; + final Iterator iterator = getCachedSourceFeatures().values().iterator(); + while (iterator.hasNext()) { + if (iterator.next().getTimestamp() < cleanBeforeTime) { + iterator.remove(); + } + } + cacheCleaner.schedule(CLEANER_DELAY); + } + + } +} diff --git a/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/SourceInfo.java b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/SourceInfo.java new file mode 100644 index 0000000..9c6adac --- /dev/null +++ b/nbcode/telemetry/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/SourceInfo.java @@ -0,0 +1,142 @@ +/* + * 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. + */ +package org.netbeans.modules.nbcode.java.lsp.server.telemetry; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.net.URI; +import java.util.Map; +import java.util.function.Function; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import org.eclipse.lsp4j.services.LanguageClient; +import org.netbeans.api.java.platform.JavaPlatform; +import org.netbeans.api.java.project.JavaProjectConstants; +import org.netbeans.api.project.FileOwnerQuery; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectUtils; +import org.netbeans.api.project.SourceGroup; +import org.netbeans.api.project.Sources; +import org.netbeans.modules.java.lsp.server.protocol.LspServerTelemetryManager; +import org.netbeans.spi.lsp.ErrorProvider; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.util.Lookup; + +class SourceInfo { + + private final FileObject file; + private final Project owner; + final JavaFileObject source; + /** + * A transient reference cache to the LanguageClient associated with this source. + * This does not need to be made concurrency-safe since the source is expected + * to be associated with a single client during its lifetime here. Further, + * it can be queried and rewritten again safely, especially across threads. + * So, there is no need to make this an atomic reference. + * Finally, it needs to be a weak-reference so that the client resources are + * released as soon as possible. + */ + private transient WeakReference client; + + public SourceInfo(FileObject file, Project owner, JavaFileObject source) { + this.file = file; + this.owner = owner; + this.source = source; + } + + public String getSourceName() { + return source == null ? "" : source.getName(); + } + + public String getProjectName() { + return owner == null ? "" : ProjectUtils.getInformation(ProjectUtils.rootOf(owner)).getName(); + } + + public String getSourcesPath() { + if (owner != null) { + final Sources sources = ProjectUtils.getSources(owner); + SourceGroup[] sourceGroups = sources.getSourceGroups(JavaProjectConstants.SOURCES_TYPE_JAVA); + if (sourceGroups.length == 0) { + sourceGroups = sources.getSourceGroups(Sources.TYPE_GENERIC); + } + StringBuilder sb = new StringBuilder(); + for (SourceGroup group : sourceGroups) { + final File root = FileUtil.toFile(group.getRootFolder()); + if (root != null) { + if (!sb.isEmpty()) { + sb.append(File.pathSeparatorChar); + } + sb.append(root.getAbsolutePath()); + } + } + return sb.toString(); + } else { + final File parent = FileUtil.toFile(file.getParent()); + return parent == null ? "." : parent.getAbsolutePath(); + } + } + + public LanguageClient getLanguageClient() { + LanguageClient client = this.client == null ? null : this.client.get(); + if (client == null) { + client = Lookup.getDefault().lookup(LanguageClient.class); + if (client != null) + this.client = new WeakReference<>(client); + } + return client; + } + + public String getJavaVersion() { + final JavaPlatform defaultPlatform = JavaPlatform.getDefault(); + final Map systemProperties = defaultPlatform.getSystemProperties(); + Function lookupFunction = systemProperties == null ? System::getProperty : systemProperties::get; + return LspServerTelemetryManager.getJavaRuntimeVersion(lookupFunction); + } + + public boolean getPreviewEnabled() { + return LspServerTelemetryManager.getInstance().isPreviewEnabled(file, + owner == null ? LspServerTelemetryManager.ProjectType.standalone : LspServerTelemetryManager.getInstance().getProjectType(owner), + getLanguageClient()); + } + + public static SourceInfo getSourceObject(ErrorProvider.Context context) { + final FileObject file = context.file(); + final Project owner = FileOwnerQuery.getOwner(file); + JavaFileObject source = null; + try { + source = new BasicJavaFileObject(file.toURI(), file.asText()); + } catch (IOException | IllegalArgumentException ignore) { + } + return new SourceInfo(file, owner, source); + } + + static class BasicJavaFileObject extends SimpleJavaFileObject { + + private final String content; + + public BasicJavaFileObject(URI uri, String content) { + super(uri, Kind.SOURCE); + this.content = content; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { + return content; + } + } +} diff --git a/nbcode/telemetry/test/unit/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLanguageFeaturesEmitterTest.java b/nbcode/telemetry/test/unit/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLanguageFeaturesEmitterTest.java new file mode 100644 index 0000000..52eb2fd --- /dev/null +++ b/nbcode/telemetry/test/unit/src/org/netbeans/modules/nbcode/java/lsp/server/telemetry/JavaLanguageFeaturesEmitterTest.java @@ -0,0 +1,71 @@ +/* + * 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. + */ +package org.netbeans.modules.nbcode.java.lsp.server.telemetry; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.Test; +import org.netbeans.junit.NbTestCase; +import org.openide.filesystems.FileUtil; + +public class JavaLanguageFeaturesEmitterTest extends NbTestCase { + + public JavaLanguageFeaturesEmitterTest(String name) { + super(name); + } + + @Test + public void testDiagToFeatureName() { + List diagCodes = Arrays.asList("feature.records", "feature.switch.rules", "feature.diamond.and.anon.class", "feature.pattern.switch", "feature.switch.expressions", "feature.xyz"); + List featNames = Arrays.asList("RECORDS", "SWITCH_RULE", "DIAMOND_WITH_ANONYMOUS_CLASS_CREATION", "PATTERN_SWITCH", "SWITCH_EXPRESSION", "feature.xyz"); + for (int i = 0; i < diagCodes.size() ; i++) { + String code = diagCodes.get(i); + String name = JavaLangFeatures.getFeatureName(code); + assertEquals(featNames.get(i), name); + } + } + + @Test + public void testCheckJavaFeatures() { + SourceInfo sourceInfo; + try { + sourceInfo = new SourceInfo(FileUtil.toFileObject(File.createTempFile("test", ".java")), null, new SourceInfo.BasicJavaFileObject(new URI("mem://test.java"), getTestCode1())); + } catch (IOException | URISyntaxException ex) { + fail(ex.toString()); + return; + } + JavaLanguageFeaturesEmitter instance = new JavaLanguageFeaturesEmitter(sourceInfo); + Set expResult = new HashSet<>(Arrays.asList("RECORDS", "DIAMOND_WITH_ANONYMOUS_CLASS_CREATION", "SWITCH_EXPRESSION", "SWITCH_RULE", "PATTERN_SWITCH")); + Set result = instance.checkJavaFeatures(); + assertEquals(expResult, result); + } + + private String getTestCode1() { + return " import java.util.*;\n" + + " public record R(int i) {\n" + + " private int t(Object o) {\n" + + " List l = new ArrayList<>() {};\n" + + " return switch (o) { default -> 0; }\n" + + " }\n" + + " }"; + } +} diff --git a/patches/nb-telemetry.diff b/patches/nb-telemetry.diff new file mode 100644 index 0000000..a0b65bd --- /dev/null +++ b/patches/nb-telemetry.diff @@ -0,0 +1,470 @@ +diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/LspServerTelemetryManager.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/LspServerTelemetryManager.java +index d82646afb1..b008279cc4 100644 +--- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/LspServerTelemetryManager.java ++++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/LspServerTelemetryManager.java +@@ -21,6 +21,7 @@ package org.netbeans.modules.java.lsp.server.protocol; + import com.google.gson.JsonArray; + import com.google.gson.JsonObject; + import com.google.gson.JsonPrimitive; ++import java.lang.ref.WeakReference; + import java.math.BigInteger; + import java.nio.charset.StandardCharsets; + import java.security.MessageDigest; +@@ -28,25 +29,29 @@ import java.security.NoSuchAlgorithmException; + import java.util.ArrayList; + import java.util.Collection; + import java.util.Collections; +-import java.util.HashSet; ++import java.util.Iterator; + import java.util.List; + import java.util.Map; +-import java.util.Set; ++import java.util.NavigableMap; ++import java.util.TreeMap; + import java.util.WeakHashMap; + import java.util.concurrent.Future; +-import java.util.concurrent.atomic.AtomicBoolean; ++import java.util.function.Function; ++import java.util.logging.Level; ++import java.util.logging.Logger; + import java.util.stream.Collectors; + import org.eclipse.lsp4j.ConfigurationItem; + import org.eclipse.lsp4j.ConfigurationParams; + import org.eclipse.lsp4j.MessageType; + import org.eclipse.lsp4j.services.LanguageClient; ++import org.netbeans.api.java.platform.JavaPlatform; + import org.netbeans.api.java.queries.CompilerOptionsQuery; + import org.netbeans.api.java.queries.CompilerOptionsQuery.Result; + import org.netbeans.api.project.Project; + import org.netbeans.api.project.ProjectManager; + import org.netbeans.api.project.ui.ProjectProblems; ++import org.netbeans.modules.java.platform.implspi.JavaPlatformProvider; + import org.openide.filesystems.FileObject; +-import org.openide.util.Exceptions; + import org.openide.util.Lookup; + + /** +@@ -55,130 +60,200 @@ import org.openide.util.Lookup; + */ + public class LspServerTelemetryManager { + +- public final String SCAN_START_EVT = "SCAN_START_EVT"; +- public final String SCAN_END_EVT = "SCAN_END_EVT"; +- public final String WORKSPACE_INFO_EVT = "WORKSPACE_INFO_EVT"; ++ private static final Logger LOG = Logger.getLogger(LspServerTelemetryManager.class.getName()); ++ public static final String SCAN_START_EVT = "SCAN_START_EVT"; ++ public static final String SCAN_END_EVT = "SCAN_END_EVT"; ++ public static final String WORKSPACE_INFO_EVT = "workspaceChange"; + +- private final String ENABLE_PREVIEW = "--enable-preview"; +- private final String STANDALONE_PRJ = "Standalone"; +- private final WeakHashMap> clients = new WeakHashMap<>(); +- private long lspServerIntiailizationTime; ++ private static final String ENABLE_PREVIEW = "--enable-preview"; + +- public synchronized void connect(LanguageClient client, Future future) { +- clients.put(client, future); +- lspServerIntiailizationTime = System.currentTimeMillis(); ++ public static enum ProjectType { ++ standalone, ++ maven, ++ gradle; + } + +- public synchronized void sendTelemetry(TelemetryEvent event) { +- Set toRemove = new HashSet<>(); +- List toSendTelemetry = new ArrayList<>(); ++ private LspServerTelemetryManager() { ++ } ++ ++ public static LspServerTelemetryManager getInstance() { ++ return Singleton.instance; ++ } ++ ++ private static class Singleton { ++ ++ private static final LspServerTelemetryManager instance = new LspServerTelemetryManager(); ++ } ++ ++ private final WeakHashMap>> clients = new WeakHashMap<>(); ++ private volatile boolean telemetryEnabled = false; ++ private long lspServerIntializationTime; + ++ public boolean isTelemetryEnabled() { ++ return telemetryEnabled; ++ } ++ ++ public void connect(LanguageClient client, Future future) { + synchronized (clients) { +- for (Map.Entry> entry : clients.entrySet()) { +- if (entry.getValue().isDone()) { +- toRemove.add(entry.getKey()); +- } else { +- toSendTelemetry.add(entry.getKey()); +- } +- } +- clients.keySet().removeAll(toRemove); ++ clients.put(client, new WeakReference<>(future)); ++ telemetryEnabled = true; ++ lspServerIntializationTime = System.currentTimeMillis(); + } ++ } + +- for (LanguageClient client : toSendTelemetry) { +- client.telemetryEvent(event); ++ public void sendTelemetry(TelemetryEvent event) { ++ if (telemetryEnabled) { ++ ArrayList clientsCopy = new ArrayList<>(2); ++ synchronized (clients) { ++ Iterator>>> iterator = clients.entrySet().iterator(); ++ while (iterator.hasNext()) { ++ Map.Entry>> e = iterator.next(); ++ if (isInvalidClient(e.getValue())) { ++ iterator.remove(); ++ } else { ++ clientsCopy.add(e.getKey()); ++ } ++ } ++ if (clientsCopy.isEmpty()) { ++ telemetryEnabled = false; ++ } ++ } ++ clientsCopy.forEach(c -> sendTelemetryToValidClient(c, event)); + } + } +- +- public void sendTelemetry(LanguageClient client, TelemetryEvent event) { +- boolean shouldSendTelemetry = false; + +- synchronized (clients) { +- if(clients.containsKey(client)){ +- if (clients.get(client).isDone()) { +- clients.remove(client); +- } else { +- shouldSendTelemetry = true; ++ public void sendTelemetry(LanguageClient client, TelemetryEvent event) { ++ if (telemetryEnabled) { ++ WeakReference> closeListener = clients.get(client); ++ if (isInvalidClient(closeListener)) { ++ synchronized (clients) { ++ if (clients.remove(client, closeListener) && clients.isEmpty()) { ++ telemetryEnabled = false; ++ } + } ++ } else { ++ sendTelemetryToValidClient(client, event); + } + } ++ } + +- if (shouldSendTelemetry) { ++ private void sendTelemetryToValidClient(LanguageClient client, TelemetryEvent event) { ++ try { + client.telemetryEvent(event); ++ } catch (Exception e) { ++ LOG.log(Level.INFO, "telemetry send failed: {0}", e.getMessage()); + } + } + +- public void sendWorkspaceInfo(LanguageClient client, List workspaceClientFolders, Collection prjs, long timeToOpenPrjs) { ++ private boolean isInvalidClient(WeakReference> closeListener) { ++ Future close = closeListener == null ? null : closeListener.get(); ++ return close == null || close.isDone(); ++ } ++ ++ public void sendWorkspaceInfo(LanguageClient client, List workspaceClientFolders, Collection projects, long timeToOpenProjects) { + JsonObject properties = new JsonObject(); + JsonArray prjProps = new JsonArray(); + +- Map mp = prjs.stream() +- .collect(Collectors.toMap(project -> project.getProjectDirectory().getPath(), project -> project)); ++ NavigableMap mp = projects.stream() ++ .collect(Collectors.toMap(project -> project.getProjectDirectory().getPath(), project -> project, (p1, p2) -> p1, TreeMap::new)); + + for (FileObject workspaceFolder : workspaceClientFolders) { + try { +- JsonObject obj = new JsonObject(); ++ boolean noProjectFound = true; + String prjPath = workspaceFolder.getPath(); +- String prjId = this.getPrjId(prjPath); +- obj.addProperty("id", prjId); +- +- // In future if different JDK is used for different project then this can be updated +- obj.addProperty("javaVersion", System.getProperty("java.version")); +- +- if (mp.containsKey(prjPath)) { +- Project prj = mp.get(prjPath); +- +- ProjectManager.Result r = ProjectManager.getDefault().isProject2(prj.getProjectDirectory()); +- String projectType = r.getProjectType(); +- obj.addProperty("buildTool", (projectType.contains("maven") ? "MavenProject" : "GradleProject")); +- +- obj.addProperty("openedWithProblems", ProjectProblems.isBroken(prj)); +- +- boolean isPreviewFlagEnabled = this.isEnablePreivew(prj.getProjectDirectory(), projectType); +- obj.addProperty("enablePreview", isPreviewFlagEnabled); +- } else { +- obj.addProperty("buildTool", this.STANDALONE_PRJ); +- obj.addProperty("javaVersion", System.getProperty("java.version")); +- obj.addProperty("openedWithProblems", false); +- +- boolean isPreviewFlagEnabled = this.isEnablePreivew(workspaceFolder, this.STANDALONE_PRJ); +- obj.addProperty("enablePreview", isPreviewFlagEnabled); ++ String prjPathWithSlash = null; ++ for (Map.Entry p : mp.tailMap(prjPath, true).entrySet()) { ++ String projectPath = p.getKey(); ++ if (prjPathWithSlash == null) { ++ if (prjPath.equals(projectPath)) { ++ prjProps.add(createProjectInfo(prjPath, p.getValue(), workspaceFolder, client)); ++ noProjectFound = false; ++ break; ++ } ++ prjPathWithSlash = prjPath + '/'; ++ } ++ if (projectPath.startsWith(prjPathWithSlash)) { ++ prjProps.add(createProjectInfo(p.getKey(), p.getValue(), workspaceFolder, client)); ++ noProjectFound = false; ++ continue; ++ } ++ break; + } +- +- prjProps.add(obj); +- +- } catch (NoSuchAlgorithmException ex) { +- Exceptions.printStackTrace(ex); ++ if (noProjectFound) { ++ // No project found ++ prjProps.add(createProjectInfo(prjPath, null, workspaceFolder, client)); ++ } ++ } catch (NoSuchAlgorithmException e) { ++ LOG.log(Level.INFO, "NoSuchAlgorithmException while creating workspaceInfo event: {0}", e.getMessage()); ++ } catch (Exception e) { ++ LOG.log(Level.INFO, "Exception while creating workspaceInfo event: {0}", e.getMessage()); + } + } + +- properties.add("prjsInfo", prjProps); ++ properties.add("projectInfo", prjProps); + +- properties.addProperty("timeToOpenPrjs", timeToOpenPrjs); +- properties.addProperty("numOfPrjsOpened", workspaceClientFolders.size()); +- properties.addProperty("lspServerInitializationTime", System.currentTimeMillis() - this.lspServerIntiailizationTime); ++ properties.addProperty("projInitTimeTaken", timeToOpenProjects); ++ properties.addProperty("numProjects", workspaceClientFolders.size()); ++ properties.addProperty("lspInitTimeTaken", System.currentTimeMillis() - this.lspServerIntializationTime); + +- this.sendTelemetry(client, new TelemetryEvent(MessageType.Info.toString(), this.WORKSPACE_INFO_EVT, properties)); ++ this.sendTelemetry(client, new TelemetryEvent(MessageType.Info.toString(), LspServerTelemetryManager.WORKSPACE_INFO_EVT, properties)); + } +- +- private boolean isEnablePreivew(FileObject source, String prjType) { +- if (prjType.equals(this.STANDALONE_PRJ)) { +- NbCodeLanguageClient client = Lookup.getDefault().lookup(NbCodeLanguageClient.class); ++ ++ private JsonObject createProjectInfo(String prjPath, Project prj, FileObject workspaceFolder, LanguageClient client) throws NoSuchAlgorithmException { ++ JsonObject obj = new JsonObject(); ++ String prjId = getPrjId(prjPath); ++ obj.addProperty("id", prjId); ++ FileObject projectDirectory; ++ ProjectType projectType; ++ if (prj == null) { ++ projectType = ProjectType.standalone; ++ projectDirectory = workspaceFolder; ++ } else { ++ projectType = getProjectType(prj); ++ projectDirectory = prj.getProjectDirectory(); ++ boolean projectHasProblems; ++ try { ++ projectHasProblems = ProjectProblems.isBroken(prj); ++ } catch (RuntimeException e) { ++ LOG.log(Level.INFO, "Exception while checking project problems for workspaceInfo event: {0}", e.getMessage()); ++ projectHasProblems = true; ++ } ++ obj.addProperty("isOpenedWithProblems", projectHasProblems); ++ } ++ String javaVersion = getProjectJavaVersion(); ++ obj.addProperty("javaVersion", javaVersion); ++ obj.addProperty("buildTool", projectType.name()); ++ boolean isPreviewFlagEnabled = isPreviewEnabled(projectDirectory, projectType, client); ++ obj.addProperty("isPreviewEnabled", isPreviewFlagEnabled); ++ return obj; ++ } ++ ++ public boolean isPreviewEnabled(FileObject source, ProjectType prjType) { ++ return isPreviewEnabled(source, prjType, null); ++ } ++ ++ public boolean isPreviewEnabled(FileObject source, ProjectType prjType, LanguageClient languageClient) { ++ if (prjType == ProjectType.standalone) { ++ NbCodeLanguageClient client = languageClient instanceof NbCodeLanguageClient ? (NbCodeLanguageClient) languageClient : null ; + if (client == null) { +- return false; ++ client = Lookup.getDefault().lookup(NbCodeLanguageClient.class); ++ if (client == null) { ++ return false; ++ } + } +- AtomicBoolean isEnablePreviewSet = new AtomicBoolean(false); ++ boolean[] isEnablePreviewSet = {false}; + ConfigurationItem conf = new ConfigurationItem(); +- conf.setSection(client.getNbCodeCapabilities().getAltConfigurationPrefix() + "runConfig.vmOptions"); +- client.configuration(new ConfigurationParams(Collections.singletonList(conf))).thenAccept(c -> { +- String config = ((JsonPrimitive) ((List) c).get(0)).getAsString(); +- isEnablePreviewSet.set(config.contains(this.ENABLE_PREVIEW)); +- }); +- +- return isEnablePreviewSet.get(); ++ conf.setSection(client.getNbCodeCapabilities().getConfigurationPrefix() + "runConfig.vmOptions"); ++ client.configuration(new ConfigurationParams(Collections.singletonList(conf))) ++ .thenAccept(c -> { ++ isEnablePreviewSet[0] = c != null && !c.isEmpty() ++ && ((JsonPrimitive) c.get(0)).getAsString().contains(ENABLE_PREVIEW); ++ }); ++ return isEnablePreviewSet[0]; + } +- ++ + Result result = CompilerOptionsQuery.getOptions(source); +- return result.getArguments().contains(this.ENABLE_PREVIEW); ++ return result.getArguments().contains(ENABLE_PREVIEW); + } + + private String getPrjId(String prjPath) throws NoSuchAlgorithmException { +@@ -187,15 +262,50 @@ public class LspServerTelemetryManager { + + BigInteger number = new BigInteger(1, hash); + +- // Convert message digest into hex value + StringBuilder hexString = new StringBuilder(number.toString(16)); + +- // Pad with leading zeros + while (hexString.length() < 64) { + hexString.insert(0, '0'); + } + + return hexString.toString(); + } +- ++ ++ private String getProjectJavaVersion() { ++ final JavaPlatformProvider javaPlatformProvider = Lookup.getDefault().lookup(JavaPlatformProvider.class); ++ final JavaPlatform defaultPlatform = javaPlatformProvider == null ? null : javaPlatformProvider.getDefaultPlatform(); ++ final Map props = defaultPlatform == null ? null : defaultPlatform.getSystemProperties(); ++ final Function propLookup = props == null ? System::getProperty : props::get; ++ ++ return getJavaRuntimeVersion(propLookup) + ';' + getJavaVmVersion(propLookup) + ';' + getJavaVmName(propLookup); ++ } ++ ++ public static String getJavaRuntimeVersion(Function propertyLookup) { ++ String version = propertyLookup.apply("java.runtime.version"); ++ if (version == null) { ++ version = propertyLookup.apply("java.version"); ++ } ++ return version; ++ } ++ ++ public static String getJavaVmVersion(Function propertyLookup) { ++ String version = propertyLookup.apply("java.vendor.version"); ++ if (version == null) { ++ version = propertyLookup.apply("java.vm.version"); ++ if (version == null) { ++ version = propertyLookup.apply("java.version"); ++ } ++ } ++ return version; ++ } ++ ++ public static String getJavaVmName(Function propertyLookup) { ++ return propertyLookup.apply("java.vm.name"); ++ } ++ ++ public ProjectType getProjectType(Project prj) { ++ ProjectManager.Result r = ProjectManager.getDefault().isProject2(prj.getProjectDirectory()); ++ String projectType = r == null ? null : r.getProjectType(); ++ return projectType != null && projectType.contains(ProjectType.maven.name()) ? ProjectType.maven : ProjectType.gradle; ++ } + } +diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeClientCapabilities.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeClientCapabilities.java +index 9134992f5f..f070fec320 100644 +--- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeClientCapabilities.java ++++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeClientCapabilities.java +@@ -90,6 +90,11 @@ public final class NbCodeClientCapabilities { + * Secondary prefix for configuration. + */ + private String altConfigurationPrefix = "java+."; ++ ++ /** ++ * Whether telemetry needs to be enabled. ++ */ ++ private Boolean wantsTelemetryEnabled = Boolean.FALSE; + + public ClientCapabilities getClientCapabilities() { + return clientCaps; +@@ -179,6 +184,10 @@ public final class NbCodeClientCapabilities { + this.altConfigurationPrefix = altConfigurationPrefix; + } + ++ public boolean wantsTelemetryEnabled() { ++ return wantsTelemetryEnabled == Boolean.TRUE; ++ } ++ + private NbCodeClientCapabilities withCapabilities(ClientCapabilities caps) { + if (caps == null) { + caps = new ClientCapabilities(); +diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java +index 13cbcdd628..4b02a84c8b 100644 +--- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java ++++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java +@@ -164,7 +164,6 @@ public final class Server { + * Special logger that logs LSP in/out messages. + */ + private static final Logger LSP_LOG = Logger.getLogger("org.netbeans.modules.java.lsp.server.lsptrace"); // NOI18N +- private static final LspServerTelemetryManager LSP_SERVER_TELEMETRY = new LspServerTelemetryManager(); + private static final ErrorsNotifier ERR_NOTIFIER = new ErrorsNotifier(); + + private Server() { +@@ -188,7 +187,6 @@ public final class Server { + ((LanguageClientAware) server).connect(remote); + msgProcessor.attachClient(server.client); + Future runningServer = serverLauncher.startListening(); +- LSP_SERVER_TELEMETRY.connect(server.client, runningServer); + ERR_NOTIFIER.connect(server, runningServer); + return new NbLspServer(server, runningServer); + } +@@ -793,7 +791,7 @@ public final class Server { + } + f.complete(candidateMapping); + List workspaceClientFolders = workspaceService.getClientWorkspaceFolders(); +- LSP_SERVER_TELEMETRY.sendWorkspaceInfo(client, workspaceClientFolders, openedProjects, System.currentTimeMillis() - t); ++ LspServerTelemetryManager.getInstance().sendWorkspaceInfo(client, workspaceClientFolders, openedProjects, System.currentTimeMillis() - t); + LOG.log(Level.INFO, "{0} projects opened in {1}ms", new Object[] { prjsRequested.length, (System.currentTimeMillis() - t) }); + } else { + LOG.log(Level.FINER, "{0}: Collecting projects to prime from: {1}", new Object[]{id, Arrays.asList(additionalProjects)}); +@@ -953,6 +951,9 @@ public final class Server { + public CompletableFuture initialize(InitializeParams init) { + NbCodeClientCapabilities capa = NbCodeClientCapabilities.get(init); + client.setClientCaps(capa); ++ if (capa.wantsTelemetryEnabled()) { ++ LspServerTelemetryManager.getInstance().connect(client, lspSession.getLspServer().getRunningFuture()); ++ } + hackConfigureGroovySupport(capa); + hackNoReuseOfOutputsForAntProjects(); + List projectCandidates = new ArrayList<>(); +@@ -1452,13 +1453,13 @@ public final class Server { + + @Override + public synchronized boolean scanStarted(Context context) { +- LSP_SERVER_TELEMETRY.sendTelemetry(new TelemetryEvent(MessageType.Info.toString(), LSP_SERVER_TELEMETRY.SCAN_START_EVT, "nbls.scanStarted")); +- return true; ++ LspServerTelemetryManager.getInstance().sendTelemetry(new TelemetryEvent(MessageType.Info.toString(), LspServerTelemetryManager.SCAN_START_EVT, "nbls.scanStarted")); ++ return true; + } + + @Override + public synchronized void scanFinished(Context context) { +- LSP_SERVER_TELEMETRY.sendTelemetry(new TelemetryEvent(MessageType.Info.toString(),LSP_SERVER_TELEMETRY.SCAN_END_EVT,"nbls.scanFinished")); ++ LspServerTelemetryManager.getInstance().sendTelemetry(new TelemetryEvent(MessageType.Info.toString(), LspServerTelemetryManager.SCAN_END_EVT,"nbls.scanFinished")); + ERR_NOTIFIER.notifyErrors(context.getRootURI()); + } + diff --git a/vscode/.vscode/launch.json b/vscode/.vscode/launch.json index 1bcbd4e..45bf9d5 100644 --- a/vscode/.vscode/launch.json +++ b/vscode/.vscode/launch.json @@ -20,6 +20,22 @@ "env": { "nbcode_userdir": "global" } + },{ + "name": "Debug Telemetry", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "${defaultBuildTask}", + "env": { + "nbcode_userdir": "global", + "oracle_oracleJava_enable_debugLogs": "true" + } }, { "name": "Extension Tests", diff --git a/vscode/TELEMETRY.md b/vscode/TELEMETRY.md new file mode 100644 index 0000000..b3c4e0d --- /dev/null +++ b/vscode/TELEMETRY.md @@ -0,0 +1,69 @@ +# About Oracle Java Platform Extension for Visual Studio Code use of Telemetry +## First Your Privacy and Anonymity + +During startup, close or usage of the Oracle Java Platform extension for Visual Studio Code (hereafter referred to as "JVSCE"), Oracle does not collect or track any personally identifiable information (such as names or email addresses) about you or your source code, or associate your usage of JVSCE with personally identifiable data. + +If you choose to enable telemetry collection in JVSCE, we will collect a limited set of information that is not personally identifiable. It is anonymous technical data commonly known as "telemetry". An element-by-element description of what is collected and how it helps us, [is listed below](#specifics-about-java-extension-usage-metrics). + +We do not transmit your source code nor the contents of the files you edit with Visual Studio Code. + +## We Collect this Data to Measure Our Own Performance, Diagnose Problems, and Improve Your Experience +The purpose of the telemetry transmitted to Oracle during the JVSCE startup, close, or usage, is to find any technical problems or anomalies that occur, so we can understand and correct them; as well as, to understand the usage of Java versions and language features, so we can help improve the platform. It's similar to a car mechanic's practice of plugging into your engine's diagnostic port to identify performance issues (not to identify the car's owner!). The issues we identify could be anything from slow performance or missing platforms for JDK builds to adoption of popular Java language capabilities. Your source code, privacy and anonymity are not compromised by our collecting this telemetry. The information thus collected helps to improve the extension experience and the Java platform. + +## If You Would Like to Enable or Disable the Collection of this Data +If you have not already made a choice for this setting, the extension might request that you enable Telemetry via a notification pop-up at the time of activation of the extension. + +If you wish to enable or disable the collection and transmission of the telemetry, you may do so from VS Code Settings → [jdk.telemetry.enabled](vscode://settings/jdk.telemetry.enabled). + +No information is sent to Oracle prior to you enabling Telemetry. + +# Specifics About Java Extension Usage Metrics +## WHO Sends Information to Oracle? +We, the authors of the JVSCE, collect and send information from the Java extension, when it is running in VS Code, only for users who consent to its collection. + +## HOW Is Information Sent to Oracle? +Small JSON messages are transported via a secure HTTPS connection over the Internet. + +## WHEN Is Information Sent to Oracle? +Information may be sent in one or more separate messages, depending on the events reported by JVSCE. + +The following types of messages may be sent if users have consented to telemetry from JVSCE: +1. Start + - When a JVSCE instance is launched in a window, a "Start" event is sent. + - This contains information about the version, language, architecture etc. of JVSCE, VSCode and the OS. +2. Close + - When a JVSCE instance is closed in a window, a "Close" event is sent. + - This contains timing information for the session. +3. Workspace Change + - When a workspace is loaded for which JVSCE is active, a "Workspace Change" event is sent. + - This contains information about the Java version used, build tool used, and performance times. +4. JDK Download + - When a JDK is downloaded and installed using JVSCE, a "JDK Download" event is sent. + - This contains information about the Java version downloaded. +5. JDK Feature + - A "JDK Feature" event is sent when certain Java language features, such as `records`, `switch-expressions`, `pattern-matching` etc., or, functionality introduced in [JEPs](https://openjdk.org/jeps/0), is used while editing a Java file. + - This contains information about the Java version in use and the feature names or JEP numbers used. + +## WHAT Information is Sent to Oracle? +Some or all of the following information may be sent to Oracle depending on the type of event triggered: + +| Event | Data Name | Value Description | +|-------|----------------|--------------------------------------------------------------| +| _ALL_ | VsCodeId | Anonymous machine identifier generated by VS Code.
It cannot be used to track the IP address or identity of the user.
Example: `9c5aa07a61ed54bbf61705d17bd214ef269bbed8d5a557ba9e50658b31ffae79` | +| _ALL_ | VscSessionId | Anonymous session identifier generated by VS Code.
It cannot be used to track the IP address or identity of the user.
Example: `15cf5498-7981-45af-aca3-34e0fa6e906c1716187959345` | +| Start | ExtensionInfo | JVSCE information like extension name, id and version.
Example: `Oracle.oracle-java`, `oracle-java`, `23.1.0` | +| Start | VsCodeInfo | VS Code related information like version, language and host-type.
Example: `1.97.2`, `en`, `desktop` | +| Start | PlatformInfo | Host platform/OS information like os name, architecture and version.
Example: `mac`, `arm64`, `14.6` | +| Start | LocationInfo | Platform location information like language-region and time-zone.
Example: `en-US`, `Asia/Jakarta` | +| Close | SessionTime | Indicates the time for which JVSCE was active in a session. | +| Workspace Change | Timing Info | Indicates performance information like the time taken to initialise the language server and the time taken to parse and load the project information | +| Workspace Change | ProjectInfo | Indicates project-related Java and JVSCE information like an anonymous project identifier, Java version, Java VM name, Java preview-feature enablement, build tool and whether project loading had problems.
The identifier cannot be used to track the IP address or identity of the user.
Example: `[{"id": "02a4eb67236abe93d137136be01f1372b8372e2230896932ef25267df32b4f52", "buildTool": "MavenProject", "isOpenedWithProblems": false, "javaVersion": "21.0.6+8-LTS-jvmci-23.1-b59;Oracle GraalVM 21.0.6+8.1;Java HotSpot(TM) 64-Bit Server VM", "isPreviewEnabled": false}]` | +| JDK Download | Vendor | Indicates the vendor of the JDK downloaded using JVSCE.
Example: `OracleJDK` | +| JDK Download | Version | Indicates the version of the JDK downloaded.
Example: `21` | +| JDK Download | Platform | Indicates the OS name and architecture of the downloaded JDK.
Example: `windows`, `x64` | +| JDK Download | Timing Info | Indicates the time taken to download the JDK. | +| JDK Feature | JEPs | Indicates the Java language features used as denoted by their [JEP numbers](https://openjdk.org/jeps/0).
Example: `[477, 395, 394]` | +| JDK Feature | Features | Indicates the Java language features used as denoted by their common names.
Example: `["RECORDS", "SWITCH_RULE", "PATTERN_MATCHING_IN_INSTANCEOF"]` | +| JDK Feature | Version | Indicates the Java version and enablement of Java preview-features.
Example: `23.0.2+7-58`, `"isPreviewEnabled": true` | + +See information on [Oracle Privacy Policies](https://www.oracle.com/legal/privacy/). diff --git a/vscode/esbuild.js b/vscode/esbuild.js index dbcdf5d..61db24a 100644 --- a/vscode/esbuild.js +++ b/vscode/esbuild.js @@ -15,19 +15,19 @@ const scriptConfig = { }; const watchConfig = { - watch: { - onRebuild(error, result) { - console.log("[watch] build started"); - if (error) { - error.errors.forEach(error => - console.error(`> ${error.location.file}:${error.location.line}:${error.location.column}: error: ${error.text}`) - ); - } else { - console.log("[watch] build finished"); - } - }, + watch: { + onRebuild(error, result) { + console.log("[watch] build started"); + if (error) { + error.errors.forEach(error => + console.error(`> ${error.location.file}:${error.location.line}:${error.location.column}: error: ${error.text}`) + ); + } else { + console.log("[watch] build finished"); + } }, - }; + }, +}; const NON_NPM_ARTIFACTORY = new RegExp( String.raw`"resolved"\s*:\s*"http[s]*://(?!registry.npmjs.org)[^"]+"`, @@ -43,26 +43,80 @@ const checkAritfactoryUrl = () => { } } - (async () => { - const args = process.argv.slice(2); - try { - if (args.includes("--watch")) { - // Build and watch source code - console.log("[watch] build started"); - await build({ - ...scriptConfig, - ...watchConfig, +const createTelemetryConfig = () => { + const defaultConfig = { + telemetryRetryConfig: { + maxRetries: 6, + baseCapacity: 256, + baseTimer: 5000, + maxDelayMs: 100000, + backoffFactor: 2, + jitterFactor: 0.25 + }, + telemetryApi: { + baseUrl: null, + baseEndpoint: "/vscode/java/sendTelemetry", + version: "/v1" + }, + metadata: { + consentSchemaVersion: "v1" + } + } + + const envConfig = Object.freeze({ + telemetryRetryConfig: { + maxRetries: process.env.TELEMETRY_MAX_RETRIES, + baseCapacity: process.env.TELEMETRY_BASE_CAPACITY, + baseTimer: process.env.TELEMETRY_BASE_TIMER, + maxDelayMs: process.env.TELEMETRY_MAX_DELAY, + backoffFactor: process.env.TELEMETRY_BACKOFF_FACTOR, + jitterFactor: process.env.TELEMETRY_JITTER_FACTOR + }, + telemetryApi: { + 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 + } + }); + + Object.entries(defaultConfig).forEach(([parent, configs]) => { + if (parent in envConfig) { + Object.entries(configs).forEach(([key, _]) => { + if (envConfig[parent]?.[key]) { + defaultConfig[parent][key] = envConfig[parent][key]; + } }); - console.log("[watch] build finished"); - } else if(args.includes("--artifactory-check")){ - checkAritfactoryUrl(); - } else { - // Build source code - await build(scriptConfig); - console.log("build complete"); } - } catch (err) { - process.stderr.write(err.message); - process.exit(1); + }); + + fs.writeFileSync("telemetryConfig.json", JSON.stringify(defaultConfig, null, 4)); + console.log("Telemetry config generated successfully."); +} + +(async () => { + const args = process.argv.slice(2); + try { + if (args.includes("--watch")) { + // Build and watch source code + console.log("[watch] build started"); + await build({ + ...scriptConfig, + ...watchConfig, + }); + console.log("[watch] build finished"); + } else if (args.includes("--artifactory-check")) { + checkAritfactoryUrl(); + } else { + // Build source code + createTelemetryConfig(); + await build(scriptConfig); + console.log("build complete"); } - })(); \ No newline at end of file + } catch (err) { + process.stderr.write(err.message); + process.exit(1); + } +})(); \ No newline at end of file diff --git a/vscode/l10n/bundle.l10n.en.json b/vscode/l10n/bundle.l10n.en.json index 6e6fb30..d08bba0 100644 --- a/vscode/l10n/bundle.l10n.en.json +++ b/vscode/l10n/bundle.l10n.en.json @@ -3,6 +3,8 @@ "jdk.downloader.html.details":"

This tool enables you to download either the latest Oracle Java SE JDK with Oracle No-Fee Terms and Conditions or the Oracle OpenJDK builds under the GNU Public License with ClassPath Exception

It will then handle the installation and configuration on your behalf.

This enables you to take full advantage of all the features offered by this extension.

", "jdk.downloader.button.label.oracleJdk": "Download Oracle Java SE JDK", "jdk.downloader.label.or": "or", + "jdk.downloader.label.openJdk": "OpenJDK", + "jdk.downloader.label.oracleJdk": "Oracle JDK", "jdk.downloader.button.label.openJdk": "Download Oracle OpenJDK", "jdk.downloader.button.label.selectJdkFromSystem": "Select installed JDK from my system", "jdk.downloader.label.selectOracleJdkVersion": "Select Oracle Java SE Version", @@ -93,5 +95,6 @@ "jdk.extension.debugger.error_msg.debugAdapterNotInitialized":"Oracle Java SE Debug Server Adapter not yet initialized. Please wait for a while and try again.", "jdk.workspace.new.prompt": "Input the directory path where the new file will be generated", "jdk.extension.utils.error_message.failedHttpsRequest": "Failed to get {url} ({statusCode})", - "jdk.extension.error_msg.notEnabled": "{SERVER_NAME} not enabled" -} \ No newline at end of file + "jdk.extension.error_msg.notEnabled": "{SERVER_NAME} not enabled", + "jdk.telemetry.consent": "Allow anonymous telemetry data to be reported to Oracle? You may opt-out or in at any time from the Settings for jdk.telemetry.enabled." +} diff --git a/vscode/l10n/bundle.l10n.ja.json b/vscode/l10n/bundle.l10n.ja.json index 2d31ebc..6cbed8c 100755 --- a/vscode/l10n/bundle.l10n.ja.json +++ b/vscode/l10n/bundle.l10n.ja.json @@ -3,6 +3,8 @@ "jdk.downloader.html.details":"

このツールは、Oracle No-Fee Terms and Conditionsの最新のOracle Java SE JDKまたは、クラスパス例外付きGNU Public Licenseに基づいたOracle OpenJDKビルドのいずれかをダウンロードできます

次に、インストールおよび構成をかわりに処理します。

これにより、この拡張によって提供されたすべての機能を最大限活用できます。

", "jdk.downloader.button.label.oracleJdk": "Oracle Java SE JDKのダウンロード", "jdk.downloader.label.or": "または", + "jdk.downloader.label.openJdk": "OpenJDK", + "jdk.downloader.label.oracleJdk": "Oracle JDK", "jdk.downloader.button.label.openJdk": "Oracle OpenJDKのダウンロード", "jdk.downloader.button.label.selectJdkFromSystem": "システムからインストール済JDKの選択", "jdk.downloader.label.selectOracleJdkVersion": "Oracle Java SEバージョンの選択", @@ -93,5 +95,6 @@ "jdk.extension.debugger.error_msg.debugAdapterNotInitialized":"Oracle Java SEのデバッグ・サーバー・アダプタが、まだ初期化されていません。しばらく待ってから再試行してください。", "jdk.workspace.new.prompt": "新しいファイルを生成するディレクトリのパスを入力してください", "jdk.extension.utils.error_message.failedHttpsRequest": "{url}の取得に失敗しました({statusCode})", - "jdk.extension.error_msg.notEnabled": "{SERVER_NAME}が有効化されていません" -} \ No newline at end of file + "jdk.extension.error_msg.notEnabled": "{SERVER_NAME}が有効化されていません", + "jdk.telemetry.consent": "Allow anonymous telemetry data to be reported to Oracle? You may opt-out or in at any time from the Settings for jdk.telemetry.enabled." +} diff --git a/vscode/l10n/bundle.l10n.zh-cn.json b/vscode/l10n/bundle.l10n.zh-cn.json index c84d824..c9bec83 100755 --- a/vscode/l10n/bundle.l10n.zh-cn.json +++ b/vscode/l10n/bundle.l10n.zh-cn.json @@ -3,6 +3,8 @@ "jdk.downloader.html.details":"

使用此工具,您可以遵循 Oracle 免费条款和条件下载最新的 Oracle Java SE JDK,或者依照 GNU 公共许可证(包含 ClassPath 例外条款)下载 Oracle OpenJDK 构建

之后,它将代表您处理安装和配置。

这样,您可以充分利用此扩展提供的所有功能。

", "jdk.downloader.button.label.oracleJdk": "下载 Oracle Java SE JDK", "jdk.downloader.label.or": "或", + "jdk.downloader.label.openJdk": "OpenJDK", + "jdk.downloader.label.oracleJdk": "Oracle JDK", "jdk.downloader.button.label.openJdk": "下载 Oracle OpenJDK", "jdk.downloader.button.label.selectJdkFromSystem": "从我的系统选择安装的 JDK", "jdk.downloader.label.selectOracleJdkVersion": "选择 Oracle Java SE 版本", @@ -93,5 +95,6 @@ "jdk.extension.debugger.error_msg.debugAdapterNotInitialized":"Oracle Java SE 调试服务器适配器尚未初始化。请稍候,然后重试。", "jdk.workspace.new.prompt": "输入生成新文件的目录路径", "jdk.extension.utils.error_message.failedHttpsRequest": "无法获取 {url} ({statusCode})", - "jdk.extension.error_msg.notEnabled": "{SERVER_NAME} 未启用" -} \ No newline at end of file + "jdk.extension.error_msg.notEnabled": "{SERVER_NAME} 未启用", + "jdk.telemetry.consent": "Allow anonymous telemetry data to be reported to Oracle? You may opt-out or in at any time from the Settings for jdk.telemetry.enabled." +} diff --git a/vscode/package.json b/vscode/package.json index 83bd1ee..745c160 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -241,6 +241,15 @@ "type": "integer", "default": 10000, "description": "%jdk.debugger.configuration.completion.warning.time.description%" + }, + "jdk.telemetry.enabled": { + "type": "boolean", + "description": "%jdk.configuration.telemetry.enabled.description%", + "markdownDescription": "%jdk.configuration.telemetry.enabled.markdownDescription%", + "default": false, + "tags": [ + "telemetry" + ] } } }, @@ -808,4 +817,4 @@ "jsonc-parser": "3.3.1", "vscode-languageclient": "^9.0.1" } -} +} \ No newline at end of file diff --git a/vscode/package.nls.ja.json b/vscode/package.nls.ja.json index 6c5d838..aa1daec 100755 --- a/vscode/package.nls.ja.json +++ b/vscode/package.nls.ja.json @@ -46,6 +46,8 @@ "jdk.configuration.runConfig.cwd.description": "作業ディレクトリ", "jdk.configuration.disableNbJavac.description": "拡張オプション: nb-javacライブラリを無効化すると、選択したJDKからのjavacが使用されます。選択したJDKは少なくともJDK 24である必要があります。", "jdk.configuration.disableProjectSearchLimit.description": "拡張オプション: プロジェクト情報が含まれているフォルダの検索に対する制限を無効化します。", + "jdk.configuration.telemetry.enabled.description": "Allow 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 https://github.com/oracle/javavscode/blob/main/vscode/TELEMETRY.md", + "jdk.configuration.telemetry.enabled.markdownDescription": "Allow 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](https://github.com/oracle/javavscode/blob/main/vscode/TELEMETRY.md).", "jdk.debugger.configuration.mainClass.markdownDescription": "Main class specification. Supported formats:\n - an absolute path\n - a path relative to any of the workspace folders\n - a fully qualified name of a class.", "jdk.debugger.configuration.classPaths.description": "JVMの起動のためのクラスパス。", "jdk.debugger.configuration.console.description": "プログラムを起動する指定されたコンソール。", @@ -66,4 +68,4 @@ "jdk.configurationSnippets.name": "Javaアプリケーションの起動", "jdk.configurationSnippets.label": "Java+: Javaアプリケーションの起動", "jdk.configurationSnippets.description": "デバッグ・モードでのJavaアプリケーションの起動" -} \ No newline at end of file +} diff --git a/vscode/package.nls.json b/vscode/package.nls.json index 556974a..8f4c4d7 100644 --- a/vscode/package.nls.json +++ b/vscode/package.nls.json @@ -46,6 +46,8 @@ "jdk.configuration.runConfig.cwd.description": "Working directory", "jdk.configuration.disableNbJavac.description": "Advanced option: disable nb-javac library, javac from the selected JDK will be used. The selected JDK must be at least JDK 24.", "jdk.configuration.disableProjectSearchLimit.description": "Advanced option: disable limits on searching in containing folders for project information.", + "jdk.configuration.telemetry.enabled.description": "Allow 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 https://github.com/oracle/javavscode/blob/main/vscode/TELEMETRY.md", + "jdk.configuration.telemetry.enabled.markdownDescription": "Allow 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](https://github.com/oracle/javavscode/blob/main/vscode/TELEMETRY.md).", "jdk.debugger.configuration.mainClass.markdownDescription": "Main class specification. Supported formats:\n - an absolute path\n - a path relative to any of the workspace folders\n - a fully qualified name of a class.", "jdk.debugger.configuration.classPaths.description": "The classpaths for launching the JVM.", "jdk.debugger.configuration.console.description": "The specified console to launch the program.", diff --git a/vscode/package.nls.zh-cn.json b/vscode/package.nls.zh-cn.json index 571b2db..b7573c8 100755 --- a/vscode/package.nls.zh-cn.json +++ b/vscode/package.nls.zh-cn.json @@ -46,6 +46,8 @@ "jdk.configuration.runConfig.cwd.description": "工作目录", "jdk.configuration.disableNbJavac.description": "高级选项:禁用 nb-javac 库,将使用来自所选 JDK 的 javac。所选 JDK 必须至少为 JDK 24。", "jdk.configuration.disableProjectSearchLimit.description": "高级选项:禁用在包含项目信息的文件夹中搜索的限制。", + "jdk.configuration.telemetry.enabled.description": "Allow 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 https://github.com/oracle/javavscode/blob/main/vscode/TELEMETRY.md", + "jdk.configuration.telemetry.enabled.markdownDescription": "Allow 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](https://github.com/oracle/javavscode/blob/main/vscode/TELEMETRY.md)", "jdk.debugger.configuration.mainClass.markdownDescription": "Main class specification. Supported formats:\n - an absolute path\n - a path relative to any of the workspace folders\n - a fully qualified name of a class.", "jdk.debugger.configuration.classPaths.description": "用于启动 JVM 的类路径。", "jdk.debugger.configuration.console.description": "用于启动程序的指定控制台。", @@ -66,4 +68,4 @@ "jdk.configurationSnippets.name": "启动 Java 应用程序", "jdk.configurationSnippets.label": "Java+:启动 Java 应用程序", "jdk.configurationSnippets.description": "以调试模式启动 Java 应用程序" -} \ No newline at end of file +} diff --git a/vscode/src/configurations/configuration.ts b/vscode/src/configurations/configuration.ts index eea3132..fadbe64 100644 --- a/vscode/src/configurations/configuration.ts +++ b/vscode/src/configurations/configuration.ts @@ -30,7 +30,8 @@ export const configKeys = { runConfigEnv: 'runConfig.env', verbose: 'verbose', userdir: 'userdir', - revealInActivteProj: "revealActiveInProjects" + revealInActivteProj: "revealActiveInProjects", + telemetryEnabled: 'telemetry.enabled', }; export const builtInConfigKeys = { diff --git a/vscode/src/configurations/handlers.ts b/vscode/src/configurations/handlers.ts index 2263bd8..33888e4 100644 --- a/vscode/src/configurations/handlers.ts +++ b/vscode/src/configurations/handlers.ts @@ -44,6 +44,10 @@ export const getBuiltinConfigurationValue = (key: string, defaultValue: T | u return defaultValue != undefined ? conf?.get(confKey, defaultValue) : conf?.get(confKey) as T; } +export const inspectConfiguration = (config: string) => { + return workspace.getConfiguration().inspect(config); +} + export const jdkHomeValueHandler = (): string | null => { return getConfigurationValue(configKeys.jdkHome) || process.env.JDK_HOME || diff --git a/vscode/src/constants.ts b/vscode/src/constants.ts index ffdf783..18d93c1 100644 --- a/vscode/src/constants.ts +++ b/vscode/src/constants.ts @@ -26,13 +26,13 @@ export namespace extConstants { } export namespace jdkDownloaderConstants { - + export const ORACLE_JDK_RELEASES_BASE_URL = `https://java.oraclecloud.com/currentJavaReleases`; export const ORACLE_JDK_BASE_DOWNLOAD_URL = `https://download.oracle.com/java`; export const ORACLE_JDK_FALLBACK_VESIONS = ['24', '21']; - + export const OPEN_JDK_VERSION_DOWNLOAD_LINKS: { [key: string]: string } = { "24": "https://download.java.net/java/GA/jdk24.0.1/24a58e0e276943138bf3e963e6291ac2/9/GPL/openjdk-24.0.1" }; diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 1dce05d..41ff804 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -33,11 +33,14 @@ import { registerFileProviders } from './lsp/listeners/textDocumentContentProvid import { ExtensionContextInfo } from './extensionContextInfo'; import { ClientPromise } from './lsp/clientPromise'; import { globalState } from './globalState'; +import { Telemetry } from './telemetry/telemetry'; export function activate(context: ExtensionContext): VSNetBeansAPI { - globalState.initialize(new ExtensionContextInfo(context), new ClientPromise()); + const contextInfo = new ExtensionContextInfo(context); + globalState.initialize(contextInfo, new ClientPromise()); globalState.getClientPromise().initialize(); + Telemetry.initializeTelemetry(contextInfo); registerConfigChangeListeners(context); clientInit(); @@ -59,6 +62,7 @@ export function activate(context: ExtensionContext): VSNetBeansAPI { export function deactivate(): Thenable { + Telemetry.enqueueCloseEvent(); const process = globalState.getNbProcessManager()?.getProcess(); if (process != null) { process?.kill(); diff --git a/vscode/src/extensionContextInfo.ts b/vscode/src/extensionContextInfo.ts index da0cdeb..fc893a4 100644 --- a/vscode/src/extensionContextInfo.ts +++ b/vscode/src/extensionContextInfo.ts @@ -23,4 +23,7 @@ export class ExtensionContextInfo { getExtensionStorageUri = () => this.context.extensionUri; getExtensionContext = () => this.context; pushSubscription = (listener: Disposable) => this.context.subscriptions.push(listener); + getExtensionId = () => this.context.extension.id; + getPackageJson = () => this.context.extension.packageJSON; + getVscGlobalState = () => this.context.globalState; } \ No newline at end of file diff --git a/vscode/src/logger.ts b/vscode/src/logger.ts index c0c4fa4..660956a 100644 --- a/vscode/src/logger.ts +++ b/vscode/src/logger.ts @@ -20,13 +20,16 @@ enum LogLevel { INFO = 'INFO', WARN = 'WARN', ERROR = 'ERROR', + DEBUG = 'DEBUG', } export class ExtensionLogger { private outChannel: OutputChannel; + private isDebugLogEnabled: boolean; constructor(channelName: string) { this.outChannel = window.createOutputChannel(channelName); + this.isDebugLogEnabled = process.env['oracle_oracleJava_enable_debugLogs'] === "true"; } public log(message: string): void { @@ -44,6 +47,13 @@ export class ExtensionLogger { this.printLog(formattedMessage); } + public debug(message: string): void { + if(this.isDebugLogEnabled){ + const formattedMessage = `[${LogLevel.DEBUG}]: ${message}`; + this.printLog(formattedMessage); + } + } + public logNoNL(message: string): void { this.outChannel.append(message); } diff --git a/vscode/src/lsp/initializer.ts b/vscode/src/lsp/initializer.ts index 793967b..36816b7 100644 --- a/vscode/src/lsp/initializer.ts +++ b/vscode/src/lsp/initializer.ts @@ -28,6 +28,7 @@ import { registerNotificationListeners } from "./listeners/notifications/registe import { registerRequestListeners } from "./listeners/requests/register"; import { createViews } from "../views/initializer"; import { globalState } from "../globalState"; +import { Telemetry } from "../telemetry/telemetry"; const establishConnection = () => new Promise((resolve, reject) => { const nbProcessManager = globalState.getNbProcessManager(); @@ -107,9 +108,7 @@ export const clientInit = () => { const client = NbLanguageClient.build(connection, LOGGER); LOGGER.log('Language Client: Starting'); - client.start().then(() => { - - + client.start().then(() => { registerListenersAfterClientInit(); registerNotificationListeners(client); registerRequestListeners(client); diff --git a/vscode/src/lsp/launchOptions.ts b/vscode/src/lsp/launchOptions.ts index d104414..646df53 100644 --- a/vscode/src/lsp/launchOptions.ts +++ b/vscode/src/lsp/launchOptions.ts @@ -53,7 +53,8 @@ const extraLaunchOptions = [ "-J-XX:PerfMaxStringConstLength=10240", "--locale", l10n.nbLocaleCode(), "--start-java-language-server=listen-hash:0", - "--start-java-debug-adapter-server=listen-hash:0" + "--start-java-debug-adapter-server=listen-hash:0", + "-J--add-exports=jdk.compiler/com.sun.tools.javac.resources=ALL-UNNAMED" ]; const prepareUserConfigLaunchOptions = (): string[] => { diff --git a/vscode/src/lsp/listeners/notifications/handlers.ts b/vscode/src/lsp/listeners/notifications/handlers.ts index 0db0c48..2ef5cf6 100644 --- a/vscode/src/lsp/listeners/notifications/handlers.ts +++ b/vscode/src/lsp/listeners/notifications/handlers.ts @@ -23,6 +23,9 @@ import { configKeys } from "../../../configurations/configuration"; import { builtInCommands } from "../../../commands/commands"; import { LOGGER } from '../../../logger'; import { globalState } from "../../../globalState"; +import { WorkspaceChangeData, WorkspaceChangeEvent } from "../../../telemetry/events/workspaceChange"; +import { Telemetry } from "../../../telemetry/telemetry"; +import { JdkFeatureEvent, JdkFeatureEventData } from "../../../telemetry/events/jdkFeature"; const checkInstallNbJavac = (msg: string) => { const NO_JAVA_SUPPORT = "Cannot initialize Java support"; @@ -42,8 +45,8 @@ const checkInstallNbJavac = (msg: string) => { } } -const showStatusBarMessageHandler = (params : ShowStatusMessageParams) => { - let decorated : string = params.message; +const showStatusBarMessageHandler = (params: ShowStatusMessageParams) => { + let decorated: string = params.message; let defTimeout; switch (params.type) { @@ -98,7 +101,7 @@ const textEditorDecorationDisposeHandler = (param: any) => { if (decorationType) { globalState.removeDecoration(param); decorationType.dispose(); - + globalState.getDecorationParamsByUri().forEach((value, key) => { if (value.key == param) { globalState.removeDecorationParams(key); @@ -109,6 +112,30 @@ const textEditorDecorationDisposeHandler = (param: any) => { const telemetryEventHandler = (param: any) => { + if (WorkspaceChangeEvent.NAME === param?.name) { + const { projectInfo, numProjects, lspInitTimeTaken, projInitTimeTaken } = param?.properties; + const eventData: WorkspaceChangeData = { + projectInfo, + numProjects, + lspInitTimeTaken, + projInitTimeTaken + }; + const workspaceChangeEvent: WorkspaceChangeEvent = new WorkspaceChangeEvent(eventData); + Telemetry.sendTelemetry(workspaceChangeEvent); + return; + } + if (JdkFeatureEvent.NAME === param?.name) { + const { javaVersion, names, jeps, isPreviewEnabled } = param?.properties; + const eventData: JdkFeatureEventData = { + jeps, + names, + javaVersion, + isPreviewEnabled + }; + const jdkFeatureEvent: JdkFeatureEvent = new JdkFeatureEvent(eventData); + Telemetry.sendTelemetry(jdkFeatureEvent); + return; + } const ls = globalState.getListener(param); if (ls) { for (const listener of ls) { @@ -117,7 +144,7 @@ const telemetryEventHandler = (param: any) => { } } -export const notificationListeners : notificationOrRequestListenerType[] = [{ +export const notificationListeners: notificationOrRequestListenerType[] = [{ type: StatusMessageRequest.type, handler: showStatusBarMessageHandler }, { @@ -126,13 +153,13 @@ export const notificationListeners : notificationOrRequestListenerType[] = [{ }, { type: TestProgressNotification.type, handler: testProgressHandler -},{ +}, { type: TextEditorDecorationSetNotification.type, handler: textEditorSetDecorationHandler -},{ +}, { type: TextEditorDecorationDisposeNotification.type, handler: textEditorDecorationDisposeHandler -},{ +}, { type: TelemetryEventNotification.type, handler: telemetryEventHandler }]; \ No newline at end of file diff --git a/vscode/src/lsp/nbLanguageClient.ts b/vscode/src/lsp/nbLanguageClient.ts index c2f1388..05efe6e 100644 --- a/vscode/src/lsp/nbLanguageClient.ts +++ b/vscode/src/lsp/nbLanguageClient.ts @@ -22,6 +22,7 @@ import { userConfigsListenedByServer } from '../configurations/configuration'; import { restartWithJDKLater } from './utils'; import { ExtensionLogger } from '../logger'; import { globalState } from '../globalState'; +import { Telemetry } from '../telemetry/telemetry'; export class NbLanguageClient extends LanguageClient { @@ -61,6 +62,7 @@ export class NbLanguageClient extends LanguageClient { 'showHtmlPageSupport': true, 'wantsJavaSupport': true, 'wantsGroovySupport': false, + 'wantsTelemetryEnabled': Telemetry.getIsTelemetryFeatureAvailable(), 'commandPrefix': extConstants.COMMAND_PREFIX, 'configurationPrefix': `${extConstants.COMMAND_PREFIX}.`, 'altConfigurationPrefix': `${extConstants.COMMAND_PREFIX}.` diff --git a/vscode/src/telemetry/config.ts b/vscode/src/telemetry/config.ts new file mode 100644 index 0000000..f18a171 --- /dev/null +++ b/vscode/src/telemetry/config.ts @@ -0,0 +1,80 @@ +/* + 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 { RetryConfig, TelemetryApi, TelemetryConfigMetadata } from "./types"; +import * as path from 'path'; +import * as fs from 'fs'; +import { LOGGER } from "../logger"; + +export class TelemetryConfiguration { + private static CONFIG_FILE_PATH = path.resolve(__dirname, "..", "..", "telemetryConfig.json"); + + private static instance: TelemetryConfiguration; + private retryConfig!: RetryConfig; + private apiConfig!: TelemetryApi; + private metadata!: TelemetryConfigMetadata; + + public constructor() { + this.initialize(); + } + + public static getInstance(): TelemetryConfiguration { + if (!TelemetryConfiguration.instance) { + TelemetryConfiguration.instance = new TelemetryConfiguration(); + } + return TelemetryConfiguration.instance; + } + + private initialize(): void { + try { + const config = JSON.parse(fs.readFileSync(TelemetryConfiguration.CONFIG_FILE_PATH).toString()); + + this.retryConfig = Object.freeze({ + maxRetries: config.telemetryRetryConfig.maxRetries, + baseCapacity: config.telemetryRetryConfig.baseCapacity, + baseTimer: config.telemetryRetryConfig.baseTimer, + maxDelayMs: config.telemetryRetryConfig.maxDelayMs, + backoffFactor: config.telemetryRetryConfig.backoffFactor, + jitterFactor: config.telemetryRetryConfig.jitterFactor + }); + + this.apiConfig = Object.freeze({ + baseUrl: config.telemetryApi.baseUrl, + 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); + } + } + + public getRetryConfig(): Readonly { + return this.retryConfig; + } + + public getApiConfig(): Readonly { + 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 0000000..f21dbf0 --- /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 new file mode 100644 index 0000000..224dd2a --- /dev/null +++ b/vscode/src/telemetry/events/baseEvent.ts @@ -0,0 +1,65 @@ +/* + 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 { LOGGER } from "../../logger"; +import { AnonymousIdManager } from "../impl/AnonymousIdManager"; +import { cacheService } from "../impl/cacheServiceImpl"; +import { getHashCode } from "../utils"; + +export interface BaseEventPayload { + vsCodeId: string; + vscSessionId: string; +} + +export abstract class BaseEvent { + protected _payload: T & BaseEventPayload; + protected _data: T + + constructor(public readonly NAME: string, + public readonly ENDPOINT: string, + data: T + ) { + this._data = data; + this._payload = { + vsCodeId: AnonymousIdManager.machineId, + vscSessionId: AnonymousIdManager.sessionId, + ...data + }; + } + + get getPayload(): T & BaseEventPayload { + return this._payload; + } + + get getData(): T { + return this._data; + } + + public onSuccessPostEventCallback = async (): Promise => { + LOGGER.debug(`${this.NAME} sent successfully`); + } + + public onFailPostEventCallback = async (): Promise => { + LOGGER.debug(`${this.NAME} send failed`); + } + + protected addEventToCache = (): void => { + const dataString = JSON.stringify(this.getData); + const calculatedHashVal = getHashCode(dataString); + 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 new file mode 100644 index 0000000..0f5abc3 --- /dev/null +++ b/vscode/src/telemetry/events/close.ts @@ -0,0 +1,39 @@ +/* + 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 { getCurrentUTCDateInSeconds } from "../utils"; +import { BaseEvent } from "./baseEvent"; + +export interface CloseEventData { + totalSessionTime: number; +} + +export class ExtensionCloseEvent extends BaseEvent { + public static readonly NAME = "close"; + public static readonly ENDPOINT = "/close"; + + constructor(payload: CloseEventData){ + super(ExtensionCloseEvent.NAME, ExtensionCloseEvent.ENDPOINT, payload); + } + + public static builder = (activationTime: number): ExtensionCloseEvent => { + const totalActiveSessionTimeInSeconds = getCurrentUTCDateInSeconds() - activationTime; + const closeEvent: ExtensionCloseEvent = new ExtensionCloseEvent({ + totalSessionTime: totalActiveSessionTimeInSeconds + }); + + return closeEvent; + } +} \ No newline at end of file diff --git a/vscode/src/telemetry/events/jdkDownload.ts b/vscode/src/telemetry/events/jdkDownload.ts new file mode 100644 index 0000000..b6eb158 --- /dev/null +++ b/vscode/src/telemetry/events/jdkDownload.ts @@ -0,0 +1,33 @@ +/* + 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 { BaseEvent } from "./baseEvent"; + +export interface JdkDownloadEventData { + vendor: string; + version: string; + os: string; + arch: string; + timeTaken: number; +} + +export class JdkDownloadEvent extends BaseEvent { + public static readonly NAME = "jdkDownload"; + public static readonly ENDPOINT = "/jdkDownload"; + + constructor(payload: JdkDownloadEventData) { + super(JdkDownloadEvent.NAME, JdkDownloadEvent.ENDPOINT, payload); + } +} \ No newline at end of file diff --git a/vscode/src/telemetry/events/jdkFeature.ts b/vscode/src/telemetry/events/jdkFeature.ts new file mode 100644 index 0000000..9d89226 --- /dev/null +++ b/vscode/src/telemetry/events/jdkFeature.ts @@ -0,0 +1,81 @@ +/* + 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 { BaseEvent } from "./baseEvent"; + +export interface JdkFeatureEventData { + jeps: number[]; + names: string[]; + javaVersion: string; + isPreviewEnabled: boolean; +} + +export class JdkFeatureEvent extends BaseEvent { + public static readonly NAME = "jdkFeature"; + public static readonly ENDPOINT = "/jdkFeature"; + + constructor(payload: JdkFeatureEventData) { + super(JdkFeatureEvent.NAME, JdkFeatureEvent.ENDPOINT, payload); + } + + public static concatEvents(events:JdkFeatureEvent[]): JdkFeatureEvent[] { + const jdkFeatureEvents = events.filter(event => event.NAME === this.NAME); + const { previewEnabledMap, previewDisabledMap } = this.groupEvents(jdkFeatureEvents); + + return [ + ...this.createEventsFromMap(previewEnabledMap, true), + ...this.createEventsFromMap(previewDisabledMap, false) + ]; + } + + private static createEventsFromMap( + map: Map, + isPreviewEnabled: boolean + ): JdkFeatureEvent[] { + return Array.from(map.entries()).map(([javaVersion, events]) => { + const jeps: number[] = []; + const names: string[] = []; + + events.forEach(event => { + if (event.getPayload.jeps) jeps.push(...event.getPayload.jeps); + if (event.getPayload.names) names.push(...event.getPayload.names); + }); + + return new JdkFeatureEvent({ + jeps, + names, + javaVersion, + isPreviewEnabled + }); + }); + } + + private static groupEvents(jdkFeatureEvents: JdkFeatureEvent[]) { + return jdkFeatureEvents.reduce((acc, event) => { + const { isPreviewEnabled, javaVersion } = event.getPayload; + const targetMap = isPreviewEnabled ? acc.previewEnabledMap : acc.previewDisabledMap; + + if (!targetMap.has(javaVersion)) { + targetMap.set(javaVersion, []); + } + targetMap.get(javaVersion)!.push(event); + + return acc; + }, { + previewEnabledMap: new Map(), + previewDisabledMap: new Map() + }); + } +} \ No newline at end of file diff --git a/vscode/src/telemetry/events/start.ts b/vscode/src/telemetry/events/start.ts new file mode 100644 index 0000000..9334064 --- /dev/null +++ b/vscode/src/telemetry/events/start.ts @@ -0,0 +1,81 @@ +/* + 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 { globalState } from "../../globalState"; +import { LOGGER } from "../../logger"; +import { cacheService } from "../impl/cacheServiceImpl"; +import { getEnvironmentInfo } from "../impl/enviromentDetails"; +import { getHashCode } from "../utils"; +import { BaseEvent } from "./baseEvent"; + +interface ExtensionInfo { + id: string; + name: string; + version: string; +} + +interface VscodeInfo { + version: string; + hostType: string; + locale: string; +} + +interface PlatformInfo { + os: string; + arch: string; + osVersion: string; +} + +interface LocationInfo { + timeZone: string; + locale: string; +} + +export interface StartEventData { + extension: ExtensionInfo; + vsCode: VscodeInfo; + platform: PlatformInfo; + location: LocationInfo; +} + +export class ExtensionStartEvent extends BaseEvent { + public static readonly NAME = "startup"; + public static readonly ENDPOINT = "/start"; + + constructor(payload: StartEventData) { + super(ExtensionStartEvent.NAME, ExtensionStartEvent.ENDPOINT, payload); + } + + onSuccessPostEventCallback = async (): Promise => { + LOGGER.debug(`Start event sent successfully`); + this.addEventToCache(); + } + + public static builder = (): ExtensionStartEvent | null => { + const startEventData = getEnvironmentInfo(globalState.getExtensionContextInfo()); + const cachedValue: string | undefined = cacheService.get(this.NAME); + const envString = JSON.stringify(startEventData); + const newValue = getHashCode(envString); + + if (cachedValue != newValue) { + const startEvent: ExtensionStartEvent = new ExtensionStartEvent(startEventData); + return startEvent; + } + + LOGGER.debug(`No change in start event`); + + return null; + } +} diff --git a/vscode/src/telemetry/events/workspaceChange.ts b/vscode/src/telemetry/events/workspaceChange.ts new file mode 100644 index 0000000..23f3b43 --- /dev/null +++ b/vscode/src/telemetry/events/workspaceChange.ts @@ -0,0 +1,53 @@ +/* + 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 { LOGGER } from "../../logger"; +import { Telemetry } from "../telemetry"; +import { BaseEvent } from "./baseEvent"; + +interface ProjectInfo { + id: string; + buildTool: string; + javaVersion: string; + isOpenedWithProblems: boolean; + isPreviewEnabled: boolean; +} + +export interface WorkspaceChangeData { + projectInfo: ProjectInfo[]; + numProjects: number; + lspInitTimeTaken: number; + projInitTimeTaken: number; +} + +let workspaceChangeEventTimeout: NodeJS.Timeout | null = null; + +export class WorkspaceChangeEvent extends BaseEvent { + public static readonly NAME = "workspaceChange"; + public static readonly ENDPOINT = "/workspaceChange"; + + constructor(payload: WorkspaceChangeData) { + super(WorkspaceChangeEvent.NAME, WorkspaceChangeEvent.ENDPOINT, payload); + } + + public onSuccessPostEventCallback = async (): Promise => { + LOGGER.debug(`WorkspaceChange event sent successfully`); + if (workspaceChangeEventTimeout != null) { + clearTimeout(workspaceChangeEventTimeout); + workspaceChangeEventTimeout = null; + } + workspaceChangeEventTimeout = setTimeout(() => Telemetry.sendTelemetry(this), 60 * 60 * 24 * 1000); + }; +} \ No newline at end of file diff --git a/vscode/src/telemetry/impl/AnonymousIdManager.ts b/vscode/src/telemetry/impl/AnonymousIdManager.ts new file mode 100644 index 0000000..092475c --- /dev/null +++ b/vscode/src/telemetry/impl/AnonymousIdManager.ts @@ -0,0 +1,21 @@ +/* + 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 { env } from "vscode"; + +export class AnonymousIdManager { + public static readonly machineId: string = env.machineId; + public static readonly sessionId: string = env.sessionId; +} \ No newline at end of file diff --git a/vscode/src/telemetry/impl/cacheServiceImpl.ts b/vscode/src/telemetry/impl/cacheServiceImpl.ts new file mode 100644 index 0000000..8374687 --- /dev/null +++ b/vscode/src/telemetry/impl/cacheServiceImpl.ts @@ -0,0 +1,44 @@ +/* + 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"; + +class CacheServiceImpl implements CacheService { + public get = (key: string): string | undefined => { + try { + const vscGlobalState = globalState.getExtensionContextInfo().getVscGlobalState(); + 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 => { + 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}`); + return false; + } + } +} + +export const cacheService = new CacheServiceImpl(); \ No newline at end of file diff --git a/vscode/src/telemetry/impl/enviromentDetails.ts b/vscode/src/telemetry/impl/enviromentDetails.ts new file mode 100644 index 0000000..c14d267 --- /dev/null +++ b/vscode/src/telemetry/impl/enviromentDetails.ts @@ -0,0 +1,76 @@ +/* + 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 * as os from 'os'; +import { env as vscodeEnv, version } from 'vscode'; +import { ExtensionContextInfo } from '../../extensionContextInfo'; +import { StartEventData } from '../events/start'; + +const getPlatform = (): string => { + const platform: string = os.platform(); + if (platform.startsWith('darwin')) { + return 'Mac'; + } + if (platform.startsWith('win')) { + return 'Windows'; + } + return platform.charAt(0).toUpperCase() + platform.slice(1); +} + +const getArchType = (): string => { + return os.arch(); +} + +const getPlatformVersion = (): string => { + return os.release(); +} + +const getTimeZone = (): string => { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +const getLocale = (): string => { + return Intl.DateTimeFormat().resolvedOptions().locale; +} + +export const PLATFORM = getPlatform(); +export const ARCH_TYPE = getArchType(); +export const PLATFORM_VERSION = getPlatformVersion(); +export const TIMEZONE = getTimeZone(); +export const LOCALE = getLocale(); + +export const getEnvironmentInfo = (contextInfo: ExtensionContextInfo): StartEventData => { + return { + extension: { + id: contextInfo.getExtensionId(), + name: contextInfo.getPackageJson().name, + version: contextInfo.getPackageJson().version + }, + vsCode: { + version: version, + hostType: vscodeEnv.appHost, + locale: vscodeEnv.language, + }, + platform: { + os: PLATFORM, + arch: ARCH_TYPE, + osVersion: PLATFORM_VERSION, + }, + location: { + timeZone: TIMEZONE, + locale: LOCALE, + } + }; +} \ No newline at end of file diff --git a/vscode/src/telemetry/impl/postTelemetry.ts b/vscode/src/telemetry/impl/postTelemetry.ts new file mode 100644 index 0000000..2fbe8b5 --- /dev/null +++ b/vscode/src/telemetry/impl/postTelemetry.ts @@ -0,0 +1,98 @@ +/* + 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 { integer } from "vscode-languageclient"; +import { LOGGER } from "../../logger"; +import { TelemetryConfiguration } from "../config"; +import { BaseEvent } from "../events/baseEvent"; + +interface TelemetryEventResponse { + statusCode: number; + event: BaseEvent; +}; + +export interface TelemetryPostResponse { + success: TelemetryEventResponse[]; + failures: TelemetryEventResponse[]; +}; + +export class PostTelemetry { + private TELEMETRY_API = TelemetryConfiguration.getInstance().getApiConfig(); + + public post = async (events: BaseEvent[]): Promise => { + try { + if (this.TELEMETRY_API.baseUrl == null) { + return { + success: [], + failures: [] + } + } + LOGGER.debug("Posting telemetry..."); + const results = await Promise.allSettled(events.map(event => this.postEvent(event))); + + return this.parseTelemetryResponse(events, results); + } catch (err) { + LOGGER.debug(`Error occurred while posting telemetry : ${(err as Error)?.message}`); + throw err; + } + }; + + private addBaseEndpoint = (endpoint: string) => { + return `${this.TELEMETRY_API.baseUrl}${this.TELEMETRY_API.baseEndpoint}${this.TELEMETRY_API.version}${endpoint}`; + } + + private postEvent = (event: BaseEvent): Promise => { + const { ENDPOINT, getPayload: payload } = event; + + const serverEndpoint = this.addBaseEndpoint(ENDPOINT); + + return fetch(serverEndpoint, { + method: "POST", + body: JSON.stringify(payload), + redirect: "follow", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }); + } + + private parseTelemetryResponse = (events: BaseEvent[], eventResponses: PromiseSettledResult[]): TelemetryPostResponse => { + let success: TelemetryEventResponse[] = [], failures: TelemetryEventResponse[] = []; + eventResponses.forEach((eventResponse, index) => { + const event = events[index]; + let list: TelemetryEventResponse[] = success; + let statusCode: integer = 0; + if (eventResponse.status === "rejected") { + list = failures; + statusCode = -1; + } else { + statusCode = eventResponse.value.status; + if (statusCode <= 0 || statusCode >= 400) { + list = failures; + } + } + list.push({ + event, + statusCode + }); + }); + + return { + success, + failures + }; + } +} \ No newline at end of file diff --git a/vscode/src/telemetry/impl/telemetryEventQueue.ts b/vscode/src/telemetry/impl/telemetryEventQueue.ts new file mode 100644 index 0000000..5a6e44f --- /dev/null +++ b/vscode/src/telemetry/impl/telemetryEventQueue.ts @@ -0,0 +1,61 @@ +/* + 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 { LOGGER } from "../../logger"; +import { BaseEvent } from "../events/baseEvent"; + +export class TelemetryEventQueue { + private events: BaseEvent[] = []; + + public enqueue = (e: BaseEvent): void => { + this.events.push(e); + } + + public dequeue = (): BaseEvent | undefined => this.events.shift(); + + public concatQueue = (queue: BaseEvent[], mergeAtStarting = false): void => { + this.events = mergeAtStarting ? [...queue, ...this.events] : [...this.events, ...queue]; + } + + public size = (): number => this.events.length; + + public flush = (): BaseEvent[] => { + const queue = [...this.events]; + 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 new file mode 100644 index 0000000..620a677 --- /dev/null +++ b/vscode/src/telemetry/impl/telemetryPrefs.ts @@ -0,0 +1,171 @@ +/* + 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 { 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 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.isTelemetryEnabled = 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); + } + + public isTelemetrySettingSet = (): boolean => { + if (this.isTelemetryEnabled === undefined) return false; + const config = inspectConfiguration(this.CONFIG); + return ( + config?.globalValue !== undefined || + config?.globalLanguageValue !== undefined + ); + } +} + +class VscodeTelemetryPreference implements TelemetryPreference { + private isTelemetryEnabled: boolean; + + constructor() { + this.isTelemetryEnabled = env.isTelemetryEnabled; + } + + public getIsTelemetryEnabled = (): boolean => this.isTelemetryEnabled; + + 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 new file mode 100644 index 0000000..7478acd --- /dev/null +++ b/vscode/src/telemetry/impl/telemetryReporterImpl.ts @@ -0,0 +1,149 @@ +/* + 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 { getCurrentUTCDateInSeconds } from "../utils"; +import { TelemetryEventQueue } from "./telemetryEventQueue"; +import { TelemetryReporter } from "../types"; +import { LOGGER } from "../../logger"; +import { isError } from "../../utils"; +import { BaseEvent } from "../events/baseEvent"; +import { ExtensionCloseEvent } from "../events/close"; +import { ExtensionStartEvent } from "../events/start"; +import { TelemetryRetry } from "./telemetryRetry"; +import { JdkFeatureEvent } from "../events/jdkFeature"; +import { PostTelemetry, TelemetryPostResponse } from "./postTelemetry"; + +export class TelemetryReporterImpl implements TelemetryReporter { + private activationTime: number = getCurrentUTCDateInSeconds(); + 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, + private retryManager: TelemetryRetry, + ) { + this.retryManager.registerCallbackHandler(this.sendEvents); + } + + public startEvent = (): void => { + this.setOnCloseEventState(); + this.retryManager.startTimer(); + + const extensionStartEvent = ExtensionStartEvent.builder(); + 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); + + LOGGER.debug(`Close event enqueued: ${extensionCloseEvent.getPayload}`); + this.sendEvents(); + } + + public addEventToQueue = (event: BaseEvent): void => { + 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()) { + LOGGER.debug(`Queue is empty nothing to send`); + return; + } + const eventsCollected = this.queue.flush(); + + LOGGER.debug(`Number of events to send: ${eventsCollected.length}`); + this.retryManager.clearTimer(); + + const transformedEvents = this.transformEvents(eventsCollected); + + const response = await this.postTelemetry.post(transformedEvents); + + LOGGER.debug(`Number of events successfully sent: ${response.success.length}`); + LOGGER.debug(`Number of events failed to send: ${response.failures.length}`); + const isAllEventsSuccess = this.handlePostTelemetryResponse(response); + + this.retryManager.startTimer(isAllEventsSuccess); + + this.increaseRetryCountOrDisableRetry(); + } catch (err: any) { + 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): 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 new file mode 100644 index 0000000..8580a79 --- /dev/null +++ b/vscode/src/telemetry/impl/telemetryRetry.ts @@ -0,0 +1,148 @@ +/* + 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 { LOGGER } from "../../logger"; +import { TelemetryConfiguration } from "../config"; +import { BaseEvent } from "../events/baseEvent"; +import { TelemetryPostResponse } from "./postTelemetry"; + +export class TelemetryRetry { + private TELEMETRY_RETRY_CONFIG = TelemetryConfiguration.getInstance().getRetryConfig(); + private timePeriod: number = this.TELEMETRY_RETRY_CONFIG?.baseTimer; + private timeout?: NodeJS.Timeout | null; + private numOfAttemptsWhenTimerHits: number = 1; + private queueCapacity: number = this.TELEMETRY_RETRY_CONFIG?.baseCapacity; + private numOfAttemptsWhenQueueIsFull: number = 1; + private triggeredDueToQueueOverflow: boolean = false; + private callbackHandler?: () => {}; + + public registerCallbackHandler = (callbackHandler: () => {}): void => { + this.callbackHandler = callbackHandler; + } + + public startTimer = (resetParameters: boolean = true): void => { + if (!this.callbackHandler) { + LOGGER.debug("Callback handler is not set for telemetry retry mechanism"); + return; + } + 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) { + this.timePeriod = this.calculateDelay(); + this.numOfAttemptsWhenTimerHits++; + return; + } + LOGGER.debug("Keeping timer same as max capactiy reached"); + } + + public clearTimer = (): void => { + if (this.timeout) { + clearInterval(this.timeout); + this.timeout = null; + } + } + + private calculateDelay = (): number => { + const baseDelay = this.TELEMETRY_RETRY_CONFIG.baseTimer * + Math.pow(this.TELEMETRY_RETRY_CONFIG.backoffFactor, this.numOfAttemptsWhenTimerHits); + + const cappedDelay = Math.min(baseDelay, this.TELEMETRY_RETRY_CONFIG.maxDelayMs); + + const jitterMultiplier = 1 + (Math.random() * 2 - 1) * this.TELEMETRY_RETRY_CONFIG.jitterFactor; + + return Math.floor(cappedDelay * jitterMultiplier); + }; + + private increaseQueueCapacity = (): void => { + if (this.numOfAttemptsWhenQueueIsFull < this.TELEMETRY_RETRY_CONFIG.maxRetries) { + this.queueCapacity = this.TELEMETRY_RETRY_CONFIG.baseCapacity * + Math.pow(this.TELEMETRY_RETRY_CONFIG.backoffFactor, this.numOfAttemptsWhenQueueIsFull); + } + 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; + } + + public isQueueOverflow = (queueSize: number): boolean => { + if (queueSize >= this.queueCapacity) { + this.triggeredDueToQueueOverflow = true; + return true; + } + return false; + } + + private isEventRetryable = (statusCode: number): boolean => { + return statusCode <= 0 || statusCode > 500 || statusCode == 429; + } + + public eventsToBeEnqueuedAgain = (eventResponses: TelemetryPostResponse): BaseEvent[] => { + eventResponses.success.forEach(res => { + res.event.onSuccessPostEventCallback(); + }); + + if (eventResponses.failures.length) { + const eventsToBeEnqueuedAgain: BaseEvent[] = []; + eventResponses.failures.forEach((eventRes) => { + if (this.isEventRetryable(eventRes.statusCode)) + eventsToBeEnqueuedAgain.push(eventRes.event); + }); + + if (eventsToBeEnqueuedAgain.length) { + + this.triggeredDueToQueueOverflow ? + this.increaseQueueCapacity() : + this.increaseTimePeriod(); + LOGGER.debug(`Queue max capacity size: ${this.queueCapacity}`); + LOGGER.debug(`Timer period: ${this.timePeriod}`); + } else { + eventResponses.failures.forEach(res => { + res.event.onFailPostEventCallback(); + }); + } + + return eventsToBeEnqueuedAgain; + } + + return []; + } +} \ No newline at end of file diff --git a/vscode/src/telemetry/telemetry.ts b/vscode/src/telemetry/telemetry.ts new file mode 100644 index 0000000..fc55d4c --- /dev/null +++ b/vscode/src/telemetry/telemetry.ts @@ -0,0 +1,61 @@ +/* + 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 { TelemetryManager } from "./telemetryManager"; +import { ExtensionContextInfo } from "../extensionContextInfo"; +import { LOGGER } from "../logger"; +import { BaseEvent } from "./events/baseEvent"; +import { TelemetryReporter } from "./types"; +import { TelemetryConfiguration } from "./config"; + +export namespace Telemetry { + + let telemetryManager: TelemetryManager; + + export const getIsTelemetryFeatureAvailable = (): boolean => { + const TELEMETRY_API = TelemetryConfiguration.getInstance()?.getApiConfig(); + return TELEMETRY_API?.baseUrl != null && TELEMETRY_API?.baseUrl.trim().length > 0; + } + + export const initializeTelemetry = (contextInfo: ExtensionContextInfo): TelemetryManager => { + if (!!telemetryManager) { + LOGGER.warn("Telemetry is already initialized"); + return telemetryManager; + } + telemetryManager = new TelemetryManager(contextInfo); + if (getIsTelemetryFeatureAvailable()) { + telemetryManager.initializeReporter(); + } + + return telemetryManager; + } + + const enqueueEvent = (cbFunction: (reporter: TelemetryReporter) => void) => { + if (telemetryManager.isTelemetryEnabled() && getIsTelemetryFeatureAvailable()) { + const reporter = telemetryManager.getReporter(); + if (reporter) { + cbFunction(reporter); + } + } + } + + export const sendTelemetry = (event: BaseEvent): void => { + enqueueEvent((reporter) => reporter.addEventToQueue(event)); + } + + export const enqueueCloseEvent = (): void => { + enqueueEvent((reporter) => reporter.closeEvent()); + } +} diff --git a/vscode/src/telemetry/telemetryManager.ts b/vscode/src/telemetry/telemetryManager.ts new file mode 100644 index 0000000..28b1242 --- /dev/null +++ b/vscode/src/telemetry/telemetryManager.ts @@ -0,0 +1,81 @@ +/* + 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 { window } from "vscode"; +import { TelemetrySettings } from "./impl/telemetryPrefs"; +import { TelemetryEventQueue } from "./impl/telemetryEventQueue"; +import { TelemetryReporterImpl } from "./impl/telemetryReporterImpl"; +import { TelemetryReporter } from "./types"; +import { LOGGER } from "../logger"; +import { ExtensionContextInfo } from "../extensionContextInfo"; +import { l10n } from "../localiser"; +import { TelemetryRetry } from "./impl/telemetryRetry"; + +export class TelemetryManager { + private extensionContextInfo: ExtensionContextInfo; + 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 isTelemetryEnabled = (): boolean => { + return this.settings.getIsTelemetryEnabled(); + } + + public initializeReporter = (): void => { + const queue = new TelemetryEventQueue(); + this.reporter = new TelemetryReporterImpl(queue, this.telemetryRetryManager); + + this.isTelemetryEnabled() ? this.onTelemetryEnable() : this.openTelemetryDialog(); + } + + public getReporter = (): TelemetryReporter | null => { + return this.reporter || null; + } + + private openTelemetryDialog = async () => { + if (this.settings?.isConsentPopupToBeTriggered()) { + LOGGER.log('Telemetry not enabled yet'); + + const yesLabel = l10n.value("jdk.downloader.message.confirmation.yes"); + const noLabel = l10n.value("jdk.downloader.message.confirmation.no"); + const telemetryLabel = l10n.value("jdk.telemetry.consent"); + + const enable = await window.showInformationMessage(telemetryLabel, yesLabel, noLabel); + if (enable == undefined) { + return; + } + + 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 new file mode 100644 index 0000000..bb273d2 --- /dev/null +++ b/vscode/src/telemetry/types.ts @@ -0,0 +1,63 @@ +/* + 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 { BaseEvent } from "./events/baseEvent"; +import { Disposable } from "vscode"; + +export interface TelemetryReporter { + startEvent(): void; + + addEventToQueue(event: BaseEvent): void; + + closeEvent(): void; +} + +export interface CacheService { + get(key: string): string | undefined; + + put(key: string, value: string): Promise; +} + +export interface TelemetryEventQueue { + enqueue(e: BaseEvent): void; + + flush(): BaseEvent[]; +} + +export interface RetryConfig { + maxRetries: number; + baseTimer: number; + baseCapacity: number; + maxDelayMs: number; + backoffFactor: number; + jitterFactor: number; +} + +export interface TelemetryApi { + baseUrl: string | null; + baseEndpoint: string; + version: string; +} + +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 new file mode 100644 index 0000000..fcf1a0f --- /dev/null +++ b/vscode/src/telemetry/utils.ts @@ -0,0 +1,72 @@ +/* + 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 * as crypto from 'crypto'; +import { Uri, workspace } from 'vscode'; + +export const getCurrentUTCDateInSeconds = () => { + const date = Date.now(); + return Math.floor(date / 1000); +} + +export const getOriginalDateFromSeconds = (seconds: number) => { + return new Date(seconds * 1000); +} + +export const exists = async (pathOrUri: Uri | string): Promise => { + const uri = getUri(pathOrUri); + try { + await workspace.fs.stat(uri); + return true; + } catch (e) { + return false; + } +} + +export const writeFile = async (pathOrUri: Uri | string, content: string): Promise => { + const uri = getUri(pathOrUri); + const parent = Uri.joinPath(uri, ".."); + if (!(await exists(parent))) { + await mkdir(parent); + } + const res: Uint8Array = new TextEncoder().encode(content); + return workspace.fs.writeFile(uri, res); +} + +export const readFile = async (pathOrUri: Uri | string): Promise => { + const uri = getUri(pathOrUri); + if (!(await exists(uri))) { + return undefined; + } + const read = await workspace.fs.readFile(uri); + return new TextDecoder().decode(read); +} + +export const mkdir = async (pathOrUri: Uri | string): Promise => { + const uri = getUri(pathOrUri); + await workspace.fs.createDirectory(uri); +} + +export const getHashCode = (value: string, algorithm: string = 'sha256') => { + const hash: string = crypto.createHash(algorithm).update(value).digest('hex'); + return hash; +} + +const getUri = (pathOrUri: Uri | string): Uri => { + if (pathOrUri instanceof Uri) { + return pathOrUri; + } + return Uri.file(pathOrUri); +} diff --git a/vscode/src/test/unit/mocks/vscode/mockVscode.ts b/vscode/src/test/unit/mocks/vscode/mockVscode.ts index acb1305..0fe128a 100644 --- a/vscode/src/test/unit/mocks/vscode/mockVscode.ts +++ b/vscode/src/test/unit/mocks/vscode/mockVscode.ts @@ -17,6 +17,7 @@ import * as vscode from 'vscode'; import { URI } from './uri'; import { mockWindowNamespace } from './namespaces/window'; +import { mockEnvNamespace } from './namespaces/env'; import { mockedEnums } from './vscodeHostedTypes'; type VSCode = typeof vscode; @@ -29,6 +30,7 @@ const mockedVscodeClassesAndTypes = () => { const mockNamespaces = () => { mockWindowNamespace(mockedVSCode); + mockEnvNamespace(mockedVSCode); } export const initMockedVSCode = () => { diff --git a/vscode/src/test/unit/mocks/vscode/namespaces/env.ts b/vscode/src/test/unit/mocks/vscode/namespaces/env.ts new file mode 100644 index 0000000..3546b36 --- /dev/null +++ b/vscode/src/test/unit/mocks/vscode/namespaces/env.ts @@ -0,0 +1,32 @@ +/* + 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 * as vscode from 'vscode'; +import { mock, when, anyString, anyOfClass, anything, instance } from "ts-mockito"; + +type VSCode = typeof vscode; + +let mockedEnv: typeof vscode.env; +export const mockEnvNamespace = (mockedVSCode: Partial) => { + mockedEnv = mock(); + mockedVSCode.env = instance(mockedEnv); + mockTelemetryFields(); +} + +const mockTelemetryFields = () => { + when(mockedEnv.machineId).thenReturn("00mocked-xVSx-Code-0000-machineIdxxx"); + when(mockedEnv.sessionId).thenReturn("00mocked-xVSx-Code-0000-sessionIdxxx"); +} \ No newline at end of file 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 0000000..8ab10d7 --- /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 0000000..8e195ae --- /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 diff --git a/vscode/src/views/projects.ts b/vscode/src/views/projects.ts index 5f48453..c69e7cd 100644 --- a/vscode/src/views/projects.ts +++ b/vscode/src/views/projects.ts @@ -23,6 +23,7 @@ import { NbLanguageClient } from '../lsp/nbLanguageClient'; import { NodeChangedParams, NodeInfoNotification, NodeInfoRequest, GetResourceParams, NodeChangeType, NodeChangesParams } from '../lsp/protocol'; import { l10n } from '../localiser'; import { extCommands } from '../commands/commands'; +import { globalState } from '../globalState'; const doLog: boolean = false; const EmptyIcon = "EMPTY_ICON"; @@ -913,6 +914,7 @@ export function createTreeViewService(log: vscode.OutputChannel, c: NbLanguageCl })); } }); + globalState.getExtensionContextInfo().pushSubscription(d); const ts: TreeViewService = new TreeViewService(log, c, [d]); return ts; } diff --git a/vscode/src/webviews/jdkDownloader/action.ts b/vscode/src/webviews/jdkDownloader/action.ts index 75140da..ecd9943 100644 --- a/vscode/src/webviews/jdkDownloader/action.ts +++ b/vscode/src/webviews/jdkDownloader/action.ts @@ -14,7 +14,7 @@ limitations under the License. */ -import { commands, OpenDialogOptions, window, workspace } from "vscode"; +import { commands, OpenDialogOptions, window } from "vscode"; import { JdkDownloaderView } from "./view"; import { jdkDownloaderConstants } from "../../constants"; import * as path from 'path'; @@ -26,13 +26,17 @@ import { l10n } from "../../localiser"; import { LOGGER } from "../../logger"; import { updateConfigurationValue } from "../../configurations/handlers"; import { configKeys } from "../../configurations/configuration"; +import { Telemetry } from "../../telemetry/telemetry"; +import { JdkDownloadEvent, JdkDownloadEventData } from "../../telemetry/events/jdkDownload"; +import { getCurrentUTCDateInSeconds } from "../../telemetry/utils"; export class JdkDownloaderAction { public static readonly MANUAL_INSTALLATION_TYPE = "manual"; public static readonly AUTO_INSTALLATION_TYPE = "automatic"; private readonly DOWNLOAD_DIR = path.join(__dirname, 'jdk_downloads'); + private startTimer: number | null = null; - private jdkType?: string; + private jdkType!: string; private jdkVersion?: string; private osType?: string; private machineArch?: string; @@ -45,7 +49,7 @@ export class JdkDownloaderAction { public attachListener = async (message: any) => { const { command, id, jdkVersion, jdkOS, jdkArch, installType } = message; - if (command === JdkDownloaderView.DOWNLOAD_CMD_LABEL) { + if (command === JdkDownloaderView.DOWNLOAD_CMD) { LOGGER.log(`Request received for downloading ${id} version ${jdkVersion}`); this.jdkType = id; @@ -98,11 +102,14 @@ export class JdkDownloaderAction { LOGGER.log(`manual JDK installation completed successfully`); return; } + this.startTimer = getCurrentUTCDateInSeconds(); await this.jdkInstallationManager(); } catch (err: any) { window.showErrorMessage(l10n.value("jdk.downloader.error_message.installingJDK", { error: err })); LOGGER.error(err?.message || "No Error message received"); + } finally { + this.startTimer = null; } } @@ -125,7 +132,7 @@ export class JdkDownloaderAction { private jdkInstallationManager = async () => { const startingInstallationMessage = l10n.value("jdk.downloader.message.downloadingAndCompletingSetup", { - jdkType: this.jdkType, + jdkType: JdkDownloaderView.getJdkLabel(this.jdkType), jdkVersion: this.jdkVersion }); @@ -137,7 +144,7 @@ export class JdkDownloaderAction { } await this.downloadAndVerify(); const downloadSuccessLabel = l10n.value("jdk.downloader.message.downloadCompleted", { - jdkType: this.jdkType, + jdkType: JdkDownloaderView.getJdkLabel(this.jdkType), jdkVersion: this.jdkVersion, osType: this.osType }); @@ -153,10 +160,10 @@ export class JdkDownloaderAction { private generateDownloadUrl = (): string => { let baseDownloadUrl: string = ''; - if (this.jdkType === JdkDownloaderView.OPEN_JDK_LABEL) { + if (this.jdkType === JdkDownloaderView.JDK_TYPE.openJdk) { baseDownloadUrl = `${jdkDownloaderConstants.OPEN_JDK_VERSION_DOWNLOAD_LINKS[`${this.jdkVersion}`]}_${this.osType!.toLowerCase()}-${this.machineArch}_bin`; } - else if (this.jdkType === JdkDownloaderView.ORACLE_JDK_LABEL) { + else if (this.jdkType === JdkDownloaderView.JDK_TYPE.oracleJdk) { baseDownloadUrl = `${jdkDownloaderConstants.ORACLE_JDK_BASE_DOWNLOAD_URL}/${this.jdkVersion}/latest/jdk-${this.jdkVersion}_${this.osType!.toLowerCase()}-${this.machineArch}_bin`; } const downloadUrl = this.osType === 'windows' ? `${baseDownloadUrl}.zip` : `${baseDownloadUrl}.tar.gz`; @@ -180,7 +187,7 @@ export class JdkDownloaderAction { private downloadAndVerify = async (): Promise => { const message = l10n.value("jdk.downloader.message.downloadProgressBar", { - jdkType: this.jdkType, + jdkType: JdkDownloaderView.getJdkLabel(this.jdkType), jdkVersion: this.jdkVersion }); await downloadFileWithProgressBar(this.downloadUrl!, this.downloadFilePath!, message); @@ -189,7 +196,7 @@ export class JdkDownloaderAction { const doesMatch = await this.checksumMatch(); if (!doesMatch) { const checksumMatchFailedLabel = l10n.value("jdk.downloader.message.downloadFailed", { - jdkType: this.jdkType, + jdkType: JdkDownloaderView.getJdkLabel(this.jdkType), jdkVersion: this.jdkVersion, osType: this.osType }); @@ -223,7 +230,7 @@ export class JdkDownloaderAction { } catch (err) { LOGGER.error(`Error while extracting JDK: ${(err as Error).message}`); throw new Error(l10n.value("jdk.downloader.error_message.extractionError", { - jdkType: this.jdkType, + jdkType: JdkDownloaderView.getJdkLabel(this.jdkType), jdkVersion: this.jdkVersion })); } @@ -243,11 +250,11 @@ export class JdkDownloaderAction { const tempDirectoryPath = path.join(this.DOWNLOAD_DIR, matchingJdkDir[0]); // If directory with same name is present in the user selected download location then ask user if they want to delete it or not? - const newDirName = `${this.jdkType!.split(' ').join('_')}-${this.jdkVersion}`; + const newDirName = `${this.jdkType.split(' ').join('_')}-${this.jdkVersion}`; const newDirectoryPath = await this.handleJdkPaths(newDirName, this.installationPath!, this.osType!); if (newDirectoryPath === null) { throw new Error(l10n.value('jdk.downloader.error_message.jdkNewDirectoryIssueCannotInstall', { - jdkType: this.jdkType, + jdkType: JdkDownloaderView.getJdkLabel(this.jdkType), jdkVersion: this.jdkVersion, newDirName })); @@ -269,6 +276,18 @@ 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}`); diff --git a/vscode/src/webviews/jdkDownloader/view.ts b/vscode/src/webviews/jdkDownloader/view.ts index 17085b7..9733119 100644 --- a/vscode/src/webviews/jdkDownloader/view.ts +++ b/vscode/src/webviews/jdkDownloader/view.ts @@ -24,9 +24,18 @@ import { LOGGER } from '../../logger'; import { httpsGet, isError, isString } from '../../utils'; export class JdkDownloaderView { - public static readonly OPEN_JDK_LABEL = "OpenJDK"; - public static readonly ORACLE_JDK_LABEL = "Oracle JDK"; - public static readonly DOWNLOAD_CMD_LABEL = 'downloadJDK'; + public static readonly DOWNLOAD_CMD = 'downloadJDK'; + public static readonly JDK_TYPE = { + oracleJdk: "oracleJdk", + openJdk: "openJdk" + } + + public static getJdkLabel = (id: string): string => { + const key = "jdk.downloader.label." + id; + const label = l10n.value(key); + return label !== key ? label : id; + } + private readonly jdkDownloaderTitle = l10n.value("jdk.downloader.heading"); private jdkDownloaderWebView?: WebviewPanel; @@ -247,7 +256,7 @@ export class JdkDownloaderView { document.getElementById("addJDKPathManually")?.addEventListener('click', event => { vscode.postMessage({ - command: "${JdkDownloaderView.DOWNLOAD_CMD_LABEL}", + command: "${JdkDownloaderView.DOWNLOAD_CMD}", installType: "${JdkDownloaderAction.MANUAL_INSTALLATION_TYPE}", }); }); @@ -301,9 +310,9 @@ export class JdkDownloaderView { const triggerJDKDownload = (e) => { const { id } = e.target; - const jdkType = id === openJdkButtonId+'DownloadButton' ? "${JdkDownloaderView.OPEN_JDK_LABEL}" : "${JdkDownloaderView.ORACLE_JDK_LABEL}"; + const jdkType = id === openJdkButtonId+'DownloadButton' ? "${JdkDownloaderView.JDK_TYPE.openJdk}" : "${JdkDownloaderView.JDK_TYPE.oracleJdk}"; vscode.postMessage({ - command: "${JdkDownloaderView.DOWNLOAD_CMD_LABEL}", + command: "${JdkDownloaderView.DOWNLOAD_CMD}", id: jdkType, installType: "${JdkDownloaderAction.AUTO_INSTALLATION_TYPE}", jdkVersion: document.getElementById(activeButton.id+'VersionDropdown').value,