Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Reqnroll.IdeSupport.Common.Diagnostics;
using Reqnroll.IdeSupport.LSP.Server.Hosting;
using Reqnroll.IdeSupport.LSP.Server.Telemetry;
using Reqnroll.IdeSupport.LSP.Server.Tracing;

namespace Reqnroll.IdeSupport.LSP.Server.Diagnostics.Performance;

Expand All @@ -29,17 +30,20 @@ public sealed class OperationDurationRecorder : IOperationDurationRecorder
private readonly ClientIdeContext _ide;
private readonly ILspTelemetryService? _telemetry;
private readonly IPerfTelemetrySampler _sampler;
private readonly ITraceService? _trace;

public OperationDurationRecorder(
IDeveroomLogger logger,
ClientIdeContext ide,
ILspTelemetryService? telemetry = null,
IPerfTelemetrySampler? sampler = null)
IPerfTelemetrySampler? sampler = null,
ITraceService? trace = null)
{
_logger = logger;
_ide = ide;
_telemetry = telemetry;
_sampler = sampler ?? PerfTelemetrySampler.FromEnvironment();
_trace = trace;
}

public IDisposable Measure(string operation, DocumentUri? uri = null)
Expand All @@ -53,6 +57,13 @@ public void Record(string operation, double elapsedMs, DocumentUri? uri = null)
? $"PERF op={operation} ms={elapsedMs:F1}"
: $"PERF op={operation} ms={elapsedMs:F1} uri={uri}");

// F41: mirror the same measurement as a $/logTrace notification (a no-op unless the
// client opted into tracing via InitializeParams.Trace or $/setTrace). The URI only goes
// into the verbose detail, matching the log line's own privacy posture.
_trace?.Trace(
$"{operation}: {elapsedMs:F1}ms",
uri is null ? null : () => uri.ToString());

// Secondary sink: sampled telemetry metric — no URI/path (privacy).
if (_telemetry is not null && _sampler.ShouldSample())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using Reqnroll.IdeSupport.LSP.Server.Features.Rename;
using Reqnroll.IdeSupport.LSP.Server.Features.SemanticTokens;
using Reqnroll.IdeSupport.LSP.Server.Diagnostics.Performance;
using Reqnroll.IdeSupport.LSP.Server.Tracing;
using Reqnroll.IdeSupport.LSP.Server.Protocol;
using Reqnroll.IdeSupport.LSP.Server.Workspace;
using OmniSharp.Extensions.LanguageServer.Protocol;
Expand All @@ -44,7 +45,10 @@ public static void AddStandardHandlers(this LanguageServerOptions options)
.AddHandler<FeatureDocumentSymbolHandler>()
.AddHandler<FeatureFoldingRangeHandler>()
// F23: textDocument/inlayHint — binding info hints on .feature steps.
.AddHandler<FeatureInlayHintHandler>();
.AddHandler<FeatureInlayHintHandler>()
// F41: standard $/setTrace notification, letting the client change the trace
// level at runtime.
.AddHandler<SetTraceNotificationHandler>();
}

/// <summary>
Expand Down
81 changes: 74 additions & 7 deletions src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
using Reqnroll.IdeSupport.LSP.Server.Configuration;
using Reqnroll.IdeSupport.LSP.Server.Pipeline;
using Reqnroll.IdeSupport.LSP.Server.Features.SemanticTokens;
using Reqnroll.IdeSupport.LSP.Server.Tracing;
using Reqnroll.IdeSupport.LSP.Server.Workspace;

namespace Reqnroll.IdeSupport.LSP.Server.Hosting;
Expand All @@ -40,8 +41,22 @@ public static async Task Main(string[] args)
// Each IDE's glue component may pass --log-level <level> (Off/Error/Warning/Info/Verbose)
// when spawning the server. Defaults to Warning when absent so a normal session doesn't
// write maximum-verbosity logs indefinitely; pass --log-level Verbose for full tracing.
// Controls ONLY our own app-level IDeveroomLogger file (reqnroll-*-server-*.log).
var logLevel = ParseLogLevel(args);

