Log failure-notification timing and plan faster Windows toasts.

Append app-side delays to logs/notify-timing.log, add a PowerShell baseline
script (~773 ms), and track native WinRT toasts in ROADMAP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 23:15:44 +03:00
parent 0aab9d8db6
commit 1e3d14bef2
6 changed files with 210 additions and 3 deletions
+19 -3
View File
@@ -13,6 +13,8 @@ import (
"fyne.io/fyne/v2/theme"
)
const runRecordTimeLayout = "2006-01-02 15:04:05"
// The UI package aliases domain types to keep widget callbacks short. The actual
// durable model still lives in src/domain, so UI code does not define a second
// copy of the scheduler data.
@@ -73,9 +75,23 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
if r.State == "Failed" &&
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
svc.ShouldNotifyOnFailure() {
fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: "GoSentry: Job Failed",
Content: r.JobName + ": " + r.Detail,
timing := notificationTiming{
JobName: r.JobName,
EmittedAt: time.Now(),
}
if finished, err := time.ParseInLocation(runRecordTimeLayout, r.Time, time.Local); err == nil {
timing.RunFinished = finished
}
fyne.Do(func() {
timing.UIQueuedAt = time.Now()
fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: "GoSentry: Job Failed",
Content: r.JobName + ": " + r.Detail,
})
timing.AfterSendAt = time.Now()
if err := appendNotificationTimingLog(svc.Store().Paths.LogsDir, timing); err != nil {
fyne.LogError("Failed to write notification timing log", err)
}
})
}
}
+69
View File
@@ -0,0 +1,69 @@
package ui
import (
"fmt"
"os"
"path/filepath"
"time"
)
const notificationTimingLogName = "notify-timing.log"
// notificationTiming captures wall-clock points from a failed run through
// SendNotification. It does not include OS toast display latency — Fyne on
// Windows shows toasts via a separate PowerShell process after SendNotification
// returns.
type notificationTiming struct {
JobName string
RunFinished time.Time
EmittedAt time.Time
UIQueuedAt time.Time
AfterSendAt time.Time
}
func (t notificationTiming) formatLine() string {
return fmt.Sprintf(
"%s\tjob=%s\tms_after_run=%s\tms_fyne_do=%s\tms_send=%s\tms_app_total=%s\n",
t.AfterSendAt.Format(time.RFC3339Nano),
t.JobName,
msBetween(t.RunFinished, t.EmittedAt),
msBetween(t.EmittedAt, t.UIQueuedAt),
msBetween(t.UIQueuedAt, t.AfterSendAt),
msBetween(t.EmittedAt, t.AfterSendAt),
)
}
func msBetween(from, to time.Time) string {
if from.IsZero() || to.IsZero() || to.Before(from) {
return "-"
}
return fmt.Sprintf("%d", to.Sub(from).Milliseconds())
}
func appendNotificationTimingLog(logsDir string, timing notificationTiming) error {
if logsDir == "" {
return nil
}
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return err
}
path := filepath.Join(logsDir, notificationTimingLogName)
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
if info.Size() == 0 {
if _, err := file.WriteString("# GoSentry failure-notification timing (app side only; OS toast delay is not included)\n" +
"# columns: timestamp job ms_after_run ms_fyne_do ms_send ms_app_total\n"); err != nil {
return err
}
}
_, err = file.WriteString(timing.formatLine())
return err
}
+59
View File
@@ -0,0 +1,59 @@
package ui
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNotificationTimingFormatLine(t *testing.T) {
runFinished := time.Date(2026, 8, 5, 23, 0, 0, 0, time.Local)
emitted := runFinished.Add(15 * time.Millisecond)
uiQueued := emitted.Add(4 * time.Millisecond)
afterSend := uiQueued.Add(2 * time.Millisecond)
line := notificationTiming{
JobName: "Failure notification test",
RunFinished: runFinished,
EmittedAt: emitted,
UIQueuedAt: uiQueued,
AfterSendAt: afterSend,
}.formatLine()
if !strings.Contains(line, "job=Failure notification test") {
t.Fatalf("line = %q, want job name", line)
}
for _, want := range []string{"ms_after_run=15", "ms_fyne_do=4", "ms_send=2", "ms_app_total=6"} {
if !strings.Contains(line, want) {
t.Fatalf("line = %q, want substring %q", line, want)
}
}
}
func TestAppendNotificationTimingLogWritesHeaderAndRow(t *testing.T) {
dir := t.TempDir()
timing := notificationTiming{
JobName: "demo",
RunFinished: time.Now().Add(-10 * time.Millisecond),
EmittedAt: time.Now().Add(-5 * time.Millisecond),
UIQueuedAt: time.Now().Add(-2 * time.Millisecond),
AfterSendAt: time.Now(),
}
if err := appendNotificationTimingLog(dir, timing); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, notificationTimingLogName))
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.HasPrefix(text, "# GoSentry failure-notification timing") {
t.Fatalf("log = %q, want header", text)
}
if !strings.Contains(text, "job=demo") {
t.Fatalf("log = %q, want timing row", text)
}
}