This commit is contained in:
lincube
2026-03-21 16:16:02 +08:00
parent 2a1c09ae39
commit 46a8df5900
20 changed files with 1744 additions and 1585 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,629 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public sealed class PostHogUsageTelemetryService : IDisposable
{
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
private const string PostHogHost = "https://us.i.posthog.com/capture/";
private readonly ISettingsFacadeService _settingsFacade;
private readonly ISettingsService _settingsService;
private readonly HttpClient _httpClient = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
private readonly Queue<TelemetryEvent> _eventQueue = new();
private readonly object _queueLock = new();
private Timer? _flushTimer;
private bool _isInitialized;
private bool _isUsageEnabled;
private bool _sessionActive;
private string _sessionId = string.Empty;
private DateTimeOffset _sessionStartUtc;
private long _sequence;
private readonly string _launchId = Guid.NewGuid().ToString("N");
public PostHogUsageTelemetryService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_settingsService = settingsFacade.Settings;
_settingsService.Changed += OnSettingsChanged;
}
public bool IsUsageEnabled => _isUsageEnabled;
public void Initialize()
{
if (_isInitialized)
{
return;
}
_isInitialized = true;
EnsureBaselineEventSent();
RefreshEnabledState(forceSessionStart: true);
_flushTimer = new Timer(
_ => FlushEvents(),
null,
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30));
AppLogger.Info(
"PostHogUsage",
$"Usage telemetry initialized. Enabled={_isUsageEnabled}; InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}.");
}
public void RefreshEnabledState(bool forceSessionStart = false)
{
try
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var enabled = snapshot.UploadAnonymousUsageData;
if (_isUsageEnabled == enabled && !forceSessionStart)
{
return;
}
var previous = _isUsageEnabled;
_isUsageEnabled = enabled;
AppLogger.Info("PostHogUsage", $"Usage analytics enabled state changed from '{previous}' to '{_isUsageEnabled}'.");
if (_isUsageEnabled)
{
StartSession("usage_enabled");
return;
}
ClearQueuedEvents();
StopSessionWithoutSending();
}
catch (Exception ex)
{
AppLogger.Warn("PostHogUsage", "Failed to refresh usage analytics enabled state.", ex);
_isUsageEnabled = false;
ClearQueuedEvents();
StopSessionWithoutSending();
}
}
public void TrackMainWindowOpened(string source, bool isVisible, string windowState)
{
CaptureEvent(
"main_window_opened",
new Dictionary<string, object?>
{
["source"] = source,
["is_visible"] = isVisible,
["window_state"] = windowState
},
forceFlush: true);
}
public void TrackMainWindowClosed(string source, bool wasVisible, string windowState)
{
CaptureEvent(
"main_window_closed",
new Dictionary<string, object?>
{
["source"] = source,
["was_visible"] = wasVisible,
["window_state"] = windowState
},
forceFlush: true);
}
public void TrackSettingsWindowOpened(string source, string? currentPageId)
{
CaptureEvent(
"settings_window_opened",
new Dictionary<string, object?>
{
["source"] = source,
["current_page_id"] = currentPageId
},
forceFlush: true);
}
public void TrackSettingsWindowClosed(string source, string? currentPageId)
{
CaptureEvent(
"settings_window_closed",
new Dictionary<string, object?>
{
["source"] = source,
["current_page_id"] = currentPageId
},
forceFlush: true);
}
public void TrackSettingsNavigation(string? fromPageId, string? toPageId, string source)
{
CaptureEvent(
"settings_navigation",
new Dictionary<string, object?>
{
["source"] = source,
["from_page_id"] = fromPageId,
["to_page_id"] = toPageId
},
stateBefore: CreatePageState(fromPageId),
stateAfter: CreatePageState(toPageId));
}
public void TrackSettingsDrawerOpened(string? pageId, string? drawerTitle)
{
CaptureEvent(
"settings_drawer_opened",
new Dictionary<string, object?>
{
["page_id"] = pageId,
["drawer_title"] = drawerTitle
},
forceFlush: true);
}
public void TrackSettingsDrawerClosed(string? pageId, string? drawerTitle)
{
CaptureEvent(
"settings_drawer_closed",
new Dictionary<string, object?>
{
["page_id"] = pageId,
["drawer_title"] = drawerTitle
},
forceFlush: true);
}
public void TrackDesktopComponentPlaced(DesktopComponentPlacementSnapshot placement, string source)
{
CaptureEvent(
"desktop_component_placed",
new Dictionary<string, object?>
{
["source"] = source
},
stateAfter: DescribePlacement(placement),
forceFlush: true);
}
public void TrackDesktopComponentMoved(
DesktopComponentPlacementSnapshot before,
DesktopComponentPlacementSnapshot after,
string source)
{
CaptureEvent(
"desktop_component_moved",
new Dictionary<string, object?>
{
["source"] = source
},
stateBefore: DescribePlacement(before),
stateAfter: DescribePlacement(after),
forceFlush: true);
}
public void TrackDesktopComponentResized(
DesktopComponentPlacementSnapshot before,
DesktopComponentPlacementSnapshot after,
string source)
{
CaptureEvent(
"desktop_component_resized",
new Dictionary<string, object?>
{
["source"] = source
},
stateBefore: DescribePlacement(before),
stateAfter: DescribePlacement(after),
forceFlush: true);
}
public void TrackDesktopComponentDeleted(DesktopComponentPlacementSnapshot before, string source)
{
CaptureEvent(
"desktop_component_deleted",
new Dictionary<string, object?>
{
["source"] = source
},
stateBefore: DescribePlacement(before),
forceFlush: true);
}
public void TrackDesktopComponentEditorOpened(DesktopComponentPlacementSnapshot placement, string source)
{
CaptureEvent(
"desktop_component_editor_opened",
new Dictionary<string, object?>
{
["source"] = source
},
stateBefore: DescribePlacement(placement),
forceFlush: true);
}
public void TrackSessionStarted(string source)
{
StartSession(source);
}
public void TrackSessionEnded(string source)
{
EndSession(source);
}
public void Shutdown(bool isRestart, string source)
{
if (!_isInitialized)
{
return;
}
if (_isUsageEnabled && _sessionActive)
{
EndSession(source, isRestart);
}
FlushEvents();
AppLogger.Info(
"PostHogUsage",
$"Usage telemetry shutdown complete. Source='{source}'; Restart='{isRestart}'; Enabled={_isUsageEnabled}.");
}
public void Dispose()
{
try
{
_flushTimer?.Dispose();
_settingsService.Changed -= OnSettingsChanged;
Shutdown(isRestart: false, source: "Dispose");
FlushEvents();
}
catch (Exception ex)
{
AppLogger.Warn("PostHogUsage", "Error disposing usage telemetry service.", ex);
}
finally
{
_httpClient.Dispose();
}
}
private void EnsureBaselineEventSent()
{
try
{
var identity = TelemetryIdentityService.Instance;
if (identity.HasReportedBaseline)
{
return;
}
var now = DateTimeOffset.UtcNow;
if (SendBaselineEventToPostHog(identity.InstallId, now))
{
identity.MarkBaselineReported();
}
}
catch (Exception ex)
{
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
}
}
private bool SendBaselineEventToPostHog(string installId, DateTimeOffset timestamp)
{
try
{
var requestBody = new Dictionary<string, object?>
{
["api_key"] = PostHogApiKey,
["event"] = "app_first_launch",
["distinct_id"] = installId,
["timestamp"] = timestamp.ToString("o"),
["properties"] = new Dictionary<string, object?>
{
["launch_time_utc"] = timestamp.ToString("o")
}
};
var json = JsonSerializer.Serialize(requestBody);
var bytes = Encoding.UTF8.GetBytes(json);
using var content = new ByteArrayContent(bytes);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
{
AppLogger.Warn(
"PostHogUsage",
$"PostHog baseline event failed: {response.StatusCode} - {responseBody}");
return false;
}
AppLogger.Info("PostHogUsage", "Sent first-launch baseline event.");
return true;
}
catch (Exception ex)
{
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
return false;
}
}
private void StartSession(string source)
{
if (!_isInitialized || !_isUsageEnabled)
{
return;
}
if (_sessionActive)
{
return;
}
_sessionActive = true;
_sessionId = Guid.NewGuid().ToString("N");
_sessionStartUtc = DateTimeOffset.UtcNow;
_sequence = 0;
CaptureEvent(
"app_session_start",
new Dictionary<string, object?>
{
["source"] = source,
["launch_id"] = _launchId,
["session_start_utc"] = _sessionStartUtc.ToString("o"),
["local_hour"] = _sessionStartUtc.ToLocalTime().Hour,
["day_part"] = TelemetryEnvironmentInfo.GetLocalDayPart(_sessionStartUtc),
["timezone"] = TimeZoneInfo.Local.Id,
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture()
},
forceFlush: true);
AppLogger.Info("PostHogUsage", $"Session started. SessionId={_sessionId}; Source='{source}'.");
}
private void EndSession(string source, bool isRestart = false)
{
if (!_isInitialized || !_sessionActive)
{
return;
}
var endUtc = DateTimeOffset.UtcNow;
var durationMs = Math.Max(0, (long)(endUtc - _sessionStartUtc).TotalMilliseconds);
CaptureEvent(
"app_session_end",
new Dictionary<string, object?>
{
["source"] = source,
["launch_id"] = _launchId,
["session_start_utc"] = _sessionStartUtc.ToString("o"),
["session_end_utc"] = endUtc.ToString("o"),
["duration_ms"] = durationMs,
["is_restart"] = isRestart
},
forceFlush: true);
_sessionActive = false;
_sessionId = string.Empty;
_sessionStartUtc = default;
_sequence = 0;
AppLogger.Info("PostHogUsage", $"Session ended. Source='{source}'; DurationMs={durationMs}; Restart={isRestart}.");
}
private void StopSessionWithoutSending()
{
_sessionActive = false;
_sessionId = string.Empty;
_sessionStartUtc = default;
_sequence = 0;
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
_ = sender;
if (e.Scope != SettingsScope.App ||
e.ChangedKeys is null ||
!e.ChangedKeys.Contains(nameof(AppSettingsSnapshot.UploadAnonymousUsageData), StringComparer.OrdinalIgnoreCase))
{
return;
}
AppLogger.Info("PostHogUsage", "Usage analytics settings changed. Refreshing enabled state.");
RefreshEnabledState();
}
private void CaptureEvent(
string eventName,
IReadOnlyDictionary<string, object?>? payload = null,
IReadOnlyDictionary<string, object?>? stateBefore = null,
IReadOnlyDictionary<string, object?>? stateAfter = null,
bool forceFlush = false)
{
if (!_isInitialized || !_isUsageEnabled || !_sessionActive)
{
return;
}
var eventData = new TelemetryEvent(
eventName,
TelemetryIdentityService.Instance.TelemetryId,
TelemetryIdentityService.Instance.InstallId,
TelemetryIdentityService.Instance.TelemetryId,
_sessionId,
Interlocked.Increment(ref _sequence),
DateTimeOffset.UtcNow,
payload ?? new Dictionary<string, object?>(),
stateBefore,
stateAfter);
lock (_queueLock)
{
_eventQueue.Enqueue(eventData);
}
if (forceFlush)
{
FlushEvents();
return;
}
var shouldFlush = false;
lock (_queueLock)
{
shouldFlush = _eventQueue.Count >= 20;
}
if (shouldFlush)
{
FlushEvents();
}
}
private void FlushEvents()
{
List<TelemetryEvent> eventsToSend;
lock (_queueLock)
{
if (_eventQueue.Count == 0)
{
return;
}
eventsToSend = new List<TelemetryEvent>();
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
{
eventsToSend.Add(_eventQueue.Dequeue());
}
}
try
{
foreach (var telemetryEvent in eventsToSend)
{
if (!SendEventToPostHog(telemetryEvent, flushImmediately: false))
{
throw new InvalidOperationException($"Failed to send PostHog event '{telemetryEvent.EventName}'.");
}
}
}
catch (Exception ex)
{
AppLogger.Warn("PostHogUsage", "Failed to send queued events to PostHog.", ex);
lock (_queueLock)
{
foreach (var evt in eventsToSend)
{
if (_eventQueue.Count >= 100)
{
break;
}
_eventQueue.Enqueue(evt);
}
}
}
}
private bool SendEventToPostHog(TelemetryEvent telemetryEvent, bool flushImmediately)
{
try
{
var requestBody = new Dictionary<string, object?>
{
["api_key"] = PostHogApiKey,
["event"] = telemetryEvent.EventName,
["distinct_id"] = telemetryEvent.DistinctId,
["timestamp"] = telemetryEvent.Timestamp.ToString("o"),
["properties"] = telemetryEvent.ToPostHogProperties()
};
var json = JsonSerializer.Serialize(requestBody);
var bytes = Encoding.UTF8.GetBytes(json);
using var content = new ByteArrayContent(bytes);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
{
AppLogger.Warn(
"PostHogUsage",
$"PostHog event '{telemetryEvent.EventName}' failed: {response.StatusCode} - {responseBody}");
return false;
}
if (flushImmediately)
{
AppLogger.Info("PostHogUsage", $"Sent event '{telemetryEvent.EventName}' immediately.");
}
return true;
}
catch (Exception ex)
{
AppLogger.Warn("PostHogUsage", $"Failed to send PostHog event '{telemetryEvent.EventName}'.", ex);
return false;
}
}
private void ClearQueuedEvents()
{
lock (_queueLock)
{
_eventQueue.Clear();
}
}
private static IReadOnlyDictionary<string, object?> CreatePageState(string? pageId)
{
return new Dictionary<string, object?>
{
["page_id"] = pageId
};
}
private static IReadOnlyDictionary<string, object?> DescribePlacement(DesktopComponentPlacementSnapshot placement)
{
return new Dictionary<string, object?>
{
["placement_id"] = placement.PlacementId,
["component_id"] = placement.ComponentId,
["page_index"] = placement.PageIndex,
["row"] = placement.Row,
["column"] = placement.Column,
["width_cells"] = placement.WidthCells,
["height_cells"] = placement.HeightCells
};
}
}

