Skip to content
11 changes: 6 additions & 5 deletions src/LanguageServer/DaemonConnection/DaemonPipeName.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ internal static class DaemonPipeName
public const string PipeNameOverrideEnvironmentVariable = "ROSLYN_LANGUAGE_SERVER_DAEMON_PIPE_NAME";

/// <summary>
/// Computes the pipe name for the current user, scoped by <paramref name="toolIdentifier"/>.
/// Computes the pipe name for the current user, scoped by <paramref name="toolIdentifier"/> and
/// <paramref name="telemetryLevel"/>.
/// </summary>
public static string GetPipeName(string toolIdentifier)
public static string GetPipeName(string toolIdentifier, string? telemetryLevel)
{
// Prefix with username and elevation so different users / elevation levels don't share a daemon.
var isAdmin = false;
Expand All @@ -54,22 +55,22 @@ public static string GetPipeName(string toolIdentifier)
isAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator);
}

return GetPipeName(Environment.UserName, isAdmin, toolIdentifier);
return GetPipeName(Environment.UserName, isAdmin, toolIdentifier, telemetryLevel);
}

/// <summary>
/// Computes the pipe name from the user identity and a tool identifier. The
/// <paramref name="toolIdentifier"/> ensures only compatible clients connect to a compatible
/// server; we use the full path to the server executable (in a versioned location).
/// </summary>
public static string GetPipeName(string userName, bool isAdmin, string toolIdentifier)
public static string GetPipeName(string userName, bool isAdmin, string toolIdentifier, string? telemetryLevel)
{
// Windows paths are case-insensitive. Preserve casing on other platforms, where paths may be
// case-sensitive and distinct executables must not share a daemon.
if (OperatingSystem.IsWindows())
toolIdentifier = toolIdentifier.ToLowerInvariant();

var pipeNameInput = $"{userName}.{isAdmin}.{toolIdentifier}";
var pipeNameInput = $"{userName}.{isAdmin}.{toolIdentifier}.{telemetryLevel}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(pipeNameInput));
return Convert.ToBase64String(bytes)
.Replace("/", "_")
Expand Down
13 changes: 13 additions & 0 deletions src/LanguageServer/DaemonConnection/TelemetryLevelResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// 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.

namespace Microsoft.CodeAnalysis.LanguageServer.Daemon;

internal static class TelemetryLevelResolver
{
private const string CopilotTelemetryLevelEnvironmentVariable = "COPILOT_TELEMETRY_LEVEL";

public static string? Resolve(string? telemetryLevel)
=> telemetryLevel ?? Environment.GetEnvironmentVariable(CopilotTelemetryLevelEnvironmentVariable);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// 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.

Expand All @@ -14,40 +14,40 @@ public sealed class DaemonPipeNameTests
[Fact]
public void PipeName_IsDeterministic()
{
var first = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier);
var second = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier);
var first = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier, telemetryLevel: "all");
var second = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier, telemetryLevel: "all");
Assert.Equal(first, second);
}

[Fact]
public void PipeName_DiffersByToolIdentifier()
{
var v1 = DaemonPipeName.GetPipeName("user", isAdmin: false, "/tools/v1/server.dll");
var v2 = DaemonPipeName.GetPipeName("user", isAdmin: false, "/tools/v2/server.dll");
var v1 = DaemonPipeName.GetPipeName("user", isAdmin: false, "/tools/v1/server.dll", telemetryLevel: "all");
var v2 = DaemonPipeName.GetPipeName("user", isAdmin: false, "/tools/v2/server.dll", telemetryLevel: "all");
Assert.NotEqual(v1, v2);
}

[Fact]
public void PipeName_DiffersByUser()
{
var user1 = DaemonPipeName.GetPipeName("user1", isAdmin: false, ToolIdentifier);
var user2 = DaemonPipeName.GetPipeName("user2", isAdmin: false, ToolIdentifier);
var user1 = DaemonPipeName.GetPipeName("user1", isAdmin: false, ToolIdentifier, telemetryLevel: "all");
var user2 = DaemonPipeName.GetPipeName("user2", isAdmin: false, ToolIdentifier, telemetryLevel: "all");
Assert.NotEqual(user1, user2);
}

