Skip to content

Commit a55a4e3

Browse files
committed
chore: Send instrumentation data on kill
chore: ensure to kill CLI processes
1 parent 951bab0 commit a55a4e3

8 files changed

Lines changed: 308 additions & 102 deletions

File tree

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: 90 additions & 94 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"
@@ -88,6 +89,7 @@ const (
8889
debug_level_flag string = "log-level"
8990
integrationNameFlag string = "integration-name"
9091
maxNetworkRequestAttempts string = "max-attempts"
92+
teardownTimeout = 5 * time.Second
9193
)
9294

9395
type JsonErrorStruct struct {
@@ -221,71 +223,6 @@ func runWorkflowAndProcessData(engine workflow.Engine, logger *zerolog.Logger, n
221223
return err
222224
}
223225

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-
289226
func help(_ *cobra.Command, _ []string) error {
290227
helpProvided = true
291228
args := utils.RemoveSimilar(os.Args[1:], "--") // remove all double dash arguments to avoid issues with the help command
@@ -548,11 +485,55 @@ func initExtensions(engine workflow.Engine, config configuration.Configuration)
548485
}
549486
}
550487

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

554533
errorList := []error{}
555534
errorListMutex := sync.Mutex{}
535+
var tearDownOnce sync.Once
536+
var finalExitCode int
556537

