From 4abe8cd56a98a403421a59995d966160958daa3a Mon Sep 17 00:00:00 2001 From: Jason Ginchereau Date: Mon, 4 Aug 2025 15:03:09 -1000 Subject: [PATCH 1/3] Report connection events to the tunnel service --- cs/src/Connections/RelayTunnelConnector.cs | 30 +- .../RetryingTunnelConnectionEventArgs.cs | 8 +- cs/src/Connections/TunnelConnection.cs | 44 +- cs/src/Connections/TunnelRelayConnection.cs | 40 +- cs/src/Connections/TunnelRelayTunnelClient.cs | 23 +- cs/src/Connections/TunnelRelayTunnelHost.cs | 62 ++- cs/src/Contracts/TunnelConstraints.cs | 31 ++ cs/src/Contracts/TunnelEvent.cs | 121 ++++++ .../Validation/DictionaryCountAttribute.cs | 57 +++ .../DictionaryRegularExpressionAttribute.cs | 58 +++ .../DictionaryStringLengthAttribute.cs | 57 +++ cs/src/Management/ITunnelManagementClient.cs | 15 +- cs/src/Management/TunnelManagementClient.cs | 199 ++++++++- .../Mocks/MockTunnelManagementClient.cs | 11 +- .../TunnelClientEventsTests.cs | 392 ++++++++++++++++++ go/tunnels/tunnel_constraints.go | 18 + go/tunnels/tunnel_event.go | 40 ++ .../tunnels/contracts/TunnelConstraints.java | 30 ++ .../tunnels/contracts/TunnelEvent.java | 63 +++ rs/src/contracts/mod.rs | 2 + rs/src/contracts/tunnel_constraints.rs | 18 + rs/src/contracts/tunnel_event.rs | 40 ++ ts/src/connections/tunnelConnectionSession.ts | 104 ++++- ts/src/connections/tunnelRelayTunnelHost.ts | 72 +++- ts/src/contracts/index.ts | 1 + ts/src/contracts/tunnelConstraints.ts | 30 ++ ts/src/contracts/tunnelEvent.ts | 59 +++ ts/src/management/tunnelManagementClient.ts | 18 + .../management/tunnelManagementHttpClient.ts | 156 ++++++- .../mocks/mockTunnelManagementClient.ts | 9 + ts/test/tunnels-test/tunnelEventsTests.ts | 324 +++++++++++++++ version.json | 2 +- 32 files changed, 2065 insertions(+), 69 deletions(-) create mode 100644 cs/src/Contracts/TunnelEvent.cs create mode 100644 cs/src/Contracts/Validation/DictionaryCountAttribute.cs create mode 100644 cs/src/Contracts/Validation/DictionaryRegularExpressionAttribute.cs create mode 100644 cs/src/Contracts/Validation/DictionaryStringLengthAttribute.cs create mode 100644 cs/test/TunnelsSDK.Test/TunnelClientEventsTests.cs create mode 100644 go/tunnels/tunnel_event.go create mode 100644 java/src/main/java/com/microsoft/tunnels/contracts/TunnelEvent.java create mode 100644 rs/src/contracts/tunnel_event.rs create mode 100644 ts/src/contracts/tunnelEvent.ts create mode 100644 ts/test/tunnels-test/tunnelEventsTests.ts diff --git a/cs/src/Connections/RelayTunnelConnector.cs b/cs/src/Connections/RelayTunnelConnector.cs index 65b08093..eab89c43 100644 --- a/cs/src/Connections/RelayTunnelConnector.cs +++ b/cs/src/Connections/RelayTunnelConnector.cs @@ -79,7 +79,7 @@ public async Task ConnectSessionAsync( } catch (UnauthorizedAccessException uaex) // Tunnel access token validation failed. { - if (!IsRetryAllowed(uaex, SshDisconnectReason.AuthCancelledByUser, delayNeeded: false)) + if (!IsRetryAllowed(uaex, attempt, SshDisconnectReason.AuthCancelledByUser, delayNeeded: false)) { throw; } @@ -88,7 +88,7 @@ public async Task ConnectSessionAsync( } catch (SshReconnectException srex) { - if (!IsRetryAllowed(srex, SshDisconnectReason.ProtocolError, delayNeeded: false)) + if (!IsRetryAllowed(srex, attempt, SshDisconnectReason.ProtocolError, delayNeeded: false)) { throw; } @@ -99,7 +99,7 @@ public async Task ConnectSessionAsync( when (scex.DisconnectReason == SshDisconnectReason.ConnectionLost) { // Recoverable - if (!IsRetryAllowed(scex, scex.DisconnectReason)) + if (!IsRetryAllowed(scex, attempt, scex.DisconnectReason)) { throw; } @@ -127,7 +127,7 @@ public async Task ConnectSessionAsync( $"Unauthorized (401). Provide a fresh tunnel access token with '{this.relayClient.TunnelAccessScope}' scope.", wse); - ThrowIfRetryNotAllowed(exception, SshDisconnectReason.AuthCancelledByUser, delayNeeded: false); + ThrowIfRetryNotAllowed(exception, attempt, SshDisconnectReason.AuthCancelledByUser, delayNeeded: false); // Unauthorized error may happen when the tunnel access token is no longer valid, e.g. expired. // Try refreshing it. @@ -166,7 +166,7 @@ public async Task ConnectSessionAsync( attemptDelayMs = RetryMaxDelayMs / 2; } - if (!IsRetryAllowed(exception, SshDisconnectReason.ServiceNotAvailable) || + if (!IsRetryAllowed(exception, attempt, SshDisconnectReason.ServiceNotAvailable) || attempt > 3) { throw exception; @@ -181,7 +181,7 @@ public async Task ConnectSessionAsync( } // Other web socket errors may be recoverable - else if (!IsRetryAllowed(wse)) + else if (!IsRetryAllowed(wse, attempt)) { throw; } @@ -222,7 +222,7 @@ ex is ArgumentNullException || throw; } - if (!IsRetryAllowed(ex, SshDisconnectReason.ProtocolError)) + if (!IsRetryAllowed(ex, attempt, SshDisconnectReason.ProtocolError)) { throw; } @@ -265,8 +265,8 @@ ex is ArgumentNullException || } } - var retryTiming = isDelayNeeded ? $" in {(attemptDelayMs < 1000 ? $"0.{attemptDelayMs / 100}s" : $"{attemptDelayMs / 1000}s")}" : string.Empty; - Trace.Verbose($"Error connecting to tunnel SSH session, retrying{retryTiming}{(errorDescription != null ? $": {errorDescription}" : string.Empty)}"); + var retryTiming = isDelayNeeded ? $"{(attemptDelayMs < 1000 ? $"0.{attemptDelayMs / 100}s" : $"{attemptDelayMs / 1000}s")}" : string.Empty; + Trace.Verbose($"Error connecting to tunnel SSH session, retrying in {retryTiming}{(errorDescription != null ? $": {errorDescription}" : string.Empty)}"); if (isDelayNeeded) { @@ -361,9 +361,9 @@ async Task RefreshTunnelAccessTokenAsync(Exception exception) return null; } - void ThrowIfRetryNotAllowed(Exception ex, SshDisconnectReason reason, bool delayNeeded = true) + void ThrowIfRetryNotAllowed(Exception ex, int attemptNumber, SshDisconnectReason reason, bool delayNeeded = true) { - if (!IsRetryAllowed(ex,reason, delayNeeded)) + if (!IsRetryAllowed(ex, attemptNumber, reason, delayNeeded)) { throw ex; } @@ -371,20 +371,18 @@ void ThrowIfRetryNotAllowed(Exception ex, SshDisconnectReason reason, bool delay bool IsRetryAllowed( Exception ex, + int attemptNumber, SshDisconnectReason reason = SshDisconnectReason.ConnectionLost, bool delayNeeded = true) { disconnectReason = reason; errorDescription = ex.Message; exception = ex; - if (options?.EnableRetry == false) - { - return false; - } isDelayNeeded = delayNeeded; var retryDelay = TimeSpan.FromMilliseconds(isDelayNeeded ? attemptDelayMs : 0); - var retryingArgs = new RetryingTunnelConnectionEventArgs(ex, retryDelay); + var retryingArgs = new RetryingTunnelConnectionEventArgs(ex, attemptNumber, retryDelay); + retryingArgs.Retry = options?.EnableRetry ?? true; this.relayClient.OnRetrying(retryingArgs); if (!retryingArgs.Retry) { diff --git a/cs/src/Connections/RetryingTunnelConnectionEventArgs.cs b/cs/src/Connections/RetryingTunnelConnectionEventArgs.cs index 0c5c02b7..2ea36a77 100644 --- a/cs/src/Connections/RetryingTunnelConnectionEventArgs.cs +++ b/cs/src/Connections/RetryingTunnelConnectionEventArgs.cs @@ -15,9 +15,10 @@ public class RetryingTunnelConnectionEventArgs : EventArgs /// /// Creates a new instance of class. /// - public RetryingTunnelConnectionEventArgs(Exception exception, TimeSpan delay) + public RetryingTunnelConnectionEventArgs(Exception exception, int attemptNumber, TimeSpan delay) { Exception = Requires.NotNull(exception, nameof(exception)); + AttemptNumber = attemptNumber; Retry = true; Delay = delay; } @@ -30,6 +31,11 @@ public RetryingTunnelConnectionEventArgs(Exception exception, TimeSpan delay) /// public Exception Exception { get; } + /// + /// Gets the attempt number for the retry. + /// + public int AttemptNumber { get; } + /// /// Gets the amount of time to wait before retrying. An event handler may change this value /// to adjust the delay. diff --git a/cs/src/Connections/TunnelConnection.cs b/cs/src/Connections/TunnelConnection.cs index 26204e77..41563187 100644 --- a/cs/src/Connections/TunnelConnection.cs +++ b/cs/src/Connections/TunnelConnection.cs @@ -4,6 +4,7 @@ // using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -21,6 +22,7 @@ public abstract class TunnelConnection : IAsyncDisposable { private readonly CancellationTokenSource disposeCts = new(); private ConnectionStatus connectionStatus; + private Stopwatch connectionTimer = new(); private Tunnel? tunnel; /// @@ -417,8 +419,29 @@ public async ValueTask DisposeAsync() /// /// Event fired when the connection status has changed. /// - protected virtual void OnConnectionStatusChanged(ConnectionStatus previousConnectionStatus, ConnectionStatus connectionStatus) + protected virtual void OnConnectionStatusChanged( + ConnectionStatus previousConnectionStatus, + ConnectionStatus connectionStatus) { + TimeSpan duration = this.connectionTimer.Elapsed; + this.connectionTimer.Restart(); + + if (Tunnel != null) + { + var statusEvent = new TunnelEvent($"{ConnectionRole}_connection_status"); + statusEvent.Properties = new Dictionary + { + [nameof(ConnectionStatus)] = connectionStatus.ToString(), + [$"Previous{nameof(ConnectionStatus)}"] = previousConnectionStatus.ToString(), + }; + if (previousConnectionStatus != ConnectionStatus.None) + { + statusEvent.Properties[$"{previousConnectionStatus}Duration"] = duration.ToString(); + } + + ManagementClient?.ReportEvent(Tunnel, statusEvent); + } + var handler = ConnectionStatusChanged; if (handler != null) { @@ -441,7 +464,24 @@ protected virtual void OnConnectionStatusChanged(ConnectionStatus previousConnec /// internal void OnRetrying(RetryingTunnelConnectionEventArgs e) { - RetryingTunnelConnection?.Invoke(this, e); + if (e.Retry) + { + RetryingTunnelConnection?.Invoke(this, e); + } + + if (Tunnel != null) + { + var retryingEvent = new TunnelEvent($"{ConnectionRole}_connect_retrying"); + retryingEvent.Severity = TunnelEvent.Warning; + retryingEvent.Details = e.Exception?.ToString(); + retryingEvent.Properties = new Dictionary + { + [nameof(e.Retry)] = e.Retry.ToString(), + [nameof(e.AttemptNumber)] = e.AttemptNumber.ToString(), + [nameof(e.Delay)] = ((int)e.Delay.TotalMilliseconds).ToString(), + }; + ManagementClient?.ReportEvent(Tunnel, retryingEvent); + } } /// diff --git a/cs/src/Connections/TunnelRelayConnection.cs b/cs/src/Connections/TunnelRelayConnection.cs index 9705f0ca..44e8d604 100644 --- a/cs/src/Connections/TunnelRelayConnection.cs +++ b/cs/src/Connections/TunnelRelayConnection.cs @@ -155,7 +155,19 @@ protected async Task ConnectTunnelSessionAsync( var isReconnect = Tunnel != null; Tunnel = tunnel; this.connector ??= await CreateTunnelConnectorAsync(cancellation); - await this.connector.ConnectSessionAsync(options, isReconnect, cancellation); + + try + { + await this.connector.ConnectSessionAsync(options, isReconnect, cancellation); + } + catch (Exception ex) + { + var connectFailedEvent = new TunnelEvent($"{ConnectionRole}_connect_failed"); + connectFailedEvent.Severity = TunnelEvent.Error; + connectFailedEvent.Details = ex.ToString(); + ManagementClient?.ReportEvent(tunnel, connectFailedEvent); + throw; + } } /// @@ -197,12 +209,28 @@ protected void MaybeStartReconnecting( this.connector != null && // The connector may be null if the tunnel client/host was created directly from a stream. reason == SshDisconnectReason.ConnectionLost) // Only reconnect if it's connection lost. { + if (Tunnel != null) + { + var reconnectEvent = new TunnelEvent($"{ConnectionRole}_reconnect"); + reconnectEvent.Severity = TunnelEvent.Warning; + reconnectEvent.Details = exception?.ToString() ?? traceMessage; + ManagementClient?.ReportEvent(Tunnel, reconnectEvent); + } + Trace.TraceInformation($"{traceMessage}. Reconnecting."); var task = ReconnectAsync(DisposeToken); this.reconnectTask = !task.IsCompleted ? task : null; } else { + if (Tunnel != null) + { + var disconnectEvent = new TunnelEvent($"{ConnectionRole}_disconnect"); + disconnectEvent.Severity = TunnelEvent.Warning; + disconnectEvent.Details = exception?.ToString() ?? traceMessage; + ManagementClient?.ReportEvent(Tunnel, disconnectEvent); + } + Trace.TraceInformation(traceMessage); ConnectionStatus = ConnectionStatus.Disconnected; } @@ -530,8 +558,16 @@ await this.connector.ConnectSessionAsync( isReconnect: true, cancellation); } - catch + catch (Exception ex) { + if (Tunnel != null) + { + var connectFailedEvent = new TunnelEvent($"{ConnectionRole}_reconnect_failed"); + connectFailedEvent.Severity = TunnelEvent.Error; + connectFailedEvent.Details = ex.ToString(); + ManagementClient?.ReportEvent(Tunnel, connectFailedEvent); + } + // Tracing of the exception has already been done by ConnectSessionAsync. // As reconnection is an async process, there is nobody watching it throw. // The exception, if it was not cancellation, is stored in DisconnectException property. diff --git a/cs/src/Connections/TunnelRelayTunnelClient.cs b/cs/src/Connections/TunnelRelayTunnelClient.cs index dd8e0316..8265aea0 100644 --- a/cs/src/Connections/TunnelRelayTunnelClient.cs +++ b/cs/src/Connections/TunnelRelayTunnelClient.cs @@ -119,10 +119,25 @@ protected async Task ConnectAsync( this.accessToken = accessToken; this.HostPublicKeys = hostPublicKeys; this.connector = new RelayTunnelConnector(this); - await this.connector.ConnectSessionAsync( - options, - isReconnect: false, - cancellation); + + try + { + await this.connector.ConnectSessionAsync( + options, + isReconnect: false, + cancellation); + } + catch (Exception ex) + { + if (Tunnel != null) + { + var connectFailedEvent = new TunnelEvent($"{ConnectionRole}_connect_failed"); + connectFailedEvent.Severity = TunnelEvent.Error; + connectFailedEvent.Details = ex.ToString(); + ManagementClient?.ReportEvent(Tunnel, connectFailedEvent); + } + throw; + } } /// diff --git a/cs/src/Connections/TunnelRelayTunnelHost.cs b/cs/src/Connections/TunnelRelayTunnelHost.cs index 75d008a7..b008f751 100644 --- a/cs/src/Connections/TunnelRelayTunnelHost.cs +++ b/cs/src/Connections/TunnelRelayTunnelHost.cs @@ -200,13 +200,13 @@ protected override async Task ConfigureSessionAsync(Stream stream, bool isReconn { config.KeepAliveTimeoutInSeconds = options.KeepAliveIntervalInSeconds; } - + session = new SshClientSession(config, Trace.WithName("HostSSH")); var hostPfs = session.ActivateService(); hostPfs.MessageFactory = this; } - + session.KeepAliveFailed += (_, e) => { OnKeepAliveFailed(e.Count); @@ -381,8 +381,9 @@ private async Task AcceptClientSessionAsync(SshChannel clientSessionChannel, Can } } - private async Task ConnectAndRunClientSessionAsync(Stream stream, CancellationToken cancellation) + private async Task ConnectAndRunClientSessionAsync(SshStream stream, CancellationToken cancellation) { + var channelId = stream.Channel.ChannelId; var sshSessionOwnsStream = false; var tcs = new TaskCompletionSource(); try @@ -419,6 +420,16 @@ private async Task ConnectAndRunClientSessionAsync(Stream stream, CancellationTo AddClientSshSession(session); + if (Tunnel != null) + { + var connectedEvent = new TunnelEvent($"host_client_connect"); + connectedEvent.Properties = new Dictionary + { + ["ClientChannelId"] = channelId.ToString(), + }; + ManagementClient?.ReportEvent(Tunnel, connectedEvent); + } + await tcs.Task; } finally @@ -443,30 +454,66 @@ private async Task ConnectAndRunClientSessionAsync(Stream stream, CancellationTo throw; } + async void OnSshClientReconnected(object? sender, EventArgs e) + { + if (Tunnel != null) + { + var reconnectedEvent = new TunnelEvent($"host_client_reconnect"); + reconnectedEvent.Properties = new Dictionary + { + ["ClientChannelId"] = channelId.ToString(), + }; + ManagementClient?.ReportEvent(Tunnel, reconnectedEvent); + } + + await StartForwardingExistingPortsAsync( + (SshServerSession)sender!, + removeUnusedPorts: true); + } + void OnClientSessionClosed(object? sender, SshSessionClosedEventArgs e) { TraceSource trace = ((SshSession)sender!).Trace; + string? details = null; + string? severity = null; // Reconnecting client session may cause the new session to close with 'None' reason. if (cancellation.IsCancellationRequested) { - trace.Verbose("Session cancelled."); + details = "Session cancelled."; + trace.Verbose(details); } else if (e.Reason == SshDisconnectReason.ByApplication) { - trace.Verbose("Session closed."); + details = "Session closed."; + trace.Verbose(details); } else if (e.Reason != SshDisconnectReason.None) { + string messageFormat = "Session closed unexpectedly due to {0}, \"{1}\"\n{2}"; + details = string.Format(messageFormat, e.Reason, e.Message, e.Exception); + severity = TunnelEvent.Error; trace.TraceEvent( TraceEventType.Error, 0, - "Session closed unexpectedly due to {0}, \"{1}\"\n{2}", + messageFormat, e.Reason, e.Message, e.Exception); } + if (Tunnel != null) + { + var disconnectedEvent = new TunnelEvent($"host_client_disconnect"); + disconnectedEvent.Severity = severity; + disconnectedEvent.Details = details; + disconnectedEvent.Properties = new Dictionary + { + ["ClientChannelId"] = channelId.ToString(), + }; + ManagementClient?.ReportEvent(Tunnel, disconnectedEvent); + } + tcs.TrySetResult(null); } } @@ -505,9 +552,6 @@ private void OnSshClientAuthenticating(object? sender, SshAuthenticatingEventArg private async void OnSshClientAuthenticated(object? sender, EventArgs e) => await StartForwardingExistingPortsAsync((SshServerSession)sender!); - private async void OnSshClientReconnected(object? sender, EventArgs e) => - await StartForwardingExistingPortsAsync((SshServerSession)sender!, removeUnusedPorts: true); - private async Task StartForwardingExistingPortsAsync( SshSession session, bool removeUnusedPorts = false) { diff --git a/cs/src/Contracts/TunnelConstraints.cs b/cs/src/Contracts/TunnelConstraints.cs index a33877e0..63e51d26 100644 --- a/cs/src/Contracts/TunnelConstraints.cs +++ b/cs/src/Contracts/TunnelConstraints.cs @@ -69,6 +69,22 @@ public static class TunnelConstraints /// public const int DescriptionMaxLength = 400; + /// + /// Max length of tunnel event details. + /// + /// + public const int EventDetailsMaxLength = 4000; + + /// + /// Max number of properties in a tunnel event. + /// + public const int MaxEventProperties = 100; + + /// + /// Max length of a single tunnel event property value. + /// + public const int EventPropertyValueMaxLength = 4000; + /// /// Min length of a single tunnel or port tag. /// @@ -145,6 +161,21 @@ public static class TunnelConstraints /// public const int AccessControlMaxScopes = 10; + /// + /// Regular expression that can match or validate tunnel event name strings. + /// + public const string EventNamePattern = "^[a-z0-9_]{3,80}$"; + + /// + /// Regular expression that can match or validate tunnel event severity strings. + /// + public const string EventSeverityPattern = "^(info)|(warning)|(error)$"; + + /// + /// Regular expression that can match or validate tunnel event property name strings. + /// + public const string EventPropertyNamePattern = "^[a-zA-Z0-9_.]{3,200}$"; + /// /// Regular expression that can match or validate tunnel cluster ID strings. /// diff --git a/cs/src/Contracts/TunnelEvent.cs b/cs/src/Contracts/TunnelEvent.cs new file mode 100644 index 00000000..f2012f3a --- /dev/null +++ b/cs/src/Contracts/TunnelEvent.cs @@ -0,0 +1,121 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.DevTunnels.Contracts.Validation; + +namespace Microsoft.DevTunnels.Contracts; + +using static TunnelConstraints; + +/// +/// Data contract for tunnel client events reported to the tunnel service. +/// +[DebuggerDisplay("{ToString(),nq}")] +public class TunnelEvent +{ + /// Default event severity. + public const string Info = "info"; + + /// Warning event severity. + public const string Warning = "warning"; + + /// Error event severity. + public const string Error = "error"; + + /// + /// Initializes a new empty instance of the class + /// (for deserialization purposes). + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public TunnelEvent() + { + } + + /// + /// Initializes a new instance of the class with a specified event + /// name, and timestamp initialized to the current time. + /// + /// The required name of the event. + public TunnelEvent(string eventName) + { + if (string.IsNullOrEmpty(eventName)) + { + throw new ArgumentNullException( + nameof(eventName), + "Tunnel event name cannot be null or empty."); + } + + Name = eventName; + Timestamp = DateTime.UtcNow; + Properties = new Dictionary(); + } + + /// + /// Gets or sets the UTC timestamp of the event (using the client's clock). + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTime? Timestamp { get; set; } + + /// + /// Gets or sets name of the event. This should be a short descriptive identifier. + /// + [RegularExpression(EventNamePattern)] + public string Name { get; set; } = null!; + + /// + /// Gets or sets the severity of the event, such as , + /// , or . + /// + /// + /// If not specified, the default severity is "info". + /// + [RegularExpression(EventSeverityPattern)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Severity { get; set; } + + /// + /// Gets or sets optional unstructured details about the event, such as a message or + /// description. For warning or error events this may include a stack trace. + /// + [StringLength(EventDetailsMaxLength)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Details { get; set; } + + /// + /// Gets or sets semi-structured event properties. + /// + [DictionaryCount(MaxEventProperties)] + [DictionaryRegularExpression(EventPropertyNamePattern)] + [DictionaryStringLength(EventPropertyValueMaxLength)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Properties { get; set; } + + /// + /// Returns a string representation of the tunnel event. + /// + public override string ToString() + { + var s = $"{Timestamp:s} [{Severity ?? Info}] {Name}"; + + if (!string.IsNullOrEmpty(Details)) + { + s += $": {Details}"; + } + + if (Properties?.Count > 0) + { + s += $"; {string.Join(", ", Properties.Select(p => $"{p.Key}={p.Value}"))}"; + } + + return s; + } +} diff --git a/cs/src/Contracts/Validation/DictionaryCountAttribute.cs b/cs/src/Contracts/Validation/DictionaryCountAttribute.cs new file mode 100644 index 00000000..a5b4e203 --- /dev/null +++ b/cs/src/Contracts/Validation/DictionaryCountAttribute.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.DevTunnels.Contracts.Validation; + +/// +/// Validates the number of items in a string dictionary. +/// +public class DictionaryCountAttribute : ValidationAttribute +{ + /// + /// Initializes a new instance of the class, + /// with a maximum count. + /// + public DictionaryCountAttribute(int maximumCount) + { + if (maximumCount < 0) + { + throw new ArgumentOutOfRangeException( + nameof(maximumCount), "Maximum count must be non-negative."); + } + + MaximumCount = maximumCount; + } + + /// + /// Gets the maximum number of items allowed in the dictionary. + /// + public int MaximumCount { get; } + + /// + /// Checks whether the number of items in the dictionary is valid. + /// + public override bool IsValid(object? value) + { + var dictionary = value as IDictionary; + if (dictionary == null) + { + if (value != null) + { + throw new InvalidOperationException( + "Value to be validated must be a dictionary of strings."); + } + + // RequiredAttribute should be used to assert a value is not null. + return true; + } + + return dictionary.Count <= MaximumCount; + } +} diff --git a/cs/src/Contracts/Validation/DictionaryRegularExpressionAttribute.cs b/cs/src/Contracts/Validation/DictionaryRegularExpressionAttribute.cs new file mode 100644 index 00000000..c2f156dc --- /dev/null +++ b/cs/src/Contracts/Validation/DictionaryRegularExpressionAttribute.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.DevTunnels.Contracts.Validation; + +/// +/// Similar to but validates every key in a +/// string dictionary. +/// +public class DictionaryRegularExpressionAttribute : RegularExpressionAttribute +{ + /// + /// Initializes a new instance of the class, + /// with a regular expression pattern. + /// + public DictionaryRegularExpressionAttribute(string pattern) : base(pattern) + { + } + + /// + /// Checks whether all keys in the dictionary match the regular expression pattern. + /// + /// + /// Null dictionary keys are not considered valid. A null dictionary is valid unless there + /// is also a applied. + /// + public override bool IsValid(object? value) + { + var dictionary = value as IDictionary; + if (dictionary == null) + { + if (value != null) + { + throw new InvalidOperationException( + "Value to be validated must be a dictionary of strings."); + } + + // RequiredAttribute should be used to assert a value is not null. + return true; + } + + foreach (var key in dictionary.Keys) + { + if (key == null || !base.IsValid(key)) + { + return false; + } + } + + return true; + } +} diff --git a/cs/src/Contracts/Validation/DictionaryStringLengthAttribute.cs b/cs/src/Contracts/Validation/DictionaryStringLengthAttribute.cs new file mode 100644 index 00000000..bcb3c422 --- /dev/null +++ b/cs/src/Contracts/Validation/DictionaryStringLengthAttribute.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.DevTunnels.Contracts.Validation; + +/// +/// Validates the length of string values in a dictionary. +/// +public class DictionaryStringLengthAttribute : StringLengthAttribute +{ + /// + /// Initializes a new instance of the class, + /// with a maximum length. + /// + public DictionaryStringLengthAttribute(int maximumLength) : base(maximumLength) + { + } + + /// + /// Checks whether all items in the dictionary are a valid length. + /// + /// + /// Null dictionary items are not considered valid. A null dictionary is valid unless + /// there is also a applied. + /// + public override bool IsValid(object? value) + { + var dictionary = value as IDictionary; + if (dictionary == null) + { + if (value != null) + { + throw new InvalidOperationException( + "Value to be validated must be a dictionary of strings."); + } + + // RequiredAttribute should be used to assert a value is not null. + return true; + } + + foreach (var item in dictionary.Values) + { + if (item == null || !base.IsValid(item)) + { + return false; + } + } + + return true; + } +} diff --git a/cs/src/Management/ITunnelManagementClient.cs b/cs/src/Management/ITunnelManagementClient.cs index 70d5b231..6bfc6b3f 100644 --- a/cs/src/Management/ITunnelManagementClient.cs +++ b/cs/src/Management/ITunnelManagementClient.cs @@ -15,7 +15,7 @@ namespace Microsoft.DevTunnels.Management /// Interface for a client that manages tunnels and tunnel ports via the tunnel service /// management API. /// - public interface ITunnelManagementClient : IDisposable + public interface ITunnelManagementClient : IAsyncDisposable { /// /// Lists tunnels that are owned by the caller. @@ -404,5 +404,18 @@ Task ResolveSubjectsAsync( Task CheckNameAvailabilityAsync( string name, CancellationToken cancellation = default); + + /// + /// Reports a tunnel event to the tunnel service. + /// + /// + /// This method does not block; events are batched and uploaded by a background task. + /// The tunnel service and SDK automatically record some events related to tunnel operations + /// and connections. This method allows applications to report additional custom events. + /// + void ReportEvent( + Tunnel tunnel, + TunnelEvent tunnelEvent, + TunnelRequestOptions? options = null); } } diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index f2d84532..f77bcaaf 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -39,6 +39,7 @@ public class TunnelManagementClient : ITunnelManagementClient private const string UserLimitsApiPath = "/userlimits"; private const string EndpointsApiSubPath = "/endpoints"; private const string PortsApiSubPath = "/ports"; + private const string EventsApiSubPath = "/events"; private const string ClustersApiPath = "/clusters"; private const string ClustersV1ApiPath = ApiV1Path + "/clusters"; private const string TunnelAuthenticationScheme = "Tunnel"; @@ -89,6 +90,18 @@ public class TunnelManagementClient : ITunnelManagementClient private readonly HttpClient httpClient; private readonly Func> userTokenCallback; + private class EventInfo + { + public Tunnel Tunnel { get; set; } = null!; + public TunnelEvent Event { get; set; } = null!; + public TunnelRequestOptions? RequestOptions { get; set; } + } + + private readonly Queue eventsQueue = new Queue(); + private readonly SemaphoreSlim eventsSemaphore = new SemaphoreSlim(0); + private Task? eventsTask; + private bool isDisposed; + /// /// Initializes a new instance of the class /// with an optional client authentication callback. @@ -144,7 +157,7 @@ public TunnelManagementClient( /// for HTTPS requests to the tunnel service. The or /// specified (or at the end of the chain) must have /// automatic redirection disabled. The provided HTTP handler will not be disposed - /// by . + /// by . /// Api version to use for tunnels requests, accepted /// values are public TunnelManagementClient( @@ -174,7 +187,7 @@ public TunnelManagementClient( /// for HTTPS requests to the tunnel service. The or /// specified (or at the end of the chain) must have /// automatic redirection disabled. The provided HTTP handler will not be disposed - /// by . + /// by . /// Api version to use for tunnels requests, accepted /// values are public TunnelManagementClient( @@ -222,6 +235,15 @@ public TunnelManagementClient( }; } + /// + /// Gets or sets a value indicating whether events reporting is enabled. + /// + /// + /// When not enabled, any events reported via + /// (either by the tunnel SDK or the application) will be ignored. + /// + public bool EnableEventsReporting { get; set; } + private static void ValidateHttpHandler(HttpMessageHandler httpHandler) { while (httpHandler is DelegatingHandler delegatingHandler) @@ -526,7 +548,7 @@ private string UserLimitsPath } var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); - if(localMachineHeaders != null) + if (localMachineHeaders != null) { request.Headers.UserAgent.Add(localMachineHeaders); } @@ -774,9 +796,33 @@ private class ErrorDetails } /// - public void Dispose() + public async ValueTask DisposeAsync() { - this.httpClient.Dispose(); + Task? eventsTask = null; + + lock (this.eventsQueue) + { + this.isDisposed = true; + + eventsTask = this.eventsTask; + if (eventsTask != null) + { + // Releasing the semaphore an extra time will cause the events processing task + // to exit after processing any remaining already-queued events. + this.eventsSemaphore.Release(); + } + } + + if (eventsTask != null) + { + // The events processing task will dispose the HTTP client before completing. + await eventsTask; + } + else + { + // The HTTP client is not needed for processing events, so dispose it now. + this.httpClient.Dispose(); + } } private Uri BuildUri( @@ -1008,7 +1054,7 @@ public async Task CreateTunnelAsync( { Requires.NotNull(tunnel, nameof(tunnel)); options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); var tunnelId = tunnel.TunnelId; var idGenerated = string.IsNullOrEmpty(tunnelId); @@ -1054,7 +1100,7 @@ public async Task CreateTunnelAsync( return result2!; } - /// + /// public async Task CreateOrUpdateTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options, @@ -1262,7 +1308,7 @@ public async Task CreateTunnelPortAsync( this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); var result = (await this.SendTunnelRequestAsync( @@ -1297,7 +1343,7 @@ public async Task UpdateTunnelPortAsync( { Requires.NotNull(tunnelPort, nameof(tunnelPort)); options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && @@ -1333,7 +1379,7 @@ public async Task UpdateTunnelPortAsync( return result; } - /// + /// public async Task CreateOrUpdateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, @@ -1536,7 +1582,8 @@ public async Task ListUserLimitsAsync(CancellationToken cance } /// - public async Task ListClustersAsync(CancellationToken cancellation) { + public async Task ListClustersAsync(CancellationToken cancellation) + { var baseAddress = this.httpClient.BaseAddress!; var builder = new UriBuilder(baseAddress); builder.Path = ClustersPath; @@ -1619,5 +1666,135 @@ private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? res } } } + + /// + /// Reports a tunnel event to the tunnel service. + /// + /// + /// This method does not block; events are batched and uploaded by a background task. + /// Any errors while uploading events are ignored. + /// + /// The tunnel service and SDK automatically record some events related to tunnel operations + /// and connections. This method allows applications to report additional custom events. + /// + /// + public void ReportEvent( + Tunnel tunnel, + TunnelEvent tunnelEvent, + TunnelRequestOptions? options = null) + { + Requires.NotNull(tunnel, nameof(tunnel)); + Requires.NotNull(tunnelEvent, nameof(tunnelEvent)); + + if (string.IsNullOrEmpty(ApiVersion)) + { + // Events are not supported by the V1 API. + return; + } + + lock (this.eventsQueue) + { + if (this.isDisposed) + { + // Do not queue any more events after the client is disposed. + return; + } + + bool wasEmpty = this.eventsQueue.Count == 0; + this.eventsQueue.Enqueue(new EventInfo + { + Tunnel = tunnel, + Event = tunnelEvent, + RequestOptions = options + }); + + if (wasEmpty) + { + // Wake up the processing task if it was waiting. + this.eventsSemaphore.Release(); + } + + if (this.eventsTask == null) + { + this.eventsTask = Task.Run(this.ProcessPendingEventsAsync); + } + } + } + + private async Task ProcessPendingEventsAsync() + { + List eventsToSend = new(); + while (true) + { + // Wait for some event(s) to be reported. + await this.eventsSemaphore.WaitAsync(); + Tunnel tunnel; + TunnelRequestOptions? requestOptions; + lock (this.eventsQueue) + { + if (this.eventsQueue.Count == 0) + { + // The semaphore was released, but no events were queued. + // This indicates the client is being disposed. + break; + } + + var nextEventInfo = this.eventsQueue.Dequeue(); + tunnel = nextEventInfo.Tunnel; + requestOptions = nextEventInfo.RequestOptions; + eventsToSend.Add(nextEventInfo.Event); + + while (this.eventsQueue.Count > 0) + { + nextEventInfo = this.eventsQueue.Peek(); + + // Comparisons here are intentionally using reference equality. + // If different events have tunnels with only value equality then + // they may be processed in separate batches, which is fine. + if (nextEventInfo.Tunnel != tunnel || + nextEventInfo.RequestOptions != requestOptions) + { + // The next event is for a different tunnel or has different request + // options, so process as a separate batch. + this.eventsSemaphore.Release(); + break; + } + + eventsToSend.Add(this.eventsQueue.Dequeue().Event); + } + } + + // Upload a batch of events for the same tunnel. + try + { + // Do not use SendTunnelRequestAsync() here, to avoid reporting progress + // for these requests. + var uri = BuildTunnelUri( + tunnel, + EventsApiSubPath, + query: GetApiQuery(), + requestOptions); + var authHeader = await GetAuthenticationHeaderAsync( + tunnel, + ReadAccessTokenScopes, + requestOptions); + await SendRequestAsync( + HttpMethod.Post, + uri, + requestOptions, + authHeader, + body: eventsToSend.ToArray(), + CancellationToken.None); + } + catch (Exception) + { + // Errors uploading events are ignored. + } + + eventsToSend.Clear(); + } + + this.httpClient.Dispose(); + } } } diff --git a/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs b/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs index 0afa892d..5ffc450c 100644 --- a/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs +++ b/cs/test/TunnelsSDK.Test/Mocks/MockTunnelManagementClient.cs @@ -311,9 +311,9 @@ public Task ResolveSubjectsAsync( return Task.FromResult(resolvedSubjects.ToArray()); } - public void Dispose() + public ValueTask DisposeAsync() { - throw new NotImplementedException(); + return ValueTask.CompletedTask; } private static void IssueMockTokens(Tunnel tunnel, TunnelRequestOptions options) @@ -385,4 +385,11 @@ public Task CreateOrUpdateTunnelPortAsync(Tunnel tunnel, TunnelPort { throw new NotImplementedException(); } + + public void ReportEvent( + Tunnel tunnel, + TunnelEvent tunnelEvent, + TunnelRequestOptions options = null) + { + } } diff --git a/cs/test/TunnelsSDK.Test/TunnelClientEventsTests.cs b/cs/test/TunnelsSDK.Test/TunnelClientEventsTests.cs new file mode 100644 index 00000000..c1c33747 --- /dev/null +++ b/cs/test/TunnelsSDK.Test/TunnelClientEventsTests.cs @@ -0,0 +1,392 @@ +using System.Net; +using System.Net.Http.Headers; +using Microsoft.DevTunnels.Contracts; +using Microsoft.DevTunnels.Management; +using Xunit; + +namespace Microsoft.DevTunnels.Test; + +public class TunnelClientEventsTests +{ + // Note: Tests in this class are mostly AI-generated. + + private readonly CancellationToken timeout = System.Diagnostics.Debugger.IsAttached ? default : new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token; + private readonly ProductInfoHeaderValue userAgent = TunnelUserAgent.GetUserAgent(typeof(TunnelClientEventsTests).Assembly); + private readonly Uri tunnelServiceUri = new Uri("https://localhost:3000/"); + + private static Tunnel TestTunnel { get; } = new Tunnel + { + TunnelId = "tnnl0001", + ClusterId = "usw2", + }; + + private static Tunnel TestTunnel2 { get; } = new Tunnel + { + TunnelId = "tnnl0002", + ClusterId = "usw2", + }; + + /// + /// Waits for the expected number of HTTP requests to be captured by the mock handler. + /// + /// The list that captures HTTP requests. + /// The expected number of requests. + /// Timeout in milliseconds (default: 5000). + private static async Task WaitForRequestsAsync(List requestCapture, int expectedCount, int timeoutMs = 5000) + { + var timeout = TimeSpan.FromMilliseconds(timeoutMs); + var endTime = DateTime.Now.Add(timeout); + + while (DateTime.Now < endTime) + { + if (requestCapture.Count >= expectedCount) + { + return; + } + await Task.Delay(10); + } + + throw new TimeoutException($"Expected {expectedCount} requests but only received {requestCapture.Count} within {timeoutMs}ms"); + } + + [Fact] + public async Task SingleEventSendsHttpPostWithCorrectPayload() + { + var tunnelEvent = new TunnelEvent("test-event") + { + Severity = TunnelEvent.Info, + Details = "Test event details", + Properties = new Dictionary + { + { "property1", "value1" }, + { "property2", "value2" } + } + }; + + var requestCapture = new List(); + var handler = new MockHttpMessageHandler( + (message, ct) => + { + requestCapture.Add(message); + var result = new HttpResponseMessage(HttpStatusCode.OK); + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + client.EnableEventsReporting = true; + + // Act + client.ReportEvent(TestTunnel, tunnelEvent); + + // Wait for the expected number of requests to be processed + await WaitForRequestsAsync(requestCapture, 1); + + // Assert + Assert.Single(requestCapture); + var request = requestCapture[0]; + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Contains("/events", request.RequestUri!.ToString()); + Assert.Contains("api-version=2023-09-27-preview", request.RequestUri.ToString()); + + // Verify the request body contains the event + var content = await request.Content!.ReadAsStringAsync(); + Assert.Contains("test-event", content); + Assert.Contains("Test event details", content); + Assert.Contains("property1", content); + Assert.Contains("value1", content); + } + + [Fact] + public async Task MultipleEventsForSameTunnelBatchesIntoSingleRequest() + { + var event1 = new TunnelEvent("event-1"); + var event2 = new TunnelEvent("event-2"); + var event3 = new TunnelEvent("event-3"); + + var requestCapture = new List(); + var handler = new MockHttpMessageHandler( + (message, ct) => + { + requestCapture.Add(message); + var result = new HttpResponseMessage(HttpStatusCode.OK); + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + client.EnableEventsReporting = true; + + // Act - report multiple events for the same tunnel + client.ReportEvent(TestTunnel, event1); + client.ReportEvent(TestTunnel, event2); + client.ReportEvent(TestTunnel, event3); + + // Wait for the expected number of requests to be processed + await WaitForRequestsAsync(requestCapture, 1); + + // Assert - should batch into a single request + Assert.Single(requestCapture); + var request = requestCapture[0]; + Assert.Equal(HttpMethod.Post, request.Method); + + var content = await request.Content!.ReadAsStringAsync(); + Assert.Contains("event-1", content); + Assert.Contains("event-2", content); + Assert.Contains("event-3", content); + } + + [Fact] + public async Task MultipleEventsForDifferentTunnelsSendsSeparateRequests() + { + var event1 = new TunnelEvent("event-1"); + var event2 = new TunnelEvent("event-2"); + + var requestCapture = new List(); + var handler = new MockHttpMessageHandler( + (message, ct) => + { + requestCapture.Add(message); + var result = new HttpResponseMessage(HttpStatusCode.OK); + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + client.EnableEventsReporting = true; + + // Act - report events for different tunnels + client.ReportEvent(TestTunnel, event1); + client.ReportEvent(TestTunnel2, event2); + + // Wait for the expected number of requests to be processed + await WaitForRequestsAsync(requestCapture, 2); + + // Assert - should send separate requests for different tunnels + Assert.Equal(2, requestCapture.Count); + + // Check first request + var request1 = requestCapture[0]; + Assert.Equal(HttpMethod.Post, request1.Method); + var content1 = await request1.Content!.ReadAsStringAsync(); + Assert.Contains("event-1", content1); + Assert.DoesNotContain("event-2", content1); + + // Check second request + var request2 = requestCapture[1]; + Assert.Equal(HttpMethod.Post, request2.Method); + var content2 = await request2.Content!.ReadAsStringAsync(); + Assert.Contains("event-2", content2); + Assert.DoesNotContain("event-1", content2); + } + + [Fact] + public async Task WithRequestOptionsIncludesOptionsInRequest() + { + var tunnelEvent = new TunnelEvent("test-event"); + + var options = new TunnelRequestOptions + { + AccessToken = "test-access-token", + AdditionalHeaders = new List> + { + new KeyValuePair("X-Custom-Header", "CustomValue") + } + }; + + var requestCapture = new List(); + var handler = new MockHttpMessageHandler( + (message, ct) => + { + requestCapture.Add(message); + var result = new HttpResponseMessage(HttpStatusCode.OK); + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + client.EnableEventsReporting = true; + + // Act + client.ReportEvent(TestTunnel, tunnelEvent, options); + + // Wait for the expected number of requests to be processed + await WaitForRequestsAsync(requestCapture, 1); + + // Assert + Assert.Single(requestCapture); + var request = requestCapture[0]; + + // Check that custom headers are included + Assert.True(request.Headers.Contains("X-Custom-Header")); + Assert.Equal("CustomValue", request.Headers.GetValues("X-Custom-Header").First()); + + // Check authorization header contains the access token + Assert.NotNull(request.Headers.Authorization); + Assert.Contains("test-access-token", request.Headers.Authorization.Parameter); + } + + [Fact] + public async Task WithHttpRequestExceptionIgnoresError() + { + var tunnelEvent = new TunnelEvent("test-event"); + + var handler = new MockHttpMessageHandler( + (message, ct) => + { + // Simulate HTTP error + throw new HttpRequestException("Network error"); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + client.EnableEventsReporting = true; + + // Act - this should not throw an exception + client.ReportEvent(TestTunnel, tunnelEvent); + + // Should not throw when disposing + await client.DisposeAsync(); + } + + [Fact] + public async Task WithV1ApiVersionDoesNotSendEvents() + { + var tunnelEvent = new TunnelEvent("test-event"); + + var requestCapture = new List(); + var handler = new MockHttpMessageHandler( + (message, ct) => + { + requestCapture.Add(message); + var result = new HttpResponseMessage(HttpStatusCode.OK); + return Task.FromResult(result); + }); + + // Create client with empty API version (simulating V1) + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + client.EnableEventsReporting = true; + + // Use reflection to set ApiVersion to null (simulating V1 API) + var apiVersionField = typeof(TunnelManagementClient).GetProperty("ApiVersion", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + // Since we can't easily set this in the constructor, we'll test the current behavior + + // Act + client.ReportEvent(TestTunnel, tunnelEvent); + + // Wait for the expected number of requests to be processed + await WaitForRequestsAsync(requestCapture, 1); + + // For current implementation with default API version, we expect the event to be sent + // If V1 API support is added later, this test will need to be updated + Assert.Single(requestCapture); + } + + [Fact] + public async Task UsesAccessToken() + { + var tunnel = new Tunnel + { + TunnelId = TestTunnel.TunnelId, + ClusterId = TestTunnel.ClusterId, + AccessTokens = new Dictionary + { + [TunnelAccessScopes.Connect] = "connect-token" + } + }; + + var tunnelEvent = new TunnelEvent("test-event"); + + var requestCapture = new List(); + var handler = new MockHttpMessageHandler( + (message, ct) => + { + requestCapture.Add(message); + var result = new HttpResponseMessage(HttpStatusCode.OK); + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + client.EnableEventsReporting = true; + + // Act + client.ReportEvent(tunnel, tunnelEvent); + + // Wait for the expected number of requests to be processed + await WaitForRequestsAsync(requestCapture, 1); + + // Assert + Assert.Single(requestCapture); + var request = requestCapture[0]; + + // The ReportEvent method should use ReadAccessTokenScopes, which includes + // Manage, ManagePorts, Host, and Connect scopes. Since the tunnel has a Connect + // scope access token, that should be used for authentication. + Assert.NotNull(request.Headers.Authorization); + var authParam = request.Headers.Authorization.Parameter; + + // Should use one of the available tokens + Assert.True( + authParam!.Contains("connect-token"), + $"Authorization header should contain the connect token, but was: {authParam}"); + } + + [Fact] + public async Task DisposedImmediatelyStillSendsEvents() + { + var tunnelEvent = new TunnelEvent("test-event-dispose"); + var tunnelEvent2 = new TunnelEvent("test-event-after-dispose"); + + var requestCapture = new List(); + var lastRequestTime = DateTime.MinValue; + var disposalTime = DateTime.MinValue; + + var handler = new MockHttpMessageHandler( + (message, ct) => + { + requestCapture.Add(message); + lastRequestTime = DateTime.Now; + var result = new HttpResponseMessage(HttpStatusCode.OK); + return Task.FromResult(result); + }); + + var client = new TunnelManagementClient(this.userAgent, null, this.tunnelServiceUri, handler); + client.EnableEventsReporting = true; + + // Act - report event and dispose immediately + client.ReportEvent(TestTunnel, tunnelEvent); + var disposeTask = client.DisposeAsync(); // Dispose immediately after reporting + client.ReportEvent(TestTunnel, tunnelEvent2); // Ignored after dispose + + // Wait for the expected number of requests to be processed + // Even though we disposed immediately, the background task should complete + Task.WaitAll( + disposeTask.AsTask(), + WaitForRequestsAsync(requestCapture, 1) + ); + + // Assert + Assert.Single(requestCapture); + var request = requestCapture[0]; + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Contains("/events", request.RequestUri!.ToString()); + + // Verify the request body contains the event + var content = await request.Content!.ReadAsStringAsync(); + Assert.Contains("test-event-dispose", content); + } + + private sealed class MockHttpMessageHandler : DelegatingHandler + { + private readonly Func> handler; + + public MockHttpMessageHandler(Func> handler) + : base(new HttpClientHandler + { + AllowAutoRedirect = false, + UseDefaultCredentials = false, + }) + { + this.handler = Requires.NotNull(handler, nameof(handler)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + this.handler(request, cancellationToken); + } +} diff --git a/go/tunnels/tunnel_constraints.go b/go/tunnels/tunnel_constraints.go index 42534d2a..547fe0c4 100644 --- a/go/tunnels/tunnel_constraints.go +++ b/go/tunnels/tunnel_constraints.go @@ -37,6 +37,15 @@ const ( // Max length of tunnel or port description. TunnelConstraintsDescriptionMaxLength = 400 + // Max length of tunnel event details. + TunnelConstraintsEventDetailsMaxLength = 4000 + + // Max number of properties in a tunnel event. + TunnelConstraintsMaxEventProperties = 100 + + // Max length of a single tunnel event property value. + TunnelConstraintsEventPropertyValueMaxLength = 4000 + // Min length of a single tunnel or port tag. TunnelConstraintsLabelMinLength = 1 @@ -74,6 +83,15 @@ const ( // Maximum number of scopes in an access control entry. TunnelConstraintsAccessControlMaxScopes = 10 + // Regular expression that can match or validate tunnel event name strings. + TunnelConstraintsEventNamePattern = "^[a-z0-9_]{3,80}$" + + // Regular expression that can match or validate tunnel event severity strings. + TunnelConstraintsEventSeverityPattern = "^(info)|(warning)|(error)$" + + // Regular expression that can match or validate tunnel event property name strings. + TunnelConstraintsEventPropertyNamePattern = "^[a-zA-Z0-9_.]{3,200}$" + // Regular expression that can match or validate tunnel cluster ID strings. // // Cluster IDs are alphanumeric; hyphens are not permitted. diff --git a/go/tunnels/tunnel_event.go b/go/tunnels/tunnel_event.go new file mode 100644 index 00000000..69bc6441 --- /dev/null +++ b/go/tunnels/tunnel_event.go @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/TunnelEvent.cs + +package tunnels + +import ( + "time" +) + +// Data contract for tunnel client events reported to the tunnel service. +type TunnelEvent struct { + // Gets or sets the UTC timestamp of the event (using the client's clock). + Timestamp *time.Time `json:"timestamp,omitempty"` + + // Gets or sets name of the event. This should be a short descriptive identifier. + Name string `json:"name"` + + // Gets or sets the severity of the event, such as `TunnelEvent.Info`, + // `TunnelEvent.Warning`, or `TunnelEvent.Error`. + // + // If not specified, the default severity is "info". + Severity string `json:"severity,omitempty"` + + // Gets or sets optional unstructured details about the event, such as a message or + // description. For warning or error events this may include a stack trace. + Details string `json:"details,omitempty"` + + // Gets or sets semi-structured event properties. + Properties map[string]string `json:"properties,omitempty"` +} + +// Default event severity. +var Info = "info" + +// Warning event severity. +var Warning = "warning" + +// Error event severity. +var Error = "error" diff --git a/java/src/main/java/com/microsoft/tunnels/contracts/TunnelConstraints.java b/java/src/main/java/com/microsoft/tunnels/contracts/TunnelConstraints.java index 4efda610..8f338826 100644 --- a/java/src/main/java/com/microsoft/tunnels/contracts/TunnelConstraints.java +++ b/java/src/main/java/com/microsoft/tunnels/contracts/TunnelConstraints.java @@ -55,6 +55,21 @@ public class TunnelConstraints { */ public static final int descriptionMaxLength = 400; + /** + * Max length of tunnel event details. + */ + public static final int eventDetailsMaxLength = 4000; + + /** + * Max number of properties in a tunnel event. + */ + public static final int maxEventProperties = 100; + + /** + * Max length of a single tunnel event property value. + */ + public static final int eventPropertyValueMaxLength = 4000; + /** * Min length of a single tunnel or port tag. */ @@ -114,6 +129,21 @@ public class TunnelConstraints { */ public static final int accessControlMaxScopes = 10; + /** + * Regular expression that can match or validate tunnel event name strings. + */ + public static final String eventNamePattern = "^[a-z0-9_]{3,80}$"; + + /** + * Regular expression that can match or validate tunnel event severity strings. + */ + public static final String eventSeverityPattern = "^(info)|(warning)|(error)$"; + + /** + * Regular expression that can match or validate tunnel event property name strings. + */ + public static final String eventPropertyNamePattern = "^[a-zA-Z0-9_.]{3,200}$"; + /** * Regular expression that can match or validate tunnel cluster ID strings. * diff --git a/java/src/main/java/com/microsoft/tunnels/contracts/TunnelEvent.java b/java/src/main/java/com/microsoft/tunnels/contracts/TunnelEvent.java new file mode 100644 index 00000000..29171d74 --- /dev/null +++ b/java/src/main/java/com/microsoft/tunnels/contracts/TunnelEvent.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../../../../../../cs/src/Contracts/TunnelEvent.cs + +package com.microsoft.tunnels.contracts; + +import com.google.gson.annotations.Expose; +import java.util.Date; +import java.util.Map; + +/** + * Data contract for tunnel client events reported to the tunnel service. + */ +public class TunnelEvent { + /** + * Default event severity. + */ + public static final String info = "info"; + + /** + * Warning event severity. + */ + public static final String warning = "warning"; + + /** + * Error event severity. + */ + public static final String error = "error"; + + /** + * Gets or sets the UTC timestamp of the event (using the client's clock). + */ + @Expose + public Date timestamp; + + /** + * Gets or sets name of the event. This should be a short descriptive identifier. + */ + @Expose + public String name; + + /** + * Gets or sets the severity of the event, such as {@link TunnelEvent#info}, {@link + * TunnelEvent#warning}, or {@link TunnelEvent#error}. + * + * If not specified, the default severity is "info". + */ + @Expose + public String severity; + + /** + * Gets or sets optional unstructured details about the event, such as a message or + * description. For warning or error events this may include a stack trace. + */ + @Expose + public String details; + + /** + * Gets or sets semi-structured event properties. + */ + @Expose + public Map properties; +} diff --git a/rs/src/contracts/mod.rs b/rs/src/contracts/mod.rs index bde00a1a..ac7b445c 100644 --- a/rs/src/contracts/mod.rs +++ b/rs/src/contracts/mod.rs @@ -23,6 +23,7 @@ mod tunnel_connection_mode; mod tunnel_constraints; mod tunnel_endpoint; mod tunnel_environments; +mod tunnel_event; mod tunnel_header_names; mod tunnel_list_by_region; mod tunnel_list_by_region_response; @@ -58,6 +59,7 @@ pub use tunnel_connection_mode::*; pub use tunnel_constraints::*; pub use tunnel_endpoint::*; pub use tunnel_environments::*; +pub use tunnel_event::*; pub use tunnel_header_names::*; pub use tunnel_list_by_region::*; pub use tunnel_list_by_region_response::*; diff --git a/rs/src/contracts/tunnel_constraints.rs b/rs/src/contracts/tunnel_constraints.rs index fd4eb0e5..91001e52 100644 --- a/rs/src/contracts/tunnel_constraints.rs +++ b/rs/src/contracts/tunnel_constraints.rs @@ -31,6 +31,15 @@ pub const TUNNEL_NAME_MAX_LENGTH: i32 = 60; // Max length of tunnel or port description. pub const DESCRIPTION_MAX_LENGTH: i32 = 400; +// Max length of tunnel event details. +pub const EVENT_DETAILS_MAX_LENGTH: i32 = 4000; + +// Max number of properties in a tunnel event. +pub const MAX_EVENT_PROPERTIES: i32 = 100; + +// Max length of a single tunnel event property value. +pub const EVENT_PROPERTY_VALUE_MAX_LENGTH: i32 = 4000; + // Min length of a single tunnel or port tag. pub const LABEL_MIN_LENGTH: i32 = 1; @@ -68,6 +77,15 @@ pub const ACCESS_CONTROL_SUBJECT_NAME_MAX_LENGTH: i32 = 200; // Maximum number of scopes in an access control entry. pub const ACCESS_CONTROL_MAX_SCOPES: i32 = 10; +// Regular expression that can match or validate tunnel event name strings. +pub const EVENT_NAME_PATTERN: &str = r#"^[a-z0-9_]{3,80}$"#; + +// Regular expression that can match or validate tunnel event severity strings. +pub const EVENT_SEVERITY_PATTERN: &str = r#"^(info)|(warning)|(error)$"#; + +// Regular expression that can match or validate tunnel event property name strings. +pub const EVENT_PROPERTY_NAME_PATTERN: &str = r#"^[a-zA-Z0-9_.]{3,200}$"#; + // Regular expression that can match or validate tunnel cluster ID strings. // // Cluster IDs are alphanumeric; hyphens are not permitted. diff --git a/rs/src/contracts/tunnel_event.rs b/rs/src/contracts/tunnel_event.rs new file mode 100644 index 00000000..1d43890b --- /dev/null +++ b/rs/src/contracts/tunnel_event.rs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/TunnelEvent.cs + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// Data contract for tunnel client events reported to the tunnel service. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all(serialize = "camelCase", deserialize = "camelCase"))] +pub struct TunnelEvent { + // Gets or sets the UTC timestamp of the event (using the client's clock). + pub timestamp: Option>, + + // Gets or sets name of the event. This should be a short descriptive identifier. + pub name: String, + + // Gets or sets the severity of the event, such as `TunnelEvent.Info`, + // `TunnelEvent.Warning`, or `TunnelEvent.Error`. + // + // If not specified, the default severity is "info". + pub severity: Option, + + // Gets or sets optional unstructured details about the event, such as a message or + // description. For warning or error events this may include a stack trace. + pub details: Option, + + // Gets or sets semi-structured event properties. + pub properties: Option>, +} + +// Default event severity. +pub const INFO: &str = "info"; + +// Warning event severity. +pub const WARNING: &str = "warning"; + +// Error event severity. +pub const ERROR: &str = "error"; diff --git a/ts/src/connections/tunnelConnectionSession.ts b/ts/src/connections/tunnelConnectionSession.ts index a6ae2538..0878a58d 100644 --- a/ts/src/connections/tunnelConnectionSession.ts +++ b/ts/src/connections/tunnelConnectionSession.ts @@ -1,7 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { Tunnel, TunnelAccessScopes, TunnelProgress, TunnelReportProgressEventArgs } from '@microsoft/dev-tunnels-contracts'; +import { + Tunnel, + TunnelAccessScopes, + TunnelProgress, + TunnelReportProgressEventArgs, + TunnelEvent, +} from '@microsoft/dev-tunnels-contracts'; import { TunnelAccessTokenProperties, TunnelManagementClient, @@ -35,6 +41,7 @@ import { PortRelayConnectRequestMessage } from './messages/portRelayConnectReque import * as http from 'http'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; import { RefreshingTunnelEventArgs } from './refreshingTunnelEventArgs'; +import { RetryingTunnelConnectionEventArgs } from './retryingTunnelConnectionEventArgs'; import { TunnelRelayStreamFactory } from './tunnelRelayStreamFactory'; import { DefaultTunnelRelayStreamFactory } from './defaultTunnelRelayStreamFactory'; import { IClientConfig } from 'websocket'; @@ -99,6 +106,59 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun return this.isClientConnection ? 'client' : 'host'; } + /** + * @internal onRetrying override to report tunnel events. + */ + public onRetrying(event: RetryingTunnelConnectionEventArgs): void { + // Report tunnel event for retry + if (this.tunnel && this.managementClient) { + const retryingEvent: TunnelEvent = { + name: `${this.connectionRole}_connect_retrying`, + severity: TunnelEvent.warning, + details: event.error?.toString(), + properties: { + 'Retry': event.retry.toString(), + 'Delay': event.delayMs.toString() + }, + }; + this.managementClient.reportEvent(this.tunnel, retryingEvent); + } + + super.onRetrying(event); + } + + /** + * @internal onConnectionStatusChanged override to report tunnel events. + */ + protected onConnectionStatusChanged( + previousStatus: ConnectionStatus, + status: ConnectionStatus, + ) { + // Report tunnel event for connection status change + if (this.tunnel && this.managementClient) { + const statusEvent: TunnelEvent = { + name: `${this.connectionRole}_connection_status`, + severity: TunnelEvent.info, + details: undefined, + properties: { + 'ConnectionStatus': status.toString(), + 'PreviousConnectionStatus': previousStatus.toString() + }, + }; + + // Add duration property if we had a previous status + if (previousStatus !== ConnectionStatus.None) { + // Note: In C# this uses a duration calculation, but we don't have + // timing information readily available in TypeScript, so we'll skip this for now + // statusEvent.properties[`${previousStatus}Duration`] = duration.toString(); + } + + this.managementClient.reportEvent(this.tunnel, statusEvent); + } + + super.onConnectionStatusChanged(previousStatus, status); + } + /** * Tunnel access token. */ @@ -509,11 +569,30 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun reason === SshDisconnectReason.connectionLost && this.connector) { + // Report reconnect event + if (this.tunnel && this.managementClient) { + const reconnectEvent: TunnelEvent = { + name: `${this.connectionRole}_reconnect`, + severity: TunnelEvent.warning, + details: error?.toString() ?? message, + }; + this.managementClient.reportEvent(this.tunnel, reconnectEvent); + } + this.traceInfo(`${traceMessage} Reconnecting.`); this.reconnectPromise = (async () => { try { await this.connectTunnelSession(); - } catch { + } catch (ex) { + // Report reconnect failed event + if (this.tunnel && this.managementClient) { + const reconnectFailedEvent: TunnelEvent = { + name: `${this.connectionRole}_reconnect_failed`, + severity: TunnelEvent.error, + details: ex instanceof Error ? ex.toString() : String(ex), + }; + this.managementClient.reportEvent(this.tunnel, reconnectFailedEvent); + } // Tracing of the error has already been done by connectTunnelSession. // As reconnection is an async process, there is nobody watching it throw. // The error, if it was not cancellation, is stored in disconnectError property. @@ -522,6 +601,16 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun this.reconnectPromise = undefined; })(); } else { + // Report disconnect event + if (this.tunnel && this.managementClient) { + const disconnectEvent: TunnelEvent = { + name: `${this.connectionRole}_disconnect`, + severity: TunnelEvent.warning, + details: error?.toString() ?? message, + }; + this.managementClient.reportEvent(this.tunnel, disconnectEvent); + } + this.traceInfo(traceMessage); this.connectionStatus = ConnectionStatus.Disconnected; } @@ -549,7 +638,7 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun case SshDisconnectReason.tooManyConnections: return this.isClientConnection ? ' Too many client connections.' : ' Another host for the tunnel has connected.'; default: - return ''; + return ''; } } @@ -567,6 +656,15 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun const message = `Error connecting ${this.connectionRole} tunnel session: ${e}`; this.traceError(message); } + + if (this.tunnel && this.managementClient) { + const connectFailedEvent: TunnelEvent = { + name: `${this.connectionRole}_connect_failed`, + severity: TunnelEvent.error, + details: e instanceof Error ? e.toString() : String(e), + }; + this.managementClient.reportEvent(this.tunnel, connectFailedEvent); + } } throw e; } diff --git a/ts/src/connections/tunnelRelayTunnelHost.ts b/ts/src/connections/tunnelRelayTunnelHost.ts index 3ddc986d..d0077184 100644 --- a/ts/src/connections/tunnelRelayTunnelHost.ts +++ b/ts/src/connections/tunnelRelayTunnelHost.ts @@ -9,6 +9,7 @@ import { Tunnel, TunnelAccessScopes, TunnelProgress, + TunnelEvent, } from '@microsoft/dev-tunnels-contracts'; import { TunnelManagementClient } from '@microsoft/dev-tunnels-management'; import { @@ -462,6 +463,8 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu throw new CancellationError(); } + const clientChannelId = stream.channel.channelId; + const session = SshHelpers.createSshServerSession(this.reconnectableSessions, (config) => { config.protocolExtensions.push(SshProtocolExtensionNames.sessionReconnect); config.addService(PortForwardingService); @@ -485,13 +488,16 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu void this.onSshClientAuthenticated(session); }); const requestRegistration = session.onRequest((e) => { - this.onSshSessionRequest(e, session); + this.onClientSessionRequest(e, session); }); const channelOpeningEventRegistration = session.onChannelOpening((e) => { this.onSshChannelOpening(e, session); }); + const reconnectedEventRegistration = session.onReconnected(() => { + this.onClientSessionReconnecting(session, clientChannelId); + }) const closedEventRegistration = session.onClosed((e) => { - this.session_Closed(session, e, cancellation); + this.onClientSessionClosed(session, e, clientChannelId, cancellation); tcs.resolve(); }); @@ -502,11 +508,23 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu cancellation.onCancellationRequested((e) => { tcs.reject(new CancellationError()); }); + + if (this.tunnel && this.managementClient) { + const connectedEvent: TunnelEvent = { + name: 'host_client_connected', + properties: { + 'ClientChannelId': clientChannelId.toString(), + } + }; + this.managementClient.reportEvent(this.tunnel, connectedEvent); + } + await tcs.promise; } finally { authenticatingEventRegistration.dispose(); requestRegistration.dispose(); channelOpeningEventRegistration.dispose(); + reconnectedEventRegistration.dispose(); closedEventRegistration.dispose(); await session.close(SshDisconnectReason.byApplication); @@ -545,7 +563,7 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu } } - private onSshSessionRequest(e: SshRequestEventArgs, session: any) { + private onClientSessionRequest(e: SshRequestEventArgs, session: any) { if (e.requestType === 'RefreshPorts') { e.responsePromise = (async () => { await this.refreshPorts(); @@ -598,20 +616,56 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu } } - private session_Closed( + private onClientSessionReconnecting(session: SshServerSession, clientChannelId: number) { + if (this.tunnel && this.managementClient) { + const reconnectedEvent: TunnelEvent = { + name: 'host_client_reconnecting', + properties: { + 'ClientChannelId': clientChannelId.toString(), + } + }; + this.managementClient.reportEvent(this.tunnel, reconnectedEvent); + } + } + + private onClientSessionClosed( session: SshServerSession, e: SshSessionClosedEventArgs, + clientChannelId: number, cancellation: CancellationToken, ) { + // Determine severity based on the disconnect reason + let severity: string | undefined; + let details: string; + // Reconnecting client session may cause the new session to close with 'None' reason. if (e.reason === SshDisconnectReason.byApplication) { - this.traceInfo('Client ssh session closed.'); + details = 'Client ssh session closed by application.'; + this.traceInfo(details); } else if (cancellation.isCancellationRequested) { - this.traceInfo('Client ssh session cancelled.'); + details = 'Client ssh session cancelled.'; + this.traceInfo(details); } else if (e.reason !== SshDisconnectReason.none) { - this.traceError( - `Client ssh session closed unexpectedly due to ${e.reason}, "${e.message}"\n${e.error}`, - ); + severity = TunnelEvent.error; + details = `Client ssh session closed unexpectedly due to ${e.reason}, ` + + `"${e.message}"\n${e.error}`; + this.traceError(details); + } else { + details = 'Client ssh session closed.'; + } + + // Report client disconnected event + if (this.tunnel && this.managementClient) { + const disconnectedEvent: TunnelEvent = { + timestamp: new Date(), + name: 'host_client_disconnected', + severity: severity, + details: details, + properties: { + 'ClientChannelId': clientChannelId.toString(), + } + }; + this.managementClient.reportEvent(this.tunnel, disconnectedEvent); } for (const [key, forwarder] of this.remoteForwarders.entries()) { diff --git a/ts/src/contracts/index.ts b/ts/src/contracts/index.ts index bba83533..9b3b367b 100644 --- a/ts/src/contracts/index.ts +++ b/ts/src/contracts/index.ts @@ -10,6 +10,7 @@ export { TunnelAccessControlEntryType } from './tunnelAccessControlEntryType'; export { TunnelAccessScopes } from './tunnelAccessScopes'; export { TunnelConnectionMode } from './tunnelConnectionMode'; export { TunnelEndpoint } from './tunnelEndpoint'; +export { TunnelEvent } from './tunnelEvent'; export { TunnelHeaderNames } from './tunnelHeaderNames'; export { TunnelOptions } from './tunnelOptions'; export { TunnelPort } from './tunnelPort'; diff --git a/ts/src/contracts/tunnelConstraints.ts b/ts/src/contracts/tunnelConstraints.ts index 5dab0838..985b8815 100644 --- a/ts/src/contracts/tunnelConstraints.ts +++ b/ts/src/contracts/tunnelConstraints.ts @@ -52,6 +52,21 @@ export namespace TunnelConstraints { */ export const descriptionMaxLength: number = 400; + /** + * Max length of tunnel event details. + */ + export const eventDetailsMaxLength: number = 4000; + + /** + * Max number of properties in a tunnel event. + */ + export const maxEventProperties: number = 100; + + /** + * Max length of a single tunnel event property value. + */ + export const eventPropertyValueMaxLength: number = 4000; + /** * Min length of a single tunnel or port tag. */ @@ -111,6 +126,21 @@ export namespace TunnelConstraints { */ export const accessControlMaxScopes: number = 10; + /** + * Regular expression that can match or validate tunnel event name strings. + */ + export const eventNamePattern: string = '^[a-z0-9_]{3,80}$'; + + /** + * Regular expression that can match or validate tunnel event severity strings. + */ + export const eventSeverityPattern: string = '^(info)|(warning)|(error)$'; + + /** + * Regular expression that can match or validate tunnel event property name strings. + */ + export const eventPropertyNamePattern: string = '^[a-zA-Z0-9_.]{3,200}$'; + /** * Regular expression that can match or validate tunnel cluster ID strings. * diff --git a/ts/src/contracts/tunnelEvent.ts b/ts/src/contracts/tunnelEvent.ts new file mode 100644 index 00000000..dcb4c01e --- /dev/null +++ b/ts/src/contracts/tunnelEvent.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// Generated from ../../../cs/src/Contracts/TunnelEvent.cs +/* eslint-disable */ + +/** + * Data contract for tunnel client events reported to the tunnel service. + */ +export interface TunnelEvent { + /** + * Gets or sets the UTC timestamp of the event (using the client's clock). + */ + timestamp?: Date; + + /** + * Gets or sets name of the event. This should be a short descriptive identifier. + */ + name: string; + + /** + * Gets or sets the severity of the event, such as {@link TunnelEvent.info}, {@link + * TunnelEvent.warning}, or {@link TunnelEvent.error}. + * + * If not specified, the default severity is "info". + */ + severity?: string; + + /** + * Gets or sets optional unstructured details about the event, such as a message or + * description. For warning or error events this may include a stack trace. + */ + details?: string; + + /** + * Gets or sets semi-structured event properties. + */ + properties?: { [key: string]: string }; +} + +/** + * Default event severity. + */ +export const info = 'info'; + +/** + * Warning event severity. + */ +export const warning = 'warning'; + +/** + * Error event severity. + */ +export const error = 'error'; + +export const TunnelEvent = { + info, + warning, + error, +}; diff --git a/ts/src/management/tunnelManagementClient.ts b/ts/src/management/tunnelManagementClient.ts index 50f9a9d1..3c5c020b 100644 --- a/ts/src/management/tunnelManagementClient.ts +++ b/ts/src/management/tunnelManagementClient.ts @@ -6,6 +6,7 @@ import { NamedRateStatus, Tunnel, TunnelEndpoint, + TunnelEvent, TunnelPort, } from '@microsoft/dev-tunnels-contracts'; import { TunnelRequestOptions } from './tunnelRequestOptions'; @@ -213,6 +214,23 @@ export interface TunnelManagementClient { * @param cancellation Optional cancellation token for the request. */ checkNameAvailablility(tunnelName: string, cancellation?: CancellationToken): Promise; + + /** + * Reports a tunnel event to the tunnel service. + * + * This method does not block; events are batched and uploaded by a background task. + * The tunnel service and SDK automatically record some events related to tunnel operations + * and connections. This method allows applications to report additional custom events. + * @param tunnel Tunnel that the event is associated with. + * @param tunnelEvent Event to report. + * @param options Optional request options. + */ + reportEvent(tunnel: Tunnel, tunnelEvent: TunnelEvent, options?: TunnelRequestOptions): void; + + /** + * Disposes the client and any background tasks. + */ + dispose(): Promise; } /** diff --git a/ts/src/management/tunnelManagementHttpClient.ts b/ts/src/management/tunnelManagementHttpClient.ts index 2dd376d7..09cabd48 100644 --- a/ts/src/management/tunnelManagementHttpClient.ts +++ b/ts/src/management/tunnelManagementHttpClient.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { CancellationToken, Disposable, Emitter, Event } from 'vscode-jsonrpc'; +import { PromiseCompletionSource } from '@microsoft/dev-tunnels-ssh'; import { Tunnel, TunnelAccessControl, @@ -16,6 +18,7 @@ import { TunnelPortListResponse, TunnelProgress, TunnelReportProgressEventArgs, + TunnelEvent, } from '@microsoft/dev-tunnels-contracts'; import { ProductHeaderValue, @@ -29,7 +32,6 @@ import axios, { AxiosAdapter, AxiosError, AxiosRequestConfig, AxiosResponse, Met import * as https from 'https'; import { TunnelPlanTokenProperties } from './tunnelPlanTokenProperties'; import { IdGeneration } from './idGeneration'; -import { CancellationToken, Disposable, Emitter, Event } from 'vscode-jsonrpc'; type NullableIfNotBoolean = T extends boolean ? T : T | null; @@ -37,6 +39,7 @@ const tunnelsApiPath = '/tunnels'; const limitsApiPath = '/userlimits'; const endpointsApiSubPath = '/endpoints'; const portsApiSubPath = '/ports'; +const eventsApiSubPath = '/events'; const clustersApiPath = '/clusters'; const tunnelAuthentication = 'Authorization'; const checkAvailablePath = ':checkNameAvailability'; @@ -113,6 +116,12 @@ const readAccessTokenScopes = [ const apiVersions = ["2023-09-27-preview"]; const defaultRequestTimeoutMS = 20000; +interface EventInfo { + tunnel: Tunnel; + event: TunnelEvent; + requestOptions?: TunnelRequestOptions; +} + export class TunnelManagementHttpClient implements TunnelManagementClient { public additionalRequestHeaders?: { [header: string]: string }; public apiVersion: string; @@ -132,6 +141,19 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { public trace: (msg: string) => void = (msg) => {}; + /** + * Gets or sets a value indicating whether events reporting is enabled. + * + * When not enabled, any events reported via {@link reportEvent} + * (either by the tunnel SDK or the application) will be ignored. + */ + public enableEventsReporting: boolean = false; + + private readonly eventsQueue: EventInfo[] = []; + private eventsPromise: Promise | null = null; + private isDisposed: boolean = false; + private eventsAvailableCompletion = new PromiseCompletionSource(); + /** * Initializes a new instance of the `TunnelManagementHttpClient` class * with a client authentication callback, service URI, and HTTP handler. @@ -786,6 +808,111 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { return await this.request('GET', uri, undefined, config, undefined, cancellation); } + public reportEvent(tunnel: Tunnel, tunnelEvent: TunnelEvent, options?: TunnelRequestOptions): void { + if (!tunnel) { + throw new TypeError('A tunnel is required.'); + } + if (!tunnelEvent) { + throw new TypeError('A tunnelEvent is required.'); + } + + if (!this.apiVersion) { + // Events are not supported by the V1 API. + return; + } + + if (!this.enableEventsReporting) { + return; + } + + if (this.isDisposed) { + // Do not queue any more events after the client is disposed. + return; + } + + // Set the client timestamp if it wasn't already initialized. + tunnelEvent.timestamp ??= new Date(); + + const wasEmpty = this.eventsQueue.length === 0; + this.eventsQueue.push({ + tunnel: tunnel, + event: tunnelEvent, + requestOptions: options + }); + + // Signal that events are available + if (wasEmpty) { + this.eventsAvailableCompletion.resolve(); + } + + if (this.eventsPromise === null) { + this.eventsPromise = this.processPendingEventsAsync(); + } + } + + private async processPendingEventsAsync(): Promise { + const eventsToSend: TunnelEvent[] = []; + + while (!this.isDisposed) { + await this.eventsAvailableCompletion.promise; + this.eventsAvailableCompletion = new PromiseCompletionSource(); + + // Get the first event + const nextEventInfo = this.eventsQueue.shift(); + if (!nextEventInfo) { + // The completion was resolved, but no events were queued. + // This indicates the client is being disposed. + break; + } + + const tunnel = nextEventInfo.tunnel; + const requestOptions = nextEventInfo.requestOptions; + eventsToSend.length = 0; + eventsToSend.push(nextEventInfo.event); + + // Batch events for the same tunnel with the same request options + while (this.eventsQueue.length > 0) { + const peekEventInfo = this.eventsQueue[0]; + + // Check if next event is for the same tunnel and has same request options + if (peekEventInfo.tunnel !== tunnel || peekEventInfo.requestOptions !== requestOptions) { + // Different tunnel or options, process as separate batch + break; + } + + eventsToSend.push(this.eventsQueue.shift()!.event); + } + + // Upload a batch of events for the same tunnel + try { + // Do not use sendTunnelRequest() here, to avoid reporting progress + // for these requests. + const uri = await this.buildUriForTunnel( + tunnel, + eventsApiSubPath, + this.tunnelRequestOptionsToQueryString(requestOptions), + requestOptions + ); + const config = await this.getAxiosRequestConfig( + tunnel, + requestOptions, + readAccessTokenScopes + ); + await this.request( + 'POST', + uri, + [...eventsToSend], // Create a copy to avoid mutation issues + config, + undefined, + undefined + ); + } catch (error) { + // Errors uploading events are ignored. + this.trace(`Error uploading events: ${error}`); + } + } + } + private raiseReportProgress(progress: TunnelProgress) { const args : TunnelReportProgressEventArgs = { progress: progress @@ -912,21 +1039,18 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { ): string { // tunnels.local.api.visualstudio.com resolves to localhost (for local development). if (!clusterId || - hostname == 'localhost' || - hostname == 'tunnels.local.api.visualstudio.com' + hostname === 'localhost' || + hostname === 'tunnels.local.api.visualstudio.com' ) { return hostname; } if (hostname.startsWith('global.') || - TunnelConstraints.clusterIdPrefixRegex.test(hostname)) - { + TunnelConstraints.clusterIdPrefixRegex.test(hostname)) { // Hostname is in the form "global.rel.tunnels..." or ".rel.tunnels..." // Replace the first part of the hostname with the specified cluster ID. return clusterId + hostname.substring(hostname.indexOf('.')); - } - else - { + } else { // Hostname does not have a recognized cluster prefix. Prepend the cluster ID. return `${clusterId}.${hostname}`; } @@ -1268,4 +1392,20 @@ export class TunnelManagementHttpClient implements TunnelManagementClient { return content; } + + /** + * Disposes the client and any background tasks. + */ + public async dispose(): Promise { + this.isDisposed = true; + + // Resolving the events-available completion will cause the events processing task + // to exit after processing any remaining already-queued events. + this.eventsAvailableCompletion.resolve(); + + if (this.eventsPromise) { + await this.eventsPromise; + this.eventsPromise = null; + } + } } \ No newline at end of file diff --git a/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts b/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts index a940b1d2..7c2eba11 100644 --- a/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts +++ b/ts/test/tunnels-test/mocks/mockTunnelManagementClient.ts @@ -8,6 +8,7 @@ import { TunnelPort, TunnelConnectionMode, TunnelEndpoint, + TunnelEvent, ClusterDetails, NamedRateStatus, } from '@microsoft/dev-tunnels-contracts'; @@ -283,4 +284,12 @@ export class MockTunnelManagementClient implements TunnelManagementClient { }); } } + + reportEvent(tunnel: Tunnel, tunnelEvent: TunnelEvent, options?: TunnelRequestOptions): void { + // Mock implementation - do nothing + } + + async dispose(): Promise { + // Mock implementation - do nothing + } } diff --git a/ts/test/tunnels-test/tunnelEventsTests.ts b/ts/test/tunnels-test/tunnelEventsTests.ts new file mode 100644 index 00000000..08b10410 --- /dev/null +++ b/ts/test/tunnels-test/tunnelEventsTests.ts @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as assert from 'assert'; +import { AxiosHeaders, AxiosError, AxiosRequestConfig, AxiosResponse, Method } from 'axios'; +import { suite, test, slow, timeout } from '@testdeck/mocha'; +import { ManagementApiVersions, TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; +import { Tunnel, TunnelEvent } from '@microsoft/dev-tunnels-contracts'; +import { CancellationToken } from 'vscode-jsonrpc'; + +@suite +@slow(3000) +@timeout(10000) +export class TunnelEventsTests { + + private readonly managementClient: TunnelManagementHttpClient; + + private static readonly testServiceUri = 'http://global.tunnels.test.api.visualstudio.com'; + + public constructor() { + this.managementClient = new TunnelManagementHttpClient( + 'test/0.0.0', ManagementApiVersions.Version20230927preview, undefined, TunnelEventsTests.testServiceUri); + (this.managementClient).axiosRequest = this.mockAxiosRequest.bind(this); + } + + private requestCapture: Array<{ + method: Method, + uri: string, + data: any, + config: AxiosRequestConfig, + }> = []; + private nextResponse?: any; + + /** + * Waits for the expected number of HTTP requests to be captured by the mock handler. + * @param expectedCount The expected number of requests. + * @param timeoutMs Timeout in milliseconds (default: 5000). + */ + private async waitForRequestsAsync(expectedCount: number, timeoutMs: number = 5000): Promise { + const startTime = Date.now(); + const endTime = startTime + timeoutMs; + + while (Date.now() < endTime) { + if (this.requestCapture.length >= expectedCount) { + return; + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + + throw new Error(`Expected ${expectedCount} requests but only received ${this.requestCapture.length} within ${timeoutMs}ms`); + } + + private async mockAxiosRequest(config: AxiosRequestConfig, cancellation: CancellationToken): Promise { + const request = { method: config.method as Method, uri: config.url || '', data: config.data, config }; + this.requestCapture.push(request); + + if (this.nextResponse instanceof AxiosError) { + throw this.nextResponse; + } + + var response = { + data: this.nextResponse, + status: 0, + statusText: '', + headers: {}, + config + } as AxiosResponse; + + // simulate an Axios connection timeout + var token = (cancellation as any); + if (token?.forceConnection) { + token.tokenSource?.cancel(); + throw new AxiosError('Network Error'); + } + + // simulate an Axios server response timeout + if (token?.forceTimeout) { + throw new AxiosError('', 'ECONNABORTED'); + } + + return Promise.resolve(response); + } + + @test + public async reportEventWithEventsDisabled() { + // Test that no request is made when events reporting is disabled + const testTunnel: Tunnel = { + tunnelId: 'test-tunnel-id', + clusterId: 'test-cluster-id', + }; + + const testEvent: TunnelEvent = { + name: 'test-event', + severity: 'info', + details: 'Test event details', + properties: { 'test-prop': 'test-value' }, + }; + + // Events reporting is disabled by default + this.managementClient.enableEventsReporting = false; + this.requestCapture = []; + + // Report an event + this.managementClient.reportEvent(testTunnel, testEvent); + + // Wait a bit to see if any request is made + await new Promise(resolve => setTimeout(resolve, 200)); + + // No request should have been made + assert.strictEqual(this.requestCapture.length, 0); + } + + @test + public async reportEventWithEventsEnabled() { + // Test that events are queued and uploaded when events reporting is enabled + const testTunnel: Tunnel = { + tunnelId: 'test-tunnel-id', + clusterId: 'test-cluster-id', + }; + + const testEvent: TunnelEvent = { + name: 'test-event', + severity: 'info', + details: 'Test event details', + properties: { 'test-prop': 'test-value' }, + }; + + // Enable events reporting + this.managementClient.enableEventsReporting = true; + this.nextResponse = true; // Mock success response + this.requestCapture = []; + + // Report an event + this.managementClient.reportEvent(testTunnel, testEvent); + + // Wait for the expected number of requests to be processed + await this.waitForRequestsAsync(1); + + // A request should have been made + assert.strictEqual(this.requestCapture.length, 1); + const request = this.requestCapture[0]; + assert.strictEqual(request.method, 'POST'); + assert(request.uri.includes('/events')); + assert(request.uri.includes('api-version=2023-09-27-preview')); + assert(request.uri.includes(testTunnel.clusterId!)); + assert(request.uri.includes(testTunnel.tunnelId!)); + + // Check the request body contains the event + assert(Array.isArray(request.data)); + assert.strictEqual(request.data.length, 1); + const sentEvent = request.data[0]; + assert.strictEqual(sentEvent.name, testEvent.name); + assert.strictEqual(sentEvent.severity, testEvent.severity); + assert.strictEqual(sentEvent.details, testEvent.details); + assert.deepStrictEqual(sentEvent.properties, testEvent.properties); + } + + @test + public async reportMultipleEvents() { + // Test that multiple events for the same tunnel are batched together + const testTunnel: Tunnel = { + tunnelId: 'test-tunnel-id', + clusterId: 'test-cluster-id', + }; + + const testEvent1: TunnelEvent = { + name: 'test-event-1', + severity: 'info', + details: 'First test event', + properties: { 'event': '1' }, + }; + + const testEvent2: TunnelEvent = { + name: 'test-event-2', + severity: 'warning', + details: 'Second test event', + properties: { 'event': '2' }, + }; + + // Enable events reporting + this.managementClient.enableEventsReporting = true; + this.nextResponse = true; // Mock success response + this.requestCapture = []; + + // Report multiple events quickly + this.managementClient.reportEvent(testTunnel, testEvent1); + this.managementClient.reportEvent(testTunnel, testEvent2); + + // Wait for the expected number of requests to be processed + await this.waitForRequestsAsync(1); + + // A single request should have been made with both events + assert.strictEqual(this.requestCapture.length, 1); + const request = this.requestCapture[0]; + assert.strictEqual(request.method, 'POST'); + assert(request.uri.includes('/events')); + + // Check the request body contains both events + assert(Array.isArray(request.data)); + assert.strictEqual(request.data.length, 2); + + const sentEvents = request.data; + assert.strictEqual(sentEvents[0].name, testEvent1.name); + assert.strictEqual(sentEvents[1].name, testEvent2.name); + } + + @test + public async reportEventErrorIsIgnored() { + // Test that errors during event upload are ignored and don't throw + const testTunnel: Tunnel = { + tunnelId: 'test-tunnel-id', + clusterId: 'test-cluster-id', + }; + + const testEvent: TunnelEvent = { + name: 'test-event', + severity: 'error', + details: 'Test error event', + }; + + // Enable events reporting + this.managementClient.enableEventsReporting = true; + + // Mock an error response + this.nextResponse = new AxiosError('Network error', '500', undefined, undefined, { + status: 500, + statusText: 'Internal Server Error', + headers: new AxiosHeaders(), + data: undefined, + config: { headers: new AxiosHeaders() } as any, + }); + this.requestCapture = []; + + // Report an event - this should not throw even though the upload fails + this.managementClient.reportEvent(testTunnel, testEvent); + + // Wait for the expected number of requests to be processed + await this.waitForRequestsAsync(1); + + // A request should have been attempted + assert.strictEqual(this.requestCapture.length, 1); + const request = this.requestCapture[0]; + assert.strictEqual(request.method, 'POST'); + } + + @test + public async reportEventValidatesParameters() { + // Test parameter validation + const testTunnel: Tunnel = { + tunnelId: 'test-tunnel-id', + clusterId: 'test-cluster-id', + }; + + const testEvent: TunnelEvent = { + name: 'test-event', + }; + + this.managementClient.enableEventsReporting = true; + + // Test null tunnel parameter + assert.throws(() => { + this.managementClient.reportEvent(null as any, testEvent); + }, /A tunnel is required/); + + // Test null event parameter + assert.throws(() => { + this.managementClient.reportEvent(testTunnel, null as any); + }, /A tunnelEvent is required/); + } + + @test + public async disposedImmediatelyStillSendsEvents() { + // Test that events reported before disposal are still sent even if client is disposed immediately + const testTunnel: Tunnel = { + tunnelId: 'test-tunnel-id', + clusterId: 'test-cluster-id', + }; + + const testEvent: TunnelEvent = { + name: 'test-event-dispose', + severity: 'info', + details: 'Test event before dispose', + }; + + const testEvent2: TunnelEvent = { + name: 'test-event-after-dispose', + severity: 'info', + details: 'Test event after dispose', + }; + + // Enable events reporting + this.managementClient.enableEventsReporting = true; + this.nextResponse = true; // Mock success response + this.requestCapture = []; + + // Report an event and dispose immediately + this.managementClient.reportEvent(testTunnel, testEvent); + const disposePromise = this.managementClient.dispose(); // Dispose immediately after reporting + this.managementClient.reportEvent(testTunnel, testEvent2); // This should be ignored after dispose + + // Wait for both disposal and request processing to complete + await Promise.all([ + disposePromise, + this.waitForRequestsAsync(1) + ]); + + // Even though we disposed immediately, the background task should complete + // and send the event that was reported before disposal + assert.strictEqual(this.requestCapture.length, 1); + const request = this.requestCapture[0]; + assert.strictEqual(request.method, 'POST'); + assert(request.uri.includes('/events')); + + // Verify the request body contains only the first event (before disposal) + assert(Array.isArray(request.data)); + assert.strictEqual(request.data.length, 1); + const sentEvent = request.data[0]; + assert.strictEqual(sentEvent.name, testEvent.name); + assert.strictEqual(sentEvent.details, testEvent.details); + + // The second event should not be present since it was reported after disposal + assert(!request.data.some((e: any) => e.name === testEvent2.name)); + } +} diff --git a/version.json b/version.json index 63beb128..bc8e2731 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.2", + "version": "1.3", "versionHeightOffset": 0, "pathFilters": ["cs", "ts", "./"], From f869e51d60a930d9d3aa7290f6a97d739ea542ff Mon Sep 17 00:00:00 2001 From: Jason Ginchereau Date: Mon, 11 Aug 2025 07:59:45 -1000 Subject: [PATCH 2/3] Bump package version --- go/tunnels/tunnels.go | 2 +- ts/src/connections/package.json | 4 ++-- ts/src/management/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go/tunnels/tunnels.go b/go/tunnels/tunnels.go index 728d4491..6c1d2a2d 100644 --- a/go/tunnels/tunnels.go +++ b/go/tunnels/tunnels.go @@ -10,7 +10,7 @@ import ( "github.com/rodaine/table" ) -const PackageVersion = "0.1.16" +const PackageVersion = "0.1.17" func (tunnel *Tunnel) requestObject() (*Tunnel, error) { convertedTunnel := &Tunnel{ diff --git a/ts/src/connections/package.json b/ts/src/connections/package.json index 9810ee9e..72bbb722 100644 --- a/ts/src/connections/package.json +++ b/ts/src/connections/package.json @@ -18,8 +18,8 @@ "buffer": "^5.2.1", "debug": "^4.1.1", "vscode-jsonrpc": "^4.0.0", - "@microsoft/dev-tunnels-contracts": ">1.2.5", - "@microsoft/dev-tunnels-management": ">1.2.5", + "@microsoft/dev-tunnels-contracts": "^1.3.0", + "@microsoft/dev-tunnels-management": "^1.3.0", "@microsoft/dev-tunnels-ssh": "^3.12.5", "@microsoft/dev-tunnels-ssh-tcp": "^3.12.5", "uuid": "^3.3.3", diff --git a/ts/src/management/package.json b/ts/src/management/package.json index 03bb0db0..fad6a4cb 100644 --- a/ts/src/management/package.json +++ b/ts/src/management/package.json @@ -18,7 +18,7 @@ "buffer": "^5.2.1", "debug": "^4.1.1", "vscode-jsonrpc": "^4.0.0", - "@microsoft/dev-tunnels-contracts": ">1.2.5", + "@microsoft/dev-tunnels-contracts": "^1.3.0", "axios": "^1.8.4" } } From 751b6c61cab553718865a036c6d150cf1874c2e0 Mon Sep 17 00:00:00 2001 From: Jason Ginchereau Date: Mon, 11 Aug 2025 09:45:00 -1000 Subject: [PATCH 3/3] Fix tunnel client constructors --- ts/src/connections/tunnelConnectionSession.ts | 4 ++-- ts/src/connections/tunnelRelayTunnelClient.ts | 6 +++--- ts/src/connections/tunnelRelayTunnelHost.ts | 2 +- ts/test/tunnels-test/testTunnelRelayTunnelClient.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ts/src/connections/tunnelConnectionSession.ts b/ts/src/connections/tunnelConnectionSession.ts index 0878a58d..ecb6721e 100644 --- a/ts/src/connections/tunnelConnectionSession.ts +++ b/ts/src/connections/tunnelConnectionSession.ts @@ -174,11 +174,11 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun public constructor( tunnelAccessScope: string, protected readonly connectionProtocols: string[], - trace?: Trace, /** * Gets the management client used for the connection. */ - protected readonly managementClient?: TunnelManagementClient + protected readonly managementClient?: TunnelManagementClient, + trace?: Trace, ) { super(tunnelAccessScope); this.trace = trace ?? (() => {}); diff --git a/ts/src/connections/tunnelRelayTunnelClient.ts b/ts/src/connections/tunnelRelayTunnelClient.ts index fa6f4a25..c3b5e757 100644 --- a/ts/src/connections/tunnelRelayTunnelClient.ts +++ b/ts/src/connections/tunnelRelayTunnelClient.ts @@ -56,8 +56,8 @@ export class TunnelRelayTunnelClient extends TunnelConnectionSession implements public static readonly webSocketSubProtocol = webSocketSubProtocol; public static readonly webSocketSubProtocolv2 = webSocketSubProtocolv2; - public constructor(trace?: Trace, managementClient?: TunnelManagementClient) { - super(TunnelAccessScopes.Connect, connectionProtocols, trace, managementClient); + public constructor(managementClient?: TunnelManagementClient, trace?: Trace) { + super(TunnelAccessScopes.Connect, connectionProtocols, managementClient, trace); } private readonly portForwardingEmitter = new Emitter(); @@ -194,7 +194,7 @@ export class TunnelRelayTunnelClient extends TunnelConnectionSession implements 'There are multiple hosts for the tunnel. Specify a host ID to connect to.', ); } else { - this.endpoints = endpointGroups.entries().next().value[1]; + this.endpoints = endpointGroups.entries().next().value?.[1]; } const tunnelEndpoints: TunnelRelayTunnelEndpoint[] = this.endpoints!.filter( diff --git a/ts/src/connections/tunnelRelayTunnelHost.ts b/ts/src/connections/tunnelRelayTunnelHost.ts index d0077184..5031ecd8 100644 --- a/ts/src/connections/tunnelRelayTunnelHost.ts +++ b/ts/src/connections/tunnelRelayTunnelHost.ts @@ -126,7 +126,7 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu private endpointSignature?: string; public constructor(managementClient: TunnelManagementClient, trace?: Trace) { - super(TunnelAccessScopes.Host, connectionProtocols, trace, managementClient); + super(TunnelAccessScopes.Host, connectionProtocols, managementClient, trace); const publicKey = SshAlgorithms.publicKey.ecdsaSha2Nistp384!; if (publicKey) { this.hostPrivateKeyPromise = publicKey.generateKeyPair(); diff --git a/ts/test/tunnels-test/testTunnelRelayTunnelClient.ts b/ts/test/tunnels-test/testTunnelRelayTunnelClient.ts index be38caa0..c47aa872 100644 --- a/ts/test/tunnels-test/testTunnelRelayTunnelClient.ts +++ b/ts/test/tunnels-test/testTunnelRelayTunnelClient.ts @@ -9,7 +9,7 @@ import { TunnelManagementClient } from "@microsoft/dev-tunnels-management"; */ export class TestTunnelRelayTunnelClient extends TunnelRelayTunnelClient { constructor(managementClient?: TunnelManagementClient) { - super(undefined, managementClient); + super(managementClient); } public get isSshSessionActiveProperty(): boolean {