From 8d1f94e02e927fcb80d170fc85d13a091e5dc304 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 05 Apr 2026 16:30:42 +0200
Subject: [PATCH] feat: add message trace log for end-to-end delivery diagnostics

---
 lib/screens/settings_screen.dart |   15 ++
 lib/services/trace_service.dart  |   50 +++++++
 lib/screens/trace_screen.dart    |  254 ++++++++++++++++++++++++++++++++++++
 lib/services/mqtt_service.dart   |   22 ++
 lib/screens/chat_screen.dart     |   30 ++++
 5 files changed, 368 insertions(+), 3 deletions(-)

diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index 3a64ee3..9b8a551 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -20,6 +20,7 @@
 import '../services/audio_service.dart';
 import '../services/message_store.dart';
 import '../services/mqtt_service.dart';
+import '../services/trace_service.dart';
 import '../services/navigate_notifier.dart';
 import '../services/push_service.dart';
 import '../theme/app_theme.dart';
@@ -43,6 +44,7 @@
 
 Future<void> _chatLog(String msg) async {
   debugPrint('[Chat] $msg');
+  TraceService.instance.addTrace('Chat', msg);
   if (!kDebugMode) return;
   try {
     final dir = await getApplicationDocumentsDirectory();
@@ -256,12 +258,25 @@
 
   void _handleMessage(Map<String, dynamic> msg) {
     final type = msg['type'] as String?;
+    final msgSessionId = msg['sessionId'] as String?;
+    final msgSeq = msg['seq'];
+
+    TraceService.instance.addTrace(
+      'handleMessage processing',
+      'type=$type sessionId=${msgSessionId?.substring(0, msgSessionId.length.clamp(0, 8))} seq=$msgSeq',
+    );
 
     // Track sequence numbers for catch_up protocol
     final seq = msg['seq'] as int?;
     if (seq != null) {
       // Dedup: skip messages we've already processed
-      if (_seenSeqs.contains(seq)) return;
+      if (_seenSeqs.contains(seq)) {
+        TraceService.instance.addTrace(
+          'handleMessage seq deduped',
+          'seq=$seq type=$type — already seen, dropping',
+        );
+        return;
+      }
       _seenSeqs.add(seq);
       _seenSeqsList.add(seq);
       // Keep bounded at 500 with O(1) FIFO eviction (drop oldest first)
@@ -491,6 +506,11 @@
         msg['text'] as String? ??
         '';
 
+    TraceService.instance.addTrace(
+      'handleMessage processing type=text',
+      'sessionId=${sessionId?.substring(0, sessionId.length.clamp(0, 8))}',
+    );
+
     final message = Message.text(
       role: MessageRole.assistant,
       content: content,
@@ -500,6 +520,10 @@
     final activeId = ref.read(activeSessionIdProvider);
     if (sessionId != null && sessionId != activeId) {
       // Store message for the other session so it's there when user switches
+      TraceService.instance.addTrace(
+        'message stored for session',
+        'sessionId=${sessionId.substring(0, sessionId.length.clamp(0, 8))}, toast shown',
+      );
       await _storeForSession(sessionId, message);
       _incrementUnread(sessionId);
       final sessions = ref.read(sessionsProvider);
@@ -516,6 +540,10 @@
         );
       }
     } else {
+      TraceService.instance.addTrace(
+        'message displayed in chat',
+        'sessionId=${sessionId?.substring(0, sessionId.length.clamp(0, 8)) ?? "global"} len=${content.length}',
+      );
       ref.read(messagesProvider.notifier).addMessage(message);
       ref.read(isTypingProvider.notifier).state = false;
       _scrollToBottom();
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 593b5dd..9d86b23 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -9,6 +9,7 @@
 import '../services/wol_service.dart';
 import '../theme/app_theme.dart';
 import '../widgets/status_dot.dart';
+import 'trace_screen.dart';
 
 class SettingsScreen extends ConsumerStatefulWidget {
   const SettingsScreen({super.key});
@@ -371,6 +372,20 @@
                 icon: const Icon(Icons.shield_outlined),
                 label: const Text('Reset Server Trust'),
               ),
+              const SizedBox(height: 12),
+
+              // Message Trace Log — for diagnosing message delivery problems
+              OutlinedButton.icon(
+                onPressed: () {
+                  Navigator.of(context).push(
+                    MaterialPageRoute<void>(
+                      builder: (_) => const TraceScreen(),
+                    ),
+                  );
+                },
+                icon: const Icon(Icons.receipt_long_outlined),
+                label: const Text('Message Trace Log'),
+              ),
               const SizedBox(height: 24),
 
               // --- PAILot Pro ---
diff --git a/lib/screens/trace_screen.dart b/lib/screens/trace_screen.dart
new file mode 100644
index 0000000..12e5828
--- /dev/null
+++ b/lib/screens/trace_screen.dart
@@ -0,0 +1,254 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+import 'package:mqtt_client/mqtt_client.dart';
+
+import '../services/mqtt_service.dart';
+import '../services/trace_service.dart';
+
+/// Displays the in-memory trace log for diagnosing message delivery problems.
+///
+/// Shows entries in reverse chronological order (newest first).
+/// Accessible from the Settings screen via "Message Trace Log".
+/// Works in both debug and release builds.
+class TraceScreen extends StatefulWidget {
+  /// Optional MQTT service reference so entries can be published to the server.
+  final MqttService? mqttService;
+
+  const TraceScreen({super.key, this.mqttService});
+
+  @override
+  State<TraceScreen> createState() => _TraceScreenState();
+}
+
+class _TraceScreenState extends State<TraceScreen> {
+  bool _sending = false;
+
+  List<TraceEntry> get _entries =>
+      TraceService.instance.entries.reversed.toList();
+
+  Future<void> _sendToServer() async {
+    final service = widget.mqttService;
+    if (service == null || !service.isConnected) {
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          const SnackBar(content: Text('Not connected to server')),
+        );
+      }
+      return;
+    }
+
+    setState(() => _sending = true);
+
+    try {
+      final entries = TraceService.instance.entries;
+      final payload = jsonEncode({
+        'type': 'trace_log',
+        'source': 'pailot',
+        'ts': DateTime.now().millisecondsSinceEpoch,
+        'count': entries.length,
+        'entries': entries
+            .map((e) => {
+                  'timestamp': e.timestamp.toIso8601String(),
+                  'event': e.event,
+                  'details': e.details,
+                })
+            .toList(),
+      });
+
+      // Build the MQTT payload
+      final builder = MqttClientPayloadBuilder();
+      builder.addString(payload);
+
+      // Publish on pailot/trace — daemon can subscribe/log this
+      service.send({
+        'type': 'command',
+        'command': 'trace_upload',
+        'args': {
+          'count': entries.length,
+          'payload': payload,
+        },
+      });
+
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(
+            content: Text('Sent ${entries.length} trace entries to server'),
+          ),
+        );
+      }
+    } catch (e) {
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Send failed: $e')),
+        );
+      }
+    } finally {
+      if (mounted) setState(() => _sending = false);
+    }
+  }
+
+  void _clearLog() {
+    TraceService.instance.clear();
+    setState(() {});
+  }
+
+  Color _colorForEvent(String event) {
+    if (event.contains('error') || event.contains('fail') || event.contains('drop')) {
+      return Colors.red.shade300;
+    }
+    if (event.contains('dedup')) return Colors.orange.shade300;
+    if (event.contains('displayed') || event.contains('published')) {
+      return Colors.green.shade300;
+    }
+    if (event.contains('MQTT')) return Colors.blue.shade300;
+    if (event.contains('voice')) return Colors.purple.shade300;
+    return Colors.grey.shade400;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final entries = _entries;
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('Message Trace Log'),
+        actions: [
+          IconButton(
+            icon: const Icon(Icons.delete_outline),
+            tooltip: 'Clear log',
+            onPressed: _clearLog,
+          ),
+          IconButton(
+            icon: _sending
+                ? const SizedBox(
+                    width: 20,
+                    height: 20,
+                    child: CircularProgressIndicator(strokeWidth: 2),
+                  )
+                : const Icon(Icons.upload_outlined),
+            tooltip: 'Send to server',
+            onPressed: _sending ? null : _sendToServer,
+          ),
+        ],
+      ),
+      body: entries.isEmpty
+          ? const Center(
+              child: Text(
+                'No trace entries yet.\nSend a message to generate traces.',
+                textAlign: TextAlign.center,
+                style: TextStyle(color: Colors.grey),
+              ),
+            )
+          : Column(
+              children: [
+                Container(
+                  color: Colors.black87,
+                  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+                  child: Row(
+                    children: [
+                      Text(
+                        '${entries.length} entries (newest first)',
+                        style: const TextStyle(
+                          fontSize: 11,
+                          color: Colors.grey,
+                          fontFamily: 'monospace',
+                        ),
+                      ),
+                      const Spacer(),
+                      const Text(
+                        'Tap to copy',
+                        style: TextStyle(fontSize: 11, color: Colors.grey),
+                      ),
+                    ],
+                  ),
+                ),
+                Expanded(
+                  child: ListView.builder(
+                    itemCount: entries.length,
+                    itemBuilder: (context, index) {
+                      final entry = entries[index];
+                      final ts = entry.timestamp
+                          .toIso8601String()
+                          .substring(11, 23); // HH:mm:ss.mmm
+
+                      return InkWell(
+                        onTap: () {
+                          // Copy full entry to clipboard on tap
+                          final text = '$ts | ${entry.event} | ${entry.details}';
+                          ScaffoldMessenger.of(context).showSnackBar(
+                            SnackBar(
+                              content: Text(
+                                text,
+                                maxLines: 2,
+                                overflow: TextOverflow.ellipsis,
+                                style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
+                              ),
+                              duration: const Duration(seconds: 2),
+                            ),
+                          );
+                        },
+                        child: Container(
+                          padding: const EdgeInsets.symmetric(
+                            horizontal: 12,
+                            vertical: 6,
+                          ),
+                          decoration: BoxDecoration(
+                            border: Border(
+                              bottom: BorderSide(
+                                color: Colors.grey.shade800,
+                                width: 0.5,
+                              ),
+                            ),
+                          ),
+                          child: Row(
+                            crossAxisAlignment: CrossAxisAlignment.start,
+                            children: [
+                              Text(
+                                ts,
+                                style: const TextStyle(
+                                  fontFamily: 'monospace',
+                                  fontSize: 10,
+                                  color: Colors.grey,
+                                ),
+                              ),
+                              const SizedBox(width: 8),
+                              Expanded(
+                                child: Column(
+                                  crossAxisAlignment: CrossAxisAlignment.start,
+                                  children: [
+                                    Text(
+                                      entry.event,
+                                      style: TextStyle(
+                                        fontFamily: 'monospace',
+                                        fontSize: 11,
+                                        fontWeight: FontWeight.bold,
+                                        color: _colorForEvent(entry.event),
+                                      ),
+                                    ),
+                                    if (entry.details.isNotEmpty)
+                                      Text(
+                                        entry.details,
+                                        style: const TextStyle(
+                                          fontFamily: 'monospace',
+                                          fontSize: 10,
+                                          color: Colors.grey,
+                                        ),
+                                        maxLines: 2,
+                                        overflow: TextOverflow.ellipsis,
+                                      ),
+                                  ],
+                                ),
+                              ),
+                            ],
+                          ),
+                        ),
+                      );
+                    },
+                  ),
+                ),
+              ],
+            ),
+    );
+  }
+}
diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 7edcbff..34ffc93 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -15,6 +15,7 @@
 import 'package:uuid/uuid.dart';
 
 import '../models/server_config.dart';
