Skip to content

Commit 5ac0f27

Browse files
authored
feat(proxy): emit session summary on persistent proxy shutdown (#352)
* feat(proxy): emit session summary on persistent proxy shutdown The persistent proxy daemon never ran the per-invocation flow that calls LogSessionComplete, so no session summary reached the cloud for server-mode runs. Emit one from the daemon's aggregate stats collector on shutdown, before the final cloud flush, so it is delivered with the run's events and carries the CI/invocation context. Extract the event emission into a shared LogSessionSummary(SessionData); both the per-invocation flow and the daemon now use it. The daemon serves every package manager, so the summary carries no single package manager. * chore(action): shorten default cloud endpoint id prefix to gha/
1 parent c47776d commit 5ac0f27

4 files changed

Lines changed: 100 additions & 24 deletions

File tree

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ runs:
283283
# Stable per-repo identifier; the runner hostname is ephemeral
284284
# so PMG's hostname fallback would create a new "machine" entry
285285
# in SafeDep Cloud for every job.
286-
export_var PMG_CLOUD_ENDPOINT_ID "github-actions/${GH_REPOSITORY}"
286+
export_var PMG_CLOUD_ENDPOINT_ID "gha/${GH_REPOSITORY}"
287287
fi
288288
fi
289289

internal/audit/audit.go

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -276,25 +276,37 @@ func LogSessionComplete(outcome Outcome, flowType FlowType) {
276276

277277
cfg := config.Get()
278278

279+
LogSessionSummary(SessionData{
280+
PackageManager: s.packageManager,
281+
FlowType: flowType,
282+
Outcome: outcome,
283+
TotalAnalyzed: s.totalAnalyzed,
284+
AllowedCount: s.allowedCount,
285+
BlockedCount: s.blockedCount,
286+
ConfirmedCount: s.confirmedCount,
287+
TrustedSkipped: s.trustedSkipped,
288+
InsecureBypassed: s.insecureBypassed,
289+
CooldownBlockedCount: s.cooldownBlockedCount,
290+
Duration: time.Since(s.startTime),
291+
SandboxEnabled: cfg.Config.Sandbox.Enabled,
292+
ParanoidMode: cfg.Config.Paranoid,
293+
TransitiveEnabled: cfg.Config.Transitive,
294+
})
295+
}
296+
297+
// LogSessionSummary emits a session-complete audit event from explicit session
298+
// data. The persistent proxy daemon uses this because it aggregates run stats in
299+
// a stats collector rather than the per-invocation audit session that
300+
// LogSessionComplete reads from.
301+
func LogSessionSummary(data SessionData) {
302+
if global == nil {
303+
return
304+
}
305+
279306
logEvent(AuditEvent{
280-
Type: EventTypeSessionComplete,
281-
Message: fmt.Sprintf("Session complete: %s", outcome),
282-
SessionData: &SessionData{
283-
PackageManager: s.packageManager,
284-
FlowType: flowType,
285-
Outcome: outcome,
286-
TotalAnalyzed: s.totalAnalyzed,
287-
AllowedCount: s.allowedCount,
288-
BlockedCount: s.blockedCount,
289-
ConfirmedCount: s.confirmedCount,
290-
TrustedSkipped: s.trustedSkipped,
291-
InsecureBypassed: s.insecureBypassed,
292-
CooldownBlockedCount: s.cooldownBlockedCount,
293-
Duration: time.Since(s.startTime),
294-
SandboxEnabled: cfg.Config.Sandbox.Enabled,
295-
ParanoidMode: cfg.Config.Paranoid,
296-
TransitiveEnabled: cfg.Config.Transitive,
297-
},
307+
Type: EventTypeSessionComplete,
308+
Message: fmt.Sprintf("Session complete: %s", data.Outcome),
309+
SessionData: &data,
298310
})
299311
}
300312

internal/audit/audit_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,37 @@ func TestLogSessionCompleteSilentWhenNotInitialized(t *testing.T) {
273273
LogSessionComplete(OutcomeSuccess, FlowTypeGuard)
274274
}
275275

276+
func TestLogSessionSummaryDispatchesEvent(t *testing.T) {
277+
s := &mockSink{}
278+
setGlobal(newAuditor(s))
279+
defer resetGlobal()
280+
281+
// The persistent proxy daemon serves every package manager, so the summary
282+
// carries no single one.
283+
LogSessionSummary(SessionData{
284+
FlowType: FlowTypeProxy,
285+
Outcome: OutcomeBlocked,
286+
TotalAnalyzed: 3,
287+
BlockedCount: 1,
288+
AllowedCount: 2,
289+
})
290+
291+
events := s.getEvents()
292+
require.Len(t, events, 1)
293+
assert.Equal(t, EventTypeSessionComplete, events[0].Type)
294+
require.NotNil(t, events[0].SessionData)
295+
assert.Empty(t, events[0].SessionData.PackageManager)
296+
assert.Equal(t, FlowTypeProxy, events[0].SessionData.FlowType)
297+
assert.Equal(t, OutcomeBlocked, events[0].SessionData.Outcome)
298+
assert.Equal(t, uint32(1), events[0].SessionData.BlockedCount)
299+
}
300+
301+
func TestLogSessionSummarySilentWhenNotInitialized(t *testing.T) {
302+
resetGlobal()
303+
// Should not panic
304+
LogSessionSummary(SessionData{Outcome: OutcomeSuccess})
305+
}
306+
276307
// TestUIOutcomesMappToAuditOutcomes ensures every ui.ExecutionOutcome has a
277308
// corresponding audit.Outcome constant. If someone adds a new outcome to the
278309
// UI layer without updating the audit package, this test will fail.

internal/proxyserver/server.go

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func Run(ctx context.Context, cfg *config.RuntimeConfig, statePath, host string,
6767
return fmt.Errorf("proxy already running (pid %d, addr %s) — run 'pmg proxy stop' first", existing.PID, existing.Addr)
6868
}
6969

70+
startTime := time.Now()
71+
7072
caCertPath := certmanager.ProxyCABundlePath(cfg.ConfigDir())
7173
caCert, _, err := flows.SetupCACertificate(cfg.ConfigDir(), caCertPath)
7274
if err != nil {
@@ -91,12 +93,12 @@ func Run(ctx context.Context, cfg *config.RuntimeConfig, statePath, host string,
9193
}
9294

9395
cache := interceptors.NewInMemoryAnalysisCache()
94-
stats := interceptors.NewAnalysisStatsCollector()
96+
statsCollector := interceptors.NewAnalysisStatsCollector()
9597
confirmationChan := make(chan *interceptors.ConfirmationRequest, 100)
9698
go autoBlockConfirmations(confirmationChan)
9799

98100
factory := interceptors.NewInterceptorFactory(
99-
malysisAnalyzer, cache, stats, confirmationChan, interceptors.InterceptorContext{},
101+
malysisAnalyzer, cache, statsCollector, confirmationChan, interceptors.InterceptorContext{},
100102
)
101103

102104
var interceptorList []pmgproxy.Interceptor
@@ -158,15 +160,22 @@ func Run(ctx context.Context, cfg *config.RuntimeConfig, statePath, host string,
158160

159161
close(confirmationChan)
160162

161-
// Count is read after drain so a package analyzed at shutdown is not missed.
162-
// Persist it BEFORE the (possibly slow) cloud flush so the blocked count
163+
// Stats are read after drain so a package analyzed at shutdown is not missed.
164+
// Persist the blocked count BEFORE the (possibly slow) cloud flush so it
163165
// survives even if the flush hangs or the daemon is killed mid-flush, which
164166
// keeps `stop --fail-on-violation` correct in those cases.
165-
state.BlockedCount = stats.GetStats().BlockedCount
167+
stats := statsCollector.GetStats()
168+
state.BlockedCount = stats.BlockedCount
166169
if werr := writeState(statePath, state); werr != nil {
167170
log.Warnf("failed to write final proxy state: %v", werr)
168171
}
169172

173+
// Emit the daemon-lifetime session summary before the final flush so it is
174+
// delivered alongside the run's other events. Unlike the per-invocation flow,
175+
// the daemon serves every package manager, so the summary carries no single
176+
// package manager.
177+
logSessionSummary(cfg, stats, time.Since(startTime))
178+
170179
// Halt the periodic sync (waits for any in-flight drain) before the final
171180
// flush, so the two never hold the sync lock at once.
172181
periodicSynced := stopSyncLoop()
@@ -181,6 +190,30 @@ func Run(ctx context.Context, cfg *config.RuntimeConfig, statePath, host string,
181190
return stopErr
182191
}
183192

193+
// logSessionSummary emits an aggregate session-complete audit event for the
194+
// daemon's lifetime, mapping the proxy stats collector onto SessionData. The
195+
// outcome is blocked when anything was blocked, otherwise success.
196+
func logSessionSummary(cfg *config.RuntimeConfig, stats interceptors.AnalysisStats, duration time.Duration) {
197+
outcome := audit.OutcomeSuccess
198+
if stats.BlockedCount > 0 {
199+
outcome = audit.OutcomeBlocked
200+
}
201+
202+
audit.LogSessionSummary(audit.SessionData{
203+
FlowType: audit.FlowTypeProxy,
204+
Outcome: outcome,
205+
TotalAnalyzed: uint32(stats.TotalAnalyzed),
206+
AllowedCount: uint32(stats.AllowedCount),
207+
BlockedCount: uint32(stats.BlockedCount),
208+
ConfirmedCount: uint32(stats.ConfirmedCount),
209+
CooldownBlockedCount: uint32(stats.CooldownBlockedCount),
210+
Duration: duration,
211+
SandboxEnabled: cfg.Config.Sandbox.Enabled,
212+
ParanoidMode: cfg.Config.Paranoid,
213+
TransitiveEnabled: cfg.Config.Transitive,
214+
})
215+
}
216+
184217
// cloudFlush drains whatever the periodic sync left and returns the outcome
185218
// (total delivered, including periodicSynced). Returns nil when automatic cloud
186219
// delivery is off (cloud or auto-sync disabled) — same gate as the periodic

0 commit comments

Comments
 (0)