From a3892def4a1644f94eef77cd46126348abc799d6 Mon Sep 17 00:00:00 2001 From: Youssef Elshemi <70439384+YoussefElshemi@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:31:41 +0000 Subject: [PATCH 1/4] Add native ActivitySource and Meter for client operations Emits spans and an ssh.client.operation.duration histogram from an ActivitySource/Meter named "Renci.SshNet" for Connect, SFTP upload, download and list, and SshCommand execution. Nothing is allocated or read when there are no listeners. --- Directory.Packages.props | 1 + src/Renci.SshNet/BaseClient.cs | 36 ++++++++ src/Renci.SshNet/NetConfClient.cs | 6 ++ src/Renci.SshNet/Renci.SshNet.csproj | 4 + src/Renci.SshNet/ScpClient.cs | 6 ++ src/Renci.SshNet/SftpClient.cs | 99 ++++++++++++++++++++- src/Renci.SshNet/SshCommand.cs | 54 +++++++++++- src/Renci.SshNet/SshNetTelemetry.cs | 125 +++++++++++++++++++++++++++ 8 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 src/Renci.SshNet/SshNetTelemetry.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2ed0dc3e9..e5fc9884d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/src/Renci.SshNet/BaseClient.cs b/src/Renci.SshNet/BaseClient.cs index 243d83dab..074fe24dc 100644 --- a/src/Renci.SshNet/BaseClient.cs +++ b/src/Renci.SshNet/BaseClient.cs @@ -48,6 +48,14 @@ internal IServiceFactory ServiceFactory get { return _serviceFactory; } } + /// + /// Gets the protocol name used in telemetry for this client. + /// + private protected virtual string TelemetryProtocolName + { + get { return "ssh"; } + } + /// /// Gets the connection info. /// @@ -209,6 +217,20 @@ public void Connect() { CheckDisposed(); + using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "Connect", _connectionInfo); + try + { + ConnectCore(); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } + } + + private void ConnectCore() + { // TODO (see issue #1758): // we're not stopping the keep-alive timer and disposing the session here // @@ -278,6 +300,20 @@ public async Task ConnectAsync(CancellationToken cancellationToken) CheckDisposed(); cancellationToken.ThrowIfCancellationRequested(); + using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "Connect", _connectionInfo); + try + { + await ConnectCoreAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } + } + + private async Task ConnectCoreAsync(CancellationToken cancellationToken) + { // TODO (see issue #1758): // we're not stopping the keep-alive timer and disposing the session here // diff --git a/src/Renci.SshNet/NetConfClient.cs b/src/Renci.SshNet/NetConfClient.cs index f2e2299c3..76e58e7ce 100644 --- a/src/Renci.SshNet/NetConfClient.cs +++ b/src/Renci.SshNet/NetConfClient.cs @@ -202,6 +202,12 @@ public XmlDocument ClientCapabilities /// public bool AutomaticMessageIdHandling { get; set; } + /// + private protected override string TelemetryProtocolName + { + get { return "netconf"; } + } + /// /// Sends the receive RPC. /// diff --git a/src/Renci.SshNet/Renci.SshNet.csproj b/src/Renci.SshNet/Renci.SshNet.csproj index 9b7af1590..8e0bc1e94 100644 --- a/src/Renci.SshNet/Renci.SshNet.csproj +++ b/src/Renci.SshNet/Renci.SshNet.csproj @@ -53,6 +53,10 @@ + + + + diff --git a/src/Renci.SshNet/ScpClient.cs b/src/Renci.SshNet/ScpClient.cs index 074227531..a1fa3e82a 100644 --- a/src/Renci.SshNet/ScpClient.cs +++ b/src/Renci.SshNet/ScpClient.cs @@ -185,6 +185,12 @@ private static IRemotePathTransformation DefaultTransform /// public bool UseDirectoryFlag { get; set; } = true; + /// + private protected override string TelemetryProtocolName + { + get { return "scp"; } + } + private string EnsureIsDirectoryArg { get diff --git a/src/Renci.SshNet/SftpClient.cs b/src/Renci.SshNet/SftpClient.cs index 947139f84..4525b6a43 100644 --- a/src/Renci.SshNet/SftpClient.cs +++ b/src/Renci.SshNet/SftpClient.cs @@ -169,6 +169,12 @@ public int ProtocolVersion } } + /// + private protected override string TelemetryProtocolName + { + get { return "sftp"; } + } + /// /// Gets the current SFTP session. /// @@ -643,9 +649,23 @@ public async IAsyncEnumerable ListDirectoryAsync(string path, [Enumer cancellationToken.ThrowIfCancellationRequested(); - var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + // The operation spans the whole enumeration. C# does not allow yield + // inside try/catch, so errors are recorded around each request instead. + using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "ListDirectory", ConnectionInfo, path); + + string fullPath; + byte[] handle; + try + { + fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + handle = await _sftpSession.RequestOpenDirAsync(fullPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } - var handle = await _sftpSession.RequestOpenDirAsync(fullPath, cancellationToken).ConfigureAwait(false); try { var basePath = (fullPath[fullPath.Length - 1] == '/') ? @@ -654,7 +674,17 @@ public async IAsyncEnumerable ListDirectoryAsync(string path, [Enumer while (true) { - var files = await _sftpSession.RequestReadDirAsync(handle, cancellationToken).ConfigureAwait(false); + KeyValuePair[]? files; + try + { + files = await _sftpSession.RequestReadDirAsync(handle, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } + if (files is null) { break; @@ -2284,6 +2314,20 @@ private List InternalSynchronizeDirectories(string sourcePath, string /// is . /// Client not connected. private List InternalListDirectory(string path, SftpListDirectoryAsyncResult? asyncResult, Action? listCallback) + { + using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "ListDirectory", ConnectionInfo, path); + try + { + return InternalListDirectoryCore(path, asyncResult, listCallback); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } + } + + private List InternalListDirectoryCore(string path, SftpListDirectoryAsyncResult? asyncResult, Action? listCallback) { ArgumentNullException.ThrowIfNull(path); @@ -2345,6 +2389,27 @@ private async Task InternalDownloadFile( IProgress? downloadProgress, bool isAsync, CancellationToken cancellationToken) + { + using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "DownloadFile", ConnectionInfo, path); + try + { + var bytesDownloaded = await InternalDownloadFileCore(path, output, asyncResult, downloadProgress, isAsync, cancellationToken).ConfigureAwait(false); + operation?.SetTag("sftp.transfer.bytes", (long)bytesDownloaded); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } + } + + private async Task InternalDownloadFileCore( + string path, + Stream output, + SftpDownloadAsyncResult? asyncResult, + IProgress? downloadProgress, + bool isAsync, + CancellationToken cancellationToken) { Debug.Assert(!string.IsNullOrWhiteSpace(path)); Debug.Assert(output is not null); @@ -2386,10 +2451,10 @@ private async Task InternalDownloadFile( // The below is effectively sftpStream.CopyTo{Async}(output) with consideration // for downloadCallback/asyncResult. + ulong totalBytesRead = 0; var buffer = ArrayPool.Shared.Rent(81920); try { - ulong totalBytesRead = 0; while (true) { // Cancel download @@ -2447,6 +2512,8 @@ private async Task InternalDownloadFile( sftpStream.Dispose(); } } + + return totalBytesRead; } private async Task InternalUploadFile( @@ -2457,6 +2524,28 @@ private async Task InternalUploadFile( IProgress? uploadProgress, bool isAsync, CancellationToken cancellationToken) + { + using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "UploadFile", ConnectionInfo, path); + try + { + var bytesUploaded = await InternalUploadFileCore(input, path, flags, asyncResult, uploadProgress, isAsync, cancellationToken).ConfigureAwait(false); + operation?.SetTag("sftp.transfer.bytes", (long)bytesUploaded); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } + } + + private async Task InternalUploadFileCore( + Stream input, + string path, + Flags flags, + SftpUploadAsyncResult? asyncResult, + IProgress? uploadProgress, + bool isAsync, + CancellationToken cancellationToken) { Debug.Assert(isAsync || cancellationToken == default); @@ -2593,6 +2682,8 @@ private async Task InternalUploadFile( { _sftpSession.RequestClose(handle); } + + return offset; } #pragma warning restore CA1849 // Call async methods when in an async method diff --git a/src/Renci.SshNet/SshCommand.cs b/src/Renci.SshNet/SshCommand.cs index ce1042244..e738ac8d3 100644 --- a/src/Renci.SshNet/SshCommand.cs +++ b/src/Renci.SshNet/SshCommand.cs @@ -46,6 +46,8 @@ public sealed class SshCommand : IDisposable private int _exitStatus; private volatile bool _haveExitStatus; // volatile to prevent re-ordering of reads/writes of _exitStatus. + private SshNetTelemetry.Operation? _operation; + /// /// Gets the command text. /// @@ -290,9 +292,29 @@ public Task ExecuteAsync(CancellationToken cancellationToken = default) _channel.ExtendedDataReceived += Channel_ExtendedDataReceived; _channel.RequestReceived += Channel_RequestReceived; _channel.Closed += Channel_Closed; - _channel.Open(); - _ = _channel.SendExecRequest(CommandText); + // This method is not async, so restore Activity.Current to keep the + // caller's subsequent work from being parented to the command. + if (SshNetTelemetry.IsEnabled) + { + var previousActivity = Activity.Current; + _operation = SshNetTelemetry.StartOperation("ssh", "Execute", _session.ConnectionInfo); + Activity.Current = previousActivity; + } + + try + { + _channel.Open(); + + _ = _channel.SendExecRequest(CommandText); + } + catch (Exception ex) + { + var operation = Interlocked.Exchange(ref _operation, value: null); + operation?.SetError(ex); + operation?.Dispose(); + throw; + } if (CommandTimeout != Timeout.InfiniteTimeSpan) { @@ -552,6 +574,34 @@ private void SetAsyncComplete(bool setResult = true) OutputStream.Dispose(); ExtendedOutputStream.Dispose(); + + CompleteOperation(); + } + + private void CompleteOperation() + { + var operation = Interlocked.Exchange(ref _operation, value: null); + if (operation is null) + { + return; + } + + if (ExitStatus is int exitStatus) + { + operation.SetTag("ssh.command.exit_code", exitStatus); + } + + var task = _tcs?.Task; + if (task is { IsFaulted: true, Exception.InnerException: Exception ex }) + { + operation.SetError(ex); + } + else if (task is { IsCanceled: true }) + { + operation.SetError(typeof(OperationCanceledException).FullName!, "The command was cancelled."); + } + + operation.Dispose(); } private void Channel_Closed(object? sender, ChannelEventArgs e) diff --git a/src/Renci.SshNet/SshNetTelemetry.cs b/src/Renci.SshNet/SshNetTelemetry.cs new file mode 100644 index 000000000..e7472c05c --- /dev/null +++ b/src/Renci.SshNet/SshNetTelemetry.cs @@ -0,0 +1,125 @@ +#nullable enable +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Renci.SshNet +{ + /// + /// The and used for SSH.NET's + /// traces and metrics. Both are named Renci.SshNet. + /// + internal static class SshNetTelemetry + { + internal const string Name = "Renci.SshNet"; + + internal static readonly ActivitySource ActivitySource = new(Name, ThisAssembly.NuGetPackageVersion); + + internal static readonly Meter Meter = new(Name, ThisAssembly.NuGetPackageVersion); + + private static readonly Histogram OperationDuration = Meter.CreateHistogram( + "ssh.client.operation.duration", + unit: "s", + description: "Duration of SSH client operations."); + + /// + /// Gets a value indicating whether anybody is listening to the traces or metrics. + /// + internal static bool IsEnabled + { + get { return OperationDuration.Enabled || ActivitySource.HasListeners(); } + } + + /// + /// Starts tracking an operation, or returns if nobody is listening. + /// + internal static Operation? StartOperation(string protocol, string operation, IConnectionInfo? connectionInfo, string? path = null) + { + if (!IsEnabled) + { + return null; + } + + return new Operation(protocol, operation, connectionInfo, path); + } + + internal sealed class Operation : IDisposable + { + private readonly Activity? _activity; + private readonly long _startTimestamp; + private TagList _metricTags; + private bool _isDisposed; + + internal Operation(string protocol, string operation, IConnectionInfo? connectionInfo, string? path) + { + _startTimestamp = Stopwatch.GetTimestamp(); + + _metricTags = new TagList + { + { "network.protocol.name", protocol }, + { "ssh.operation.name", operation }, + }; + + if (connectionInfo is not null) + { + _metricTags.Add("server.address", connectionInfo.Host); + _metricTags.Add("server.port", connectionInfo.Port); + } + + _activity = ActivitySource.StartActivity(protocol + " " + operation, ActivityKind.Client); + + if (_activity is { IsAllDataRequested: true }) + { + foreach (var tag in _metricTags) + { + _ = _activity.SetTag(tag.Key, tag.Value); + } + + if (path is not null) + { + _ = _activity.SetTag("file.path", path); + } + } + } + + public void SetTag(string key, object? value) + { + if (_activity is { IsAllDataRequested: true }) + { + _ = _activity.SetTag(key, value); + } + } + + public void SetError(Exception exception) + { + SetError(exception.GetType().FullName!, exception.Message); + } + + public void SetError(string errorType, string? description) + { + _metricTags.Add("error.type", errorType); + + if (_activity is not null) + { + _ = _activity.SetTag("error.type", errorType); + _ = _activity.SetStatus(ActivityStatusCode.Error, description); + } + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + var elapsedSeconds = (Stopwatch.GetTimestamp() - _startTimestamp) / (double)Stopwatch.Frequency; + OperationDuration.Record(elapsedSeconds, _metricTags); + + _activity?.Dispose(); + } + } + } +} From d4262ba214d5b48652086f3af582704f583b24c2 Mon Sep 17 00:00:00 2001 From: Youssef Elshemi <70439384+YoussefElshemi@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:57:15 +0000 Subject: [PATCH 2/4] End SshCommand span before completing its task; add telemetry tests The command span is now ended before the task completes, so it has ended by the time awaiters resume, and a command disposed mid-run also ends its span. Adds integration tests for SFTP, connect and command telemetry. --- src/Renci.SshNet/SshCommand.cs | 34 ++- .../TelemetryTests.cs | 199 ++++++++++++++++++ 2 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 test/Renci.SshNet.IntegrationTests/TelemetryTests.cs diff --git a/src/Renci.SshNet/SshCommand.cs b/src/Renci.SshNet/SshCommand.cs index e738ac8d3..6ed64f74d 100644 --- a/src/Renci.SshNet/SshCommand.cs +++ b/src/Renci.SshNet/SshCommand.cs @@ -529,13 +529,17 @@ public string Execute(string commandText) private void Session_Disconnected(object? sender, EventArgs e) { - _ = _tcs?.TrySetException(new SshConnectionException("An established connection was aborted by the software in your host machine.", DisconnectReason.ConnectionLost)); + var exception = new SshConnectionException("An established connection was aborted by the software in your host machine.", DisconnectReason.ConnectionLost); + + CompleteOperation(exception); + _ = _tcs?.TrySetException(exception); SetAsyncComplete(setResult: false); } private void Session_ErrorOccurred(object? sender, ExceptionEventArgs e) { + CompleteOperation(e.Exception); _ = _tcs?.TrySetException(e.Exception); SetAsyncComplete(setResult: false); @@ -551,18 +555,24 @@ private void SetAsyncComplete(bool setResult = true) if (_userToken.IsCancellationRequested) { + CompleteOperation(isCanceled: true); _ = _tcs.TrySetCanceled(_userToken); } else if (_cts?.Token.IsCancellationRequested == true) { - _ = _tcs.TrySetException(new SshOperationTimeoutException($"Command '{CommandText}' timed out. ({nameof(CommandTimeout)}: {CommandTimeout}).")); + var exception = new SshOperationTimeoutException($"Command '{CommandText}' timed out. ({nameof(CommandTimeout)}: {CommandTimeout})."); + + CompleteOperation(exception); + _ = _tcs.TrySetException(exception); } else if (_cancellationRequested) { + CompleteOperation(isCanceled: true); _ = _tcs.TrySetCanceled(); } else { + CompleteOperation(); _ = _tcs.TrySetResult(null!); } } @@ -574,11 +584,13 @@ private void SetAsyncComplete(bool setResult = true) OutputStream.Dispose(); ExtendedOutputStream.Dispose(); - - CompleteOperation(); } - private void CompleteOperation() + /// + /// Ends the telemetry operation, if any. Called before completing + /// so that the operation has ended by the time awaiters resume. + /// + private void CompleteOperation(Exception? exception = null, bool isCanceled = false) { var operation = Interlocked.Exchange(ref _operation, value: null); if (operation is null) @@ -591,12 +603,11 @@ private void CompleteOperation() operation.SetTag("ssh.command.exit_code", exitStatus); } - var task = _tcs?.Task; - if (task is { IsFaulted: true, Exception.InnerException: Exception ex }) + if (exception is not null) { - operation.SetError(ex); + operation.SetError(exception); } - else if (task is { IsCanceled: true }) + else if (isCanceled) { operation.SetError(typeof(OperationCanceledException).FullName!, "The command was cancelled."); } @@ -712,7 +723,10 @@ private void Dispose(bool disposing) if (_tcs is { Task.IsCompleted: false } tcs) { // In case an operation is still running, try to complete it with an ObjectDisposedException. - _ = tcs.TrySetException(new ObjectDisposedException(GetType().FullName)); + var exception = new ObjectDisposedException(GetType().FullName); + + CompleteOperation(exception); + _ = tcs.TrySetException(exception); } _isDisposed = true; diff --git a/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs b/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs new file mode 100644 index 000000000..20338f8ef --- /dev/null +++ b/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs @@ -0,0 +1,199 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +using Renci.SshNet.Common; + +namespace Renci.SshNet.IntegrationTests +{ + [TestClass] + public class TelemetryTests : TestBase, IDisposable + { + private readonly ActivitySource _testSource = new ActivitySource("Renci.SshNet.IntegrationTests"); + private readonly ConcurrentQueue _activities = new ConcurrentQueue(); + private readonly ConcurrentQueue<(double Value, Dictionary Tags)> _durations = new ConcurrentQueue<(double, Dictionary)>(); + private readonly ActivityListener _activityListener; + private readonly MeterListener _meterListener; + + public TelemetryTests() + { + // .NET Framework defaults to hierarchical IDs; OpenTelemetry forces W3C, so do the same. + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = true; + + _activityListener = new ActivityListener + { + ShouldListenTo = source => string.Equals(source.Name, "Renci.SshNet", StringComparison.Ordinal) || string.Equals(source.Name, _testSource.Name, StringComparison.Ordinal), + Sample = (ref _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + if (activity.Source.Name == "Renci.SshNet") + { + _activities.Enqueue(activity); + } + }, + }; + ActivitySource.AddActivityListener(_activityListener); + + _meterListener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "Renci.SshNet") + { + listener.EnableMeasurementEvents(instrument); + } + }, + }; + _meterListener.SetMeasurementEventCallback((instrument, value, tags, state) => + { + var tagDictionary = new Dictionary(); + foreach (var tag in tags) + { + tagDictionary[tag.Key] = tag.Value; + } + + _durations.Enqueue((value, tagDictionary)); + }); + _meterListener.Start(); + } + + public void Dispose() + { + _activityListener.Dispose(); + _meterListener.Dispose(); + _testSource.Dispose(); + } + + [TestMethod] + public async Task Sftp_Operations_ProduceSpansAndDurations() + { + var directory = $"/tmp/telemetry-{Guid.NewGuid():N}"; + var path = $"{directory}/file.bin"; + + using (var parent = _testSource.StartActivity("parent")) + using (var client = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password)) + { + await client.ConnectAsync(CancellationToken.None); + client.CreateDirectory(directory); + + using (var input = CreateMemoryStream(10_000)) + { + input.Position = 0; + await client.UploadFileAsync(input, path); + } + + _ = client.ListDirectory(directory); + + await foreach (var file in client.ListDirectoryAsync(directory, CancellationToken.None)) + { + Assert.IsNotNull(file); + } + + using (var output = new MemoryStream()) + { + client.DownloadFile(path, output); + } + + client.DeleteFile(path); + client.DeleteDirectory(directory); + + var spans = _activities.ToList(); + + CollectionAssert.AreEqual( + new[] { "sftp Connect", "sftp UploadFile", "sftp ListDirectory", "sftp ListDirectory", "sftp DownloadFile" }, + spans.Select(s => s.DisplayName).ToArray()); + + foreach (var span in spans) + { + Assert.AreEqual(ActivityKind.Client, span.Kind); + Assert.AreEqual(parent!.TraceId, span.TraceId); + Assert.AreEqual(parent.SpanId, span.ParentSpanId); + Assert.AreEqual(ActivityStatusCode.Unset, span.Status); + Assert.AreEqual("sftp", span.GetTagItem("network.protocol.name")); + Assert.AreEqual(SshServerHostName, span.GetTagItem("server.address")); + Assert.AreEqual((int)SshServerPort, span.GetTagItem("server.port")); + } + + var upload = spans.Single(s => s.DisplayName == "sftp UploadFile"); + Assert.AreEqual(path, upload.GetTagItem("file.path")); + Assert.AreEqual(10_000L, upload.GetTagItem("sftp.transfer.bytes")); + + var download = spans.Single(s => s.DisplayName == "sftp DownloadFile"); + Assert.AreEqual(10_000L, download.GetTagItem("sftp.transfer.bytes")); + + Assert.AreEqual(spans.Count, _durations.Count); + Assert.IsTrue(_durations.All(d => d.Value > 0 && !d.Tags.ContainsKey("file.path"))); + } + } + + [TestMethod] + public void Sftp_DownloadMissingFile_RecordsError() + { + using var client = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password); + client.Connect(); + + using var output = new MemoryStream(); + _ = Assert.ThrowsExactly(() => client.DownloadFile($"/tmp/missing-{Guid.NewGuid():N}", output)); + + var span = _activities.Single(s => s.DisplayName == "sftp DownloadFile"); + Assert.AreEqual(ActivityStatusCode.Error, span.Status); + Assert.AreEqual(typeof(SftpPathNotFoundException).FullName, span.GetTagItem("error.type")); + + var duration = _durations.Single(d => (string)d.Tags["ssh.operation.name"] == "DownloadFile"); + Assert.AreEqual(typeof(SftpPathNotFoundException).FullName, duration.Tags["error.type"]); + } + + [TestMethod] + public void Connect_WrongPassword_RecordsError() + { + using var client = new SshClient(SshServerHostName, SshServerPort, User.UserName, "wrong-password"); + + _ = Assert.ThrowsExactly(client.Connect); + + var span = _activities.Single(); + Assert.AreEqual("ssh Connect", span.DisplayName); + Assert.AreEqual(ActivityStatusCode.Error, span.Status); + Assert.AreEqual(typeof(SshAuthenticationException).FullName, span.GetTagItem("error.type")); + } + + [TestMethod] + public async Task SshCommand_ExecuteAsync_EndsSpanBeforeAwaitResumes() + { + using var client = new SshClient(SshServerHostName, SshServerPort, User.UserName, User.Password); + client.Connect(); + + using var parent = _testSource.StartActivity("parent"); + using var command = client.CreateCommand("echo hello; exit 3"); + + var task = command.ExecuteAsync(); + + // ExecuteAsync is not async, so it must not leave its span as Activity.Current. + Assert.AreSame(parent, Activity.Current); + + await task; + + var span = _activities.Single(s => s.DisplayName == "ssh Execute"); + Assert.AreEqual(parent!.SpanId, span.ParentSpanId); + Assert.AreEqual(3, span.GetTagItem("ssh.command.exit_code")); + Assert.AreEqual(ActivityStatusCode.Unset, span.Status); + Assert.IsNull(span.GetTagItem("ssh.command.text")); + } + + [TestMethod] + public void SshCommand_Timeout_RecordsError() + { + using var client = new SshClient(SshServerHostName, SshServerPort, User.UserName, User.Password); + client.Connect(); + + using var command = client.CreateCommand("sleep 10"); + command.CommandTimeout = TimeSpan.FromMilliseconds(200); + + _ = Assert.ThrowsExactly(command.Execute); + + var span = _activities.Single(s => s.DisplayName == "ssh Execute"); + Assert.AreEqual(ActivityStatusCode.Error, span.Status); + Assert.AreEqual(typeof(SshOperationTimeoutException).FullName, span.GetTagItem("error.type")); + } + } +} From 55dec030378acdfec08917acc2b49dfc3fed3f92 Mon Sep 17 00:00:00 2001 From: Youssef Elshemi <70439384+YoussefElshemi@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:23:42 +0000 Subject: [PATCH 3/4] Trace remaining SFTP operations; leave SCP untouched Adds spans and durations for ChangeDirectory, ChangePermissions, CreateDirectory, DeleteDirectory, DeleteFile, Delete, RenameFile, SymbolicLink, Get, Exists, GetStatus, GetAttributes and SetAttributes, sync and async. Exists reporting a missing path is not an error. Reverts the SCP protocol name, as SCP is a legacy protocol. --- src/Renci.SshNet/ScpClient.cs | 6 - src/Renci.SshNet/SftpClient.cs | 423 +++++++++++++----- .../TelemetryTests.cs | 63 ++- 3 files changed, 382 insertions(+), 110 deletions(-) diff --git a/src/Renci.SshNet/ScpClient.cs b/src/Renci.SshNet/ScpClient.cs index a1fa3e82a..074227531 100644 --- a/src/Renci.SshNet/ScpClient.cs +++ b/src/Renci.SshNet/ScpClient.cs @@ -185,12 +185,6 @@ private static IRemotePathTransformation DefaultTransform /// public bool UseDirectoryFlag { get; set; } = true; - /// - private protected override string TelemetryProtocolName - { - get { return "scp"; } - } - private string EnsureIsDirectoryArg { get diff --git a/src/Renci.SshNet/SftpClient.cs b/src/Renci.SshNet/SftpClient.cs index 4525b6a43..58cf76078 100644 --- a/src/Renci.SshNet/SftpClient.cs +++ b/src/Renci.SshNet/SftpClient.cs @@ -310,7 +310,16 @@ public void ChangeDirectory(string path) throw new SshConnectionException("Client not connected."); } - _sftpSession.ChangeDirectory(path); + using var operation = StartOperation("ChangeDirectory", path); + try + { + _sftpSession.ChangeDirectory(path); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -337,7 +346,7 @@ public Task ChangeDirectoryAsync(string path, CancellationToken cancellationToke cancellationToken.ThrowIfCancellationRequested(); - return _sftpSession.ChangeDirectoryAsync(path, cancellationToken); + return ChangeDirectoryCoreAsync(_sftpSession, path, cancellationToken); } /// @@ -353,8 +362,17 @@ public Task ChangeDirectoryAsync(string path, CancellationToken cancellationToke /// The method was called after the client was disposed. public void ChangePermissions(string path, short mode) { - var file = Get(path); - file.SetPermissions(mode); + using var operation = StartOperation("ChangePermissions", path); + try + { + var file = Get(path); + file.SetPermissions(mode); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -376,9 +394,18 @@ public void CreateDirectory(string path) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); + using var operation = StartOperation("CreateDirectory", path); + try + { + var fullPath = _sftpSession.GetCanonicalPath(path); - _sftpSession.RequestMkDir(fullPath); + _sftpSession.RequestMkDir(fullPath); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -402,9 +429,18 @@ public async Task CreateDirectoryAsync(string path, CancellationToken cancellati throw new SshConnectionException("Client not connected."); } - var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + using var operation = StartOperation("CreateDirectory", path); + try + { + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); - await _sftpSession.RequestMkDirAsync(fullPath, cancellationToken).ConfigureAwait(false); + await _sftpSession.RequestMkDirAsync(fullPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -427,9 +463,18 @@ public void DeleteDirectory(string path) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); + using var operation = StartOperation("DeleteDirectory", path); + try + { + var fullPath = _sftpSession.GetCanonicalPath(path); - _sftpSession.RequestRmDir(fullPath); + _sftpSession.RequestRmDir(fullPath); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -445,9 +490,18 @@ public async Task DeleteDirectoryAsync(string path, CancellationToken cancellati cancellationToken.ThrowIfCancellationRequested(); - var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + using var operation = StartOperation("DeleteDirectory", path); + try + { + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); - await _sftpSession.RequestRmDirAsync(fullPath, cancellationToken).ConfigureAwait(false); + await _sftpSession.RequestRmDirAsync(fullPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -470,9 +524,18 @@ public void DeleteFile(string path) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); + using var operation = StartOperation("DeleteFile", path); + try + { + var fullPath = _sftpSession.GetCanonicalPath(path); - _sftpSession.RequestRemove(fullPath); + _sftpSession.RequestRemove(fullPath); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -488,8 +551,17 @@ public async Task DeleteFileAsync(string path, CancellationToken cancellationTok cancellationToken.ThrowIfCancellationRequested(); - var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); - await _sftpSession.RequestRemoveAsync(fullPath, cancellationToken).ConfigureAwait(false); + using var operation = StartOperation("DeleteFile", path); + try + { + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + await _sftpSession.RequestRemoveAsync(fullPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -529,17 +601,26 @@ public void RenameFile(string oldPath, string newPath, bool isPosix) throw new SshConnectionException("Client not connected."); } - var oldFullPath = _sftpSession.GetCanonicalPath(oldPath); + using var operation = StartOperation("RenameFile", oldPath); + try + { + var oldFullPath = _sftpSession.GetCanonicalPath(oldPath); - var newFullPath = _sftpSession.GetCanonicalPath(newPath); + var newFullPath = _sftpSession.GetCanonicalPath(newPath); - if (isPosix) - { - _sftpSession.RequestPosixRename(oldFullPath, newFullPath); + if (isPosix) + { + _sftpSession.RequestPosixRename(oldFullPath, newFullPath); + } + else + { + _sftpSession.RequestRename(oldFullPath, newFullPath); + } } - else + catch (Exception ex) { - _sftpSession.RequestRename(oldFullPath, newFullPath); + operation?.SetError(ex); + throw; } } @@ -568,9 +649,18 @@ public async Task RenameFileAsync(string oldPath, string newPath, CancellationTo cancellationToken.ThrowIfCancellationRequested(); - var oldFullPath = await _sftpSession.GetCanonicalPathAsync(oldPath, cancellationToken).ConfigureAwait(false); - var newFullPath = await _sftpSession.GetCanonicalPathAsync(newPath, cancellationToken).ConfigureAwait(false); - await _sftpSession.RequestRenameAsync(oldFullPath, newFullPath, cancellationToken).ConfigureAwait(false); + using var operation = StartOperation("RenameFile", oldPath); + try + { + var oldFullPath = await _sftpSession.GetCanonicalPathAsync(oldPath, cancellationToken).ConfigureAwait(false); + var newFullPath = await _sftpSession.GetCanonicalPathAsync(newPath, cancellationToken).ConfigureAwait(false); + await _sftpSession.RequestRenameAsync(oldFullPath, newFullPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -594,11 +684,20 @@ public void SymbolicLink(string path, string linkPath) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); + using var operation = StartOperation("SymbolicLink", path); + try + { + var fullPath = _sftpSession.GetCanonicalPath(path); - var linkFullPath = _sftpSession.GetCanonicalPath(linkPath); + var linkFullPath = _sftpSession.GetCanonicalPath(linkPath); - _sftpSession.RequestSymLink(fullPath, linkFullPath); + _sftpSession.RequestSymLink(fullPath, linkFullPath); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -651,7 +750,7 @@ public async IAsyncEnumerable ListDirectoryAsync(string path, [Enumer // The operation spans the whole enumeration. C# does not allow yield // inside try/catch, so errors are recorded around each request instead. - using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "ListDirectory", ConnectionInfo, path); + using var operation = StartOperation("ListDirectory", path); string fullPath; byte[] handle; @@ -776,11 +875,20 @@ public ISftpFile Get(string path) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); + using var operation = StartOperation("Get", path); + try + { + var fullPath = _sftpSession.GetCanonicalPath(path); - var attributes = _sftpSession.RequestLStat(fullPath); + var attributes = _sftpSession.RequestLStat(fullPath); - return new SftpFile(_sftpSession, fullPath, attributes); + return new SftpFile(_sftpSession, fullPath, attributes); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -808,11 +916,20 @@ public async Task GetAsync(string path, CancellationToken cancellatio cancellationToken.ThrowIfCancellationRequested(); - var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + using var operation = StartOperation("Get", path); + try + { + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); - var attributes = await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false); + var attributes = await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false); - return new SftpFile(_sftpSession, fullPath, attributes); + return new SftpFile(_sftpSession, fullPath, attributes); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -837,35 +954,44 @@ public bool Exists(string path) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); - - /* - * Using SSH_FXP_REALPATH is not an alternative as the SFTP specification has not always - * been clear on how the server should respond when the specified path is not present on - * the server: - * - * SSH 1 to 4: - * No mention of how the server should respond if the path is not present on the server. - * - * SSH 5: - * The server SHOULD fail the request if the path is not present on the server. - * - * SSH 6: - * Draft 06: The server SHOULD fail the request if the path is not present on the server. - * Draft 07 to 13: The server MUST NOT fail the request if the path does not exist. - * - * Note that SSH 6 (draft 06 and forward) allows for more control options, but we - * currently only support up to v3. - */ - + using var operation = StartOperation("Exists", path); try { - _ = _sftpSession.RequestLStat(fullPath); - return true; + var fullPath = _sftpSession.GetCanonicalPath(path); + + /* + * Using SSH_FXP_REALPATH is not an alternative as the SFTP specification has not always + * been clear on how the server should respond when the specified path is not present on + * the server: + * + * SSH 1 to 4: + * No mention of how the server should respond if the path is not present on the server. + * + * SSH 5: + * The server SHOULD fail the request if the path is not present on the server. + * + * SSH 6: + * Draft 06: The server SHOULD fail the request if the path is not present on the server. + * Draft 07 to 13: The server MUST NOT fail the request if the path does not exist. + * + * Note that SSH 6 (draft 06 and forward) allows for more control options, but we + * currently only support up to v3. + */ + + try + { + _ = _sftpSession.RequestLStat(fullPath); + return true; + } + catch (SftpPathNotFoundException) + { + return false; + } } - catch (SftpPathNotFoundException) + catch (Exception ex) { - return false; + operation?.SetError(ex); + throw; } } @@ -895,35 +1021,44 @@ public async Task ExistsAsync(string path, CancellationToken cancellationT cancellationToken.ThrowIfCancellationRequested(); - var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); - - /* - * Using SSH_FXP_REALPATH is not an alternative as the SFTP specification has not always - * been clear on how the server should respond when the specified path is not present on - * the server: - * - * SSH 1 to 4: - * No mention of how the server should respond if the path is not present on the server. - * - * SSH 5: - * The server SHOULD fail the request if the path is not present on the server. - * - * SSH 6: - * Draft 06: The server SHOULD fail the request if the path is not present on the server. - * Draft 07 to 13: The server MUST NOT fail the request if the path does not exist. - * - * Note that SSH 6 (draft 06 and forward) allows for more control options, but we - * currently only support up to v3. - */ - + using var operation = StartOperation("Exists", path); try { - _ = await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false); - return true; + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + + /* + * Using SSH_FXP_REALPATH is not an alternative as the SFTP specification has not always + * been clear on how the server should respond when the specified path is not present on + * the server: + * + * SSH 1 to 4: + * No mention of how the server should respond if the path is not present on the server. + * + * SSH 5: + * The server SHOULD fail the request if the path is not present on the server. + * + * SSH 6: + * Draft 06: The server SHOULD fail the request if the path is not present on the server. + * Draft 07 to 13: The server MUST NOT fail the request if the path does not exist. + * + * Note that SSH 6 (draft 06 and forward) allows for more control options, but we + * currently only support up to v3. + */ + + try + { + _ = await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false); + return true; + } + catch (SftpPathNotFoundException) + { + return false; + } } - catch (SftpPathNotFoundException) + catch (Exception ex) { - return false; + operation?.SetError(ex); + throw; } } @@ -1385,9 +1520,18 @@ public SftpFileSystemInformation GetStatus(string path) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); + using var operation = StartOperation("GetStatus", path); + try + { + var fullPath = _sftpSession.GetCanonicalPath(path); - return _sftpSession.RequestStatVfs(fullPath); + return _sftpSession.RequestStatVfs(fullPath); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -1414,8 +1558,17 @@ public async Task GetStatusAsync(string path, Cancell cancellationToken.ThrowIfCancellationRequested(); - var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); - return await _sftpSession.RequestStatVfsAsync(fullPath, cancellationToken).ConfigureAwait(false); + using var operation = StartOperation("GetStatus", path); + try + { + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + return await _sftpSession.RequestStatVfsAsync(fullPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } #region File Methods @@ -1612,15 +1765,33 @@ public StreamWriter CreateText(string path, Encoding encoding) /// The method was called after the client was disposed. public void Delete(string path) { - var file = Get(path); - file.Delete(); + using var operation = StartOperation("Delete", path); + try + { + var file = Get(path); + file.Delete(); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// public async Task DeleteAsync(string path, CancellationToken cancellationToken = default) { - var file = await GetAsync(path, cancellationToken).ConfigureAwait(false); - await file.DeleteAsync(cancellationToken).ConfigureAwait(false); + using var operation = StartOperation("Delete", path); + try + { + var file = await GetAsync(path, cancellationToken).ConfigureAwait(false); + await file.DeleteAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -2073,9 +2244,18 @@ public SftpFileAttributes GetAttributes(string path) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); + using var operation = StartOperation("GetAttributes", path); + try + { + var fullPath = _sftpSession.GetCanonicalPath(path); - return _sftpSession.RequestLStat(fullPath); + return _sftpSession.RequestLStat(fullPath); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -2100,9 +2280,18 @@ public async Task GetAttributesAsync(string path, Cancellati throw new SshConnectionException("Client not connected."); } - var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); + using var operation = StartOperation("GetAttributes", path); + try + { + var fullPath = await _sftpSession.GetCanonicalPathAsync(path, cancellationToken).ConfigureAwait(false); - return await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false); + return await _sftpSession.RequestLStatAsync(fullPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } /// @@ -2122,9 +2311,18 @@ public void SetAttributes(string path, SftpFileAttributes fileAttributes) throw new SshConnectionException("Client not connected."); } - var fullPath = _sftpSession.GetCanonicalPath(path); + using var operation = StartOperation("SetAttributes", path); + try + { + var fullPath = _sftpSession.GetCanonicalPath(path); - _sftpSession.RequestSetStat(fullPath, fileAttributes); + _sftpSession.RequestSetStat(fullPath, fileAttributes); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } } #endregion // File Methods @@ -2315,7 +2513,7 @@ private List InternalSynchronizeDirectories(string sourcePath, string /// Client not connected. private List InternalListDirectory(string path, SftpListDirectoryAsyncResult? asyncResult, Action? listCallback) { - using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "ListDirectory", ConnectionInfo, path); + using var operation = StartOperation("ListDirectory", path); try { return InternalListDirectoryCore(path, asyncResult, listCallback); @@ -2381,6 +2579,25 @@ private List InternalListDirectoryCore(string path, SftpListDirectory return result; } + private SshNetTelemetry.Operation? StartOperation(string operation, string? path) + { + return SshNetTelemetry.StartOperation(TelemetryProtocolName, operation, ConnectionInfo, path); + } + + private async Task ChangeDirectoryCoreAsync(ISftpSession sftpSession, string path, CancellationToken cancellationToken) + { + using var operation = StartOperation("ChangeDirectory", path); + try + { + await sftpSession.ChangeDirectoryAsync(path, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + operation?.SetError(ex); + throw; + } + } + #pragma warning disable CA1849 // Call async methods when in an async method private async Task InternalDownloadFile( string path, @@ -2390,7 +2607,7 @@ private async Task InternalDownloadFile( bool isAsync, CancellationToken cancellationToken) { - using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "DownloadFile", ConnectionInfo, path); + using var operation = StartOperation("DownloadFile", path); try { var bytesDownloaded = await InternalDownloadFileCore(path, output, asyncResult, downloadProgress, isAsync, cancellationToken).ConfigureAwait(false); @@ -2525,7 +2742,7 @@ private async Task InternalUploadFile( bool isAsync, CancellationToken cancellationToken) { - using var operation = SshNetTelemetry.StartOperation(TelemetryProtocolName, "UploadFile", ConnectionInfo, path); + using var operation = StartOperation("UploadFile", path); try { var bytesUploaded = await InternalUploadFileCore(input, path, flags, asyncResult, uploadProgress, isAsync, cancellationToken).ConfigureAwait(false); diff --git a/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs b/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs index 20338f8ef..913ff921c 100644 --- a/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs +++ b/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs @@ -101,7 +101,17 @@ public async Task Sftp_Operations_ProduceSpansAndDurations() var spans = _activities.ToList(); CollectionAssert.AreEqual( - new[] { "sftp Connect", "sftp UploadFile", "sftp ListDirectory", "sftp ListDirectory", "sftp DownloadFile" }, + new[] + { + "sftp Connect", + "sftp CreateDirectory", + "sftp UploadFile", + "sftp ListDirectory", + "sftp ListDirectory", + "sftp DownloadFile", + "sftp DeleteFile", + "sftp DeleteDirectory", + }, spans.Select(s => s.DisplayName).ToArray()); foreach (var span in spans) @@ -127,6 +137,57 @@ public async Task Sftp_Operations_ProduceSpansAndDurations() } } + [TestMethod] + public async Task Sftp_MetadataOperations_ProduceSpans() + { + var directory = $"/tmp/telemetry-{Guid.NewGuid():N}"; + + using var client = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password); + client.Connect(); + + await client.CreateDirectoryAsync(directory); + client.WriteAllText($"{directory}/a.txt", "a"); + + Assert.IsTrue(client.Exists($"{directory}/a.txt")); + Assert.IsFalse(await client.ExistsAsync($"{directory}/missing.txt")); + client.RenameFile($"{directory}/a.txt", $"{directory}/b.txt"); + _ = await client.GetAttributesAsync($"{directory}/b.txt", CancellationToken.None); + client.ChangeDirectory(directory); + await client.ChangeDirectoryAsync("/tmp"); + client.Delete($"{directory}/b.txt"); + await client.DeleteDirectoryAsync(directory); + + var spans = _activities.ToList(); + + CollectionAssert.AreEqual( + new[] + { + "sftp Connect", + "sftp CreateDirectory", + "sftp Exists", + "sftp Exists", + "sftp RenameFile", + "sftp GetAttributes", + "sftp ChangeDirectory", + "sftp ChangeDirectory", + "sftp Get", + "sftp Delete", + "sftp DeleteDirectory", + }, + spans.Select(s => s.DisplayName).ToArray()); + + // A path that does not exist is a normal result of Exists, not an error. + Assert.IsTrue(spans.TrueForAll(s => s.Status == ActivityStatusCode.Unset)); + + var rename = spans.Single(s => s.DisplayName == "sftp RenameFile"); + Assert.AreEqual($"{directory}/a.txt", rename.GetTagItem("file.path")); + + // Delete looks the file up first, so Get is its child. + var delete = spans.Single(s => s.DisplayName == "sftp Delete"); + var get = spans.Single(s => s.DisplayName == "sftp Get"); + Assert.AreEqual(delete.SpanId, get.ParentSpanId); + } + [TestMethod] public void Sftp_DownloadMissingFile_RecordsError() { From df45da21a1fba770340060abd03cf49ddcf35949 Mon Sep 17 00:00:00 2001 From: Youssef Elshemi <70439384+YoussefElshemi@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:02:07 +0000 Subject: [PATCH 4/4] Remove null-forgiving operators flagged by S8970 --- test/Renci.SshNet.IntegrationTests/TelemetryTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs b/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs index 913ff921c..75313e5e7 100644 --- a/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs +++ b/test/Renci.SshNet.IntegrationTests/TelemetryTests.cs @@ -117,7 +117,7 @@ public async Task Sftp_Operations_ProduceSpansAndDurations() foreach (var span in spans) { Assert.AreEqual(ActivityKind.Client, span.Kind); - Assert.AreEqual(parent!.TraceId, span.TraceId); + Assert.AreEqual(parent.TraceId, span.TraceId); Assert.AreEqual(parent.SpanId, span.ParentSpanId); Assert.AreEqual(ActivityStatusCode.Unset, span.Status); Assert.AreEqual("sftp", span.GetTagItem("network.protocol.name")); @@ -235,7 +235,7 @@ public async Task SshCommand_ExecuteAsync_EndsSpanBeforeAwaitResumes() await task; var span = _activities.Single(s => s.DisplayName == "ssh Execute"); - Assert.AreEqual(parent!.SpanId, span.ParentSpanId); + Assert.AreEqual(parent.SpanId, span.ParentSpanId); Assert.AreEqual(3, span.GetTagItem("ssh.command.exit_code")); Assert.AreEqual(ActivityStatusCode.Unset, span.Status); Assert.IsNull(span.GetTagItem("ssh.command.text"));