[Fact]
public void PipeName_DiffersByElevation()
{
var standard = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier);
var elevated = DaemonPipeName.GetPipeName("user", isAdmin: true, ToolIdentifier);
var standard = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier, telemetryLevel: "all");
var elevated = DaemonPipeName.GetPipeName("user", isAdmin: true, ToolIdentifier, telemetryLevel: "all");
Assert.NotEqual(standard, elevated);
}

[Fact]
public void PipeName_NormalizesToolIdentifierCasingOnWindows()
{
var mixedCase = DaemonPipeName.GetPipeName("user", isAdmin: false, "/Tools/V1/Server.dll");
var lowerCase = DaemonPipeName.GetPipeName("user", isAdmin: false, "/tools/v1/server.dll");
var mixedCase = DaemonPipeName.GetPipeName("user", isAdmin: false, "/Tools/V1/Server.dll", telemetryLevel: "all");
var lowerCase = DaemonPipeName.GetPipeName("user", isAdmin: false, "/tools/v1/server.dll", telemetryLevel: "all");
if (OperatingSystem.IsWindows())
Assert.Equal(mixedCase, lowerCase);
else
Expand All @@ -57,7 +57,7 @@ public void PipeName_NormalizesToolIdentifierCasingOnWindows()
[Fact]
public void PipeName_IsFileSystemAndUrlSafe()
{
var name = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier);
var name = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier, telemetryLevel: "all");
Assert.False(string.IsNullOrWhiteSpace(name));
Assert.DoesNotContain('/', name);
Assert.DoesNotContain('=', name);
Expand All @@ -66,7 +66,7 @@ public void PipeName_IsFileSystemAndUrlSafe()
[Fact]
public void MutexNames_HaveExpectedShapeAndDiffer()
{
var pipeName = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier);
var pipeName = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier, telemetryLevel: "all");
var serverMutex = DaemonPipeName.GetServerMutexName(pipeName);
var clientMutex = DaemonPipeName.GetClientMutexName(pipeName);

Expand All @@ -78,4 +78,13 @@ public void MutexNames_HaveExpectedShapeAndDiffer()
Assert.Contains(pipeName, serverMutex);
Assert.Contains(pipeName, clientMutex);
}

[Fact]
public void PipeName_DiffersByTelemetryLevel()
{
var enabled = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier, telemetryLevel: "all");
var disabled = DaemonPipeName.GetPipeName("user", isAdmin: false, ToolIdentifier, telemetryLevel: "off");

Assert.NotEqual(enabled, disabled);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
using System.Runtime.CompilerServices;
using System.Threading;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.Razor;
using Microsoft.CodeAnalysis.LanguageServer.Telemetry;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Telemetry;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Test.Utilities;
using Xunit.Abstractions;
Expand Down Expand Up @@ -74,6 +78,83 @@ public async Task Daemon_SecondConcurrentConnection_IsIsolatedFromDaemonAndFirst
Assert.Equal(2, daemon.GetStartedServers().Length);
}

