Browse Source

Added Analytics Sender and Periodic Reporter

CPKreuz 1 year ago
parent
commit
3aaffa8929

+ 12 - 0
src/PixiEditor/Models/AnalyticsAPI/AnalyticEvent.cs

@@ -0,0 +1,12 @@
+namespace PixiEditor.Models.AnalyticsAPI;
+
+public class AnalyticEvent
+{
+    public string EventType { get; set; }
+    
+    public DateTimeOffset Time { get; set; }
+    
+    public DateTimeOffset End { get; set; }
+    
+    public Dictionary<string, object>? Data { get; set; }
+}

+ 21 - 0
src/PixiEditor/Models/AnalyticsAPI/AnalyticEventTypes.cs

@@ -0,0 +1,21 @@
+namespace PixiEditor.Models.AnalyticsAPI;
+
+public class AnalyticEventTypes
+{
+    public string Startup { get; } = GetEventType("Startup");
+    public string CreateDocument { get; } = GetEventType("CreateDocument");
+    public string SwitchTab { get; } = GetEventType("SwitchTab");
+    public string OpenWindow { get; } = GetEventType("OpenWindow");
+    public string CreateNode { get; } = GetEventType("CreateNode");
+    public string CreateKeyframe { get; } = GetEventType("CreateKeyframe");
+    public string CloseDocument { get; } = GetEventType("CloseDocument");
+    public string ResizeDocument { get; } = GetEventType("ResizeDocument");
+    public string OpenExample { get; } = GetEventType("OpenExample");
+    public string OpenFile { get; } = GetEventType("OpenFile");
+    public string GeneralCommand { get; } = GetEventType("GeneralCommand");
+    public string SwitchTool { get; } = GetEventType("SwitchTool");
+    public string UseTool { get; } = GetEventType("UseTool");
+    public string SetColor { get; } = GetEventType("SetColor");
+
+    private static string GetEventType(string value) => $"PixiEditor.{value}";
+}

+ 68 - 0
src/PixiEditor/Models/AnalyticsAPI/AnalyticsClient.cs

@@ -0,0 +1,68 @@
+using System.Net;
+using System.Net.Http.Json;
+using PixiEditor.Helpers;
+
+namespace PixiEditor.Models.AnalyticsAPI;
+
+public class AnalyticsClient
+{
+    private readonly HttpClient _client = new();
+
+    public AnalyticsClient(string url)
+    {
+        _client.BaseAddress = new Uri(url);
+    }
+
+    public async Task<Guid?> CreateSessionAsync(CancellationToken cancellationToken = default)
+    {
+        var response = await _client.GetAsync("init-session", cancellationToken);
+
+        if (response.IsSuccessStatusCode)
+        {
+            return Guid.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
+        }
+
+        if (response.StatusCode is not HttpStatusCode.ServiceUnavailable)
+        {
+            await ReportInvalidStatusCodeAsync(response.StatusCode);
+        }
+            
+        return null;
+
+    }
+
+    public async Task<string?> SendEventsAsync(Guid sessionId, IEnumerable<AnalyticEvent> events,
+        CancellationToken cancellationToken = default)
+    {
+        var response = await _client.PostAsJsonAsync($"post-events?id={sessionId}", events, cancellationToken);
+        
+        if (response.IsSuccessStatusCode)
+        {
+            return await response.Content.ReadAsStringAsync(cancellationToken);
+        }
+
+        if (response.StatusCode is not (HttpStatusCode.NotFound or HttpStatusCode.ServiceUnavailable))
+        {
+            await ReportInvalidStatusCodeAsync(response.StatusCode);
+        }
+            
+        return null;
+    }
+
+    public async Task<bool> SendHeartbeatAsync(Guid sessionId, CancellationToken cancellationToken = default)
+    {
+        var response = await _client.PostAsync($"heartbeat?id={sessionId}", null, cancellationToken);
+
+        return response.IsSuccessStatusCode;
+    }
+
+    public async Task EndSessionAsync(Guid sessionId, CancellationToken cancellationToken = default)
+    {
+        await _client.PostAsync($"end-session?id={sessionId}", null, cancellationToken);
+    }
+
+    private static async Task ReportInvalidStatusCodeAsync(HttpStatusCode statusCode)
+    {
+        await CrashHelper.SendExceptionInfoToWebhookAsync(new InvalidOperationException($"Invalid status code from analytics API '{statusCode}'"));
+    }
+}

