Skip to content

Commit 80526e0

Browse files
chore(telemetry): track upload phase + per-phase sub-progress
1 parent a24d4cf commit 80526e0

8 files changed

Lines changed: 277 additions & 22 deletions

File tree

internal/detector/nodescan.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,23 @@ type NodeScanner struct {
3434
exec executor.Executor
3535
log *progress.Logger
3636
loggedInUser string // when non-empty and running as root, commands run as this user
37+
// ProgressHook, when non-nil, is invoked from inside ScanProjects /
38+
// ScanGlobalPackages with a short human-readable detail string ("project
39+
// 12 of 47", "scanning yarn", ...). Telemetry plumbs this into
40+
// PhaseTracker.UpdateDetail so heartbeats surface mid-phase progress.
41+
ProgressHook func(detail string)
3742
}
3843

3944
func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser string) *NodeScanner {
4045
return &NodeScanner{exec: exec, log: log, loggedInUser: loggedInUser}
4146
}
4247

48+
func (s *NodeScanner) emitProgress(detail string) {
49+
if s.ProgressHook != nil {
50+
s.ProgressHook(detail)
51+
}
52+
}
53+
4354
// shouldRunAsUser returns true when commands should be delegated to the logged-in user.
4455
// Only applies on Unix — RunAsUser uses sudo which is not available on Windows.
4556
func (s *NodeScanner) shouldRunAsUser() bool {
@@ -107,16 +118,19 @@ func (s *NodeScanner) checkPath(ctx context.Context, name string) error {
107118
func (s *NodeScanner) ScanGlobalPackages(ctx context.Context) []model.NodeScanResult {
108119
var results []model.NodeScanResult
109120

121+
s.emitProgress("global: npm")
110122
s.log.Progress(" Checking npm global packages...")
111123
if r, ok := s.scanNPMGlobal(ctx); ok {
112124
results = append(results, r)
113125
}
114126

127+
s.emitProgress("global: yarn")
115128
s.log.Progress(" Checking yarn global packages...")
116129
if r, ok := s.scanYarnGlobal(ctx); ok {
117130
results = append(results, r)
118131
}
119132

133+
s.emitProgress("global: pnpm")
120134
s.log.Progress(" Checking pnpm global packages...")
121135
if r, ok := s.scanPnpmGlobal(ctx); ok {
122136
results = append(results, r)
@@ -288,6 +302,10 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
288302
var results []model.NodeScanResult
289303
totalSize := int64(0)
290304

305+
totalProjects := len(projects)
306+
if totalProjects > maxNodeProjects {
307+
totalProjects = maxNodeProjects
308+
}
291309
for i, p := range projects {
292310
if i >= maxNodeProjects {
293311
s.log.Progress(" Reached maximum of %d projects, stopping search", maxNodeProjects)
@@ -300,6 +318,11 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
300318
break
301319
}
302320

321+
// Per-project sub-progress for the heartbeat goroutine. Surfaces
322+
// to console as "current_phase_detail: project 12 of 47" so a
323+
// stuck scan is visibly so, not just opaque "node_scan in progress".
324+
s.emitProgress(fmt.Sprintf("project %d of %d", i+1, totalProjects))
325+
303326
s.log.Progress(" Found project: %s", p.dir)
304327
pm := DetectProjectPM(s.exec, p.dir)
305328
s.log.Progress(" Package manager: %s", pm)

internal/detector/pythonscan.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,23 @@ import (
1515
type PythonScanner struct {
1616
exec executor.Executor
1717
log *progress.Logger
18+
// ProgressHook, when non-nil, is invoked from inside ScanGlobalPackages
19+
// with a short human-readable detail string ("scanning pip3", ...).
20+
// Telemetry plumbs this into PhaseTracker.UpdateDetail so heartbeats
21+
// surface mid-phase progress.
22+
ProgressHook func(detail string)
1823
}
1924

2025
func NewPythonScanner(exec executor.Executor, log *progress.Logger) *PythonScanner {
2126
return &PythonScanner{exec: exec, log: log}
2227
}
2328

29+
func (s *PythonScanner) emitProgress(detail string) {
30+
if s.ProgressHook != nil {
31+
s.ProgressHook(detail)
32+
}
33+
}
34+
2435
type pythonScanSpec struct {
2536
binary string
2637
name string
@@ -49,6 +60,7 @@ func (s *PythonScanner) ScanGlobalPackages(ctx context.Context) []model.PythonSc
4960
continue
5061
}
5162

63+
s.emitProgress("scanning " + spec.name)
5264
s.log.Progress(" Checking %s global packages...", spec.name)
5365
version := s.getVersion(ctx, spec.binary, spec.versionCmd)
5466

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package telemetry
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
// TestHeartbeatShutdown_NoDeadlock mirrors the cancel-then-wait pattern
10+
// Run() uses to shut down its heartbeat goroutine. Two `defer` statements
11+
// (cancel + wait) would deadlock under LIFO ordering — wait runs first,
12+
// blocks on the goroutine, and cancel never fires. Combining them into a
13+
// single deferred function is the fix; this test pins it down.
14+
func TestHeartbeatShutdown_NoDeadlock(t *testing.T) {
15+
ctx, cancel := context.WithCancel(context.Background())
16+
done := make(chan struct{})
17+
18+
go func() {
19+
defer close(done)
20+
ticker := time.NewTicker(50 * time.Millisecond)
21+
defer ticker.Stop()
22+
for {
23+
select {
24+
case <-ctx.Done():
25+
return
26+
case <-ticker.C:
27+
// no-op, mimics postPhase
28+
}
29+
}
30+
}()
31+
32+
// This is the load-bearing pattern from Run(): cancel first, THEN wait.
33+
// If a future refactor splits these into separate `defer` statements at
34+
// the top level of Run(), the LIFO ordering will deadlock.
35+
shutdownStart := time.Now()
36+
func() {
37+
defer func() {
38+
cancel()
39+
<-done
40+
}()
41+
}()
42+
elapsed := time.Since(shutdownStart)
43+
44+
// 50ms ticker + small scheduler overhead; 1s is generous and signals a
45+
// hang clearly if the pattern ever regresses.
46+
if elapsed > time.Second {
47+
t.Fatalf("heartbeat shutdown took %s — likely deadlocked on defer ordering", elapsed)
48+
}
49+
}

internal/telemetry/phase_tracker.go

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ type PhaseCompletion struct {
1717
// boundary and on every heartbeat tick. The same struct is embedded on the
1818
// final telemetry Payload so a stored telemetry record is self-describing
1919
// without joining to the run-status table.
20+
//
21+
// In-flight sub-progress (set via PhaseTracker.UpdateDetail) is folded into
22+
// CurrentPhase as "<phase> (<detail>)" rather than carried in a separate
23+
// field, so older backends that don't know about phase-detail still surface
24+
// the string verbatim. Completed phases keep their base name only.
2025
type RunStatusInfo struct {
2126
PhasesCompleted []PhaseCompletion `json:"phases_completed,omitempty"`
2227
CurrentPhase string `json:"current_phase,omitempty"`
@@ -28,12 +33,13 @@ type RunStatusInfo struct {
2833
// concurrently — Snapshot returns a defensive copy so the caller never
2934
// observes a torn slice while a phase is appended.
3035
type PhaseTracker struct {
31-
mu sync.Mutex
32-
startedAt time.Time
33-
phaseStartedAt time.Time
34-
currentPhase string
35-
completed []PhaseCompletion
36-
now func() time.Time // overridable for tests
36+
mu sync.Mutex
37+
startedAt time.Time
38+
phaseStartedAt time.Time
39+
currentPhase string
40+
currentPhaseDetail string
41+
completed []PhaseCompletion
42+
now func() time.Time // overridable for tests
3743
}
3844

3945
// NewPhaseTracker constructs a tracker anchored at the current time.
@@ -51,7 +57,8 @@ func newPhaseTrackerWithClock(now func() time.Time) *PhaseTracker {
5157
// Start records the beginning of a new phase. Calling Start while another
5258
// phase is already in flight implicitly finishes the previous one — this
5359
// keeps call sites tidy when phases run back-to-back without a Finish in
54-
// between.
60+
// between. Detail from the previous phase is cleared so it never leaks
61+
// into the new one.
5562
func (t *PhaseTracker) Start(phase string) {
5663
t.mu.Lock()
5764
defer t.mu.Unlock()
@@ -60,6 +67,7 @@ func (t *PhaseTracker) Start(phase string) {
6067
t.finishLocked()
6168
}
6269
t.currentPhase = phase
70+
t.currentPhaseDetail = ""
6371
t.phaseStartedAt = t.now()
6472
}
6573

@@ -82,17 +90,42 @@ func (t *PhaseTracker) finishLocked() {
8290
DurationMs: finishedAt.Sub(t.phaseStartedAt).Milliseconds(),
8391
})
8492
t.currentPhase = ""
93+
t.currentPhaseDetail = ""
94+
}
95+
96+
// UpdateDetail sets a free-form sub-progress string for the current
97+
// phase ("project 12 of 47", "scanning pip3", ...). No-op when no phase
98+
// is in flight — keeps call sites tidy when a scanner reports progress
99+
// from inside a goroutine that may outlive its enclosing Start/Finish.
100+
func (t *PhaseTracker) UpdateDetail(detail string) {
101+
t.mu.Lock()
102+
defer t.mu.Unlock()
103+
if t.currentPhase == "" {
104+
return
105+
}
106+
t.currentPhaseDetail = detail
85107
}
86108

87109
// Snapshot returns a copy of the tracker state safe for marshalling on
88110
// another goroutine. The returned slice is independent of the tracker's
89111
// internal buffer.
112+
//
113+
// When the in-flight phase has a detail set, it's folded into CurrentPhase
114+
// as "<phase> (<detail>)" so the wire format stays flat — older backends
115+
// without a dedicated detail field still render the progress verbatim.
116+
// PhasesCompleted entries keep their base name; detail is per-tick state,
117+
// not a permanent label.
90118
func (t *PhaseTracker) Snapshot() RunStatusInfo {
91119
t.mu.Lock()
92120
defer t.mu.Unlock()
93121

122+
current := t.currentPhase
123+
if current != "" && t.currentPhaseDetail != "" {
124+
current = current + " (" + t.currentPhaseDetail + ")"
125+
}
126+
94127
out := RunStatusInfo{
95-
CurrentPhase: t.currentPhase,
128+
CurrentPhase: current,
96129
ElapsedMs: t.now().Sub(t.startedAt).Milliseconds(),
97130
}
98131
if len(t.completed) > 0 {

internal/telemetry/phase_tracker_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,74 @@ func TestPhaseTracker_SnapshotIsDefensiveCopy(t *testing.T) {
108108
}
109109
}
110110

111+
func TestPhaseTracker_UpdateDetailFoldsIntoCurrentPhase(t *testing.T) {
112+
pt := NewPhaseTracker()
113+
114+
// Detail before Start is a no-op — keeps callers from leaking stale
115+
// per-phase strings into the next phase.
116+
pt.UpdateDetail("dropped")
117+
if got := pt.Snapshot(); got.CurrentPhase != "" {
118+
t.Errorf("detail before Start should be ignored, got %q", got.CurrentPhase)
119+
}
120+
121+
pt.Start("brew_scan")
122+
if got := pt.Snapshot(); got.CurrentPhase != "brew_scan" {
123+
t.Errorf("no detail yet: current_phase = %q, want %q", got.CurrentPhase, "brew_scan")
124+
}
125+
126+
pt.UpdateDetail("fetching formulae")
127+
if got := pt.Snapshot(); got.CurrentPhase != "brew_scan (fetching formulae)" {
128+
t.Errorf("with detail: current_phase = %q, want %q",
129+
got.CurrentPhase, "brew_scan (fetching formulae)")
130+
}
131+
132+
pt.UpdateDetail("fetching casks")
133+
if got := pt.Snapshot(); got.CurrentPhase != "brew_scan (fetching casks)" {
134+
t.Errorf("detail should overwrite, got %q", got.CurrentPhase)
135+
}
136+
}
137+
138+
func TestPhaseTracker_StartClearsPreviousDetail(t *testing.T) {
139+
pt := NewPhaseTracker()
140+
141+
pt.Start("a")
142+
pt.UpdateDetail("a-detail")
143+
pt.Start("b") // implicit finish of a; detail must reset
144+
if got := pt.Snapshot(); got.CurrentPhase != "b" {
145+
t.Errorf("detail should reset on next phase, got current_phase = %q", got.CurrentPhase)
146+
}
147+
}
148+
149+
func TestPhaseTracker_FinishClearsDetail(t *testing.T) {
150+
pt := NewPhaseTracker()
151+
152+
pt.Start("a")
153+
pt.UpdateDetail("in-flight")
154+
pt.Finish()
155+
if got := pt.Snapshot(); got.CurrentPhase != "" {
156+
t.Errorf("detail should clear on Finish, got current_phase = %q", got.CurrentPhase)
157+
}
158+
}
159+
160+
// Completed phases keep their base name only — detail is per-tick state,
161+
// not a permanent label baked into history.
162+
func TestPhaseTracker_CompletedPhasesKeepBaseName(t *testing.T) {
163+
pt := NewPhaseTracker()
164+
165+
pt.Start("node_scan")
166+
pt.UpdateDetail("project 5 of 10")
167+
pt.Finish()
168+
169+
snap := pt.Snapshot()
170+
if len(snap.PhasesCompleted) != 1 {
171+
t.Fatalf("phases_completed = %d, want 1", len(snap.PhasesCompleted))
172+
}
173+
if snap.PhasesCompleted[0].Name != "node_scan" {
174+
t.Errorf("completed name = %q, want bare %q without detail",
175+
snap.PhasesCompleted[0].Name, "node_scan")
176+
}
177+
}
178+
111179
func TestPhaseTracker_ConcurrentReadDuringWrite(t *testing.T) {
112180
// Race detector must report clean. Spawn a writer that flips phases and
113181
// a reader (mimicking the heartbeat goroutine) that snapshots repeatedly.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package telemetry
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// Verifies the wiring contract telemetry.Run() relies on: a ProgressHook
8+
// closure that calls tracker.UpdateDetail surfaces the detail through the
9+
// next Snapshot's CurrentPhase field (folded as "<phase> (<detail>)").
10+
// Keeps the scanner / tracker integration honest even when the real
11+
// scanner tests live in package detector.
12+
func TestProgressHook_PlumbsDetailIntoSnapshot(t *testing.T) {
13+
tracker := NewPhaseTracker()
14+
tracker.Start("node_scan")
15+
16+
// Simulate the closure telemetry.Run() installs on NodeScanner.ProgressHook.
17+
hook := func(detail string) { tracker.UpdateDetail(detail) }
18+
19+
hook("project 1 of 47")
20+
if got := tracker.Snapshot().CurrentPhase; got != "node_scan (project 1 of 47)" {
21+
t.Fatalf("after hook: current_phase = %q, want %q", got, "node_scan (project 1 of 47)")
22+
}
23+
24+
hook("project 47 of 47")
25+
if got := tracker.Snapshot().CurrentPhase; got != "node_scan (project 47 of 47)" {
26+
t.Fatalf("after second hook call: current_phase = %q", got)
27+
}
28+
29+
tracker.Finish()
30+
if got := tracker.Snapshot().CurrentPhase; got != "" {
31+
t.Fatalf("after Finish: current_phase = %q, want empty", got)
32+
}
33+
}

0 commit comments

Comments
 (0)