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:
+19
-3
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user