// --protocol-log-level <level> is the equivalent dial for OmniSharp's own internal
// diagnostics (request dispatch, DryIoc, JSON-RPC plumbing — whatever the library logs via
// ILogger<T>), deliberately decoupled from --log-level: turning up our own app logging
// shouldn't also flood the client's Output panel (window/logMessage) or a separate protocol
// log file with library internals, and vice versa. See ConfigureServer for where it's used.
var protocolLogLevel = ParseProtocolLogLevel(args);

// F41: --trace <Off/Messages/Verbose> seeds the LSP protocol trace level ($/logTrace) —
// yet another independent dial from the two above — before the client ever connects, so an
// IDE glue component that doesn't populate InitializeParams.Trace still gets a configurable
// default. See ConfigureServer's initialTrace parameter for the full precedence order.
var initialTrace = ParseTraceLevel(args);

// Write any unhandled startup exception to a file next to the LSP inspector logs
// so crashes are self-diagnosing without needing to capture stderr.
try
Expand All @@ -56,7 +71,7 @@ public static async Task Main(string[] args)
// Production transport: the IDE talks to the server over stdio.
options.WithInput(Console.OpenStandardInput())
.WithOutput(Console.OpenStandardOutput());
ConfigureServer(options, ideId, logLevel);
ConfigureServer(options, ideId, logLevel, initialTrace, protocolLogLevel);
});

using var preloadCts = new CancellationTokenSource();
Expand Down Expand Up @@ -109,17 +124,40 @@ public static async Task Main(string[] args)
/// </param>
/// <param name="logLevel">
/// The <c>--log-level</c> verbosity requested by the client, defaulting to
/// <see cref="TraceLevel.Warning"/>. Drives both the file-backed <see cref="IDeveroomLogger"/>
/// and the OmniSharp protocol-logging minimum level, so wire-level debugging and file logging
/// stay in lockstep unless a caller explicitly asks for more (e.g. <c>--log-level Verbose</c>).
/// <see cref="TraceLevel.Warning"/>. Drives only the file-backed <see cref="IDeveroomLogger"/>
/// (our own app-level logging) — deliberately independent of <paramref name="protocolLogLevel"/>.
/// </param>
/// <param name="initialTrace">
/// F41: the LSP protocol trace level (<c>$/logTrace</c>), resolved with the following
/// precedence — later stages only apply when they actually say something:
/// <list type="number">
/// <item><description>this <c>--trace</c> command-line default (defaults to <see cref="InitializeTrace.Off"/>);</description></item>
/// <item><description><c>InitializeParams.Trace</c>, applied in <c>OnInitialized</c> below —
/// but only when the client sent something other than <see cref="InitializeTrace.Off"/>, since
/// that value is indistinguishable from "the client didn't set this field at all" and must not
/// silently clobber an explicit <c>--trace</c> default;</description></item>
/// <item><description><c>$/setTrace</c> (<see cref="SetTraceNotificationHandler"/>), which can
/// set any value — including back to Off — at any time after that.</description></item>
/// </list>
/// </param>
/// <param name="protocolLogLevel">
/// The <c>--protocol-log-level</c> verbosity for OmniSharp's own internal diagnostics,
/// defaulting to <see cref="TraceLevel.Warning"/>. Drives the OmniSharp protocol-logging
/// minimum level, which feeds both the standard <c>window/logMessage</c> notification
/// (<c>AddLanguageProtocolLogging()</c>, visible to the client) and <see cref="ProtocolLoggerProvider"/>
/// (a dedicated <c>reqnroll-*-protocol-*.log</c> file, so that content survives even if the
/// client's Output panel is never inspected). Independent of <paramref name="logLevel"/> —
/// see that parameter's remarks.
/// </param>
internal static void ConfigureServer(LanguageServerOptions options, string? clientIde = null,
TraceLevel logLevel = TraceLevel.Warning)
TraceLevel logLevel = TraceLevel.Warning, InitializeTrace initialTrace = InitializeTrace.Off,
TraceLevel protocolLogLevel = TraceLevel.Warning)
{
options.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(ToLogLevel(logLevel));
logging.SetMinimumLevel(ToLogLevel(protocolLogLevel));
logging.AddLanguageProtocolLogging();
logging.AddProvider(new ProtocolLoggerProvider(clientIde, protocolLogLevel));
});