+ 146 - 0
src/PixiEditor/Models/AnalyticsAPI/AnalyticsPeriodicReporter.cs

@@ -0,0 +1,146 @@
+using PixiEditor.Helpers;
+
+namespace PixiEditor.Models.AnalyticsAPI;
+
+public class AnalyticsPeriodicReporter
+{
+    private int _sendExceptions = 0;
+    
+    private readonly SemaphoreSlim _semaphore = new(1, 1);
+    private readonly AnalyticsClient _client;
+    
+    private readonly List<AnalyticEvent> _backlog = new();
+    private readonly CancellationTokenSource _cancellationToken = new();
+
+    private DateTime lastActivity;
+
+    public static AnalyticsPeriodicReporter Instance { get; private set; }
+
+    public Guid SessionId { get; private set; }
+    
+    public AnalyticsPeriodicReporter(AnalyticsClient client)
+    {
+        if (Instance != null)
+            throw new InvalidOperationException("There's already a AnalyticsReporter present");
+
+        Instance = this;
+        
+        _client = client;
+    }
+
+    public void Start()
+    {
+        Task.Run(RunAsync);
+    }
+
+    public void Stop()
+    {
+        _cancellationToken.Cancel();
+    }
+
+    public void AddEvent(AnalyticEvent value)
+    {
+        _semaphore.Wait();
+
+        try
+        {
+            _backlog.Add(value);
+        }
+        finally
+        {
+            _semaphore.Release();
+        }
+    }
+
+    private async Task RunAsync()
+    {
+        var createSession = await _client.CreateSessionAsync(_cancellationToken.Token);
+
+        if (!createSession.HasValue)
+        {
+            return;
+        }
+
+        await Task.Run(RunHeartbeatAsync);
+
+        while (!_cancellationToken.IsCancellationRequested)
+        {
+            try
+            {
+                await SendBacklogAsync();
+
+                await Task.Delay(10);
+            }
+            catch (TaskCanceledException) { }
+            catch (Exception e)
+            {
+                await SendExceptionAsync(e);
+            }
+        }
+    }
+
+    private async Task SendBacklogAsync()
+    {
+        await _semaphore.WaitAsync();
+
+        try
+        {
+            var result = await _client.SendEventsAsync(SessionId, _backlog, _cancellationToken.Token);
+            _backlog.Clear();
+
+            if (result == null) _cancellationToken.Cancel();
+
+            lastActivity = DateTime.UtcNow;
+        }
+        finally
+        {
+            _semaphore.Release();
+        }
+    }
+
+    private async Task RunHeartbeatAsync()
+    {
+        lastActivity = DateTime.UtcNow;
+
+        while (!_cancellationToken.IsCancellationRequested)
+        {
+            try
+            {
+                await SendHeartbeatIfNeededAsync();
+
+                await Task.Delay(TimeSpan.FromSeconds(10), _cancellationToken.Token);
+            }
+            catch (TaskCanceledException) { }
+            catch (Exception e)
+            {
+                await SendExceptionAsync(e);
+            }
+        }
+    }
+
+    private async ValueTask SendHeartbeatIfNeededAsync()
+    {
+        var timeSinceLastActivity = DateTime.UtcNow - lastActivity;
+        if (timeSinceLastActivity.TotalSeconds < 60)
+        {
+            return;
+        }
+
+        var result = await _client.SendHeartbeatAsync(SessionId, _cancellationToken.Token);
+        lastActivity = DateTime.UtcNow;
+
+        if (!result)
+        {
+            _cancellationToken.Cancel();
+        }
+    }
+
+    private async Task SendExceptionAsync(Exception e)
+    {
+        if (_sendExceptions > 6)
+        {
+            await CrashHelper.SendExceptionInfoToWebhookAsync(e);
+            _sendExceptions++;
+        }
+    }
+}