[Fact]
public async Task Daemon_EachServerHasAnIsolatedTelemetrySession()
{
var configuration = DefaultServerConfiguration with { IsDaemon = true, TelemetryLevel = "error" };
await using var daemon = await CreateDaemonServerAsync(serverConfiguration: configuration);
var daemonClientDisconnected = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var daemonEvents = new RecordingEventSink(onLog: functionId =>
{
if (functionId == FunctionId.VSCode_LanguageServer_Daemon_Client_Disconnected)
daemonClientDisconnected.TrySetResult(true);
});
using var daemonEventRegistration = daemon.DaemonTelemetry.AddEventSink(daemonEvents);

var first = await daemon.CreateClientAsync();
await using var second = await daemon.CreateClientAsync();

var firstTelemetry = first.GetRequiredLspService<RoslynTelemetry>();
var secondTelemetry = second.GetRequiredLspService<RoslynTelemetry>();
var firstSession = Assert.IsType<TelemetrySession>(TelemetryReporterWrapper.GetSession(firstTelemetry));
var secondSession = Assert.IsType<TelemetrySession>(TelemetryReporterWrapper.GetSession(secondTelemetry));

// Each server has a distinct child session correlated to the shared daemon session.
Assert.NotNull(daemon.DaemonSessionId);
Assert.NotEqual(firstSession.SessionId, secondSession.SessionId);
Assert.Equal(
daemon.DaemonSessionId,
GetDaemonSessionId(firstSession));
Assert.Equal(
daemon.DaemonSessionId,
GetDaemonSessionId(secondSession));

var firstEvents = new RecordingEventSink();
var secondEvents = new RecordingEventSink();
var firstTelemetryFlushed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var firstMetrics = new RecordingMetricSink(onFlush: () => firstTelemetryFlushed.TrySetResult(true));
var secondMetrics = new RecordingMetricSink();
using var firstEventRegistration = firstTelemetry.AddEventSink(firstEvents);
using var secondEventRegistration = secondTelemetry.AddEventSink(secondEvents);
using var firstMetricRegistration = firstTelemetry.AddMetricSink(firstMetrics);
using var secondMetricRegistration = secondTelemetry.AddMetricSink(secondMetrics);

var firstDocumentUri = LoadProjectWithDocument(
first.GetRequiredLspService<LanguageServerWorkspaceFactory>(),
"FirstTelemetryServer");
var hover = await first.ExecuteRequestAsync<HoverParams, Hover>(
Methods.TextDocumentHoverName,
new HoverParams
{
TextDocument = new TextDocumentIdentifier { DocumentUri = firstDocumentUri },
Position = new Position(0, 6),
},
CancellationToken.None);

// A request handled by the first server records metrics only in that server's sink.
Assert.NotNull(hover);
Assert.True(firstMetrics.MeasurementCount > 0);
Assert.Equal(0, secondMetrics.MeasurementCount);

var daemonEventsBeforeDisconnect = daemonEvents.Events.Length;
await first.DisposeAsync();
await Task.WhenAll(firstTelemetryFlushed.Task, daemonClientDisconnected.Task);

// Disconnecting the first server flushes it, preserves the second, and attributes lifecycle telemetry to the daemon.
Assert.Null(TelemetryReporterWrapper.GetSession(firstTelemetry));
Assert.Equal(0, secondMetrics.FlushCount);
Assert.Equal(daemonEventsBeforeDisconnect + 1, daemonEvents.Events.Length);
Assert.Equal(FunctionId.VSCode_LanguageServer_Daemon_Client_Disconnected, daemonEvents.Events[^1]);
Assert.DoesNotContain(FunctionId.VSCode_LanguageServer_Daemon_Client_Disconnected, firstEvents.Events);
Assert.DoesNotContain(FunctionId.VSCode_LanguageServer_Daemon_Client_Disconnected, secondEvents.Events);

static string GetDaemonSessionId(TelemetrySession telemetrySession)
{
Assert.True(telemetrySession.TryGetCommonPropertyValue(LanguageServerTelemetry.DaemonSessionIdPropertyName, out var daemonSessionId));
return Assert.IsType<string>(daemonSessionId);
}
}

// Each connected client gets its own server with its own Host workspace. A project loaded into one server's
// workspace must not be visible to the other server, and each server's registration service must track only
// its own workspaces.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@
// 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.Generic;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Telemetry;
using Microsoft.VisualStudio.Telemetry;
using Microsoft.VisualStudio.Telemetry.Metrics.Events;
using Roslyn.LanguageServer.Protocol;
using Xunit;
using Xunit.Abstractions;
Expand All @@ -21,16 +18,6 @@ namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests;
public sealed class LanguageServerRequestTelemetryTests(ITestOutputHelper testOutputHelper)
: AbstractLanguageServerHostTests(testOutputHelper)
{
private sealed class RecordingPoster : VSMetricSink.IMetricPoster
{
public List<TelemetryEvent> PostedEvents { get; } = [];

public bool IsOptedIn => true;

public void Post(TelemetryEvent telemetryEvent, TelemetryMetricEvent metricEvent)
=> PostedEvents.Add(telemetryEvent);
}

[Fact]
public async Task RealRequestsProduceAggregatedTelemetry()
{
Expand Down
Loading