using System.Collections.ObjectModel; using System.Text; using IronTelemetry.Client; namespace IronServices.Maui.Controls; /// /// A page for displaying captured app logs (exceptions, telemetry events) with share and clear functionality. /// Use within a NavigationPage for proper toolbar display. /// /// /// /// // Navigate to the log view /// await Navigation.PushAsync(new AppLogView /// { /// TelemetryClient = myTelemetryClient /// }); /// /// // Or with custom title /// await Navigation.PushAsync(new AppLogView /// { /// TelemetryClient = myTelemetryClient, /// Title = "Error Logs" /// }); /// /// public partial class AppLogView : ContentPage { private TelemetryClient? _telemetryClient; private readonly ObservableCollection _logItems = new(); private readonly List _manualLogItems = new(); // Items added via AddLog() - preserved across RefreshLogs() private LogItem? _selectedItem; private bool _enableLiveUpdates; /// /// Creates a new AppLogView instance. /// public AppLogView() { InitializeComponent(); LogList.BindingContext = _logItems; } #region Bindable Properties /// /// The TelemetryClient to pull queued logs from. When set, automatically refreshes the log list. /// public static readonly BindableProperty TelemetryClientProperty = BindableProperty.Create( nameof(TelemetryClient), typeof(TelemetryClient), typeof(AppLogView), null, propertyChanged: OnTelemetryClientChanged); /// /// Gets or sets the TelemetryClient used to retrieve queued log items. /// public TelemetryClient? TelemetryClient { get => (TelemetryClient?)GetValue(TelemetryClientProperty); set => SetValue(TelemetryClientProperty, value); } private static void OnTelemetryClientChanged(BindableObject bindable, object oldValue, object newValue) { var view = (AppLogView)bindable; view._telemetryClient = newValue as TelemetryClient; view.RefreshLogs(); view.UpdateLiveUpdatesSubscription(); } /// /// Whether to show the share toolbar item. Default: true. /// public static readonly BindableProperty ShowShareButtonProperty = BindableProperty.Create( nameof(ShowShareButton), typeof(bool), typeof(AppLogView), true, propertyChanged: (b, o, n) => ((AppLogView)b).ShareToolbarItem.IsEnabled = (bool)n); /// /// Gets or sets whether the Share toolbar button is visible. /// public bool ShowShareButton { get => (bool)GetValue(ShowShareButtonProperty); set => SetValue(ShowShareButtonProperty, value); } /// /// Whether to show the clear toolbar item. Default: true. /// public static readonly BindableProperty ShowClearButtonProperty = BindableProperty.Create( nameof(ShowClearButton), typeof(bool), typeof(AppLogView), true, propertyChanged: (b, o, n) => ((AppLogView)b).ClearToolbarItem.IsEnabled = (bool)n); /// /// Gets or sets whether the Clear toolbar button is visible. /// public bool ShowClearButton { get => (bool)GetValue(ShowClearButtonProperty); set => SetValue(ShowClearButtonProperty, value); } /// /// Whether to show the Copy for AI toolbar item. Default: true. /// public static readonly BindableProperty ShowCopyForAIButtonProperty = BindableProperty.Create( nameof(ShowCopyForAIButton), typeof(bool), typeof(AppLogView), true, propertyChanged: (b, o, n) => ((AppLogView)b).CopyForAIToolbarItem.IsEnabled = (bool)n); /// /// Gets or sets whether the Copy for AI toolbar button is visible. /// public bool ShowCopyForAIButton { get => (bool)GetValue(ShowCopyForAIButtonProperty); set => SetValue(ShowCopyForAIButtonProperty, value); } /// /// Whether to automatically refresh when new items are added to the TelemetryClient. Default: false. /// When enabled, polls for new items every 2 seconds. /// public static readonly BindableProperty EnableLiveUpdatesProperty = BindableProperty.Create( nameof(EnableLiveUpdates), typeof(bool), typeof(AppLogView), false, propertyChanged: OnEnableLiveUpdatesChanged); /// /// Gets or sets whether to automatically refresh the log list when new items are added. /// public bool EnableLiveUpdates { get => (bool)GetValue(EnableLiveUpdatesProperty); set => SetValue(EnableLiveUpdatesProperty, value); } private static void OnEnableLiveUpdatesChanged(BindableObject bindable, object oldValue, object newValue) { var view = (AppLogView)bindable; view._enableLiveUpdates = (bool)newValue; view.UpdateLiveUpdatesSubscription(); } private Timer? _liveUpdateTimer; private int _lastKnownItemCount; private void UpdateLiveUpdatesSubscription() { _liveUpdateTimer?.Dispose(); _liveUpdateTimer = null; if (_enableLiveUpdates && _telemetryClient != null) { _lastKnownItemCount = _telemetryClient.GetLocalLogItems().Count; _liveUpdateTimer = new Timer(CheckForNewItems, null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); } } private void CheckForNewItems(object? state) { if (_telemetryClient == null) return; var currentCount = _telemetryClient.GetLocalLogItems().Count; if (currentCount != _lastKnownItemCount) { _lastKnownItemCount = currentCount; MainThread.BeginInvokeOnMainThread(RefreshLogs); } } #endregion #region Events /// /// Raised when logs are cleared via the Clear button. /// public event EventHandler? LogsCleared; /// /// Raised when logs are shared via the Share button. /// public event EventHandler? LogsShared; /// /// Raised when a log item is selected from the list. /// public event EventHandler? LogSelected; /// /// Raised when the log list is refreshed. /// public event EventHandler? LogsRefreshed; /// /// Raised when logs are copied for AI debugging. /// public event EventHandler? LogsCopiedForAI; #endregion #region Public Methods /// /// Refresh the log list from the telemetry client's local log queue. /// Preserves manually added items (via AddLog()) and merges with telemetry items. /// public void RefreshLogs() { _logItems.Clear(); // Collect all items to display var allItems = new List(); // Add items from telemetry client if (_telemetryClient != null) { var localItems = _telemetryClient.GetLocalLogItems(); foreach (var item in localItems) { allItems.Add(LogItem.FromEnvelopeItem(item)); } } // Add manually added items (preserved across refreshes) allItems.AddRange(_manualLogItems); // Sort by timestamp descending and add to observable collection foreach (var item in allItems.OrderByDescending(i => i.Timestamp)) { _logItems.Add(item); } UpdateCount(); LogsRefreshed?.Invoke(this, EventArgs.Empty); } /// /// Add a log item manually (for local-only logging without TelemetryClient). /// These items are preserved across RefreshLogs() calls. /// /// Log type: "exception", "message", "info", "warning", etc. /// Title or exception type name. /// Log message or exception message. /// Optional stack trace for exceptions. public void AddLog(string type, string title, string message, string? stackTrace = null) { var item = new LogItem { Type = type, Title = title, Message = message, StackTrace = stackTrace, Timestamp = DateTime.UtcNow, IsManualEntry = true }; // Store in manual items list (preserved across refreshes) _manualLogItems.Add(item); // Also add to display list immediately _logItems.Insert(0, item); UpdateCount(); } /// /// Add an exception as a log entry. /// /// The exception to log. public void AddException(Exception ex) { AddLog("exception", ex.GetType().Name, ex.Message, ex.StackTrace); } /// /// Clear all displayed logs including manually added ones. Does not clear the TelemetryClient's offline queue. /// public void ClearLogs() { _logItems.Clear(); _manualLogItems.Clear(); HideDetail(); UpdateCount(); LogsCleared?.Invoke(this, EventArgs.Empty); } /// /// Clear all logs including the TelemetryClient's local log queue. /// public void ClearAllLogs() { _telemetryClient?.ClearLocalLogItems(); _telemetryClient?.OfflineQueue?.Clear(); ClearLogs(); } /// /// Export all logs as a formatted string suitable for sharing or saving. /// /// Formatted log export string. public string ExportLogs() { var sb = new StringBuilder(); sb.AppendLine($"=== App Logs Export ==="); sb.AppendLine($"Exported: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine($"Total items: {_logItems.Count}"); sb.AppendLine(); foreach (var item in _logItems) { sb.AppendLine($"--- [{item.Type.ToUpperInvariant()}] {item.Timestamp:yyyy-MM-dd HH:mm:ss UTC} ---"); sb.AppendLine($"Title: {item.Title}"); sb.AppendLine($"Message: {item.Message}"); if (!string.IsNullOrEmpty(item.StackTrace)) { sb.AppendLine("Stack Trace:"); sb.AppendLine(item.StackTrace); } sb.AppendLine(); } return sb.ToString(); } /// /// Export logs in a format optimized for AI debugging assistance. /// Includes system context, environment info, and structured log data. /// /// AI-optimized log export string. public string ExportLogsForAI() { var sb = new StringBuilder(); // Header for AI sb.AppendLine("# Application Debug Log for AI Analysis"); sb.AppendLine(); sb.AppendLine("Please analyze the following application logs and help identify issues, root causes, and potential fixes."); sb.AppendLine(); // System Context sb.AppendLine("## Environment"); sb.AppendLine("```"); sb.AppendLine($"Platform: {DeviceInfo.Platform}"); sb.AppendLine($"OS Version: {DeviceInfo.VersionString}"); sb.AppendLine($"Device: {DeviceInfo.Model} ({DeviceInfo.Manufacturer})"); sb.AppendLine($"Device Type: {DeviceInfo.DeviceType}"); sb.AppendLine($"App: {AppInfo.Name} v{AppInfo.VersionString} (Build {AppInfo.BuildString})"); sb.AppendLine($"Framework: .NET MAUI"); sb.AppendLine($"Captured: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); sb.AppendLine($"Local Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine($"Timezone: {TimeZoneInfo.Local.DisplayName}"); sb.AppendLine("```"); sb.AppendLine(); // Summary var exceptions = _logItems.Where(i => i.Type == "exception").ToList(); var warnings = _logItems.Where(i => i.Type == "warning").ToList(); var journeyEvents = _logItems.Where(i => i.Type.StartsWith("journey_") || i.Type.StartsWith("step_")).ToList(); sb.AppendLine("## Summary"); sb.AppendLine($"- **Total Entries:** {_logItems.Count}"); sb.AppendLine($"- **Exceptions:** {exceptions.Count}"); sb.AppendLine($"- **Warnings:** {warnings.Count}"); sb.AppendLine($"- **Journey Events:** {journeyEvents.Count}"); if (_logItems.Count > 0) { var oldest = _logItems.Min(i => i.Timestamp); var newest = _logItems.Max(i => i.Timestamp); sb.AppendLine($"- **Time Range:** {oldest:HH:mm:ss} to {newest:HH:mm:ss} UTC ({(newest - oldest).TotalSeconds:F1}s span)"); } sb.AppendLine(); // Exceptions first (most important for debugging) if (exceptions.Count > 0) { sb.AppendLine("## Exceptions"); sb.AppendLine(); foreach (var item in exceptions.OrderByDescending(i => i.Timestamp)) { sb.AppendLine($"### {item.Title}"); sb.AppendLine($"**Time:** {item.Timestamp:yyyy-MM-dd HH:mm:ss} UTC"); if (!string.IsNullOrEmpty(item.JourneyId)) sb.AppendLine($"**Journey ID:** {item.JourneyId}"); if (!string.IsNullOrEmpty(item.UserId)) sb.AppendLine($"**User ID:** {item.UserId}"); sb.AppendLine(); sb.AppendLine("**Message:**"); sb.AppendLine($"```"); sb.AppendLine(item.Message); sb.AppendLine($"```"); if (!string.IsNullOrEmpty(item.StackTrace)) { sb.AppendLine(); sb.AppendLine("**Stack Trace:**"); sb.AppendLine("```"); sb.AppendLine(item.StackTrace); sb.AppendLine("```"); } sb.AppendLine(); } } // All logs in chronological order sb.AppendLine("## Full Log (Chronological)"); sb.AppendLine(); sb.AppendLine("| Time (UTC) | Type | Title | Message |"); sb.AppendLine("|------------|------|-------|---------|"); foreach (var item in _logItems.OrderBy(i => i.Timestamp)) { var msg = item.Message?.Replace("|", "\\|").Replace("\n", " ").Replace("\r", "") ?? ""; if (msg.Length > 100) msg = msg[..97] + "..."; var title = item.Title?.Replace("|", "\\|") ?? ""; if (title.Length > 50) title = title[..47] + "..."; sb.AppendLine($"| {item.Timestamp:HH:mm:ss.fff} | {item.TypeDisplay} | {title} | {msg} |"); } sb.AppendLine(); // Detailed entries for non-exceptions var otherItems = _logItems.Where(i => i.Type != "exception").OrderByDescending(i => i.Timestamp).ToList(); if (otherItems.Count > 0) { sb.AppendLine("## Other Log Details"); sb.AppendLine(); foreach (var item in otherItems) { sb.AppendLine($"### [{item.TypeDisplay}] {item.Title}"); sb.AppendLine($"**Time:** {item.Timestamp:yyyy-MM-dd HH:mm:ss.fff} UTC"); if (!string.IsNullOrEmpty(item.JourneyId)) sb.AppendLine($"**Journey ID:** {item.JourneyId}"); if (!string.IsNullOrEmpty(item.Message)) { sb.AppendLine(); sb.AppendLine(item.Message); } sb.AppendLine(); } } return sb.ToString(); } /// /// Get the current log items as a read-only list. /// public IReadOnlyList GetLogs() => _logItems.ToList().AsReadOnly(); /// /// Get the count of log items. /// public int LogCount => _logItems.Count; #endregion #region Event Handlers private void OnClearClicked(object? sender, EventArgs e) { ClearLogs(); } private void OnRefreshClicked(object? sender, EventArgs e) { RefreshLogs(); } private async void OnShareClicked(object? sender, EventArgs e) { if (_logItems.Count == 0) { await DisplayAlert("No Logs", "There are no logs to share.", "OK"); return; } var content = ExportLogs(); await Share.Default.RequestAsync(new ShareTextRequest { Title = "App Logs", Text = content }); LogsShared?.Invoke(this, EventArgs.Empty); } private async void OnCopyForAIClicked(object? sender, EventArgs e) { if (_logItems.Count == 0) { await DisplayAlert("No Logs", "There are no logs to copy.", "OK"); return; } var content = ExportLogsForAI(); await Clipboard.Default.SetTextAsync(content); // Show feedback await MainThread.InvokeOnMainThreadAsync(async () => { var originalText = CountLabel.Text; CountLabel.Text = "Copied for AI!"; CountLabel.TextColor = Colors.Green; await Task.Delay(1500); CountLabel.Text = originalText; CountLabel.TextColor = Application.Current?.RequestedTheme == AppTheme.Dark ? Color.FromArgb("#9CA3AF") : Color.FromArgb("#6B7280"); }); LogsCopiedForAI?.Invoke(this, EventArgs.Empty); } private async void OnLogSelected(object? sender, SelectionChangedEventArgs e) { if (e.CurrentSelection.FirstOrDefault() is LogItem item) { _selectedItem = item; ShowDetail(item); LogSelected?.Invoke(this, item); // Copy log entry to clipboard await CopyLogToClipboardAsync(item); } } /// /// Copies a log item's details to the clipboard. /// private async Task CopyLogToClipboardAsync(LogItem item) { var sb = new StringBuilder(); sb.AppendLine("=== Exception Log ==="); sb.AppendLine($"Type: {item.Type.ToUpperInvariant()}"); sb.AppendLine($"Time: {item.Timestamp:yyyy-MM-dd HH:mm:ss UTC}"); sb.AppendLine($"Title: {item.Title}"); sb.AppendLine(); sb.AppendLine("--- Message ---"); sb.AppendLine(item.Message); if (!string.IsNullOrEmpty(item.JourneyId)) { sb.AppendLine(); sb.AppendLine($"Journey ID: {item.JourneyId}"); } if (!string.IsNullOrEmpty(item.UserId)) { sb.AppendLine($"User ID: {item.UserId}"); } if (!string.IsNullOrEmpty(item.StackTrace)) { sb.AppendLine(); sb.AppendLine("--- Stack Trace ---"); sb.AppendLine(item.StackTrace); } await Clipboard.Default.SetTextAsync(sb.ToString()); // Show brief toast/feedback await MainThread.InvokeOnMainThreadAsync(async () => { var originalText = CountLabel.Text; CountLabel.Text = "Copied to clipboard!"; CountLabel.TextColor = Colors.Green; await Task.Delay(1500); CountLabel.Text = originalText; CountLabel.TextColor = Application.Current?.RequestedTheme == AppTheme.Dark ? Color.FromArgb("#9CA3AF") : Color.FromArgb("#6B7280"); }); } private void OnCloseDetailClicked(object? sender, EventArgs e) { HideDetail(); LogList.SelectedItem = null; } private async void OnCopyStackTraceClicked(object? sender, EventArgs e) { if (_selectedItem != null) { await CopyLogToClipboardAsync(_selectedItem); if (sender is Button button) { var originalText = button.Text; button.Text = "Copied!"; await Task.Delay(1000); button.Text = originalText; } } } #endregion #region Lifecycle protected override void OnAppearing() { base.OnAppearing(); RefreshLogs(); UpdateLiveUpdatesSubscription(); } protected override void OnDisappearing() { base.OnDisappearing(); _liveUpdateTimer?.Dispose(); _liveUpdateTimer = null; } #endregion #region Helper Methods private void UpdateCount() { CountLabel.Text = _logItems.Count == 1 ? "1 item" : $"{_logItems.Count} items"; } private void ShowDetail(LogItem item) { DetailTitle.Text = item.Title; DetailMessage.Text = item.Message ?? "No message available"; DetailStackTrace.Text = item.StackTrace ?? "No stack trace available"; DetailPanel.IsVisible = true; } private void HideDetail() { DetailPanel.IsVisible = false; _selectedItem = null; } #endregion } /// /// Represents a log item for display in AppLogView. /// public class LogItem { /// /// UTC timestamp when the log was captured. /// public DateTime Timestamp { get; set; } /// /// Log type: "exception", "message", "journey_start", "journey_end", "step_start", "step_end", "info", "warning". /// public string Type { get; set; } = "info"; /// /// Title or exception type name. /// public string Title { get; set; } = ""; /// /// Log message or exception message. /// public string Message { get; set; } = ""; /// /// Stack trace for exceptions. /// public string? StackTrace { get; set; } /// /// Associated journey ID if part of a user journey. /// public string? JourneyId { get; set; } /// /// Associated user ID. /// public string? UserId { get; set; } /// /// Whether this item was added manually via AddLog() vs from TelemetryClient. /// public bool IsManualEntry { get; set; } /// /// Formatted timestamp for display (local time, HH:mm:ss). /// public string TimestampDisplay => Timestamp.ToLocalTime().ToString("HH:mm:ss"); /// /// Short type label for display badge. /// public string TypeDisplay => Type switch { "exception" => "ERROR", "message" => "MSG", "journey_start" => "START", "journey_end" => "END", "step_start" => "STEP", "step_end" => "STEP", "warning" => "WARN", "info" => "INFO", _ => Type.ToUpperInvariant() }; /// /// Color for type badge. /// public Color TypeColor => Type switch { "exception" => Colors.Red, "warning" => Colors.Orange, "message" => Colors.Blue, "info" => Colors.Teal, "journey_start" => Colors.Green, "journey_end" => Colors.Green, "step_start" => Colors.Purple, "step_end" => Colors.Purple, _ => Colors.Gray }; /// /// Create a LogItem from a TelemetryClient EnvelopeItem. /// public static LogItem FromEnvelopeItem(EnvelopeItem item) { return new LogItem { Timestamp = item.Timestamp, Type = item.Type ?? "unknown", Title = item.ExceptionType ?? item.Name ?? item.Type ?? "Log Entry", Message = item.Message ?? "", StackTrace = item.StackTrace, JourneyId = item.JourneyId, UserId = item.UserId }; } }