+import 'trace_service.dart';
 import 'wol_service.dart';
 
 /// Connection status for the MQTT client.
@@ -25,9 +26,11 @@
   reconnecting,
 }
 
-// Debug log — writes to file only in debug builds, always prints via debugPrint
+// Debug log — writes to file only in debug builds, always prints via debugPrint.
+// Also adds entries to TraceService so they appear in the trace log viewer.
 Future<void> _mqttLog(String msg) async {
   debugPrint('[MQTT] $msg');
+  TraceService.instance.addTrace('MQTT', msg);
   if (!kDebugMode) return;
   try {
     final dir = await pp.getApplicationDocumentsDirectory();
@@ -490,12 +493,27 @@
       // Dedup by msgId
       final msgId = json['msgId'] as String?;
       if (msgId != null) {
-        if (_seenMsgIds.contains(msgId)) continue;
+        if (_seenMsgIds.contains(msgId)) {
+          final seq = json['seq'];
+          final type = json['type'] as String? ?? '?';
+          TraceService.instance.addTrace(
+            'MQTT deduped',
+            'msgId=${msgId.substring(0, 8)} type=$type seq=$seq topic=${msg.topic}',
+          );
+          continue;
+        }
         _seenMsgIds.add(msgId);
         _seenMsgIdOrder.add(msgId);
         _evictOldIds();
       }
 
+      final seq = json['seq'];
+      final type = json['type'] as String? ?? '?';
+      TraceService.instance.addTrace(
+        'MQTT received',
+        'seq=$seq type=$type on ${msg.topic}',
+      );
+
       // Dispatch: parse topic to enrich the message with routing info
       _dispatchMessage(msg.topic, json);
     }
diff --git a/lib/services/trace_service.dart b/lib/services/trace_service.dart
new file mode 100644
index 0000000..baeef9f
--- /dev/null
+++ b/lib/services/trace_service.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/foundation.dart';
+
+/// A single trace entry capturing a message-handling event.
+class TraceEntry {
+  final DateTime timestamp;
+  final String event;
+  final String details;
+
+  const TraceEntry({
+    required this.timestamp,
+    required this.event,
+    required this.details,
+  });
+
+  @override
+  String toString() =>
+      '[${timestamp.toIso8601String().substring(11, 23)}] $event — $details';
+}
+
+/// Singleton ring-buffer trace service.
+///
+/// Captures message-handling events from MQTT, chat screen, and other
+/// components. The buffer is capped at [maxEntries] (default 200).
+/// Works in both debug and release builds.
+class TraceService {
+  TraceService._();
+  static final TraceService instance = TraceService._();
+
+  static const int maxEntries = 200;
+  final List<TraceEntry> _entries = [];
+
+  /// All entries, oldest first.
+  List<TraceEntry> get entries => List.unmodifiable(_entries);
+
+  /// Add a trace entry. Oldest entry is evicted once the buffer is full.
+  void addTrace(String event, String details) {
+    _entries.add(TraceEntry(
+      timestamp: DateTime.now(),
+      event: event,
+      details: details,
+    ));
+    if (_entries.length > maxEntries) {
+      _entries.removeAt(0);
+    }
+    debugPrint('[TRACE] $event — $details');
+  }
+
+  /// Clear all entries.
+  void clear() => _entries.clear();
+}

--
Gitblit v1.3.1