Skip to content

Commit a6b802f

Browse files
committed
chore: Send instrumentation data on kill
chore: ensure to kill CLI processes
1 parent 017e570 commit a6b802f

File tree

13 files changed

+404
-131
lines changed

13 files changed

+404
-131
lines changed

cliv2/cmd/cliv2/instrumentation.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@ package main
44
import _ "github.com/snyk/go-application-framework/pkg/networking/fips_enable"
55

66
import (
7+
"context"
8+
"encoding/json"
79
"os/exec"
10+
"strconv"
811
"strings"
912
"time"
1013

14+
"github.com/rs/zerolog"
1115
"github.com/snyk/go-application-framework/pkg/analytics"
1216
"github.com/snyk/go-application-framework/pkg/configuration"
1317
"github.com/snyk/go-application-framework/pkg/instrumentation"
1418

19+
"github.com/snyk/cli/cliv2/internal/constants"
1520
cli_utils "github.com/snyk/cli/cliv2/internal/utils"
1621

1722
localworkflows "github.com/snyk/go-application-framework/pkg/local_workflows"
@@ -74,3 +79,76 @@ func updateInstrumentationDataBeforeSending(cliAnalytics analytics.Analytics, st
7479
cliAnalytics.GetInstrumentation().SetStatus(analytics.Failure)
7580
}
7681
}
82+
83+
func sendAnalytics(ctx context.Context, a analytics.Analytics, debugLogger *zerolog.Logger) {
84+
debugLogger.Print("Sending Analytics")
85+
86+
a.SetApiUrl(globalConfiguration.GetString(configuration.API_URL))
87+
88+
request, err := a.GetRequest()
89+
if err != nil {
90+
debugLogger.Err(err).Msg("Failed to create Analytics request")
91+
return
92+
}
93+
94+
// Use context to respect teardown timeout
95+
request = request.WithContext(ctx)
96+
97+
client := globalEngine.GetNetworkAccess().GetHttpClient()
98+
res, err := client.Do(request)
99+
if err != nil {
100+
debugLogger.Err(err).Msg("Failed to send Analytics")
101+
return
102+
}
103+
defer func() {
104+
_ = res.Body.Close()
105+
}()
106+
107+
successfullySend := 200 <= res.StatusCode && res.StatusCode < 300
108+
if successfullySend {
109+
debugLogger.Print("Analytics successfully send")
110+
} else {
111+
debugLogger.Print("Failed to send Analytics:", res.Status)
112+
}
113+
}
114+
115+
func sendInstrumentation(ctx context.Context, eng workflow.Engine, instrumentor analytics.InstrumentationCollector, logger *zerolog.Logger) {
116+
// Avoid duplicate data to be sent for IDE integrations that use the CLI
117+
if !shallSendInstrumentation(eng.GetConfiguration(), instrumentor) {
118+
logger.Print("This CLI call is not instrumented!")
119+
return
120+
}
121+
122+
// add temporary static nodejs binary flag, remove once linuxstatic is official
123+
staticNodeJsBinaryBool, parseErr := strconv.ParseBool(constants.StaticNodeJsBinary)
124+
if parseErr != nil {
125+
logger.Print("Failed to parse staticNodeJsBinary:", parseErr)
126+
} else {
127+
// the legacycli:: prefix is added to maintain compatibility with our monitoring dashboard
128+
instrumentor.AddExtension("legacycli::static-nodejs-binary", staticNodeJsBinaryBool)
129+
}
130+
131+
logger.Print("Sending Instrumentation")
132+
data, err := analytics.GetV2InstrumentationObject(instrumentor, analytics.WithLogger(logger))
133+
if err != nil {
134+
logger.Err(err).Msg("Failed to derive data object")
135+
}
136+
137+
v2InstrumentationData := utils.ValueOf(json.Marshal(data))
138+
localConfiguration := globalConfiguration.Clone()
139+
// the report analytics workflow needs --experimental to run
140+
// we pass the flag here so that we report at every interaction
141+
localConfiguration.Set(configuration.FLAG_EXPERIMENTAL, true)
142+
localConfiguration.Set("inputData", string(v2InstrumentationData))
143+
_, err = eng.Invoke(
144+
localworkflows.WORKFLOWID_REPORT_ANALYTICS,
145+
workflow.WithConfig(localConfiguration),
146+
workflow.WithContext(ctx),
147+
)
148+
149+
if err != nil {
150+
logger.Err(err).Msg("Failed to send Instrumentation")
151+
} else {
152+
logger.Print("Instrumentation successfully sent")
153+
}
154+
}

cliv2/cmd/cliv2/main.go