557538
startTime := time.Now()
558539
var err error
@@ -656,6 +637,32 @@ func MainWithErrorCode() int {
656637
cliAnalytics.GetInstrumentation().SetStage(instrumentation.DetermineStage(cliAnalytics.IsCiEnvironment()))
657638
cliAnalytics.GetInstrumentation().SetStatus(analytics.Success)
658639

640+
// prepare for signal handling
641+
signalChan := make(chan os.Signal, 1)
642+
exitCodeChan := make(chan int, 1)
643+
644+
if globalConfiguration.GetBool(configuration.PREVIEW_FEATURES_ENABLED) {
645+
// Set up signal handling to send instrumentation on premature termination
646+
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
647+
go func() {
648+
sig := <-signalChan
649+
globalLogger.Printf("Received signal %v, attempting to send instrumentation before exit", sig)
650+
651+
tearDownOnce.Do(func() {
652+
signalError := cli.NewTerminatedBySignalError(fmt.Sprintf("Signal: %v", sig))
653+
654+
errorListMutex.Lock()
655+
errorListCopy := append([]error{}, errorList...)
656+
errorListMutex.Unlock()
657+
658+
finalExitCode = tearDown(ctx, signalError, errorListCopy, startTime, ua, cliAnalytics, networkAccess)
659+
})
660+
// Send exit code to main goroutine instead of calling os.Exit directly
661+
// This allows deferred functions (like lock cleanup) to run
662+
exitCodeChan <- finalExitCode
663+
}()
664+
}
665+
659666
setTimeout(globalConfiguration, func() {
660667
os.Exit(constants.SNYK_EXIT_CODE_EX_UNAVAILABLE)
661668
})
@@ -681,40 +688,29 @@ func MainWithErrorCode() int {
681688
// ignore
682689
}
683690

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-
}
691+
// Check if signal handler already ran teardown
692+
select {
693+
case code := <-exitCodeChan:
694+
// Signal was received and teardown completed - return its exit code
695+
return code
696+
default:
697+
// No signal received - run normal teardown
692698
}
693699

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)
700+
if globalConfiguration.GetBool(configuration.PREVIEW_FEATURES_ENABLED) {
701+
// Stop signal handling before cleanup to prevent race conditions
702+
signal.Stop(signalChan)
703703
}
704-
sendInstrumentation(globalEngine, cliAnalytics.GetInstrumentation(), globalLogger)
705704

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-
}
705+
tearDownOnce.Do(func() {
706+
errorListMutex.Lock()
707+
errorListCopy := append([]error{}, errorList...)
708+
errorListMutex.Unlock()
712709

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

717-
return exitCode
713+
return finalExitCode
718714
}
719715

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

cliv2/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ require (
2121
github.com/snyk/cli-extension-secrets v0.0.0-20260330131056-456a17f6d188
2222
github.com/snyk/code-client-go v1.26.2
2323
github.com/snyk/container-cli v0.0.0-20260213211631-cd2b2cf8f3ea
24-
github.com/snyk/error-catalog-golang-public v0.0.0-20260316131845-f02d7f42046b
25-
github.com/snyk/go-application-framework v0.0.0-20260330124826-9560aa9edaf6
24+
github.com/snyk/error-catalog-golang-public v0.0.0-20260326122451-686348fab429
25+
github.com/snyk/go-application-framework v0.0.0-20260331101552-58f300cca5e1
2626
github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65
2727
github.com/snyk/snyk-iac-capture v0.6.5
2828
github.com/snyk/snyk-ls v0.0.0-20260327145511-f13f911f19d3

cliv2/go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -559,10 +559,10 @@ github.com/snyk/container-cli v0.0.0-20260213211631-cd2b2cf8f3ea h1:/v48hCMPiZVj
559559
github.com/snyk/container-cli v0.0.0-20260213211631-cd2b2cf8f3ea/go.mod h1:P5yW8+jkwhYBsj5l2jtHeWujyX+SAtvkC8+LELKdlWI=
560560
github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62 h1:kgZNQ5ztI4+n3YKLR5LJbqL8WJmUYgDSbFKREIY79g0=
561561
github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62/go.mod h1:hTr91da/4ze2nk9q6ZW1BmfM2Z8rLUZSEZ3kK+6WGpc=
562-
github.com/snyk/error-catalog-golang-public v0.0.0-20260316131845-f02d7f42046b h1:DM2SPu7rhsD/TNS7zhv4ZoqLLi2cFOqg1VTBCP6RfSg=
563-
github.com/snyk/error-catalog-golang-public v0.0.0-20260316131845-f02d7f42046b/go.mod h1:Ytttq7Pw4vOCu9NtRQaOeDU2dhBYUyNBe6kX4+nIIQ4=
564-
github.com/snyk/go-application-framework v0.0.0-20260330124826-9560aa9edaf6 h1:Ltb6illIasRhS/MKZX2+uLtDyuxWWvDPYQJzv41EwM8=
565-
github.com/snyk/go-application-framework v0.0.0-20260330124826-9560aa9edaf6/go.mod h1:7IOOtKxiQhtTbkrX7rax20QNJ/rwGill6n2Rejtld2I=
562+
github.com/snyk/error-catalog-golang-public v0.0.0-20260326122451-686348fab429 h1:KUvautSov5PIOo3IQxbeu0d7zOVh5oO+sZ0N4lZkiJ8=
563+
github.com/snyk/error-catalog-golang-public v0.0.0-20260326122451-686348fab429/go.mod h1:Ytttq7Pw4vOCu9NtRQaOeDU2dhBYUyNBe6kX4+nIIQ4=
564+
github.com/snyk/go-application-framework v0.0.0-20260331101552-58f300cca5e1 h1:zMU74SVnhvYyKyMOqfgREqXjgyvYbtshp81X7GrL7dw=
565+
github.com/snyk/go-application-framework v0.0.0-20260331101552-58f300cca5e1/go.mod h1:7IOOtKxiQhtTbkrX7rax20QNJ/rwGill6n2Rejtld2I=
566566
github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65 h1:CEQuYv0Go6MEyRCD3YjLYM2u3Oxkx8GpCpFBd4rUTUk=
567567
github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65/go.mod h1:88KbbvGYlmLgee4OcQ19yr0bNpXpOr2kciOthaSzCAg=
568568
github.com/snyk/policy-engine v1.1.3 h1:MU+K8pxbN6VZ9P5wALUt8BwTjrPDpoEtmTtQqj7sKfY=

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ module.exports = createJestConfig({
55
displayName: 'coreCli',
66
projects: ['<rootDir>', '<rootDir>/packages/*'],
77
globalSetup: './test/setup.js',
8+
globalTeardown: './test/teardown.js',
89
setupFilesAfterEnv: ['./test/setup-jest.ts'],
910
});

0 commit comments

Comments
 (0)