From ad6c7fa0cc3ef226469c75ce45f36332e744dc0d Mon Sep 17 00:00:00 2001 From: Jason Ginchereau Date: Fri, 15 Aug 2025 11:15:17 -1000 Subject: [PATCH] Add session ID properties to connection events reporting --- cs/src/Connections/SshSessionExtensions.cs | 17 ++++ cs/src/Connections/TunnelClient.cs | 3 +- cs/src/Connections/TunnelConnection.cs | 20 ----- cs/src/Connections/TunnelRelayConnection.cs | 78 ++++++++++++++++++- cs/src/Connections/TunnelRelayTunnelHost.cs | 13 +++- cs/src/Connections/WebSocketStream.cs | 41 +++++++++- ts/src/connections/tunnelConnectionSession.ts | 65 ++++++++++++++-- ts/src/connections/tunnelRelayTunnelHost.ts | 23 ++++-- 8 files changed, 216 insertions(+), 44 deletions(-) create mode 100644 cs/src/Connections/SshSessionExtensions.cs diff --git a/cs/src/Connections/SshSessionExtensions.cs b/cs/src/Connections/SshSessionExtensions.cs new file mode 100644 index 00000000..4bcb6961 --- /dev/null +++ b/cs/src/Connections/SshSessionExtensions.cs @@ -0,0 +1,17 @@ +using System; +using Microsoft.DevTunnels.Ssh; + +namespace Microsoft.DevTunnels.Connections; + +internal static class SshSessionExtensions +{ + public static string GetShortSessionId(this SshSession session) + { + if (session.SessionId == null || session.SessionId.Length < 16) + { + return string.Empty; + } + + return new Guid(session.SessionId.AsSpan(0, 16)).ToString(); + } +} diff --git a/cs/src/Connections/TunnelClient.cs b/cs/src/Connections/TunnelClient.cs index e8048e5e..5b99a8d9 100644 --- a/cs/src/Connections/TunnelClient.cs +++ b/cs/src/Connections/TunnelClient.cs @@ -140,7 +140,7 @@ public override async Task ConnectAsync( Requires.NotNull(tunnel, nameof(tunnel)); Requires.NotNull(tunnel.Endpoints!, nameof(Tunnel.Endpoints)); - if (this.SshSession != null) + if (this.SshSession?.IsConnected == true) { throw new InvalidOperationException( "Already connected. Use separate instances to connect to multiple tunnels."); @@ -240,6 +240,7 @@ protected async Task StartSshSessionAsync(Stream stream, TunnelConnectionOptions private void OnSshSessionDisconnected(object? sender, EventArgs e) => MaybeStartReconnecting( + (SshSession)sender!, SshDisconnectReason.ConnectionLost, exception: new SshConnectionException("Connection lost.", SshDisconnectReason.ConnectionLost)); diff --git a/cs/src/Connections/TunnelConnection.cs b/cs/src/Connections/TunnelConnection.cs index 41563187..824a2c94 100644 --- a/cs/src/Connections/TunnelConnection.cs +++ b/cs/src/Connections/TunnelConnection.cs @@ -22,7 +22,6 @@ public abstract class TunnelConnection : IAsyncDisposable { private readonly CancellationTokenSource disposeCts = new(); private ConnectionStatus connectionStatus; - private Stopwatch connectionTimer = new(); private Tunnel? tunnel; /// @@ -423,25 +422,6 @@ 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) { diff --git a/cs/src/Connections/TunnelRelayConnection.cs b/cs/src/Connections/TunnelRelayConnection.cs index 44e8d604..c71937e3 100644 --- a/cs/src/Connections/TunnelRelayConnection.cs +++ b/cs/src/Connections/TunnelRelayConnection.cs @@ -4,6 +4,7 @@ // using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; @@ -55,8 +56,10 @@ public abstract class TunnelRelayConnection : TunnelConnection, IRelayClient, IP /// public const int RetryMaxDelayMs = 12_800; + private string? websocketRequestId = null; private TunnelConnectionOptions? connectionOptions; private Task? reconnectTask; + private Stopwatch connectionTimer = new(); /// /// Create a new instance of class. @@ -66,6 +69,12 @@ protected TunnelRelayConnection(ITunnelManagementClient? managementClient, Trace { } + /// + /// Gets an ID that is unique to this instance of , + /// useful for correlating connection events over time. + /// + protected virtual string ConnectionId { get; } = Guid.NewGuid().ToString(); + /// /// Connection protocol used to connect to Relay. /// @@ -170,11 +179,60 @@ protected async Task ConnectTunnelSessionAsync( } } + /// + /// Event fired when the connection status has changed. + /// + protected override void OnConnectionStatusChanged( + ConnectionStatus previousConnectionStatus, + ConnectionStatus connectionStatus) + { + TimeSpan duration = this.connectionTimer.Elapsed; + this.connectionTimer.Restart(); + + if (Tunnel != null && ManagementClient != 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(); + } + + if (IsClientConnection) + { + // For client sessions, report the SSH session ID property, which is derived from + // the SSH key-exchange such that both host and client have the same ID. + statusEvent.Properties["ClientSessionId"] = SshSession?.GetShortSessionId() ?? string.Empty; + } + else + { + // For host sessions, there is no SSH encryption or key-exchange. + // Just use a locally-generated GUID that is unique to this session. + statusEvent.Properties["HostSessionId"] = ConnectionId; + } + + if (this.websocketRequestId != null) + { + statusEvent.Properties["WebsocketRequestId"] = this.websocketRequestId; + } + + ManagementClient.ReportEvent(Tunnel, statusEvent); + } + + base.OnConnectionStatusChanged(previousConnectionStatus, connectionStatus); + } + /// /// Start reconnecting if connected, not reconnecting already, /// and is . /// protected void MaybeStartReconnecting( + SshSession session, SshDisconnectReason reason, string? message = null, Exception? exception = null) @@ -214,6 +272,10 @@ protected void MaybeStartReconnecting( var reconnectEvent = new TunnelEvent($"{ConnectionRole}_reconnect"); reconnectEvent.Severity = TunnelEvent.Warning; reconnectEvent.Details = exception?.ToString() ?? traceMessage; + reconnectEvent.Properties = new Dictionary + { + ["ClientSessionId"] = session.GetShortSessionId(), + }; ManagementClient?.ReportEvent(Tunnel, reconnectEvent); } @@ -228,6 +290,10 @@ protected void MaybeStartReconnecting( var disconnectEvent = new TunnelEvent($"{ConnectionRole}_disconnect"); disconnectEvent.Severity = TunnelEvent.Warning; disconnectEvent.Details = exception?.ToString() ?? traceMessage; + disconnectEvent.Properties = new Dictionary + { + ["ClientSessionId"] = session.GetShortSessionId(), + }; ManagementClient?.ReportEvent(Tunnel, disconnectEvent); } @@ -271,6 +337,8 @@ protected virtual async Task CreateSessionStreamAsync(CancellationToken cancellation); Trace.TraceEvent(TraceEventType.Verbose, 0, "Connected with subprotocol '{0}'", subprotocol); + this.websocketRequestId = (stream as WebSocketStream)?.RequestId; + if (this.IsClientConnection) { this.OnReportProgress(Progress.OpenedClientConnectionToRelay); @@ -329,7 +397,6 @@ protected virtual async Task CloseSessionAsync( return; } - SshSession = null; UnsubscribeSessionEvents(session); if (!session.IsClosed && session.IsConnected) { @@ -343,6 +410,11 @@ protected virtual async Task CloseSessionAsync( } } + // Set the connection status to disconnected before setting SshSession to null, + // so the session ID can be reported in the disconnect event. + ConnectionStatus = ConnectionStatus.Disconnected; + SshSession = null; + // Closing the SSH session does nothing if the session is in disconnected state, // which may happen for a reconnectable session when the connection drops. // Disposing of the session forces closing and frees up the resources. @@ -355,9 +427,7 @@ protected virtual async Task CloseSessionAsync( protected virtual void OnSshSessionClosed(object? sender, SshSessionClosedEventArgs e) { var session = (SshClientSession)sender!; - UnsubscribeSessionEvents(session); - SshSession = null; - MaybeStartReconnecting(e.Reason, e.Message, e.Exception); + MaybeStartReconnecting(session, e.Reason, e.Message, e.Exception); } /// diff --git a/cs/src/Connections/TunnelRelayTunnelHost.cs b/cs/src/Connections/TunnelRelayTunnelHost.cs index b008f751..288a7293 100644 --- a/cs/src/Connections/TunnelRelayTunnelHost.cs +++ b/cs/src/Connections/TunnelRelayTunnelHost.cs @@ -59,6 +59,9 @@ public TunnelRelayTunnelHost(ITunnelManagementClient managementClient, TraceSour this.hostId = MultiModeTunnelHost.HostId; } + /// + protected override string ConnectionId => this.hostId; + /// /// Get or set synthetic endpoint signature for the endpoint created for the host /// when connecting. @@ -426,6 +429,8 @@ private async Task ConnectAndRunClientSessionAsync(SshStream stream, Cancellatio connectedEvent.Properties = new Dictionary { ["ClientChannelId"] = channelId.ToString(), + ["ClientSessionId"] = session.GetShortSessionId(), + ["HostSessionId"] = ConnectionId, }; ManagementClient?.ReportEvent(Tunnel, connectedEvent); } @@ -456,12 +461,15 @@ private async Task ConnectAndRunClientSessionAsync(SshStream stream, Cancellatio async void OnSshClientReconnected(object? sender, EventArgs e) { + var session = (SshSession)sender!; if (Tunnel != null) { var reconnectedEvent = new TunnelEvent($"host_client_reconnect"); reconnectedEvent.Properties = new Dictionary { ["ClientChannelId"] = channelId.ToString(), + ["ClientSessionId"] = session.GetShortSessionId(), + ["HostSessionId"] = ConnectionId, }; ManagementClient?.ReportEvent(Tunnel, reconnectedEvent); } @@ -473,7 +481,8 @@ await StartForwardingExistingPortsAsync( void OnClientSessionClosed(object? sender, SshSessionClosedEventArgs e) { - TraceSource trace = ((SshSession)sender!).Trace; + var session = (SshSession)sender!; + var trace = session.Trace; string? details = null; string? severity = null; @@ -510,6 +519,8 @@ void OnClientSessionClosed(object? sender, SshSessionClosedEventArgs e) disconnectedEvent.Properties = new Dictionary { ["ClientChannelId"] = channelId.ToString(), + ["ClientSessionId"] = session.GetShortSessionId(), + ["HostSessionId"] = ConnectionId, }; ManagementClient?.ReportEvent(Tunnel, disconnectedEvent); } diff --git a/cs/src/Connections/WebSocketStream.cs b/cs/src/Connections/WebSocketStream.cs index 70b820ac..57a24287 100644 --- a/cs/src/Connections/WebSocketStream.cs +++ b/cs/src/Connections/WebSocketStream.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; +using System.Linq; using System.Net; using System.Net.WebSockets; using System.Threading; @@ -97,12 +98,46 @@ public string? CloseStatusDescription set => this.closeStatusDescription = value; } + /// + /// Gets the HTTP request ID from the web socket connection, if available. + /// + /// + /// The request ID is returned as a response HTTP header when the websocket connection + /// is established. The value can then be added to client-reported events to support + /// correlation with service events. + /// + public string? RequestId + { + get + { +#if NET8_0_OR_GREATER + var responseHeaders = (this.socket as ClientWebSocket)?.HttpResponseHeaders; + if (responseHeaders?.TryGetValue("VsSaaS-Request-ID", out var requestIdValues) == true) + { + var requestId = requestIdValues.FirstOrDefault(); + if (!string.IsNullOrEmpty(requestId)) + { + return requestId; + } + } +#endif + + return null; + } + } + /// /// Connect to web socket. /// public static async Task ConnectToWebSocketAsync(Uri uri, Action? configure = default, TraceSource? trace = default, CancellationToken cancellation = default) { var socket = new ClientWebSocket(); + +#if NET8_0_OR_GREATER + // Enable access to HTTP response headers. + socket.Options.CollectHttpResponseDetails = true; +#endif + try { configure?.Invoke(socket.Options); @@ -125,11 +160,11 @@ public static async Task ConnectToWebSocketAsync(Uri uri, Actio if (i >= 0) { int j = wse.Message.IndexOf('\'', i + 1); - if (j > i + 1 && + if (j > i + 1 && int.TryParse( - wse.Message.Substring(i + 1, j - i - 1), + wse.Message.Substring(i + 1, j - i - 1), NumberStyles.None, - CultureInfo.InvariantCulture, + CultureInfo.InvariantCulture, out var statusCode) && statusCode != 101) { diff --git a/ts/src/connections/tunnelConnectionSession.ts b/ts/src/connections/tunnelConnectionSession.ts index ecb6721e..363dc220 100644 --- a/ts/src/connections/tunnelConnectionSession.ts +++ b/ts/src/connections/tunnelConnectionSession.ts @@ -19,6 +19,7 @@ import { Progress, SshClientSession, SshDisconnectReason, + SshSession, SshSessionClosedEventArgs, Stream, Trace, @@ -45,6 +46,7 @@ import { RetryingTunnelConnectionEventArgs } from './retryingTunnelConnectionEve import { TunnelRelayStreamFactory } from './tunnelRelayStreamFactory'; import { DefaultTunnelRelayStreamFactory } from './defaultTunnelRelayStreamFactory'; import { IClientConfig } from 'websocket'; +import { v4 as uuidv4 } from 'uuid'; /** * Tunnel connection session. @@ -56,6 +58,9 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun private reconnectPromise?: Promise; private connectionProtocolValue?: string; private disconnectionReason?: SshDisconnectReason; + private connectionStartTime: number = Date.now(); + + private readonly uniqueConnectionId: string = uuidv4(); private readonly refreshingTunnelEmitter = new TrackingEmitter(); @@ -92,6 +97,14 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun this.connectionProtocolValue = value; } + /** + * Gets an ID that is unique to this instance of `TunnelConnectionSession`, + * useful for correlating connection events over time. + */ + protected get connectionId() { + return this.uniqueConnectionId; + } + /** * A value indicating if this is a client tunnel connection (as opposed to host connection). */ @@ -141,21 +154,32 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun severity: TunnelEvent.info, details: undefined, properties: { - 'ConnectionStatus': status.toString(), - 'PreviousConnectionStatus': previousStatus.toString() + 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(); + // Format the duration as a TimeSpan in the form "00:00:00.000" + const duration = Date.now() - this.connectionStartTime; + const formattedDuration = new Date(duration).toISOString().substring(11, 23); + statusEvent.properties![`${previousStatus}Duration`] = formattedDuration; } - + + if (this.isClientConnection) { + // For client sessions, report the SSH session ID property, which is derived from + // the SSH key-exchange such that both host and client have the same ID. + statusEvent.properties!.ClientSessionId = this.getShortSessionId(this.sshSession); + } else { + // For host sessions, there is no SSH encryption or key-exchange. + // Just use a locally-generated GUID that is unique to this session. + statusEvent.properties!.HostSessionId = this.connectionId; + } + this.managementClient.reportEvent(this.tunnel, statusEvent); } + this.connectionStartTime = Date.now(); super.onConnectionStatusChanged(previousStatus, status); } @@ -575,6 +599,9 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun name: `${this.connectionRole}_reconnect`, severity: TunnelEvent.warning, details: error?.toString() ?? message, + properties: { + ClientSessionId: this.getShortSessionId(this.sshSession), + }, }; this.managementClient.reportEvent(this.tunnel, reconnectEvent); } @@ -590,6 +617,9 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun name: `${this.connectionRole}_reconnect_failed`, severity: TunnelEvent.error, details: ex instanceof Error ? ex.toString() : String(ex), + properties: { + ClientSessionId: this.getShortSessionId(this.sshSession), + }, }; this.managementClient.reportEvent(this.tunnel, reconnectFailedEvent); } @@ -607,6 +637,9 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun name: `${this.connectionRole}_disconnect`, severity: TunnelEvent.warning, details: error?.toString() ?? message, + properties: { + ClientSessionId: this.getShortSessionId(this.sshSession), + }, }; this.managementClient.reportEvent(this.tunnel, disconnectEvent); } @@ -769,4 +802,20 @@ export class TunnelConnectionSession extends TunnelConnectionBase implements Tun this.sshSessionDisposables.forEach((d) => d.dispose()); this.sshSessionDisposables = []; } + + /** @internal */ + protected getShortSessionId(session?: SshSession): string { + const b = session?.sessionId; + if (!b || b.length < 16) { + return ''; + } + + // Format as a GUID. This cannot use uuid.stringify() because + // the bytes might not be technically valid for a UUID. + return b.subarray(0, 4).toString('hex') + '-' + + b.subarray(4, 6).toString('hex') + '-' + + b.subarray(6, 8).toString('hex') + '-' + + b.subarray(8, 10).toString('hex') + '-' + + b.subarray(10, 16).toString('hex'); + } } diff --git a/ts/src/connections/tunnelRelayTunnelHost.ts b/ts/src/connections/tunnelRelayTunnelHost.ts index 5031ecd8..04f1fa40 100644 --- a/ts/src/connections/tunnelRelayTunnelHost.ts +++ b/ts/src/connections/tunnelRelayTunnelHost.ts @@ -54,7 +54,6 @@ import { SessionPortKey } from './sessionPortKey'; import { PortRelayConnectRequestMessage } from './messages/portRelayConnectRequestMessage'; import { PortRelayConnectResponseMessage } from './messages/portRelayConnectResponseMessage'; import { v4 as uuidv4 } from 'uuid'; - import { TunnelHost } from './tunnelHost'; import { isNode } from './sshHelpers'; import { TunnelConnectionOptions } from './tunnelConnectionOptions'; @@ -136,6 +135,10 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu this.id = uuidv4() + "-relay"; } + protected override get connectionId() { + return this.hostId; + } + /** * A value indicating whether the port-forwarding service forwards connections to local TCP sockets. * Forwarded connections are not possible if the host is not NodeJS (e.g. browser). @@ -511,9 +514,11 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu if (this.tunnel && this.managementClient) { const connectedEvent: TunnelEvent = { - name: 'host_client_connected', + name: 'host_client_connect', properties: { - 'ClientChannelId': clientChannelId.toString(), + ClientChannelId: clientChannelId.toString(), + ClientSessionId: this.getShortSessionId(session), + HostSessionId: this.connectionId, } }; this.managementClient.reportEvent(this.tunnel, connectedEvent); @@ -619,9 +624,11 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu private onClientSessionReconnecting(session: SshServerSession, clientChannelId: number) { if (this.tunnel && this.managementClient) { const reconnectedEvent: TunnelEvent = { - name: 'host_client_reconnecting', + name: 'host_client_reconnect', properties: { - 'ClientChannelId': clientChannelId.toString(), + ClientChannelId: clientChannelId.toString(), + ClientSessionId: this.getShortSessionId(session), + HostSessionId: this.connectionId, } }; this.managementClient.reportEvent(this.tunnel, reconnectedEvent); @@ -658,11 +665,13 @@ export class TunnelRelayTunnelHost extends TunnelConnectionSession implements Tu if (this.tunnel && this.managementClient) { const disconnectedEvent: TunnelEvent = { timestamp: new Date(), - name: 'host_client_disconnected', + name: 'host_client_disconnect', severity: severity, details: details, properties: { - 'ClientChannelId': clientChannelId.toString(), + ClientChannelId: clientChannelId.toString(), + ClientSessionId: this.getShortSessionId(session), + HostSessionId: this.connectionId, } }; this.managementClient.reportEvent(this.tunnel, disconnectedEvent);