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
};
}
}