feat: record launch latency in statistics for start-only jobs

StartOnly jobs previously forced DurationMS to 0 because GoSentry does
not wait for the process to exit, leaving the Statistics line stuck at
"last 0 ms, avg 0 ms". The runner already measures launch latency (time
to spawn the process) for the History detail; this now returns that
value as the run duration so the existing duration-driven stats pipeline
folds it into last/avg/max.

Sub-millisecond launches still round to 0 and are excluded from the
average, matching prior behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-30 00:05:01 +03:00
parent 00991f24a5
commit 6ea6578acb
4 changed files with 23 additions and 11 deletions
+4 -3
View File
@@ -89,8 +89,9 @@ flowchart LR
`runner.RunJob` builds the platform-specific invocation, executes the `runner.RunJob` builds the platform-specific invocation, executes the
command through the platform shell, captures stdout and stderr, writes one command through the platform shell, captures stdout and stderr, writes one
timestamped `.log` file, and returns a `domain.RunRecord` containing timestamped `.log` file, and returns a `domain.RunRecord` containing
`DurationMS` (wall-clock milliseconds from start to finish; 0 for `DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly`
`StartOnly` fire-and-forget jobs). fire-and-forget jobs it measures launch latency — the time to spawn the
process — since there is no exit to wait for).
6. History update: 6. History update:
When a run goroutine completes, `Service` updates the job's runtime When a run goroutine completes, `Service` updates the job's runtime
@@ -132,7 +133,7 @@ in flight increments `JobRuntime.PendingRuns`. When the current run finishes,
|-------|---------| |-------|---------|
| `RunCount` | total runs recorded | | `RunCount` | total runs recorded |
| `FailCount` | runs that exited non-zero | | `FailCount` | runs that exited non-zero |
| `LastDurationMS` | wall-clock time of the most recent run | | `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) |
| `AvgDurationMS` | mean over all runs with a recorded duration | | `AvgDurationMS` | mean over all runs with a recorded duration |
| `MaxDurationMS` | longest recorded run | | `MaxDurationMS` | longest recorded run |
+8
View File
@@ -2,6 +2,14 @@
All notable GoSentry changes are recorded in this file. All notable GoSentry changes are recorded in this file.
## Unreleased
**Statistics:**
- `StartOnly` jobs now record launch latency (time to spawn the process) as the
run duration instead of a hard-coded `0`, so the Statistics line shows a real
last/avg/max for fire-and-forget jobs. Sub-millisecond launches still round to
0 and are excluded from the average, as before.
## 0.11.3 - 2026-06-29 ## 0.11.3 - 2026-06-29
**Reliability fixes from an internal code review: safer runs, a real overlap **Reliability fixes from an internal code review: safer runs, a real overlap
+3 -1
View File
@@ -32,7 +32,9 @@ type JobRuntime struct {
AvgDurationMS int64 AvgDurationMS int64
MaxDurationMS int64 MaxDurationMS int64
// TimedRunCount is the number of runs that contributed to AvgDurationMS. // TimedRunCount is the number of runs that contributed to AvgDurationMS.
// StartOnly and legacy duration-less runs increment RunCount but not this. // Runs with no recorded duration (legacy logs, or sub-millisecond StartOnly
// launches that round to 0) increment RunCount but not this. StartOnly runs
// otherwise contribute their launch latency.
TimedRunCount int TimedRunCount int
} }
+8 -7
View File
@@ -30,9 +30,9 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
var durationMS int64 var durationMS int64
if job.StartOnly { if job.StartOnly {
invocation := jobInvocation(ctx, *job) invocation := jobInvocation(ctx, *job)
state, detail, output = startJobOnly(invocation, *job, started) // StartOnly jobs don't wait for process exit, so the duration measures
// StartOnly jobs don't wait for process exit, so no meaningful duration. // launch latency (time to spawn the process) rather than run time.
durationMS = 0 state, detail, output, durationMS = startJobOnly(invocation, *job, started)
} else { } else {
var stdoutBuf strings.Builder var stdoutBuf strings.Builder
var stderrBuf strings.Builder var stderrBuf strings.Builder
@@ -72,21 +72,22 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
}, logErr }, logErr
} }
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) { func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string, int64) {
command := invocation.command command := invocation.command
if invocation.hideWindow { if invocation.hideWindow {
winproc.ConfigureHiddenWindow(command) winproc.ConfigureHiddenWindow(command)
} }
err := command.Start() err := command.Start()
duration := time.Since(started).Round(time.Millisecond) duration := time.Since(started).Round(time.Millisecond)
durationMS := duration.Milliseconds()
if err != nil { if err != nil {
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0) return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0), durationMS
} }
pid := command.Process.Pid pid := command.Process.Pid
if releaseErr := command.Process.Release(); releaseErr != nil { if releaseErr := command.Process.Release(); releaseErr != nil {
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid) return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid), durationMS
} }
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid) return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid), durationMS
} }
func startOnlyOutput(job domain.Job, pid int) string { func startOnlyOutput(job domain.Job, pid int) string {