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