File: HostWorkspace\ProjectTelemetry\ProjectLoadTelemetryReporter.cs
Web Access
Project: src\src\LanguageServer\Microsoft.CodeAnalysis.LanguageServer\Microsoft.CodeAnalysis.LanguageServer.csproj (Microsoft.CodeAnalysis.LanguageServer)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System.Collections.Immutable;
using System.Composition;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageServer.LanguageServer;
using Microsoft.CodeAnalysis.MSBuild;
using Microsoft.Extensions.Logging;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry;
 
[Export, Shared]
[method: ImportingConstructor]
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
internal class ProjectLoadTelemetryReporter(ILoggerFactory loggerFactory, ServerConfiguration serverConfiguration)
{
    private static readonly string s_hashedSessionId = VsTfmAndFileExtHashingAlgorithm.HashInput(Guid.NewGuid().ToString());
 
    private readonly ILogger _logger = loggerFactory.CreateLogger<ProjectLoadTelemetryReporter>();
 
    public record TelemetryInfo
    {
        public ImmutableArray<CommandLineReference> MetadataReferences { get; init; }
        public OutputKind OutputKind { get; init; }
        public bool IsSdkStyle { get; init; }
    }
 
    /// <summary>
    /// This is designed to report project telemetry in an extremely similar way to O#
    /// so that we are able to compare data accurately.
    /// See https://github.com/OmniSharp/omnisharp-roslyn/blob/b2e64c6006beed49460f063117793f42ab2a8a5c/src/OmniSharp.MSBuild/ProjectLoadListener.cs#L36
    /// </summary>
    public async Task ReportProjectLoadTelemetryAsync(Dictionary<ProjectFileInfo, TelemetryInfo> projectFileInfos, ProjectToLoad projectToLoad, CancellationToken cancellationToken)
    {
        try
        {
            if (serverConfiguration.TelemetryLevel is null or "off")
            {
                return;
            }
 
            if (!projectFileInfos.Any())
            {
                return;
            }
 
            // Arbitrarily pick the first.  This is an existing problem with the telemetry event where we report multiple target frameworks
            // but only the data from one of the sets of possible outputkinds / references / content / etc.
            var firstInfo = projectFileInfos.First();
            var projectFileInfo = firstInfo.Key;
            var telemetryInfo = firstInfo.Value;
 
            // Matches O# behavior to not report this event if no references found.
            if (!telemetryInfo.MetadataReferences.Any())
            {
                return;
            }
 
            var projectId = await GetProjectIdAsync(projectToLoad);
            var targetFrameworks = GetTargetFrameworks(projectFileInfos.Keys);
 
            var projectCapabilities = projectFileInfo.ProjectCapabilities;
 
            var hashedReferences = GetHashedReferences(telemetryInfo.MetadataReferences);
            var fileCounts = GetUniqueHashedFileExtensionsAndCounts(projectFileInfo);
            var isSdkStyleProject = telemetryInfo.IsSdkStyle;
 
            var projectEvent = new ProjectLoadTelemetryEvent(
                ProjectId: projectId,
                SessionId: s_hashedSessionId,
                OutputKind: (int)telemetryInfo.OutputKind,
                ProjectCapabilities: projectCapabilities,
                TargetFrameworks: targetFrameworks,
                References: hashedReferences,
                FileExtensions: fileCounts.Keys,
                FileCounts: fileCounts.Values,
                SdkStyleProject: isSdkStyleProject);
 
            await ReportEventAsync(projectEvent, cancellationToken);
        }
        catch (Exception ex)
        {
            // Don't fail project loading because we failed to report telemetry.  Just log a warning and move on.
            _logger.LogWarning($"Failed to get project telemetry data: {ex.ToString()}");
        }
    }
 
    private static async Task ReportEventAsync(ProjectLoadTelemetryEvent telemetryEvent, CancellationToken cancellationToken)
    {
        var instance = LanguageServerHost.Instance;
        Contract.ThrowIfNull(instance, nameof(instance));
        var clientLanguageServerManager = instance.GetRequiredLspService<IClientLanguageServerManager>();
        await clientLanguageServerManager.SendNotificationAsync("workspace/projectConfigurationTelemetry", telemetryEvent, cancellationToken);
    }
 
    private static ImmutableDictionary<string, int> GetUniqueHashedFileExtensionsAndCounts(ProjectFileInfo projectFileInfo)
    {
        // Similar to O#, we report the content files + any non-generated source files.
        var contentFiles = projectFileInfo.ContentFilePaths;
        var sourceFiles = projectFileInfo.Documents
            .Concat(projectFileInfo.AdditionalDocuments)
            .Concat(projectFileInfo.AnalyzerConfigDocuments)
            .Where(d => !d.IsGenerated)
            .SelectAsArray(d => d.FilePath);
        var allFiles = contentFiles.Concat(sourceFiles);
        var fileCounts = new Dictionary<string, int>();
        foreach (var file in allFiles)
        {
            var fileExtension = Path.GetExtension(file);
            fileCounts[fileExtension] = fileCounts.GetOrAdd(fileExtension, 0) + 1;
        }
 
        return fileCounts.ToImmutableDictionary(kvp => VsTfmAndFileExtHashingAlgorithm.HashInput(kvp.Key), kvp => kvp.Value);
    }
 
    private static ImmutableArray<string> GetHashedReferences(ImmutableArray<CommandLineReference> metadataReferences)
    {
        return metadataReferences.SelectAsArray(GetHashedReferenceName);
 
        static string GetHashedReferenceName(CommandLineReference reference)
        {
            var lowerCaseName = Path.GetFileNameWithoutExtension(reference.Reference).ToLower();
            return VsReferenceHashingAlgorithm.HashInput(lowerCaseName);
        }
    }
 
    /// <summary>
    /// This reads the solution file project id or hashes the contents+path
    /// Matches O# implementation - https://github.com/OmniSharp/omnisharp-roslyn/blob/master/src/OmniSharp.MSBuild/ProjectLoadListener.cs#L88
    /// </summary>
    private static async Task<string> GetProjectIdAsync(ProjectToLoad projectToLoad)
    {
        if (projectToLoad.ProjectGuid is not null)
        {
            // The projectId is formatted as {GUID}.
            // In order to match with O#, we need just the guid.
            var projectGuid = projectToLoad.ProjectGuid.Replace("{", string.Empty).Replace("}", string.Empty);
 
            // No need to actually hash the project guid.
            return projectGuid;
        }
 
        var content = await File.ReadAllTextAsync(projectToLoad.Path);
        // This should exactly match O# to ensure we get the same hashes.
        return VsReferenceHashingAlgorithm.HashInput($"Filename: {Path.GetFileName(projectToLoad.Path)}\n{content}");
    }
 
    private static ImmutableArray<string> GetTargetFrameworks(IEnumerable<ProjectFileInfo> projectFileInfos)
    {
        return projectFileInfos.Select(p => GetTargetFramework(p)?.ToLower()).WhereNotNull().ToImmutableArray();
 
        string? GetTargetFramework(ProjectFileInfo projectFileInfo)
            => projectFileInfo.TargetFramework ?? projectFileInfo.TargetFrameworkVersion;
    }
}