options.WithServerInfo(new ServerInfo
Expand All @@ -137,7 +175,7 @@ internal static void ConfigureServer(LanguageServerOptions options, string? clie
// notification to two handler instances (the transient from the scan and
// the singleton from the explicit call).
.AddMediatR(typeof(Program).Assembly)
.AddReqnrollLspCoreServices(clientIde, logLevel)
.AddReqnrollLspCoreServices(clientIde, logLevel, initialTrace)
.AddReqnrollProjectSystem()
.AddReqnrollEditorServices()
.AddReqnrollLspHandlers();
Expand All @@ -150,6 +188,13 @@ internal static void ConfigureServer(LanguageServerOptions options, string? clie

options.OnInitialized((languageServer, request, response, ct) =>
{
// F41: apply the client's requested trace level over the --trace command-line
// default, unless the client didn't actually request one. $/setTrace
// (SetTraceNotificationHandler) can still change the level — including back to
// Off — at any time after this.
var traceService = languageServer.Services.GetRequiredService<ITraceService>();
traceService.Level = ResolveInitialTrace(traceService.Level, request.Trace);

var tokenService = languageServer.Services.GetRequiredService<ISemanticTokenService>();

response.Capabilities.SemanticTokensProvider = new SemanticTokensRegistrationOptions.StaticOptions
Expand Down Expand Up @@ -221,4 +266,26 @@ internal static TraceLevel ParseLogLevel(string[] args)
=> Enum.TryParse<TraceLevel>(ParseArg(args, "--log-level"), ignoreCase: true, out var parsedLevel)
? parsedLevel
: TraceLevel.Warning;

/// <summary>Parses <c>--protocol-log-level</c> from <paramref name="args"/>, defaulting to <see cref="TraceLevel.Warning"/> when absent or unrecognized.</summary>
internal static TraceLevel ParseProtocolLogLevel(string[] args)
=> Enum.TryParse<TraceLevel>(ParseArg(args, "--protocol-log-level"), ignoreCase: true, out var parsedLevel)
? parsedLevel
: TraceLevel.Warning;

/// <summary>Parses <c>--trace</c> (Off/Messages/Verbose) from <paramref name="args"/>, defaulting to <see cref="InitializeTrace.Off"/> when absent or unrecognized.</summary>
internal static InitializeTrace ParseTraceLevel(string[] args)
=> Enum.TryParse<InitializeTrace>(ParseArg(args, "--trace"), ignoreCase: true, out var parsedLevel)
? parsedLevel
: InitializeTrace.Off;

/// <summary>
/// F41: resolves the trace level to apply at the initialize handshake. <paramref
/// name="requested"/> (<c>InitializeParams.Trace</c>) wins whenever the client actually asked
/// for something; <see cref="InitializeTrace.Off"/> there is indistinguishable from "the
/// client didn't set this field", so it must not clobber <paramref name="current"/> (the
/// <c>--trace</c> command-line default).
/// </summary>
internal static InitializeTrace ResolveInitialTrace(InitializeTrace current, InitializeTrace requested)
=> requested == InitializeTrace.Off ? current : requested;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using Reqnroll.IdeSupport.Common;
using Reqnroll.IdeSupport.Common.Configuration;
using Reqnroll.IdeSupport.Common.Diagnostics;
Expand Down Expand Up @@ -39,6 +40,7 @@
using Reqnroll.IdeSupport.LSP.Server.Features.SemanticTokens;
using Reqnroll.IdeSupport.LSP.Server.Telemetry;
using Reqnroll.IdeSupport.LSP.Server.Tagging;
using Reqnroll.IdeSupport.LSP.Server.Tracing;
using Reqnroll.IdeSupport.LSP.Server.Workspace;

namespace Reqnroll.IdeSupport.LSP.Server.Hosting;
Expand All @@ -52,7 +54,7 @@ public static class ServiceCollectionExtensions
/// Registers core infrastructure and cross-cutting services.
/// </summary>
public static IServiceCollection AddReqnrollLspCoreServices(this IServiceCollection services, string? clientIde,
TraceLevel logLevel = TraceLevel.Warning)
TraceLevel logLevel = TraceLevel.Warning, InitializeTrace initialTrace = InitializeTrace.Off)
{
return services
.AddSingleton(new ClientIdeContext(clientIde, logLevel))
Expand All @@ -74,11 +76,18 @@ public static IServiceCollection AddReqnrollLspCoreServices(this IServiceCollect
// PERF lines to the log and (when REQNROLL_PERF_TELEMETRY_SAMPLE is set) emits sampled
// PerfSample telemetry. Singleton so the sampler's RNG is shared across handlers.
.AddSingleton<IPerfTelemetrySampler>(_ => PerfTelemetrySampler.FromEnvironment())
// F41: tracks the LSP `trace` level (--trace / InitializeParams.Trace / $/setTrace) and
// issues $/logTrace notifications. Singleton so the level set by $/setTrace is visible
// to every consumer (currently OperationDurationRecorder's PERF lines).
.AddSingleton<ITraceService>(sp => new TraceService(
sp.GetRequiredService<OmniSharp.Extensions.LanguageServer.Protocol.Server.ILanguageServerFacade>(),
initialTrace))
.AddSingleton<IOperationDurationRecorder>(sp => new OperationDurationRecorder(
sp.GetRequiredService<IDeveroomLogger>(),
sp.GetRequiredService<ClientIdeContext>(),
sp.GetRequiredService<ILspTelemetryService>(),
sp.GetRequiredService<IPerfTelemetrySampler>()));
sp.GetRequiredService<IPerfTelemetrySampler>(),
sp.GetRequiredService<ITraceService>()));
}

/// <summary>
Expand Down Expand Up @@ -161,6 +170,7 @@ public static IServiceCollection AddReqnrollLspHandlers(this IServiceCollection
.AddSingleton<CommentToggleHandler>()
.AddSingleton<StepRenameHandler>()
.AddSingleton<RenameSessionManager>()
.AddSingleton<FeatureInlayHintHandler>();
.AddSingleton<FeatureInlayHintHandler>()
.AddSingleton<SetTraceNotificationHandler>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#nullable enable

using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Reqnroll.IdeSupport.Common.Diagnostics;

namespace Reqnroll.IdeSupport.LSP.Server.Logging;

/// <summary>
/// Bridges OmniSharp's own internal <see cref="Microsoft.Extensions.Logging"/> diagnostics
/// (request dispatch, DryIoc, JSON-RPC plumbing — whatever the library itself logs via
/// <c>ILogger&lt;T&gt;</c>) into a dedicated Reqnroll log file, in addition to the existing
/// <c>window/logMessage</c> notification (<c>AddLanguageProtocolLogging()</c>) that only reaches
/// the connected client.
/// </summary>
/// <remarks>
/// Without this, OmniSharp-internal diagnostics exist only as a transient client notification —
/// if the user doesn't think to copy their editor's Output panel, that information is gone by the
/// time we're asked to look at a bug report. Writes to a separate <c>reqnroll-*-protocol-*.log</c>
/// file (not the main server log) so two independent writers never append to the same file
/// concurrently, and gated by its own <c>--protocol-log-level</c> rather than the main
/// <c>--log-level</c> — the two are deliberately decoupled (F41 follow-up): a user turning up file
/// logging for our own app-level diagnosis shouldn't also flood their editor's Output panel with
/// library internals, and vice versa.
/// </remarks>
public sealed class ProtocolLoggerProvider : ILoggerProvider
{
private readonly IDeveroomLogger _logger;

public ProtocolLoggerProvider(string? clientIde, TraceLevel protocolLogLevel)
: this(BuildDefaultLogger(clientIde, protocolLogLevel))
{
}

/// <summary>Test seam: bypasses the real file/debug sinks.</summary>
internal ProtocolLoggerProvider(IDeveroomLogger logger)
{
_logger = logger;
}

private static IDeveroomLogger BuildDefaultLogger(string? clientIde, TraceLevel protocolLogLevel)
{
var idePrefix = clientIde switch
{
"visualstudio" => "vs",
"vscode" => "vscode",
_ => "lsp"
};
return new DeveroomCompositeLogger()
.Add(new DeveroomDebugLogger())
.Add(new SynchronousFileLogger(idePrefix, "protocol", protocolLogLevel));
}

public ILogger CreateLogger(string categoryName) => new ProtocolLoggerAdapter(categoryName, _logger);

public void Dispose()
{
}
}

/// <summary>Adapts a single <see cref="Microsoft.Extensions.Logging"/> category onto <see cref="IDeveroomLogger"/>.</summary>
internal sealed class ProtocolLoggerAdapter : ILogger
{
private readonly string _categoryName;
private readonly IDeveroomLogger _logger;

public ProtocolLoggerAdapter(string categoryName, IDeveroomLogger logger)
{
_categoryName = categoryName;
_logger = logger;
}

// The .NET logging pipeline already applies logging.SetMinimumLevel(...) before this
// method is ever invoked, so no further filtering is needed here — only the
// IDeveroomLogger sink's own level (protocolLogLevel) applies from this point on.
public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
_logger.Log(new LogMessage(ToTraceLevel(logLevel), formatter(state, exception), _categoryName, exception));
}

public IDisposable BeginScope<TState>(TState state) => NullScope.Instance;

private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}

internal static TraceLevel ToTraceLevel(LogLevel level) => level switch
{
LogLevel.Trace or LogLevel.Debug => TraceLevel.Verbose,
LogLevel.Information => TraceLevel.Info,
LogLevel.Warning => TraceLevel.Warning,
LogLevel.Error or LogLevel.Critical => TraceLevel.Error,
_ => TraceLevel.Off,
};
}
26 changes: 26 additions & 0 deletions src/LSP/Reqnroll.IdeSupport.LSP.Server/Tracing/ITraceService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#nullable enable

using OmniSharp.Extensions.LanguageServer.Protocol.Models;

namespace Reqnroll.IdeSupport.LSP.Server.Tracing;

/// <summary>
/// Tracks the LSP <c>trace</c> level negotiated with the client (F41) and issues
/// <c>$/logTrace</c> notifications when it is anything other than <see cref="InitializeTrace.Off"/>.
/// </summary>
public interface ITraceService
{
/// <summary>
/// The current trace level: the value from <c>InitializeParams.Trace</c> at startup,
/// subsequently overridden by any <c>$/setTrace</c> notification.
/// </summary>
InitializeTrace Level { get; set; }

/// <summary>
/// Sends a <c>$/logTrace</c> notification to the client, unless <see cref="Level"/> is
/// <see cref="InitializeTrace.Off"/>. <paramref name="verboseMessage"/> is only invoked (and
/// only sent) when <see cref="Level"/> is <see cref="InitializeTrace.Verbose"/>, so callers
/// can defer building an expensive detail string until it is actually wanted.
/// </summary>
void Trace(string message, System.Func<string>? verboseMessage = null);
}
Loading
Loading