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
- 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/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/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/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/connections/tunnelConnectionSession.ts b/ts/src/connections/tunnelConnectionSession.ts
index a6ae2538..ecb6721e 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.
*/
@@ -114,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 ?? (() => {});
@@ -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/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 3ddc986d..5031ecd8 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 {
@@ -125,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();
@@ -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/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"
}
}
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/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 {
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", "./"],