View File

@@ -0,0 +1,410 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using Sentry;
namespace LanMountainDesktop.Services;
public sealed class SentryCrashTelemetryService : IDisposable
{
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
private const string AutoIpAddress = "{{auto}}";
private readonly ISettingsFacadeService _settingsFacade;
private readonly ISettingsService _settingsService;
private readonly object _syncRoot = new();
private IDisposable? _sentryHandle;
private bool _isInitialized;
private bool _isEnabled;
private bool _disposed;
public SentryCrashTelemetryService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_settingsService = settingsFacade.Settings;
_settingsService.Changed += OnSettingsChanged;
}
public bool IsEnabled
{
get
{
lock (_syncRoot)
{
return _isInitialized && _isEnabled && SentrySdk.IsEnabled;
}
}
}
public void Initialize()
{
lock (_syncRoot)
{
EnsureNotDisposed();
if (_isInitialized)
{
return;
}
_isInitialized = true;
}
RefreshEnabledState(force: true);
}
public void RefreshEnabledState(bool force = false)
{
bool shouldEnable;
lock (_syncRoot)
{
EnsureNotDisposed();
if (!_isInitialized)
{
return;
}
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
shouldEnable = snapshot.UploadAnonymousCrashData;
if (!force && _isEnabled == shouldEnable)
{
return;
}
}
if (shouldEnable)
{
EnableSentry();
return;
}
DisableSentry();
}
public void CaptureUnhandledException(Exception exception, string source, bool isTerminating)
{
if (exception is null)
{
return;
}
lock (_syncRoot)
{
if (!CanCapture())
{
return;
}
}
var eventId = SentrySdk.CaptureException(exception, scope =>
{
ApplyCommonScope(scope, source, "unhandled_exception", includeLogTail: true);
scope.Level = isTerminating ? SentryLevel.Fatal : SentryLevel.Error;
scope.SetTag("exception_source", source);
scope.SetTag("is_terminating", isTerminating.ToString());
});
AppLogger.Info("SentryCrash", $"Captured unhandled exception from '{source}'. EventId={eventId}.");
if (isTerminating)
{
EndCrashSession();
SentrySdk.Flush(TimeSpan.FromSeconds(5));
}
}
public void CaptureTaskException(Exception exception, string source)
{
if (exception is null)
{
return;
}
lock (_syncRoot)
{
if (!CanCapture())
{
return;
}
}
var eventId = SentrySdk.CaptureException(exception, scope =>
{
ApplyCommonScope(scope, source, "task_exception", includeLogTail: true);
scope.Level = SentryLevel.Error;
scope.SetTag("exception_source", source);
});
AppLogger.Info("SentryCrash", $"Captured task exception from '{source}'. EventId={eventId}.");
SentrySdk.Flush(TimeSpan.FromSeconds(2));
}
public void CaptureShutdown(bool isRestart, string source)
{
lock (_syncRoot)
{
if (!CanCapture())
{
return;
}
}
var eventId = SentrySdk.CaptureMessage("application_shutdown", scope =>
{
ApplyCommonScope(scope, source, "shutdown", includeLogTail: true);
scope.Level = SentryLevel.Info;
scope.SetTag("shutdown_intent", isRestart ? "restart" : "exit");
scope.SetExtra("shutdown_intent", isRestart ? "restart" : "exit");
}, SentryLevel.Info);
AppLogger.Info(
"SentryCrash",
$"Captured application shutdown. Source='{source}'; Restart={isRestart}; EventId={eventId}.");
EndCrashSession();
SentrySdk.Flush(TimeSpan.FromSeconds(5));
}
public void Dispose()
{
lock (_syncRoot)
{
if (_disposed)
{
return;
}
_disposed = true;
}
try
{
_settingsService.Changed -= OnSettingsChanged;
DisableSentry();
}
catch (Exception ex)
{
AppLogger.Warn("SentryCrash", "Failed to dispose crash telemetry service.", ex);
}
}
private void EnableSentry()
{
lock (_syncRoot)
{
if (_isEnabled && _sentryHandle is not null && SentrySdk.IsEnabled)
{
return;
}
}
var handle = SentrySdk.Init(options =>
{
options.Dsn = SentryDsn;
options.AutoSessionTracking = true;
options.AttachStacktrace = true;
options.SendDefaultPii = true;
options.MaxBreadcrumbs = 100;
options.Release = TelemetryEnvironmentInfo.GetAppVersion();
options.Environment = TelemetryEnvironmentInfo.GetEnvironment();
options.DisableAppDomainUnhandledExceptionCapture();
options.DisableUnobservedTaskExceptionCapture();
});
lock (_syncRoot)
{
if (_disposed)
{
handle.Dispose();
return;
}
_sentryHandle?.Dispose();
_sentryHandle = handle;
_isEnabled = true;
}
SentrySdk.ConfigureScope(scope => ApplyCommonScope(scope, "startup", "startup", includeLogTail: false));
AppLogger.Info("SentryCrash", "Crash telemetry enabled.");
}
private void DisableSentry()
{
IDisposable? handle;
lock (_syncRoot)
{
if (!_isEnabled && _sentryHandle is null)
{
return;
}
_isEnabled = false;
handle = _sentryHandle;
_sentryHandle = null;
}
try
{
EndCrashSession();
SentrySdk.Flush(TimeSpan.FromSeconds(3));
}
catch (Exception ex)
{
AppLogger.Warn("SentryCrash", "Failed to flush Sentry while disabling crash telemetry.", ex);
}
finally
{
handle?.Dispose();
}
AppLogger.Info("SentryCrash", "Crash telemetry disabled.");
}
private void EndCrashSession()
{
try
{
if (SentrySdk.IsEnabled)
{
SentrySdk.EndSession(SessionEndStatus.Exited);
}
}
catch (Exception ex)
{
AppLogger.Warn("SentryCrash", "Failed to end Sentry session.", ex);
}
}
private bool CanCapture()
{
return !_disposed && _isInitialized && _isEnabled && SentrySdk.IsEnabled;
}
private void ApplyCommonScope(Scope scope, string source, string eventType, bool includeLogTail)
{
var installId = TelemetryIdentityService.Instance.InstallId;
var telemetryId = TelemetryIdentityService.Instance.TelemetryId;
scope.User = new SentryUser
{
Id = telemetryId,
IpAddress = AutoIpAddress
};
scope.SetTag("telemetry_channel", "sentry");
scope.SetTag("event_type", eventType);
scope.SetTag("source", source);
scope.SetTag("install_id", installId);
scope.SetTag("telemetry_id", telemetryId);
scope.SetTag("app_version", TelemetryEnvironmentInfo.GetAppVersion());
scope.SetTag("environment", TelemetryEnvironmentInfo.GetEnvironment());
scope.SetTag("os_name", TelemetryEnvironmentInfo.GetOsName());
scope.SetTag("os_version", TelemetryEnvironmentInfo.GetOsVersion());
scope.SetTag("os_build", TelemetryEnvironmentInfo.GetOsBuild());
scope.SetTag("device_model", TelemetryEnvironmentInfo.GetDeviceModel());
scope.SetTag("device_arch", TelemetryEnvironmentInfo.GetDeviceArchitecture());
scope.SetTag("processor_count", TelemetryEnvironmentInfo.GetProcessorCount().ToString());
scope.SetTag("total_memory_mb", TelemetryEnvironmentInfo.GetTotalMemoryMB().ToString());
scope.SetTag("runtime_version", TelemetryEnvironmentInfo.GetRuntimeVersion());
scope.SetTag("clr_version", TelemetryEnvironmentInfo.GetClrVersion());
scope.SetTag("language", TelemetryEnvironmentInfo.GetSystemLanguage());
scope.SetExtra("install_id", installId);
scope.SetExtra("telemetry_id", telemetryId);
scope.SetExtra("app_version", TelemetryEnvironmentInfo.GetAppVersion());
scope.SetExtra("environment", TelemetryEnvironmentInfo.GetEnvironment());
scope.SetExtra("os_name", TelemetryEnvironmentInfo.GetOsName());
scope.SetExtra("os_version", TelemetryEnvironmentInfo.GetOsVersion());
scope.SetExtra("os_build", TelemetryEnvironmentInfo.GetOsBuild());
scope.SetExtra("device_model", TelemetryEnvironmentInfo.GetDeviceModel());
scope.SetExtra("device_arch", TelemetryEnvironmentInfo.GetDeviceArchitecture());
scope.SetExtra("processor_count", TelemetryEnvironmentInfo.GetProcessorCount());
scope.SetExtra("total_memory_mb", TelemetryEnvironmentInfo.GetTotalMemoryMB());
scope.SetExtra("runtime_version", TelemetryEnvironmentInfo.GetRuntimeVersion());
scope.SetExtra("clr_version", TelemetryEnvironmentInfo.GetClrVersion());
scope.SetExtra("language", TelemetryEnvironmentInfo.GetSystemLanguage());
scope.SetExtra("log_file_path", AppLogger.LogFilePath);
if (includeLogTail)
{
var logTail = ReadLogTail(maxLines: 200, maxCharacters: 32_768);
if (!string.IsNullOrWhiteSpace(logTail))
{
scope.SetExtra("log_tail", logTail);
scope.SetExtra("log_tail_line_count", logTail.Count(character => character == '\n') + 1);
var attachment = new Attachment(
AttachmentType.Default,
new ByteAttachmentContent(Encoding.UTF8.GetBytes(logTail)),
"log-tail.txt",
"text/plain");
scope.AddAttachment(attachment);
}
}
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
_ = sender;
if (e.Scope != SettingsScope.App ||
e.ChangedKeys is null ||
!e.ChangedKeys.Contains(nameof(AppSettingsSnapshot.UploadAnonymousCrashData), StringComparer.OrdinalIgnoreCase))
{
return;
}
AppLogger.Info("SentryCrash", "Crash telemetry setting changed. Refreshing enabled state.");
RefreshEnabledState();
}
private static string ReadLogTail(int maxLines, int maxCharacters)
{
try
{
var logFilePath = AppLogger.LogFilePath;
if (string.IsNullOrWhiteSpace(logFilePath) || !File.Exists(logFilePath))
{
return string.Empty;
}
var lines = new Queue<string>(Math.Min(maxLines, 256));
using var reader = File.OpenText(logFilePath);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (lines.Count >= maxLines)
{
lines.Dequeue();
}
lines.Enqueue(line);
}
var tail = string.Join(Environment.NewLine, lines);
if (tail.Length <= maxCharacters)
{
return tail;
}
return tail[^maxCharacters..];
}
catch (Exception ex)
{
AppLogger.Warn("SentryCrash", "Failed to read log tail for crash telemetry.", ex);
return string.Empty;
}
}
private void EnsureNotDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SentryCrashTelemetryService));
}
}
}

