updates

ober

ff13076965c26db3a8d43e67be986236c542020a

diff --git a/README.md b/README.md
index a8870d9..a7acd91 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,7 @@ signal-cli listAccounts
 make install                                   # installs ~/.local/bin/jerboa-signal
 jerboa-signal send +15551234567 "hello world"
 jerboa-signal listen | jq .                    # pretty-print incoming envelopes
+jerboa-signal --trace /tmp/jerboa-signal.trace tui
 ```
 
 Without `make install`, run via the Makefile:
@@ -93,6 +94,38 @@ phone's contacts. If a removed person messages you again the thread reappears;
 to remove someone from your Signal account everywhere, delete them on your phone
 too. Use Ctrl-Q or Ctrl-C to quit.
 
+## Debug tracing
+
+Runtime tracing is opt-in and writes a chmod 600 file:
+
+```sh
+jerboa-signal --trace /tmp/jerboa-signal.trace tui
+```
+
+The default trace records safe internal telemetry such as send phases, timings,
+byte counts, log backend selection, history replay counts, and encrypted log
+persistence stages. It does not include message bodies or raw Signal RPC frames.
+
+For maximum debugging detail, including raw RPC frames and signal-cli verbose
+stderr, use:
+
+```sh
+jerboa-signal --trace-all /tmp/jerboa-signal.raw.trace tui
+```
+
+That file is sensitive: it can include message content and Signal identifiers.
+When debugging send hangs caused by rewriting a large encrypted log, this mode
+is also useful:
+
+```sh
+jerboa-signal --trace-all /tmp/jerboa-signal.raw.trace --log-persist close tui
+```
+
+`--log-persist close` defers jsqlite encrypted-container writes until clean
+exit. The default is `immediate`, which persists after every logged message. If
+the process crashes in close-only mode, new log rows from that session may be
+lost.
+
 ## Encrypted message log
 
 The TUI can log every message to an encrypted SQLite database so you keep a copy
diff --git a/signal/capture.ss b/signal/capture.ss
index bea599f..d93a9a9 100644
--- a/signal/capture.ss
+++ b/signal/capture.ss
@@ -19,7 +19,8 @@
                   make-date make-time)
           (except (jerboa prelude) meta atom?)
           (std text json)
-          (signal logdb))
+          (signal logdb)
+          (signal trace))
 
   ;; Log one notification. Returns a short summary string (for daemon output)
   ;; or #f when nothing is written. Never raises -- logging must not break
@@ -27,41 +28,77 @@
   (def (capture-notification! logdb account notif)
     (and logdb
          (hashtable? notif)
-         (guard (e [#t #f])
-           (let ([raw (event->json notif)]
-                 [env (notif-envelope notif)])
-             (if (hashtable? env)
-               (let ([row (envelope->row env)])
-                 (logdb-put logdb (safe account)
-                            (list-ref row 0)            ;; direction
-                            (safe (list-ref row 1))     ;; conversation
-                            (safe (list-ref row 2))     ;; sender
-                            (as-int (list-ref row 3))   ;; timestamp
-                            (list-ref row 4)            ;; kind
-                            (safe (list-ref row 5))     ;; body
-                            raw)
-                 (string-append (list-ref row 0) " "
-                                (safe (list-ref row 1)) " "
-                                (list-ref row 4)))
-               (begin
-                 (logdb-put logdb (safe account) "event" "" "" 0
-                            (or (notif-method notif) "event") "" raw)
-                 "event"))))))
+         (let ([started (real-time)])
+           (trace-public-event!
+             "capture-notification-start"
+             (list (cons 'method (or (notif-method notif) "event"))))
+           (guard (e [#t
+                      (trace-event! "capture-notification-failed-detail"
+                                    (safe-display e))
+                      (trace-public-event!
+                        "capture-notification-failed"
+                        (list (cons 'ms (- (real-time) started))
+                              (cons 'error-chars
+                                    (string-length (safe-display e)))))
+                      #f])
+             (let* ([raw (event->json notif)]
+                    [env (notif-envelope notif)]
+                    [summary
+                     (if (hashtable? env)
+                       (let ([row (envelope->row env)])
+                         (logdb-put logdb (safe account)
+                                    (list-ref row 0)            ;; direction
+                                    (safe (list-ref row 1))     ;; conversation
+                                    (safe (list-ref row 2))     ;; sender
+                                    (as-int (list-ref row 3))   ;; timestamp
+                                    (list-ref row 4)            ;; kind
+                                    (safe (list-ref row 5))     ;; body
+                                    raw)
+                         (string-append (list-ref row 0) " "
+                                        (safe (list-ref row 1)) " "
+                                        (list-ref row 4)))
+                       (begin
+                         (logdb-put logdb (safe account) "event" "" "" 0
+                                    (or (notif-method notif) "event") "" raw)
+                         "event"))])
+               (trace-public-event!
+                 "capture-notification-done"
+                 (list (cons 'ms (- (real-time) started))
+                       (cons 'raw-chars (string-length raw))))
+               summary)))))
 
   ;; Log a locally-sent message (TUI composer). Returns a summary or #f.
   (def (capture-outbound! logdb account conversation text timestamp)
     (and logdb
-         (guard (e [#t #f])
-           (let ([raw (make-hashtable equal-hash equal?)])
-             (hashtable-set! raw "type" "sent")
-             (hashtable-set! raw "conversation" (safe conversation))
-             (hashtable-set! raw "message" (safe text))
-             (when (number? timestamp)
-               (hashtable-set! raw "timestamp" timestamp))
-             (logdb-put logdb (safe account) "out" (safe conversation) "You"
-                        (as-int timestamp) "data" (safe text)
-                        (event->json raw))
-             "out sent"))))
+         (let ([started (real-time)])
+           (trace-public-event!
+             "capture-outbound-start"
+             (list (cons 'text-chars (string-length (safe text)))
+                   (cons 'has-timestamp (and (number? timestamp) #t))))
+           (guard (e [#t
+                      (trace-event! "capture-outbound-failed-detail"
+                                    (safe-display e))
+                      (trace-public-event!
+                        "capture-outbound-failed"
+                        (list (cons 'ms (- (real-time) started))
+                              (cons 'error-chars
+                                    (string-length (safe-display e)))))
+                      #f])
+             (let ([raw (make-hashtable equal-hash equal?)])
+               (hashtable-set! raw "type" "sent")
+               (hashtable-set! raw "conversation" (safe conversation))
+               (hashtable-set! raw "message" (safe text))
+               (when (number? timestamp)
+                 (hashtable-set! raw "timestamp" timestamp))
+               (let ([raw-json (event->json raw)])
+                 (logdb-put logdb (safe account) "out" (safe conversation) "You"
+                            (as-int timestamp) "data" (safe text)
+                            raw-json)
+                 (trace-public-event!
+                   "capture-outbound-done"
+                   (list (cons 'ms (- (real-time) started))
+                         (cons 'raw-chars (string-length raw-json))))
+                 "out sent"))))))
 
   ;; --- extraction: envelope -> (direction conversation sender ts kind body) ---
 
diff --git a/signal/logdb.ss b/signal/logdb.ss
index f68365f..eee992b 100644
--- a/signal/logdb.ss
+++ b/signal/logdb.ss
@@ -26,10 +26,11 @@
           (std os path)
           (jsqlite api)
           (jsqlite value)
-          (signal log_crypto))
+          (signal log_crypto)
+          (signal trace))
 
   (defstruct logdb-handle (backend inner))
-  (defstruct jlog (path key salt db))
+  (defstruct jlog (path key salt db dirty?))
 
   ;; --------------------------------------------------------------------------
   ;; Legacy SQLCipher shim. This stays for old messages-*.db files that were
@@ -263,7 +264,7 @@
     (let* ([salt (log-random-bytes *jlog-salt-len*)]
            [key (derive-key passphrase salt)]
            [db (sqlite-open-bytevector (make-bytevector 0 0))]
-           [log (make-jlog path key salt db)])
+           [log (make-jlog path key salt db #f)])
       (ensure-schema! db)
       (persist-jlog! log)
       (make-logdb-handle 'jsqlite log)))
@@ -272,7 +273,7 @@
     (let-values ([(key salt plain) (decrypt-container bytes passphrase)])
       (let ([db (sqlite-open-bytevector plain)])
         (ensure-schema! db)
-        (make-logdb-handle 'jsqlite (make-jlog path key salt db)))))
+        (make-logdb-handle 'jsqlite (make-jlog path key salt db #f)))))
 
   (def (open-jlog path passphrase)
     (and (log-crypto-available?)
@@ -287,19 +288,118 @@
                  [else #f]))
              (new-jlog path passphrase)))))
 
+  (def (safe-display x)
+    (cond
+      [(string? x) x]
+      [else
+       (let ([p (open-output-string)])
+         (if (condition? x)
+           (display-condition x p)
+           (display x p))
+         (get-output-string p))]))
+
+  (def (elapsed-ms start)
+    (- (real-time) start))
+
+  (def (allocated-bytes*)
+    (guard (_ [#t -1])
+      (bytes-allocated)))
+
+  (def (alloc-delta before after)
+    (if (and (integer? before) (integer? after)
+             (>= before 0) (>= after 0))
+      (- after before)
+      -1))
+
+  (def (maybe-string-length v)
+    (if (string? v) (string-length v) 0))
+
+  (def (log-persist-policy)
+    (let ([v (getenv "JERBOA_SIGNAL_LOG_PERSIST")])
+      (cond
+        [(not (and (string? v) (not (string=? v "")))) 'immediate]
+        [(or (string-ci=? v "close")
+             (string-ci=? v "defer")
+             (string-ci=? v "deferred")
+             (string-ci=? v "exit")) 'close]
+        [else 'immediate])))
+
+  (def (persist-on-write?)
+    (eq? (log-persist-policy) 'immediate))
+
   (def (persist-jlog! log)
-    (let* ([plain (sqlite-db->bytevector (jlog-db log))]
-           [container (encrypt-container (jlog-key log) (jlog-salt log) plain)])
-      (write-file-atomic! (jlog-path log) container)))
+    (let ([total-start (real-time)]
+          [path (jlog-path log)])
+      (trace-public-event!
+        "logdb-persist-start"
+        (list (cons 'path path)
+              (cons 'dirty (jlog-dirty? log))))
+      (guard (e [#t
+                 (trace-event! "logdb-persist-failed-detail"
+                               (safe-display e))
+                 (trace-public-event!
+                   "logdb-persist-failed"
+                   (list (cons 'path path)
+                         (cons 'ms (elapsed-ms total-start))
+                         (cons 'error-chars
+                               (string-length (safe-display e)))))
+                 (raise e)])
+        (let* ([plain-start (real-time)]
+               [plain-alloc-start (allocated-bytes*)]
+               [plain (sqlite-db->bytevector (jlog-db log))]
+               [plain-alloc-end (allocated-bytes*)]
+               [plain-ms (elapsed-ms plain-start)]
+               [enc-start (real-time)]
+               [enc-alloc-start (allocated-bytes*)]
+               [container (encrypt-container (jlog-key log) (jlog-salt log) plain)]
+               [enc-alloc-end (allocated-bytes*)]
+               [enc-ms (elapsed-ms enc-start)]
+               [write-start (real-time)])
+          (trace-public-event!
+            "logdb-persist-serialize"
+            (list (cons 'path path)
+                  (cons 'bytes (bytevector-length plain))
+                  (cons 'alloc-delta
+                        (alloc-delta plain-alloc-start plain-alloc-end))
+                  (cons 'ms plain-ms)))
+          (trace-public-event!
+            "logdb-persist-encrypt"
+            (list (cons 'path path)
+                  (cons 'bytes (bytevector-length container))
+                  (cons 'alloc-delta
+                        (alloc-delta enc-alloc-start enc-alloc-end))
+                  (cons 'ms enc-ms)))
+          (write-file-atomic! path container)
+          (jlog-dirty?-set! log #f)
+          (trace-public-event!
+            "logdb-persist-write"
+            (list (cons 'path path)
+                  (cons 'bytes (bytevector-length container))
+                  (cons 'ms (elapsed-ms write-start))))
+          (trace-public-event!
+            "logdb-persist-done"
+            (list (cons 'path path)
+                  (cons 'ms (elapsed-ms total-start))))))))
 
   (def (empty->null s)
     (if (and (string? s) (string=? s "")) sql-null s))
 
   (def (jlog-put log account direction conversation sender
                  timestamp kind body raw)
+    (trace-public-event!
+      "logdb-jsqlite-put-start"
+      (list (cons 'direction direction)
+            (cons 'kind kind)
+            (cons 'body-chars (maybe-string-length body))
+            (cons 'raw-chars (maybe-string-length raw))
+            (cons 'persist (log-persist-policy))))
     (jlog-put-row log (now-seconds) account direction conversation sender
                   timestamp kind body raw)
-    (persist-jlog! log)
+    (if (persist-on-write?)
+      (persist-jlog! log)
+      (trace-public-event!
+        "logdb-jsqlite-put-deferred"
+        (list (cons 'path (jlog-path log)))))
     #t)
 
   (def (jlog-put-row log logged-at account direction conversation sender
@@ -314,7 +414,8 @@
                    timestamp
                    (empty->null kind)
                    (empty->null body)
-                   (or raw ""))))
+                   (or raw ""))
+      (jlog-dirty?-set! log #t)))
 
   (def (jlog-count log)
     (let ([rows (sqlite-query (jlog-db log) "SELECT count(*) FROM messages")])
@@ -445,41 +546,90 @@
       (rename-file tmp path)))
 
   (def (logdb-open path key)
-    (cond
-      [(or (backend-env-is? "sqlcipher") (backend-env-is? "legacy"))
-       (wrap-legacy (legacy-open path key))]
-      [(backend-env-is? "jsqlite")
-       (open-jlog path key)]
-      [else
-       (or (open-jlog path key)
-           (wrap-legacy (legacy-open path key)))]))
+    (let ([started (real-time)])
+      (trace-public-event!
+        "logdb-open-start"
+        (list (cons 'path path)
+              (cons 'backend-env (or (getenv "JERBOA_SIGNAL_LOG_BACKEND") "auto"))))
+      (let ([handle
+             (cond
+               [(or (backend-env-is? "sqlcipher") (backend-env-is? "legacy"))
+                (wrap-legacy (legacy-open path key))]
+               [(backend-env-is? "jsqlite")
+                (open-jlog path key)]
+               [else
+                (or (open-jlog path key)
+                    (wrap-legacy (legacy-open path key)))])])
+        (trace-public-event!
+          "logdb-open-done"
+          (list (cons 'path path)
+                (cons 'backend (if (logdb-handle? handle)
+                                  (logdb-handle-backend handle)
+                                  'none))
+                (cons 'ms (elapsed-ms started))))
+        handle)))
 
   (def (logdb-close handle)
     (when (logdb-handle? handle)
+      (trace-public-event!
+        "logdb-close-start"
+        (list (cons 'backend (logdb-handle-backend handle))))
       (case (logdb-handle-backend handle)
         [(jsqlite)
          (guard (e [#t (void)])
            (let ([log (logdb-handle-inner handle)])
-             (persist-jlog! log)
+             (when (jlog-dirty? log)
+               (trace-public-event!
+                 "logdb-close-persist"
+                 (list (cons 'path (jlog-path log))))
+               (persist-jlog! log))
              (sqlite-close (jlog-db log))))]
         [(legacy)
          (legacy-close (logdb-handle-inner handle))]
-        [else (void)])))
+        [else (void)])
+      (trace-public-event!
+        "logdb-close-done"
+        (list (cons 'backend (logdb-handle-backend handle))))))
 
   (def (logdb-put handle account direction conversation sender
                   timestamp kind body raw)
     (and (logdb-handle? handle)
-         (guard (e [#t #f])
-           (case (logdb-handle-backend handle)
-             [(jsqlite)
-              (jlog-put (logdb-handle-inner handle)
-                        account direction conversation sender
-                        timestamp kind body raw)]
-             [(legacy)
-              (legacy-put (logdb-handle-inner handle)
-                          account direction conversation sender
-                          timestamp kind body raw)]
-             [else #f]))))
+         (let ([started (real-time)]
+               [backend (logdb-handle-backend handle)])
+           (trace-public-event!
+             "logdb-put-start"
+             (list (cons 'backend backend)
+                   (cons 'direction direction)
+                   (cons 'kind kind)
+                   (cons 'body-chars (maybe-string-length body))
+                   (cons 'raw-chars (maybe-string-length raw))))
+           (guard (e [#t
+                      (trace-event! "logdb-put-failed-detail"
+                                    (safe-display e))
+                      (trace-public-event!
+                        "logdb-put-failed"
+                        (list (cons 'backend backend)
+                              (cons 'ms (elapsed-ms started))
+                              (cons 'error-chars
+                                    (string-length (safe-display e)))))
+                      #f])
+             (let ([ok
+                    (case backend
+                      [(jsqlite)
+                       (jlog-put (logdb-handle-inner handle)
+                                 account direction conversation sender
+                                 timestamp kind body raw)]
+                      [(legacy)
+                       (legacy-put (logdb-handle-inner handle)
+                                   account direction conversation sender
+                                   timestamp kind body raw)]
+                      [else #f])])
+               (trace-public-event!
+                 "logdb-put-done"
+                 (list (cons 'backend backend)
+                       (cons 'ok ok)
+                       (cons 'ms (elapsed-ms started))))
+               ok)))))
 
   (def (logdb-count handle)
     (if (logdb-handle? handle)
@@ -492,11 +642,33 @@
 
   (def (logdb-recent handle limit)
     (if (logdb-handle? handle)
-      (guard (e [#t '()])
-        (case (logdb-handle-backend handle)
-          [(jsqlite) (jlog-recent (logdb-handle-inner handle) limit)]
-          [(legacy) (legacy-recent (logdb-handle-inner handle) limit)]
-          [else '()]))
+      (let ([started (real-time)]
+            [backend (logdb-handle-backend handle)])
+        (trace-public-event!
+          "logdb-recent-start"
+          (list (cons 'backend backend)
+                (cons 'limit limit)))
+        (guard (e [#t
+                   (trace-event! "logdb-recent-failed-detail"
+                                 (safe-display e))
+                   (trace-public-event!
+                     "logdb-recent-failed"
+                     (list (cons 'backend backend)
+                           (cons 'ms (elapsed-ms started))
+                           (cons 'error-chars
+                                 (string-length (safe-display e)))))
+                   '()])
+          (let ([rows
+                 (case backend
+                   [(jsqlite) (jlog-recent (logdb-handle-inner handle) limit)]
+                   [(legacy) (legacy-recent (logdb-handle-inner handle) limit)]
+                   [else '()])])
+            (trace-public-event!
+              "logdb-recent-done"
+              (list (cons 'backend backend)
+                    (cons 'rows (length rows))
+                    (cons 'ms (elapsed-ms started))))
+            rows)))
       '()))
 
   ;; Read a passphrase before termbox takes over the terminal. This avoids
diff --git a/signal/main.ss b/signal/main.ss
index fb71a36..80406ce 100644
--- a/signal/main.ss
+++ b/signal/main.ss
@@ -42,7 +42,13 @@
 
 (def (usage)
   (display
-"Usage: jerboa-signal SUBCOMMAND [OPTIONS]
+"Usage: jerboa-signal [GLOBAL OPTIONS] SUBCOMMAND [OPTIONS]
+
+Global options:
+  --trace FILE                         Write safe internal trace to FILE
+  --trace-all FILE                     Trace raw RPC/details too; sensitive
+  --trace-sensitive                    Include raw RPC/details in trace
+  --log-persist immediate|close        Persist encrypted log per write or exit
 
 Subcommands:
   send [-a +PHONE] RECIPIENT MESSAGE   Send a Signal message
@@ -61,6 +67,57 @@ linking once before using this tool:
   # then scan/paste the printed sgnl://linkdevice URI on your phone.
 "))
 
+(def (usage-error msg)
+  (display "jerboa-signal: " (current-error-port))
+  (display msg (current-error-port))
+  (newline (current-error-port))
+  (usage)
+  (exit 2))
+
+(def (need-option-arg opt rest)
+  (if (pair? rest)
+    (car rest)
+    (usage-error (string-append opt " requires a value"))))
+
+(def (normalize-log-persist value)
+  (cond
+    [(or (string-ci=? value "immediate")
+         (string-ci=? value "sync")
+         (string-ci=? value "write")) "immediate"]
+    [(or (string-ci=? value "close")
+         (string-ci=? value "defer")
+         (string-ci=? value "deferred")
+         (string-ci=? value "exit")) "close"]
+    [else #f]))
+
+(def (parse-global-flags args)
+  (cond
+    [(null? args) args]
+    [(string=? (car args) "--") (cdr args)]
+    [(string=? (car args) "--trace")
+     (let ([path (need-option-arg "--trace" (cdr args))])
+       (putenv "JERBOA_SIGNAL_TRACE" path)
+       (parse-global-flags (cddr args)))]
+    [(string=? (car args) "--trace-all")
+     (let ([path (need-option-arg "--trace-all" (cdr args))])
+       (putenv "JERBOA_SIGNAL_TRACE" path)
+       (putenv "JERBOA_SIGNAL_TRACE_SENSITIVE" "1")
+       (putenv "JERBOA_SIGNAL_CLI_VERBOSE" "1")
+       (parse-global-flags (cddr args)))]
+    [(string=? (car args) "--trace-sensitive")
+     (putenv "JERBOA_SIGNAL_TRACE_SENSITIVE" "1")
+     (parse-global-flags (cdr args))]
+    [(string=? (car args) "--log-persist")
+     (let* ([value (need-option-arg "--log-persist" (cdr args))]
+            [normalized (normalize-log-persist value)])
+       (if normalized
+         (begin
+           (putenv "JERBOA_SIGNAL_LOG_PERSIST" normalized)
+           (parse-global-flags (cddr args)))
+         (usage-error
+           "--log-persist must be immediate or close")))]
+    [else args]))
+
 (def (parse-account-flag args)
   ;; Returns (values account remaining-args).
   ;; Recognizes `-a VALUE` or `--account VALUE` anywhere in the first two slots.
@@ -119,7 +176,4 @@ linking once before using this tool:
           (usage)
           (exit 2)]))]))
 
-(main (let ([args (cdr (command-line))])
-        (if (and (pair? args) (string=? (car args) "--"))
-          (cdr args)
-          args)))
+(main (parse-global-flags (cdr (command-line))))
diff --git a/signal/trace.ss b/signal/trace.ss
index 606a672..71367a8 100644
--- a/signal/trace.ss
+++ b/signal/trace.ss
@@ -5,6 +5,7 @@
   (export
     trace-enabled?
     trace-event!
+    trace-public-event!
     signal-cli-command)
 
   (import (except (chezscheme)
@@ -33,21 +34,33 @@
   (def (trace-enabled?)
     (and (trace-port) #t))
 
-  (def (trace-event! tag detail)
+  (def (write-trace-line! tag detail)
     (let ([port (trace-port)])
       (when port
         (with-trace-lock
           (lambda ()
-	            (guard (_ [#t (void)])
-	              (display (number->string (real-time)) port)
-	              (display " " port)
+            (guard (_ [#t (void)])
+              (display (number->string (real-time)) port)
+              (display " " port)
               (display tag port)
               (display " " port)
-              (display (one-line (trace-detail tag detail)) port)
+              (display (runtime-stats-detail) port)
+              (display " " port)
+              (display (one-line detail) port)
               (newline port)
               (flush-output-port port))))))
     (void))
 
+  ;; Use this for potentially sensitive details, such as raw Signal RPC frames.
+  ;; The payload is redacted unless JERBOA_SIGNAL_TRACE_SENSITIVE=1 is set.
+  (def (trace-event! tag detail)
+    (write-trace-line! tag (trace-detail tag detail)))
+
+  ;; Use this for safe internal telemetry: timings, byte counts, backend names,
+  ;; and state transitions that do not include message contents or identities.
+  (def (trace-public-event! tag detail)
+    (write-trace-line! tag (safe-display detail)))
+
   (def (signal-cli-command args)
     (append (list (signal-cli-program))
             (verbose-args)
@@ -140,6 +153,16 @@
           s
           (format "[redacted tag=~a chars=~a]" tag (string-length s)))))
 
+  (def (runtime-stats-detail)
+    (guard (_ [#t "runtime=(unavailable)"])
+      (let ([p (open-output-string)])
+        (display "runtime=" p)
+        (display
+          (list (cons 'alloc-bytes (bytes-allocated))
+                (cons 'gc-count (collections)))
+          p)
+        (get-output-string p))))
+
   (def (one-line s)
     (let ([out (open-output-string)]
           [n (string-length s)])
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 9714e64..3248333 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -25,6 +25,7 @@
           (signal store)
           (signal logdb)
           (signal capture)
+          (signal trace)
           (signal attach-save)
           (signal tui ffi)
           (signal tui theme))
@@ -142,20 +143,28 @@
   (def (preload-history! state)
     (let ([logdb (tui-state-logdb state)])
       (if logdb
-        (let loop ([rows (logdb-recent logdb *history-preload-limit*)]
-                   [count 0])
-          (cond
-            [(null? rows)
-             (when (> count 0)
-               (tui-state-status-set!
-                 state
-                 (string-append "Restored "
-                                (number->string count)
-                                " messages from the encrypted log.")))
-             count]
-            [else
-             (loop (cdr rows)
-                   (+ count (if (apply-history-row! state (car rows)) 1 0)))]))
+        (let ([started (real-time)])
+          (trace-public-event!
+            "tui-history-preload-start"
+            (list (cons 'limit *history-preload-limit*)))
+          (let loop ([rows (logdb-recent logdb *history-preload-limit*)]
+                     [count 0])
+            (cond
+              [(null? rows)
+               (trace-public-event!
+                 "tui-history-preload-done"
+                 (list (cons 'restored count)
+                       (cons 'ms (- (real-time) started))))
+               (when (> count 0)
+                 (tui-state-status-set!
+                   state
+                   (string-append "Restored "
+                                  (number->string count)
+                                  " messages from the encrypted log.")))
+               count]
+              [else
+               (loop (cdr rows)
+                     (+ count (if (apply-history-row! state (car rows)) 1 0)))])))
         0)))
 
   ;; Non-interactive diagnostic/test helper: rebuild the same initial state used
@@ -1165,34 +1174,65 @@
         [(conversation-rate-limit-active? state conv)
          (tui-state-status-set! state (conversation-rate-limit-status state conv))]
         [else
-         ;; Clear the composer BEFORE the call: once the request is written to
-         ;; signal-cli the message may be delivered even if the RPC reply is an
-         ;; error (partial/multi-device failures), so Enter must never re-fire
-         ;; the same text. On failure the text is stashed for Ctrl-R.
-         (tui-state-input-set! state "")
-         (tui-state-status-set! state "Sending...")
-         (let ([outcome
-                (guard (e [#t (cons 'failed e)])
-                  (let ([result (actor-call actor "send"
-                                            (make-send-params-for-conversation conv text))])
-                    (ensure-send-result-success! result)
-                    (cons 'sent result)))])
-           (if (eq? (car outcome) 'sent)
-             (let ([ts (send-result-timestamp (cdr outcome))])
-               (append-message-to-conversation!
-                 conv
-                 (make-chat-message 'out "You" text ts 'data 'sent))
-               (capture-outbound! (tui-state-logdb state)
-                                  (tui-state-account state)
-                                  (conversation-id conv) text ts)
-               (tui-state-resend-set! state #f)
-               (clear-conversation-rate-limit! state conv)
-               (conversation-unread-set! conv 0)
-               (tui-state-status-set! state "Sent."))
-             (begin
-               (tui-state-resend-set! state text)
-               (report-send-failure! state conv (cdr outcome)
-                                     "Ctrl-R restores the message."))))])))
+         (let ([started (real-time)])
+           (trace-public-event!
+             "tui-send-start"
+             (list (cons 'text-chars (string-length text))
+                   (cons 'has-logdb (and (tui-state-logdb state) #t))))
+           ;; Clear the composer BEFORE the call: once the request is written to
+           ;; signal-cli the message may be delivered even if the RPC reply is an
+           ;; error (partial/multi-device failures), so Enter must never re-fire
+           ;; the same text. On failure the text is stashed for Ctrl-R.
+           (tui-state-input-set! state "")
+           (tui-state-status-set! state "Sending...")
+           (let* ([rpc-start (real-time)]
+                  [outcome
+                   (guard (e [#t
+                              (trace-event! "tui-send-rpc-failed-detail"
+                                            (safe-display e))
+                              (trace-public-event!
+                                "tui-send-rpc-failed"
+                                (list (cons 'ms (- (real-time) rpc-start))
+                                      (cons 'error-chars
+                                            (string-length (safe-display e)))))
+                              (cons 'failed e)])
+                     (let ([result (actor-call actor "send"
+                                               (make-send-params-for-conversation conv text))])
+                       (trace-public-event!
+                         "tui-send-rpc-done"
+                         (list (cons 'ms (- (real-time) rpc-start))))
+                       (ensure-send-result-success! result)
+                       (cons 'sent result)))])
+             (if (eq? (car outcome) 'sent)
+               (let ([ts (send-result-timestamp (cdr outcome))])
+                 (append-message-to-conversation!
+                   conv
+                   (make-chat-message 'out "You" text ts 'data 'sent))
+                 (let ([capture-start (real-time)])
+                   (capture-outbound! (tui-state-logdb state)
+                                      (tui-state-account state)
+                                      (conversation-id conv) text ts)
+                   (trace-public-event!
+                     "tui-send-capture-done"
+                     (list (cons 'ms (- (real-time) capture-start)))))
+                 (tui-state-resend-set! state #f)
+                 (clear-conversation-rate-limit! state conv)
+                 (conversation-unread-set! conv 0)
+                 (trace-public-event!
+                   "tui-send-done"
+                   (list (cons 'ms (- (real-time) started))))
+                 (tui-state-status-set! state "Sent."))
+               (begin
+                 (trace-event! "tui-send-failed-detail"
+                               (safe-display (cdr outcome)))
+                 (trace-public-event!
+                   "tui-send-failed"
+                   (list (cons 'ms (- (real-time) started))
+                         (cons 'error-chars
+                               (string-length (safe-display (cdr outcome))))))
+                 (tui-state-resend-set! state text)
+                 (report-send-failure! state conv (cdr outcome)
+                                       "Ctrl-R restores the message.")))))])))
 
   ;; Ctrl-R: put the last failed message back in the composer. Resending is a
   ;; deliberate two-step (restore, then Enter), never an accident.