/*
* Copyright (c) 2026 Proton AG
*
* This file is part of ProtonVPN.
*
* ProtonVPN is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonVPN is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonVPN. If not, see .
*/
using ProtonVPN.Client.Logic.Connection.Contracts.Enums;
using ProtonVPN.Client.Logic.Connection.Contracts.Models;
using ProtonVPN.Client.Logic.Connection.Contracts.Models.Intents.Features;
using ProtonVPN.Client.Logic.Connection.Contracts.Statistics;
using ProtonVPN.Client.Logic.Profiles.Contracts.Models;
using ProtonVPN.Client.Logic.Servers.Contracts.Enums;
using ProtonVPN.Client.Logic.Servers.Contracts.Extensions;
using ProtonVPN.Client.Logic.Connection.Contracts.Enums;
using ProtonVPN.Client.Logic.Profiles.Contracts.Models;
using ProtonVPN.Client.Settings.Contracts;
using ProtonVPN.Client.Settings.Contracts.Enums;
using ProtonVPN.Client.Settings.Contracts.Observers;
using ProtonVPN.Common.Core.Geographical;
using ProtonVPN.Logging.Contracts;
using ProtonVPN.Logging.Contracts.Events.StatisticsLogs;
using ProtonVPN.OperatingSystems.Network.Contracts;
using ProtonVPN.ProcessCommunication.Contracts.Entities.Vpn;
using ProtonVPN.StatisticalEvents.Contracts;
using ProtonVPN.StatisticalEvents.Contracts.Dimensions;
using ProtonVPN.StatisticalEvents.Contracts.Enums;
using ProtonVPN.StatisticalEvents.Contracts.Models;
namespace ProtonVPN.Client.Logic.Connection.Statistics;
public class ConnectionStatisticalEventsManager : IConnectionStatisticalEventsManager, IConnectionStatisticsFeedback
{
private readonly IVpnConnectionReporter _vpnConnectionReporter;
private readonly IVpnDisconnectionReporter _vpnDisconnectionReporter;
private readonly ISystemNetworkInterfaces _networkInterfaces;
private readonly ISettings _settings;
private readonly ILogger _logger;
private AttemptType? _currentAttemptType = null;
private VpnTriggerDimension? _currentAttemptTrigger = null;
private DateTime? _currentAttemptDateUtc = null;
private ConnectionStatus? _currentAttemptConnectionStatus = null;
private UserFeedback? _currentAttemptUserFeedback = null;
private ConnectionDetails? _lastKnownConnectionDetails = null;
public ConnectionStatisticalEventsManager(
IVpnConnectionReporter vpnConnectionReporter,
IVpnDisconnectionReporter vpnDisconnectionReporter,
ISystemNetworkInterfaces networkInterfaces,
ISettings settings,
ILogger logger)
{
_vpnConnectionReporter = vpnConnectionReporter;
_vpnDisconnectionReporter = vpnDisconnectionReporter;
_networkInterfaces = networkInterfaces;
_settings = settings;
_logger = logger;
}
public void SetConnectionAttempt(VpnTriggerDimension trigger, ConnectionStatus currentConnectionStatus)
{
if (currentConnectionStatus == ConnectionStatus.Connected)
{
SetAttempt(AttemptType.Disconnection, trigger, currentConnectionStatus);
}
SetAttempt(AttemptType.Connection, trigger, currentConnectionStatus);
}
public void SetReconnectionAttempt(VpnTriggerDimension trigger, ConnectionStatus currentConnectionStatus)
{
if (_currentAttemptType == AttemptType.Connection && trigger == VpnTriggerDimension.Auto)
{
return;
}
SetConnectionAttempt(trigger, currentConnectionStatus);
}
public void SetDisconnectionAttempt(VpnTriggerDimension trigger, ConnectionStatus currentConnectionStatus)
{
SetAttempt(AttemptType.Disconnection, trigger, currentConnectionStatus);
}
public void InitializeFeedback()
{
_currentAttemptUserFeedback = UserFeedback.Ignored;
}
public void SubmitPositiveFeedback()
{
_currentAttemptUserFeedback = UserFeedback.Positive;
}
public void SubmitNegativeFeedback()
{
_currentAttemptUserFeedback = UserFeedback.Negative;
}
public void OnVpnStateChanged(VpnStatusIpcEntity vpnStatus, VpnErrorTypeIpcEntity vpnError, ConnectionDetails? connectionDetails)
{
try
{
if (connectionDetails != null)
{
// If connection details are not null, first update the connection details, then handle the vpn state update.
_lastKnownConnectionDetails = connectionDetails;
HandleVpnState(vpnStatus, vpnError);
}
else
{
// If connection details are null, first handle the vpn state update, then update the connection details.
HandleVpnState(vpnStatus, vpnError);
_lastKnownConnectionDetails = connectionDetails;
}
}
catch (Exception e)
{
_logger.Warn($"Error while handling the vpn state change.", e);
}
}
private void HandleVpnState(VpnStatusIpcEntity vpnStatus, VpnErrorTypeIpcEntity vpnError)
{
if (_currentAttemptType is null)
{
return;
}
OutcomeDimension? outcome = _currentAttemptType switch
{
AttemptType.Connection
when vpnStatus is VpnStatusIpcEntity.Connected => OutcomeDimension.Success,
AttemptType.Disconnection
when vpnStatus is VpnStatusIpcEntity.Disconnected => OutcomeDimension.Success,
AttemptType.Connection
when vpnStatus is VpnStatusIpcEntity.Disconnected &&
vpnError is not VpnErrorTypeIpcEntity.None or VpnErrorTypeIpcEntity.NoneKeepEnabledKillSwitch => OutcomeDimension.Failure,
AttemptType.Disconnection
when vpnStatus is VpnStatusIpcEntity.Connected => OutcomeDimension.Failure,
_ => null
};
if (outcome.HasValue)
{
int? failureCode = outcome is OutcomeDimension.Failure
? (int)vpnError
: null;
HandleCurrentAttempt(outcome.Value, failureCode);
ResetAttempt();
}
}
private void HandleCurrentAttempt(OutcomeDimension outcome, int? failureCode = null)
{
if (_currentAttemptType is null)
{
return;
}
switch (_currentAttemptType)
{
case AttemptType.Connection:
SendConnectionEvent(outcome, failureCode);
break;
case AttemptType.Disconnection:
SendDisconnectionEvent(outcome, failureCode);
break;
default:
break;
}
}
private void SetAttempt(AttemptType type, VpnTriggerDimension trigger, ConnectionStatus connectionStatus)
{
HandleCurrentAttempt(OutcomeDimension.Aborted);
_currentAttemptType = type;
_currentAttemptTrigger = trigger;
_currentAttemptDateUtc = DateTime.UtcNow;
_currentAttemptConnectionStatus = connectionStatus;
}
private void ResetAttempt()
{
_currentAttemptType = null;
_currentAttemptTrigger = null;
_currentAttemptDateUtc = null;
_currentAttemptConnectionStatus = null;
_currentAttemptUserFeedback = null;
}
private void SendConnectionEvent(OutcomeDimension outcome, int? failureCode)
{
float connectionTimeInMs = _currentAttemptDateUtc.HasValue
? (float)DateTime.UtcNow.Subtract(_currentAttemptDateUtc.Value).TotalMilliseconds
: 0;
VpnConnectionEventData eventData = CreateConnectionEventData(outcome, failureCode);
_vpnConnectionReporter.Report(eventData, connectionTimeInMs);
_logger.Info($"vpn_connection event from {eventData.VpnTrigger} trigger. {eventData.Outcome}. Connection time: {connectionTimeInMs}ms");
}
private void SendDisconnectionEvent(OutcomeDimension outcome, int? failureCode)
{
float sessionTimeInMs = _lastKnownConnectionDetails?.EstablishedConnectionTimeUtc != null
? (float)DateTime.UtcNow.Subtract(_lastKnownConnectionDetails.EstablishedConnectionTimeUtc.Value).TotalMilliseconds
: 0;
VpnConnectionEventData eventData = CreateConnectionEventData(outcome, failureCode);
_vpnDisconnectionReporter.Report(eventData, sessionTimeInMs);
_logger.Info($"vpn_disconnection event from {eventData.VpnTrigger} trigger. {eventData.Outcome}. Session time: {sessionTimeInMs}ms");
}
private VpnConnectionEventData CreateConnectionEventData(OutcomeDimension outcome, int? failureCode)
{
VpnFeatureIntent vpnFeatureIntent = _lastKnownConnectionDetails?.OriginalConnectionIntent?.Feature switch
{
SecureCoreFeatureIntent => VpnFeatureIntent.SecureCore,
P2PFeatureIntent => VpnFeatureIntent.P2P,
TorFeatureIntent => VpnFeatureIntent.Tor,
B2BFeatureIntent => VpnFeatureIntent.Gateway,
_ => VpnFeatureIntent.Standard
};
DeviceLocation deviceLocation = _settings.DeviceLocation ?? DeviceLocation.Unknown;
IConnectionProfile? profile = _lastKnownConnectionDetails?.OriginalConnectionIntent as IConnectionProfile;
bool hasActiveExclusions = _settings.ExcludedLocationsList?.Count > 0;
return new VpnConnectionEventData
{
Outcome = outcome,
VpnStatus = _currentAttemptConnectionStatus is ConnectionStatus.Connected
? VpnStatusDimension.On
: VpnStatusDimension.Off,
VpnTrigger = _currentAttemptTrigger,
NetworkConnectionType = _networkInterfaces.GetNetworkConnectionType(),
DesiredProtocol = profile?.Settings.VpnProtocol ?? _settings.VpnProtocol,
ActualProtocol = _lastKnownConnectionDetails?.Protocol,
VpnFeatureIntent = vpnFeatureIntent,
Isp = deviceLocation.Isp,
UserCountry = deviceLocation.CountryCode,
VpnCountry = _lastKnownConnectionDetails?.ExitCountryCode,
Port = _lastKnownConnectionDetails?.Port ?? 0,
VpnPlan = _settings.VpnPlan,
IsIpv6Enabled = _settings.IsIpv6Enabled,
Server = new ServerDetailsEventData
{
Name = _lastKnownConnectionDetails?.Server.Name,
EntryIp = _lastKnownConnectionDetails?.EntryIpAddress,
IsFree = _lastKnownConnectionDetails?.Server.IsFree() ?? false,
IsB2B = _lastKnownConnectionDetails?.Server.Features.IsB2B() ?? false,
SupportsTor = _lastKnownConnectionDetails?.Server.Features.IsSupported(ServerFeatures.Tor) ?? false,
SupportsP2P = _lastKnownConnectionDetails?.Server.Features.IsSupported(ServerFeatures.P2P) ?? false,
SecureCore = _lastKnownConnectionDetails?.Server.Features.IsSupported(ServerFeatures.SecureCore) ?? false,
SupportsStreaming = _lastKnownConnectionDetails?.Server.Features.IsSupported(ServerFeatures.Streaming) ?? false,
SupportsIpv6 = _lastKnownConnectionDetails?.Server.Features.IsSupported(ServerFeatures.Ipv6) ?? false,
},
ClientFeatures = new ClientFeaturesEventData
{
IsKillSwitchEnabled = _settings.IsKillSwitchEnabled,
IsNetShieldEnabled = profile?.Settings.IsNetShieldEnabled ?? _settings.IsNetShieldEnabled,
IsPortForwardingEnabled = profile?.Settings.IsPortForwardingEnabled ?? _settings.IsPortForwardingEnabled,
IsSplitTunnelingEnabled = _settings.IsSplitTunnelingEnabled,
IsModerateNatEnabled = (profile?.Settings.NatType ?? _settings.NatType) == NatType.Moderate,
IsCustomDnsEnabled = profile?.Settings.IsCustomDnsServersEnabled ?? _settings.IsCustomDnsServersEnabled,
IsLanConnectionsEnabled = _settings.IsLocalAreaNetworkAccessEnabled,
IsConnectionPreferencesConfigured = hasActiveExclusions
},
HasActiveExclusions = hasActiveExclusions,
FailureCode = failureCode,
UserFeedback = _currentAttemptUserFeedback
};
}
}