View File

@@ -609,17 +609,32 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
public void Save(PrivacySettingsState state)
{
var snapshot = _settingsService.Load();
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
var changedKeys = new List<string>();
if (snapshot.UploadAnonymousCrashData != state.UploadAnonymousCrashData)
{
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
changedKeys.Add(nameof(AppSettingsSnapshot.UploadAnonymousCrashData));
}
if (snapshot.UploadAnonymousUsageData != state.UploadAnonymousUsageData)
{
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
changedKeys.Add(nameof(AppSettingsSnapshot.UploadAnonymousUsageData));
}
if (changedKeys.Count == 0)
{
return;
}
AppLogger.Info(
"PrivacySettings",
$"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys:
[
nameof(AppSettingsSnapshot.UploadAnonymousCrashData),
nameof(AppSettingsSnapshot.UploadAnonymousUsageData)
]);
changedKeys: changedKeys);
}
}

View File

@@ -0,0 +1,144 @@
using System;
using System.Globalization;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
internal static class TelemetryEnvironmentInfo
{
public static string GetAppVersion()
{
var assembly = typeof(TelemetryEnvironmentInfo).Assembly;
var version = assembly.GetName().Version;
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
}
public static string GetEnvironment()
{
#if DEBUG
return "development";
#else
return "production";
#endif
}
public static string GetOsName()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return "Windows";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return "Linux";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return "macOS";
}
return "Unknown";
}
public static string GetOsVersion()
{
try
{
return Environment.OSVersion.VersionString ?? "Unknown";
}
catch
{
return "Unknown";
}
}
public static string GetOsBuild()
{
try
{
return Environment.OSVersion.Version.Build.ToString(CultureInfo.InvariantCulture);
}
catch
{
return "Unknown";
}
}
public static string GetDeviceModel()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return "Windows PC";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
return "Linux PC";
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return "Mac";
}
return "Unknown";
}
public static string GetDeviceArchitecture()
{
return RuntimeInformation.OSArchitecture.ToString();
}
public static string GetSystemLanguage()
{
try
{
return CultureInfo.CurrentUICulture.Name ?? "en-US";
}
catch
{
return "en-US";
}
}
public static int GetProcessorCount()
{
return Environment.ProcessorCount;
}
public static long GetTotalMemoryMB()
{
try
{
return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024);
}
catch
{
return 0;
}
}
public static string GetRuntimeVersion()
{
return Environment.Version.ToString();
}
public static string GetClrVersion()
{
return Environment.Version.ToString();
}
public static string GetLocalDayPart(DateTimeOffset timestamp)
{
var hour = timestamp.ToLocalTime().Hour;
return hour switch
{
< 6 => "late_night",
< 12 => "morning",
< 18 => "afternoon",
_ => "evening"
};
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Services;
internal sealed record TelemetryEvent(
string EventName,
string DistinctId,
string InstallId,
string TelemetryId,
string SessionId,
long Sequence,
DateTimeOffset Timestamp,
IReadOnlyDictionary<string, object?> Payload,
IReadOnlyDictionary<string, object?>? StateBefore = null,
IReadOnlyDictionary<string, object?>? StateAfter = null)
{
public Dictionary<string, object?> ToPostHogProperties()
{
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["install_id"] = InstallId,
["telemetry_id"] = TelemetryId,
["session_id"] = SessionId,
["sequence"] = Sequence,
["timestamp_utc"] = Timestamp.ToString("o"),
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
["payload"] = Copy(Payload)
};
if (StateBefore is not null && StateBefore.Count > 0)
{
properties["state_before"] = Copy(StateBefore);
}
if (StateAfter is not null && StateAfter.Count > 0)
{
properties["state_after"] = Copy(StateAfter);
}
return properties;
}
private static Dictionary<string, object?> Copy(IReadOnlyDictionary<string, object?> source)
{
return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public sealed class TelemetryIdentityService
{
private static TelemetryIdentityService? _instance;
private readonly ISettingsFacadeService _settingsFacade;
private readonly object _syncRoot = new();
private string _installId = string.Empty;
private string _telemetryId = string.Empty;
private bool _hasReportedBaseline;
public static TelemetryIdentityService Instance =>
_instance ?? throw new InvalidOperationException("TelemetryIdentityService not initialized.");
private TelemetryIdentityService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
}
public static void Initialize(ISettingsFacadeService settingsFacade)
{
if (_instance is not null)
{
return;
}
var instance = new TelemetryIdentityService(settingsFacade);
instance.LoadOrCreateIdentity();
_instance = instance;
TelemetryServices.Identity = instance;
AppLogger.Info(
"TelemetryIdentity",
$"Initialized. InstallId={instance.InstallId}; TelemetryId={instance.TelemetryId}; BaselineReported={instance.HasReportedBaseline}.");
}
public string InstallId
{
get
{
lock (_syncRoot)
{
EnsureInitialized();
return _installId;
}
}
}
public string TelemetryId
{
get
{
lock (_syncRoot)
{
EnsureInitialized();
return _telemetryId;
}
}
}
public bool HasReportedBaseline
{
get
{
lock (_syncRoot)
{
EnsureInitialized();
return _hasReportedBaseline;
}
}
}
public string RefreshTelemetryId()
{
lock (_syncRoot)
{
EnsureInitialized();
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
snapshot.TelemetryId = GenerateId();
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(AppSettingsSnapshot.TelemetryId)]);
_telemetryId = snapshot.TelemetryId ?? GenerateId();
AppLogger.Info("TelemetryIdentity", $"Telemetry id refreshed. TelemetryId={_telemetryId}");
return _telemetryId;
}
}
public bool MarkBaselineReported()
{
lock (_syncRoot)
{
EnsureInitialized();
if (_hasReportedBaseline)
{
return false;
}
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (snapshot.HasReportedTelemetryBaseline)
{
_hasReportedBaseline = true;
return false;
}
snapshot.HasReportedTelemetryBaseline = true;
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(AppSettingsSnapshot.HasReportedTelemetryBaseline)]);
_hasReportedBaseline = true;
AppLogger.Info("TelemetryIdentity", "Marked baseline telemetry as reported.");
return true;
}
}
private void LoadOrCreateIdentity()
{
lock (_syncRoot)
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var changedKeys = new List<string>();
if (string.IsNullOrWhiteSpace(snapshot.TelemetryInstallId))
{
snapshot.TelemetryInstallId = GenerateId();
changedKeys.Add(nameof(AppSettingsSnapshot.TelemetryInstallId));
}
if (string.IsNullOrWhiteSpace(snapshot.TelemetryId))
{
snapshot.TelemetryId = GenerateId();
changedKeys.Add(nameof(AppSettingsSnapshot.TelemetryId));
}
_installId = snapshot.TelemetryInstallId ?? GenerateId();
_telemetryId = snapshot.TelemetryId ?? GenerateId();
_hasReportedBaseline = snapshot.HasReportedTelemetryBaseline;
if (changedKeys.Count > 0)
{
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: changedKeys);
}
}
}
private void EnsureInitialized()
{
if (!string.IsNullOrWhiteSpace(_installId) && !string.IsNullOrWhiteSpace(_telemetryId))
{
return;
}
LoadOrCreateIdentity();
}
private static string GenerateId()
{
return Guid.NewGuid().ToString("N");
}
}

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.Services;
public static class TelemetryServices
{
public static TelemetryIdentityService? Identity { get; set; }
public static PostHogUsageTelemetryService? Usage { get; set; }
public static SentryCrashTelemetryService? Crash { get; set; }
}