Lines changed: 103 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import (
1111
"io"
1212
"os"
1313
"os/exec"
14-
"strconv"
14+
"os/signal"
1515
"strings"
1616
"sync"
17+
"syscall"
1718
"time"
1819

1920
"github.com/google/uuid"
@@ -75,6 +76,7 @@ import (
7576
var internalOS string
7677
var globalEngine workflow.Engine
7778
var globalConfiguration configuration.Configuration
79+
var globalContext context.Context
7880
var helpProvided bool
7981

8082
var noopLogger zerolog.Logger = zerolog.New(io.Discard)
@@ -88,6 +90,7 @@ const (
8890
debug_level_flag string = "log-level"
8991
integrationNameFlag string = "integration-name"
9092
maxNetworkRequestAttempts string = "max-attempts"
93+
teardownTimeout = 5 * time.Second
9194
)
9295

9396
type JsonErrorStruct struct {
@@ -194,98 +197,33 @@ func runMainWorkflow(config configuration.Configuration, cmd *cobra.Command, arg
194197
globalLogger.Print("Running ", name)
195198
globalEngine.GetAnalytics().SetCommand(name)
196199

197-
err = runWorkflowAndProcessData(globalEngine, globalLogger, name)
200+
err = runWorkflowAndProcessData(globalContext, globalEngine, globalLogger, name)
198201

199202
return err
200203
}
201204

202-
func runWorkflowAndProcessData(engine workflow.Engine, logger *zerolog.Logger, name string) error {
205+
func runWorkflowAndProcessData(ctx context.Context, engine workflow.Engine, logger *zerolog.Logger, name string) error {
203206
ic := engine.GetAnalytics().GetInstrumentation()
204207

205-
output, err := engine.Invoke(workflow.NewWorkflowIdentifier(name), workflow.WithInstrumentationCollector(ic))
208+
output, err := engine.Invoke(workflow.NewWorkflowIdentifier(name), workflow.WithContext(ctx), workflow.WithInstrumentationCollector(ic))
206209
if err != nil {
207210
logger.Print("Failed to execute the command! ", err)
208211
return err
209212
}
210213

211-
outputFiltered, err := engine.Invoke(localworkflows.WORKFLOWID_FILTER_FINDINGS, workflow.WithInput(output), workflow.WithInstrumentationCollector(ic))
214+
outputFiltered, err := engine.Invoke(localworkflows.WORKFLOWID_FILTER_FINDINGS, workflow.WithContext(ctx), workflow.WithInput(output), workflow.WithInstrumentationCollector(ic))
212215
if err != nil {
213216
logger.Err(err).Msg(err.Error())
214217
return err
215218
}
216219

217-
_, err = engine.Invoke(localworkflows.WORKFLOWID_OUTPUT_WORKFLOW, workflow.WithInput(outputFiltered), workflow.WithInstrumentationCollector(ic))
220+
_, err = engine.Invoke(localworkflows.WORKFLOWID_OUTPUT_WORKFLOW, workflow.WithContext(ctx), workflow.WithInput(outputFiltered), workflow.WithInstrumentationCollector(ic))
218221
if err == nil {
219222
err = getErrorFromWorkFlowData(engine, outputFiltered)
220223
}
221224
return err
222225
}
223226

224-
func sendAnalytics(analytics analytics.Analytics, debugLogger *zerolog.Logger) {
225-
debugLogger.Print("Sending Analytics")
226-
227-
analytics.SetApiUrl(globalConfiguration.GetString(configuration.API_URL))
228-
229-
res, err := analytics.Send()
230-
if err != nil {
231-
debugLogger.Err(err).Msg("Failed to send Analytics")
232-
return
233-
}
234-
defer func() { _ = res.Body.Close() }()
235-
236-
successfullySend := 200 <= res.StatusCode && res.StatusCode < 300
237-
if successfullySend {
238-
debugLogger.Print("Analytics successfully send")
239-
} else {
240-
var details string
241-
if res != nil {
242-
details = res.Status
243-
}
244-
245-
debugLogger.Print("Failed to send Analytics:", details)
246-
}
247-
}
248-
249-
func sendInstrumentation(eng workflow.Engine, instrumentor analytics.InstrumentationCollector, logger *zerolog.Logger) {
250-
// Avoid duplicate data to be sent for IDE integrations that use the CLI
251-
if !shallSendInstrumentation(eng.GetConfiguration(), instrumentor) {
252-
logger.Print("This CLI call is not instrumented!")
253-
return
254-
}
255-
256-
// add temporary static nodejs binary flag, remove once linuxstatic is official
257-
staticNodeJsBinaryBool, parseErr := strconv.ParseBool(constants.StaticNodeJsBinary)
258-
if parseErr != nil {
259-
logger.Print("Failed to parse staticNodeJsBinary:", parseErr)
260-
} else {
261-
// the legacycli:: prefix is added to maintain compatibility with our monitoring dashboard
262-
instrumentor.AddExtension("legacycli::static-nodejs-binary", staticNodeJsBinaryBool)
263-
}
264-
265-
logger.Print("Sending Instrumentation")
266-
data, err := analytics.GetV2InstrumentationObject(instrumentor, analytics.WithLogger(logger))
267-
if err != nil {
268-
logger.Err(err).Msg("Failed to derive data object")
269-
}
270-
271-
v2InstrumentationData := utils.ValueOf(json.Marshal(data))
272-
localConfiguration := globalConfiguration.Clone()
273-
// the report analytics workflow needs --experimental to run
274-
// we pass the flag here so that we report at every interaction
275-
localConfiguration.Set(configuration.FLAG_EXPERIMENTAL, true)
276-
localConfiguration.Set("inputData", string(v2InstrumentationData))
277-
_, err = eng.InvokeWithConfig(
278-
localworkflows.WORKFLOWID_REPORT_ANALYTICS,
279-
localConfiguration,
280-
)
281-
282-
if err != nil {
283-
logger.Err(err).Msg("Failed to send Instrumentation")
284-
} else {
285-
logger.Print("Instrumentation successfully sent")
286-
}
287-
}
288-
289227
func help(_ *cobra.Command, _ []string) error {
290228
helpProvided = true
291229
args := utils.RemoveSimilar(os.Args[1:], "--") // remove all double dash arguments to avoid issues with the help command
@@ -548,11 +486,55 @@ func initExtensions(engine workflow.Engine, config configuration.Configuration)
548486
}
549487
}
550488

489+
// tearDown handles sending analytics and instrumentation
490+
// It is used both for normal exit and signal-triggered exit
491+
func tearDown(ctx context.Context, err error, errorList []error, startTime time.Time, ua networking.UserAgentInfo, cliAnalytics analytics.Analytics, networkAccess networking.NetworkAccess) int {
492+
// Create a context with timeout for teardown operations to ensure we don't hang indefinitely
493+
teardownCtx, cancel := context.WithTimeout(ctx, teardownTimeout)
494+
defer cancel()
495+
496+
if err != nil {
497+
errorList, err = processError(err, errorList)
498+
499+
for _, tempError := range errorList {
500+
if tempError != nil {
501+
cliAnalytics.AddError(tempError)
502+
}
503+
}
504+
}
505+
506+
exitCode := cliv2.DeriveExitCode(err)
507+
globalLogger.Printf("Deriving Exit Code %d (cause: %v)", exitCode, err)
508+
509+
displayError(err, globalEngine.GetUserInterface(), globalConfiguration, teardownCtx)
510+
511+
updateInstrumentationDataBeforeSending(cliAnalytics, startTime, ua, exitCode)
512+
513+
if !globalConfiguration.GetBool(configuration.ANALYTICS_DISABLED) {
514+
sendAnalytics(teardownCtx, cliAnalytics, globalLogger)
515+
}
516+
sendInstrumentation(teardownCtx, globalEngine, cliAnalytics.GetInstrumentation(), globalLogger)
517+
518+
// cleanup resources in use
519+
// WARNING: deferred actions will execute AFTER cleanup; only defer if not impacted by this
520+
if _, cleanupErr := globalEngine.Invoke(basic_workflows.WORKFLOWID_GLOBAL_CLEANUP, workflow.WithContext(teardownCtx)); cleanupErr != nil {
521+
globalLogger.Printf("Failed to cleanup %v", cleanupErr)
522+
}
523+
524+
if globalConfiguration.GetBool(configuration.DEBUG) {
525+
writeLogFooter(exitCode, errorList, globalConfiguration, networkAccess)
526+
}
527+
528+
return exitCode
529+
}
530+
551531
func MainWithErrorCode() int {
552532
initDebugBuild()
553533

554534
errorList := []error{}
555535
errorListMutex := sync.Mutex{}
536+
var tearDownOnce sync.Once
537+
var finalExitCode int
556538

557539
startTime := time.Now()
558540
var err error
@@ -633,9 +615,11 @@ func MainWithErrorCode() int {
633615
return constants.SNYK_EXIT_CODE_ERROR
634616
}
635617

636-
// init context
637-
ctx := context.Background()
618+
// init context with cancel function for signal handling
619+
ctx, ctxCancel := context.WithCancel(context.Background())
620+
defer ctxCancel() // ensure context is canceled on exit
638621
ctx = context.WithValue(ctx, networking.InteractionIdKey, instrumentation.AssembleUrnFromUUID(interactionId))
622+
globalContext = ctx
639623

640624
// add output flags as persistent flags
641625
outputWorkflow, _ := globalEngine.GetWorkflow(localworkflows.WORKFLOWID_OUTPUT_WORKFLOW)
@@ -656,6 +640,35 @@ func MainWithErrorCode() int {
656640
cliAnalytics.GetInstrumentation().SetStage(instrumentation.DetermineStage(cliAnalytics.IsCiEnvironment()))
657641
cliAnalytics.GetInstrumentation().SetStatus(analytics.Success)
658642

643+
// prepare for signal handling
644+
signalChan := make(chan os.Signal, 1)
645+
exitCodeChan := make(chan int, 1)
646+
647+
if globalConfiguration.GetBool(configuration.PREVIEW_FEATURES_ENABLED) {
648+
// Set up signal handling to send instrumentation on premature termination
649+
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
650+
go func() {
651+
sig := <-signalChan
652+
globalLogger.Printf("Received signal %v, attempting to send instrumentation before exit", sig)
653+
654+
// Cancel the context to terminate any running child processes
655+
ctxCancel()
656+
657+
tearDownOnce.Do(func() {
658+
signalError := cli.NewTerminatedBySignalError(fmt.Sprintf("Signal: %v", sig))
659+
660+
errorListMutex.Lock()
661+
errorListCopy := append([]error{}, errorList...)
662+
errorListMutex.Unlock()
663+
664+
finalExitCode = tearDown(ctx, signalError, errorListCopy, startTime, ua, cliAnalytics, networkAccess)
665+
})
666+
// Send exit code to main goroutine instead of calling os.Exit directly
667+
// This allows deferred functions (like lock cleanup) to run
668+
exitCodeChan <- finalExitCode
669+
}()
670+
}
671+
659672
setTimeout(globalConfiguration, func() {
660673
os.Exit(constants.SNYK_EXIT_CODE_EX_UNAVAILABLE)
661674
})
@@ -681,40 +694,29 @@ func MainWithErrorCode() int {
681694
// ignore
682695
}
683696

684-
if err != nil {
685-
errorList, err = processError(err, errorList)
686-
687-
for _, tempError := range errorList {
688-
if tempError != nil {
689-
cliAnalytics.AddError(tempError)
690-
}
691-
}
697+
// Check if signal handler already ran teardown
698+
select {
699+
case code := <-exitCodeChan:
700+
// Signal was received and teardown completed - return its exit code
701+
return code
702+
default:
703+
// No signal received - run normal teardown
692704
}
693705

694-
displayError(err, globalEngine.GetUserInterface(), globalConfiguration, ctx)
695-
696-
exitCode := cliv2.DeriveExitCode(err)
697-
globalLogger.Printf("Deriving Exit Code %d (cause: %v)", exitCode, err)
698-
699-
updateInstrumentationDataBeforeSending(cliAnalytics, startTime, ua, exitCode)
700-
701-
if !globalConfiguration.GetBool(configuration.ANALYTICS_DISABLED) {
702-
sendAnalytics(cliAnalytics, globalLogger)
706+
if globalConfiguration.GetBool(configuration.PREVIEW_FEATURES_ENABLED) {
707+
// Stop signal handling before cleanup to prevent race conditions
708+
signal.Stop(signalChan)
703709
}
704-
sendInstrumentation(globalEngine, cliAnalytics.GetInstrumentation(), globalLogger)
705710

706-
// cleanup resources in use
707-
// WARNING: deferred actions will execute AFTER cleanup; only defer if not impacted by this
708-
_, err = globalEngine.Invoke(basic_workflows.WORKFLOWID_GLOBAL_CLEANUP)
709-
if err != nil {
710-
globalLogger.Printf("Failed to cleanup %v", err)
711-
}
711+
tearDownOnce.Do(func() {
712+
errorListMutex.Lock()
713+
errorListCopy := append([]error{}, errorList...)
714+
errorListMutex.Unlock()
712715

713-
if debugEnabled {
714-
writeLogFooter(exitCode, errorList, globalConfiguration, networkAccess)
715-
}
716+
finalExitCode = tearDown(ctx, err, errorListCopy, startTime, ua, cliAnalytics, networkAccess)
717+
})
716718

717-
return exitCode
719+
return finalExitCode
718720
}
719721

720722
func processError(err error, errorList []error) ([]error, error) {

0 commit comments

Comments
 (0)