/* * 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 System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using ProtonVPN.Api.Contracts; using ProtonVPN.Api.Contracts.Common; using ProtonVPN.Client.Settings.Contracts; using ProtonVPN.Common.Core.StatisticalEvents; using ProtonVPN.Configurations.Contracts; using ProtonVPN.Logging.Contracts; using ProtonVPN.Logging.Contracts.Events.TelemetryLogs; using ProtonVPN.StatisticalEvents.Files; using SingleAction = ProtonVPN.Common.Core.Threading.SingleAction; using Timer = System.Threading.Timer; namespace ProtonVPN.StatisticalEvents.Events.Senders; public abstract class StatisticEventSenderBase { private const int MAX_NUM_OF_EVENTS = 100; private readonly IConfiguration _config; private readonly TimeSpan _minSendWaitTime; private readonly SingleAction _triggerSendAction; private Timer _timer; private TimeSpan _timerInterval; private DateTime _lastSendTime = DateTime.MinValue; protected IApiClient Api { get; } protected ILogger Logger { get; } protected ISettings Settings { get; } protected IStatisticalEventsFileReaderWriter StatisticalEventsFileReaderWriter { get; } protected ConcurrentQueue? EventsToSend { get; set; } protected readonly SemaphoreSlim Semaphore = new(1, 1); protected abstract bool IsShareStatisticsEnabled { get; } protected abstract bool CanSendTelemetryEvents { get; } protected StatisticEventSenderBase( IApiClient api, ILogger logger, ISettings settings, IConfiguration config, IStatisticalEventsFileReaderWriter statisticalEventsFileReaderWriter) { Api = api; Logger = logger; Settings = settings; StatisticalEventsFileReaderWriter = statisticalEventsFileReaderWriter; _config = config; _timerInterval = _config.StatisticalEventSendTriggerInterval; _minSendWaitTime = _config.StatisticalEventMinimumWaitInterval; _triggerSendAction = new SingleAction(TriggerSendAsync); _timer = new Timer(_ => { _triggerSendAction.Run(); }, null, Timeout.Infinite, Timeout.Infinite); } public async Task EnqueueAsync(StatisticalEvent statisticalEvent) { if (!CanSendTelemetryEvents) { return; } await Semaphore.WaitAsync(); try { LoadStoredEventsIfEventsToSendIsNull(); EventsToSend!.Enqueue(statisticalEvent); Logger.Debug($"Statistical event queued. {JsonConvert.SerializeObject(statisticalEvent)}"); if (EventsToSend.Count > MAX_NUM_OF_EVENTS) { int numOfEventsToDelete = EventsToSend.Count - MAX_NUM_OF_EVENTS; Logger.Warn($"Too many statistical events. Deleting the {numOfEventsToDelete} oldest" + $"events because there are {EventsToSend.Count} events when the max is {MAX_NUM_OF_EVENTS}."); for (int i = 0; i < numOfEventsToDelete; i++) { EventsToSend.TryDequeue(out StatisticalEvent? deletedStatisticalEvent); Logger.Debug($"Statistical event deleted. {JsonConvert.SerializeObject(deletedStatisticalEvent)}"); } } Logger.Info($"{EventsToSend.Count} statistical events are now queued."); SaveToFile(EventsToSend.ToList()); } finally { Semaphore.Release(); } _triggerSendAction.Run(); } private void LoadStoredEventsIfEventsToSendIsNull() { EventsToSend ??= new ConcurrentQueue(GetStoredStatisticalEvents()); } protected List GetStoredStatisticalEvents() { return GetStatisticalEventsFromFile().TakeLast(MAX_NUM_OF_EVENTS).ToList(); } protected abstract List GetStatisticalEventsFromFile(); protected async Task TriggerSendAsync() { if (CanSendTelemetryEvents && (_lastSendTime + _minSendWaitTime) <= DateTime.UtcNow) { await SendAsync(); } } private async Task SendAsync() { StatisticalEventsBatch statisticalEventsBatch = new(); await Semaphore.WaitAsync(); try { statisticalEventsBatch.EventInfo = EventsToSend?.ToList() ?? []; } finally { Semaphore.Release(); } int numOfEvents = statisticalEventsBatch.EventInfo.Count; if (numOfEvents <= 0) { return; } try { _lastSendTime = DateTime.UtcNow; Logger.Info($"Sending {numOfEvents} statistical events."); ApiResponseResult baseResponse = await SendApiRequestAsync(statisticalEventsBatch); if (baseResponse.Success) { Logger.Info($"Successfully sent {numOfEvents} statistical events. Removing them from the queue."); await RemoveSuccessfullySentEventsAsync(statisticalEventsBatch.EventInfo); } else { Logger.Error($"Failed to send {numOfEvents} statistical events. Keeping them in the queue."); } } catch (Exception ex) { Logger.Error($"Exception thrown when sending {numOfEvents} statistical events. Keeping them in the queue.", ex); } } protected abstract Task> SendApiRequestAsync(StatisticalEventsBatch statisticalEventsBatch); private async Task RemoveSuccessfullySentEventsAsync(List statisticalEventsSent) { await Semaphore.WaitAsync(); try { int numOfEventsSent = statisticalEventsSent.Count; for (int i = 0; i < numOfEventsSent; i++) { if (EventsToSend is null) { Logger.Warn($"Can't remove statistical events because the queue is null."); break; } if (!EventsToSend.TryDequeue(out StatisticalEvent? statisticalEvent)) { Logger.Warn($"The statistical events queue is unexpectedly shorter. Occurred when removing {i} of {numOfEventsSent}."); break; } } if (EventsToSend is not null) { Logger.Info($"Removed successfully sent statistical events. {EventsToSend.Count} events are now queued."); } SaveToFile(EventsToSend?.ToList() ?? []); } finally { Semaphore.Release(); } } protected abstract void SaveToFile(List events); protected async Task StartAsync() { await Semaphore.WaitAsync(); try { LoadStoredEventsIfEventsToSendIsNull(); if (!IsShareStatisticsEnabled) { ClearEventsDueToDisabledTelemetry(); } } finally { Semaphore.Release(); } _timer.Change(TimeSpan.Zero, _timerInterval); } protected void ClearEventsDueToDisabledTelemetry() { if (EventsToSend is null) { Logger.Warn($"Can't clear statistical events because the queue is null."); } else { int numOfEventsBefore = EventsToSend.Count; EventsToSend.Clear(); SaveToFile(EventsToSend.ToList()); Logger.Info($"Statistical events cleared from telemetry becoming disabled. " + $"{numOfEventsBefore} queued events before deletion. " + $"{EventsToSend.Count} queued events after deletion."); } } protected async Task StopAsync() { _timer.Change(Timeout.Infinite, Timeout.Infinite); await Semaphore.WaitAsync(); try { EventsToSend = null; } finally { Semaphore.Release(); } } }