From fe07d22f14e5a22cbbb85340ba8e0a36ea69840f Mon Sep 17 00:00:00 2001 From: Pascal Breuninger Date: Fri, 20 Dec 2024 10:33:09 +0100 Subject: [PATCH] chore(cli): update telemetry implementation and update docs page --- cmd/root.go | 20 +- cmd/up.go | 2 + docs/pages/other-topics/telemetry.mdx | 39 ++- go.mod | 1 + go.sum | 2 + pkg/config/config.go | 9 - pkg/provider/workspace.go | 23 ++ pkg/telemetry/collect.go | 250 ++++++++---------- pkg/telemetry/helpers.go | 79 ++---- pkg/telemetry/key.go | 35 --- pkg/telemetry/noop.go | 10 + pkg/telemetry/serviceaccount/jwt.go | 203 -------------- pkg/telemetry/types/types.go | 66 ----- .../loft-sh/analytics-client/LICENSE | 201 ++++++++++++++ .../loft-sh/analytics-client/client/buffer.go | 66 +++++ .../loft-sh/analytics-client/client/client.go | 179 +++++++++++++ .../loft-sh/analytics-client/client/noop.go | 11 + .../loft-sh/analytics-client/client/types.go | 15 ++ vendor/modules.txt | 3 + 19 files changed, 670 insertions(+), 544 deletions(-) delete mode 100644 pkg/telemetry/key.go create mode 100644 pkg/telemetry/noop.go delete mode 100644 pkg/telemetry/serviceaccount/jwt.go delete mode 100644 pkg/telemetry/types/types.go create mode 100644 vendor/github.com/loft-sh/analytics-client/LICENSE create mode 100644 vendor/github.com/loft-sh/analytics-client/client/buffer.go create mode 100644 vendor/github.com/loft-sh/analytics-client/client/client.go create mode 100644 vendor/github.com/loft-sh/analytics-client/client/noop.go create mode 100644 vendor/github.com/loft-sh/analytics-client/client/types.go diff --git a/cmd/root.go b/cmd/root.go index 5869a3853..12de80d60 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "os/exec" - "runtime/debug" "github.com/loft-sh/devpod/cmd/agent" "github.com/loft-sh/devpod/cmd/context" @@ -35,8 +34,6 @@ func NewRootCmd() *cobra.Command { SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cobraCmd *cobra.Command, args []string) error { - telemetry.Collector.SetCLIData(cobraCmd, globalFlags) - if globalFlags.LogOutput == "json" { log2.Default.SetFormat(log2.JSONFormat) } else if globalFlags.LogOutput == "raw" { @@ -57,6 +54,11 @@ func NewRootCmd() *cobra.Command { _ = os.Setenv(config.DEVPOD_HOME, globalFlags.DevPodHome) } + devPodConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) + if err == nil { + telemetry.StartCLI(devPodConfig, cobraCmd) + } + return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { @@ -72,21 +74,13 @@ func NewRootCmd() *cobra.Command { // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - defer func() { - // recover from panic in order to log it via telemetry - if err := recover(); err != nil { - retErr := fmt.Errorf("panic: %v %s", err, debug.Stack()) - telemetry.Collector.RecordEndEvent(retErr) - log2.Default.Fatal(retErr) - } - }() - // build the root command rootCmd := BuildRoot() // execute command err := rootCmd.Execute() - telemetry.Collector.RecordEndEvent(err) + telemetry.CollectorCLI.RecordCLI(err) + telemetry.CollectorCLI.Flush() if err != nil { //nolint:all if sshExitErr, ok := err.(*ssh.ExitError); ok { diff --git a/cmd/up.go b/cmd/up.go index e6e82a2cf..16bc00e8c 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -35,6 +35,7 @@ import ( "github.com/loft-sh/devpod/pkg/port" provider2 "github.com/loft-sh/devpod/pkg/provider" devssh "github.com/loft-sh/devpod/pkg/ssh" + "github.com/loft-sh/devpod/pkg/telemetry" "github.com/loft-sh/devpod/pkg/tunnel" "github.com/loft-sh/devpod/pkg/version" workspace2 "github.com/loft-sh/devpod/pkg/workspace" @@ -86,6 +87,7 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { if err != nil { return fmt.Errorf("prepare workspace client: %w", err) } + telemetry.CollectorCLI.SetClient(client) return cmd.Run(ctx, devPodConfig, client, logger) }, diff --git a/docs/pages/other-topics/telemetry.mdx b/docs/pages/other-topics/telemetry.mdx index 8c1d42823..e5a1df698 100644 --- a/docs/pages/other-topics/telemetry.mdx +++ b/docs/pages/other-topics/telemetry.mdx @@ -23,32 +23,25 @@ Below you can find an example of the payload that DevPod CLI would send to our t ```yaml { - "type":"cmdfinished", # type of event, another type is "cmdstarted" "event":{ + "type":"devpod_cli", # type of event + "machine_id":"3ed2c7...ee308e6", # securely hashed machine ID to de-duplicate information received from the same user "timestamp":1683878643781772, - "executionID":"23736e5...83b3d656a0", # random ID to de-duplicate information received via different event types - "command":"devpod provider delete", # the CLI command that was executed - "provider":"kubernetes", # the default provider - "processingTime":71980, # how long it took for the command to execute - "errors":"provider 'docker' does not exist" + "properties": { + "command":"devpod provider delete", # the CLI command that was executed + "provider":"kubernetes", # the default provider + "source_type":"git:", # the workspace source type (git, image, local, container, unknown) + "ide":"vscode", # the IDE used to open a workspace + "desktop":"true", # whether this cli command has been executed by DevPod Desktop or is a direct CLI invokation + "version":"v0.5.29", # the CLI version + "error":"provider 'docker' does not exist" # an error that occured during command execution + } }, - "instanceProperties":{ - "uid":"3ed2c7...ee308e6", # securely hashed machine ID to de-duplicate information received from the same user - "arch":"amd64", # CPU architecture - "os":"linux", # Operating system - "version":{ # DevPod CLI version - "major":"0", - "minor":"1", - "patch":"0" - }, - "flags":{ - "setFlags":[ # List of flags(names only) that were set - "debug" - ] - }, - "ui": false + "user":{ + "machine_id":"3ed2c7...ee308e6", # securely hashed machine ID to de-duplicate information received from the same user + "arch":"amd64", # CPU architecture + "os":"linux", # Operating system }, - "token":"eyJhbG...coNz80" # Token generated from the static key included in the CLI binary. It is used to validate payload integrity. } ``` @@ -58,4 +51,4 @@ To disable the telemetry execute the following command: ```bash devpod context set-options -o TELEMETRY=false -``` \ No newline at end of file +``` diff --git a/go.mod b/go.mod index bb503780d..a29c76ced 100644 --- a/go.mod +++ b/go.mod @@ -155,6 +155,7 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/loft-sh/admin-apis v0.0.0-20241127134028-9cfb6b23ec44 // indirect + github.com/loft-sh/analytics-client v0.0.0-20240219162240-2f4c64b2494e // indirect github.com/loft-sh/apiserver v0.0.0-20241008120650-f17d504a4d0d // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index 008086b28..a11723d51 100644 --- a/go.sum +++ b/go.sum @@ -352,6 +352,8 @@ github.com/loft-sh/admin-apis v0.0.0-20241127134028-9cfb6b23ec44 h1:Sq6qEsKSiZHY github.com/loft-sh/admin-apis v0.0.0-20241127134028-9cfb6b23ec44/go.mod h1:MWczNwKvWssHo1KaeZKaWDdRLYSNbWqQBGsTLoCNd7U= github.com/loft-sh/agentapi/v4 v4.2.0-alpha.6 h1:eVIzaW+EvIygxNXl5163c1+WcUr8c95OP6lj8FcJHUc= github.com/loft-sh/agentapi/v4 v4.2.0-alpha.6/go.mod h1:yqbIMmyXqbzZcK0DlwldRLy0xb3lYnH4NoI3K+iETlM= +github.com/loft-sh/analytics-client v0.0.0-20240219162240-2f4c64b2494e h1:JcPnMaoczikvpasi8OJ47dCkWZjfgFubWa4V2SZo7h0= +github.com/loft-sh/analytics-client v0.0.0-20240219162240-2f4c64b2494e/go.mod h1:FFWcGASyM2QlWTDTCG/WBVM/XYr8btqYt335TFNRCFg= github.com/loft-sh/api/v4 v4.0.0-alpha.6.0.20241129074910-a24d4104d586 h1:nBLJCtuGQH0Cq4lkaUJsDqSUYueWT874YVuW66BQ9S0= github.com/loft-sh/api/v4 v4.0.0-alpha.6.0.20241129074910-a24d4104d586/go.mod h1:bPDJ1+vZBBEIoPgykfy+TzOwLHtTvWAbTSHexnj4tJA= github.com/loft-sh/apiserver v0.0.0-20241008120650-f17d504a4d0d h1:73wE8wtsnJm4bXtFbTDRG1EgN4LonpPdgzF3HFhP7kA= diff --git a/pkg/config/config.go b/pkg/config/config.go index afcdf0326..07fa299c7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,9 +9,7 @@ import ( "time" "github.com/ghodss/yaml" - "github.com/loft-sh/devpod/pkg/telemetry" "github.com/loft-sh/devpod/pkg/types" - "github.com/loft-sh/devpod/pkg/version" "github.com/pkg/errors" ) @@ -271,13 +269,6 @@ func LoadConfig(contextOverride string, providerOverride string) (*Config, error config.Origin = configOrigin - // make sure to not send telemetry if disabled or in dev mode - if config.ContextOption(ContextOptionTelemetry) != "false" && version.GetVersion() != version.DevVersion { - go func() { - telemetry.Collector.RecordStartEvent(config.Current().DefaultProvider) - }() - } - return config, nil } diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 7157c822c..06f2db46f 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -16,6 +16,7 @@ var ( WorkspaceSourceLocal = "local:" WorkspaceSourceImage = "image:" WorkspaceSourceContainer = "container:" + WorkspaceSourceUnknown = "unknown:" ) type Workspace struct { @@ -262,6 +263,28 @@ func (w WorkspaceSource) String() string { return "" } +func (w WorkspaceSource) Type() string { + if w.GitRepository != "" { + if w.GitPRReference != "" { + return WorkspaceSourceGit + "pr" + } else if w.GitBranch != "" { + return WorkspaceSourceGit + "branch" + } else if w.GitCommit != "" { + return WorkspaceSourceGit + "commit" + } + + return WorkspaceSourceGit + } else if w.LocalFolder != "" { + return WorkspaceSourceLocal + } else if w.Image != "" { + return WorkspaceSourceImage + } else if w.Container != "" { + return WorkspaceSourceContainer + } + + return WorkspaceSourceUnknown +} + func ParseWorkspaceSource(source string) *WorkspaceSource { if strings.HasPrefix(source, WorkspaceSourceGit) { gitRepo, gitPRReference, gitBranch, gitCommit, gitSubdir := git.NormalizeRepository(strings.TrimPrefix(source, WorkspaceSourceGit)) diff --git a/pkg/telemetry/collect.go b/pkg/telemetry/collect.go index 1cfa0b52b..e5e1c068a 100644 --- a/pkg/telemetry/collect.go +++ b/pkg/telemetry/collect.go @@ -1,183 +1,163 @@ package telemetry import ( - "bytes" - "encoding/base64" "encoding/json" - "fmt" - "net/http" - "sync" + "os" + "runtime" + "strings" "time" - "github.com/google/uuid" - "github.com/loft-sh/devpod/cmd/flags" - "github.com/loft-sh/devpod/pkg/telemetry/serviceaccount" - "github.com/loft-sh/devpod/pkg/telemetry/types" + "github.com/loft-sh/analytics-client/client" + devpodclient "github.com/loft-sh/devpod/pkg/client" + "github.com/loft-sh/devpod/pkg/config" + "github.com/loft-sh/devpod/pkg/version" "github.com/loft-sh/log" "github.com/spf13/cobra" - "gopkg.in/square/go-jose.v2/jwt" ) +type ErrorSeverityType string + const ( - telemetryEndpoint = "https://admin.loft.sh/analytics/v1/devpod/v1/events" - telemetryRequestTimeout = 5 * time.Second - telemetrySendFinishedAfter = 10 * time.Second + WarningSeverity ErrorSeverityType = "warning" + ErrorSeverity ErrorSeverityType = "error" + FatalSeverity ErrorSeverityType = "fatal" + PanicSeverity ErrorSeverityType = "panic" ) -var ( - Collector EventCollector = NewDefaultCollector() +const UIEnvVar = "DEVPOD_UI" - UIEventsExceptions []string = []string{"devpod list", "devpod provider list", "devpod status"} +var UIEventsExceptions []string = []string{ + "devpod list", + "devpod status", + "devpod provider list", + "devpod pro list", + "devpod pro check-health", + "devpod pro check-update", +} - // a dummy key so this doesn't fail for dev/testing, this is set by build flag in release action - telemetryPrivateKey = `LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS1FJQkFBS0NBZ0VBdlE3cHhqYzEybzlJdXQyQkQ2TUtaWnhDY29hbXpJNVV0Wll6Wk5GZFVQYkJsSlI0ClBXOEM1STM3ZHk1cW1yMlU5UlJSbjNlOUpjSDRPS0QzenVHSkhDd0Z2TnpOYzJsYVQ0dlE5NjlVeVpmakdhT3AKVmxtSEhDaDJXajZvbHNUNmhldGJySTNpYzNvVm1XRHBhSHM4OGU3K2dzTnkyTUowNjNES0ZYM0VLV3pNQVVWZQprZUI1M29DWStCT0R0RExRcHd3eC9wQWp1bUFNS0dkNEc2a3FhcE1VZElpN1NKMzlyL2JxL2VUeWZwSUUzOW9ZCmoxanlhdkFpRFMxR1g0Mm5mU0lkck5NSDhERytSSzNVSHMycTFDOXI0Y1dzenVURktlOHprZ2ltdC9oY2sxS2sKZjZBTllyRE0vQmlrcWZoYXVDcHlMdDFhOTdHbUNNb0x1Z3NBWlB6TjhlTXk4eFlwZy9PZ3hjQ3d2a1E2SzVnTQo0Q3k5ZG5aOFVJTlBTVE02Z2xJUFR1cHpPUzYwVDlBb2VZREcwa245bEVYKzhHT1RyZ1NmaTBONEpxbzZ0YlU0Cjk0TXlvcTB2VmtaVjNGYWNKMDcwbFJQaUFxcm1BeDdMS1J0UUNoeiszY0hLcDhBUmNWS1RuT3VpSHdDNEk0SUYKUGhmbzZ4QWFQWUJ0RDRNQUtydFErS3pycVlwNHZaTVZZWUpWR1hzT2xFN1FGSUJjUk5FUTlPdmlTSHZpTEhsTApHbjhER1NxWVVBWEVPVVZBNWtkTUVwOGhYMXJkb2pyNk9FbEt5dnF1Wk9JMk9yekhDWmFCNS9nQVZwelRGcGhYCjlrN2oyU1FyNUcxbXI2TTE3VXJacnhBcUphSGo1ZnBuL2dCOW9ubkN1bmcvSTNDOEVzb0xVQnovd0xrQ0F3RUEKQVFLQ0FnRUFsMGVNcHBCZEpuTks5a1B5VnVuV2t2SVRkWUxyaTNsRXJUendDUGRDM1Z0bUVSY3drNi8xdDU4cApIZmZsVThicG42WlBuZlA1UlhKTnhqcC9zR3BtQlVYd25XeHRkYkZTazU1RWF6MC84a1A0Yy9heXRLYlU1eUkxCmVnYnpiaGxXZ2J5UDBhYURFbllaUEc4QXRoc083R1NhQVZhVjJuN1hnZUh4d25xdGNaeGVMWkl0bHpyeEthcnIKUEc2WkQ2TXR0TTJjWDU5RkI0aDlrZ01oWjdqWWVRa1I4Q0hOQXRGeFF0R292ZHJxYzM4eUtWRmlINnBENkhBWQpQMFVBTDh1d3Z2K0NrVjBYMkFwbHZwejl4Rnc4R3FlTGd0QmpjL1k1RWxJV2lQOGxNTWFxaFRRMjd1ekthVE1pCkE0TlFsN1ZrR2tQVXRFMXAwaE96MFFxamtZM21GSWpsTEhJYTBWN3QybmxrSXJNKzVLbVNqbFF4WjhMRHZGcnoKTkxFaUk5dnp1RWNHdVpPYXZHdjhzSmhXK2paQ3JQWVVyc3dPMTBsYnVsMkdnL2JBVFd2U3lVUGlyb1RsbEc5NApsVzFrMzl1MUk5d0tLaExSaE5TMlZQTW4xSE1CY0FFQXlTTWFudDdwbjQ4R1R1Q0VseUlEZTM4OW9KWHVOcXpNCjdrS2VaaG0wYzBJNmpvSThVcmNyMVZvTTErbmdtdzlxWldtOWJXekZpNW1IaTlCRXkyRGpjeHlOK1l3bFRVQW4Kd0EyblpoMVY0U1hUZUVWUzFOQ3J5dGNXSlZBdjJObWpTV3ZUQk4yaFdCZzEyVGpXZi9MSEF4eklyVzBaa0tKcgptVXdDQ0V3anhkM2JIMzluWnZkeG5xTXVISnZnUUpmM1NGQVRmZlZDWjI2OWdCZUtoZ0VDZ2dFQkFOc2IyTnZ5CmhxU2MwbW43VWtncHYvUjh0TVNsVVRId3g3cDNCZ21xR2tnU0JCR0t3cEI4Q1NzNWl4UHFGYzZaMFlBY0toL0wKZEFGTFNMN3NHRFFweGpleTFmWEtpeXdQUm5MZEZ2aTBac29UcXhsRUJweVBlQjlWSXZLckFuN3cyWVY0VnJIdQpsbGcwV0FmL2s3cTdsMXRGUmxnaFFSN084UVVtS1daUGNGRjhqR1lYZHZhWUdjZzRpSkM4djBBZ1VhQmxPMCt6CnEzaHBvQ0Q5NlRIUUNTdDQvT0E1Rmo2SzUweWk1N2FHd25vMTNBbVQ5eG9Qc0dqeldLMG1oUGp3M0NZQlYwbisKTit4OVBBcXBJdmhPbU1KZmpOWEQzYndoSFNjNUd4VjlpbUpBSk03MjhZazR5Nm9rVTdJNkJXZDNLV3pDaVRENwp5UHE4U1N3amxoaVk3WGtDZ2dFQkFOemp6NlFHZ1V5Zm5PZ1IwR2JEejAyMVQrOUkrdHZwSnVPQmZNWTVsTlo0CndHOGtoSkdFZk5CQUxqOXIzQXcxaDhEbEpSUmp2OVFFR2krYmFpRkJPWVB4VTlyczJnMmhIWkk5ZkdyRW1LUDMKRjJsNE1TNm1vVGx0RUxwZnM5R3FhQmxyRFlYODFYeEVSQTU0aUhQSmRoQjl0K0QxTFdxRXVLMVVaZ25NUlNQLwp2RHdZUEFXOHlvMGhiM0JDMTVvZ3BPRzMyMWl0QmpYVjhtN1pmN25rZjNyOEhWaG5BcytubW93bXg1dU8yanZ6CmtrajdxaC9WRmlHajcxc1ovV0NHQk9pQk0ySVVMNVhQUmpCM2lNQXMzNGtaTHdlOC9SL3NsaE81bzFucVhVeW4KLzV3UTFoYlNKOXpzNzdwYys0K2ROUHdJVzdHSnhEbENkQWZTd0RuNjNVRUNnZ0VBRnFhWFVZMk4yOENXZy94RwpNazJXbVhpMjIwbFh6bmpjdk9zSEJjSysrc3BaLzFJLzhOM1J1TlUzQ25UOWtpRVdwazdERUF4aFRxend0VVFFCjhJZU5CVDhJbldNMTVmVWlURWVNMDJNYTZUTUZVaFJWTnFRaVArTDJQTzN1MFI2bTdnUlZ1Z2szSTZFdHBJNEkKUUpxWitBWitVaWdGNm1Cc1RDTDR6cW5ScTZyYmZNWmFOdjNjVkhWN3NMTENkcWVncUpzdWVYdlNjeDFBUDRqZwpMWlViRFpKeFdlQ3M2d1JEQ3dvZ09COVFSWUFCNGorWW9Pb1VTNVUwaXBuYnp6eGZGZEszcWwrTWVuY3IyTkpKCldqQU4zTEl5QmZzOGxmRTZhVTZlL1NiQVFvM3RBRFJKSGUxd0tJT2UzMkxlSWljUWNqemVIK0Uza3F3YVNHVFoKWkd1U3lRS0NBUUVBbjREeGcyUWZJaEZ2NERSYzVKZ29yZGhyYkVLcXd2bk5WeU05MG5YcUFDVVo4Q2ZTZ3JIRQozeXc1T1JyTnZ4TTRnQlgzZkkyN0M0SWExcDNIT1ZROEVBYkhvcUs5b25IaFJLU1pudzl2bVpibmxRVnhubG84CnVaY0VLVkRLTEhCODB6MzJlZlprd21NWk1jbmYzcHh2WU9FblVvNDR5VjRsYlNRd3VvcUNzc2dNU09qSER1MlEKNWZCcTVBbWdYbStNSUdIL1JqMUs2cjBmWHVRMzB5Z28xY29QOXJJTDJaOFJmbnJTVUlZTEdKZDkzcTI3MzFpagpybzhPWEI2Y1ZJTHlNR0o3bENzM1lWcFhPTkJZTTAwejdXLytBZng2Vy84Zk1BY3c2ZERPcG5mNW45eVllOG90CmR0NnhEVVh2Y1hqM3RiYmpYNFEzNlpFTzhFZEMvNXNqQVFLQ0FRQkJSNHk5OTJyN05LOVZTZUh1TG1VSUZjd2kKS1dnMG0rVGk2TGxyeDFPS2k3b3cydDhubXlDQklNaDhGWTVJL0RsV1JpTTh0WWUrS2VBYTJCUFdpWmRVbUVQUgpKTHpBWVFjNXhYNVNSait3MDNHZjljdzBteFRNazNzbGxSUERYNEZ2bVpSRDkyRHBwWlFBbHdTU3haZHEyWERrCmMrZG9pVE9zTm04endNaFNXVTFiK2d6MjlabnVFamtMeU1HaXVlUll6bXNVdmdjL09KRzdSU2V5NzhmUWtZYm0KWnRIb3o3dFdJTFJxSVRYclFlZ2h6N2Jrc1lPMzM5QjE5bEI0blFPc2Y3MnBMVnMyTWdkRWlwUUp4VDlaVWRmaQpGZkQ1YzNlSkNINlNWQkRSdjFpV3Y5TzRSdWkvNThMcVRvYVE0bzdmRWxha2RoN3BrK3ZiWnRmNG41dlUKLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K` -) +// skip everything in pro mode +var CollectorCLI CLICollector = &noopCollector{} -type EventCollector interface { - // RecordStartEvent populates TelemetryRequest with the data about Command start and uploads the request to the telemetry backend - RecordStartEvent(provider string) - RecordEndEvent(err error) - SetCLIData(*cobra.Command, *flags.GlobalFlags) +type CLICollector interface { + RecordCLI(err error) + SetClient(client devpodclient.BaseWorkspaceClient) + + // Flush makes sure all events are sent to the backend + Flush() } -func NewDefaultCollector() *DefaultCollector { - decodedCertificate, err := base64.RawStdEncoding.DecodeString(telemetryPrivateKey) - if err != nil { - panic(fmt.Errorf("failed to decode telemetry key string: %w", err)) +// StartCLI starts collecting events and sending them to the backend from the CLI +func StartCLI(devPodConfig *config.Config, cmd *cobra.Command) { + telemetryOpt := devPodConfig.ContextOption(config.ContextOptionTelemetry) + if telemetryOpt == "false" || version.GetVersion() == version.DevVersion { + return } - privateKey, err := parsePrivateKey(decodedCertificate) + // create a new default collector + collector, err := newCLICollector(cmd) if err != nil { - panic(fmt.Errorf("failed to parse telemetry key: %w", err)) + // Log the problem but don't fail - use disabled Collector instead + log.Default.WithPrefix("telemetry").Infof("%s", err.Error()) + } else { + CollectorCLI = collector } +} - tokenGenerator, err := serviceaccount.JWTTokenGenerator("devpod-telemetry", privateKey) - if err != nil { - panic(fmt.Errorf("failed to create JWTTokenGenerator: %w", err)) +func newCLICollector(cmd *cobra.Command) (*cliCollector, error) { + defaultCollector := &cliCollector{ + analyticsClient: client.NewClient(), + log: log.Default.WithPrefix("telemetry"), + cmd: cmd, } - return &DefaultCollector{ - executionID: uuid.New().String(), - startTime: time.Now(), - tokenGenerator: tokenGenerator, - } + return defaultCollector, nil } -type DefaultCollector struct { - mux sync.Mutex - startOnce sync.Once - - startTime time.Time - executionID string +type cliCollector struct { + analyticsClient client.Client + cmd *cobra.Command + client devpodclient.BaseWorkspaceClient - command *cobra.Command - globalFlags *flags.GlobalFlags - provider string - - tokenGenerator serviceaccount.TokenGenerator + log log.Logger } -func (d *DefaultCollector) SetCLIData(command *cobra.Command, globalFlags *flags.GlobalFlags) { - d.mux.Lock() - defer d.mux.Unlock() - - d.command = command - d.globalFlags = globalFlags +func (d *cliCollector) SetClient(client devpodclient.BaseWorkspaceClient) { + d.client = client } -func (d *DefaultCollector) RecordStartEvent(provider string) { - d.startOnce.Do(func() { - d.mux.Lock() - defer d.mux.Unlock() - - d.provider = provider - cmd := "" - if d.command != nil { - cmd = d.command.CommandPath() - } - - if shouldSkipCommand(cmd) { - return - } - - ts := time.Now().UnixMicro() - recordEvent(d.tokenGenerator, &types.TelemetryRequest{ - EventType: types.EventCommandStarted, - Event: types.CMDStartedEvent{ - Timestamp: ts, - ExecutionID: d.executionID, - Command: cmd, - Provider: provider, - }, - InstanceProperties: d.getInstanceProperties(d.command, d.executionID, ts), - }) - }) +func (d *cliCollector) Flush() { + d.analyticsClient.Flush() } -func (d *DefaultCollector) RecordEndEvent(err error) { - d.mux.Lock() - defer d.mux.Unlock() - - // only record if there is a start event - if d.provider == "" || (time.Since(d.startTime) < telemetrySendFinishedAfter && err == nil) { +func (d *cliCollector) RecordCLI(err error) { + if d.cmd == nil { + d.log.Debug("no command found, skipping") return } - - cmd := "" - if d.command != nil { - cmd = d.command.CommandPath() + cmd := d.cmd.CommandPath() + isUI := false + if os.Getenv(UIEnvVar) == "true" { + isUI = true } - - if shouldSkipCommand(cmd) { - return + // Ignore certain commands triggered by DevPod Desktop + if isUI { + for _, exception := range UIEventsExceptions { + if cmd == exception { + return + } + } } - cmdErr := "" - if err != nil { - cmdErr = err.Error() + timezone, _ := time.Now().Zone() + eventProperties := map[string]interface{}{ + "command": cmd, + "version": version.GetVersion(), + "desktop": isUI, } + if d.client != nil { + eventProperties["provider"] = d.client.Provider() - ts := time.Now().UnixMicro() - recordEvent(d.tokenGenerator, &types.TelemetryRequest{ - EventType: types.EventCommandFinished, - Event: types.CMDFinishedEvent{ - Timestamp: ts, - ExecutionID: d.executionID, - Command: cmd, - Provider: d.provider, - Success: err == nil, - ProcessingTime: int(time.Since(d.startTime).Microseconds()), - Errors: cmdErr, - }, - InstanceProperties: d.getInstanceProperties(d.command, d.executionID, ts), - }) -} - -func recordEvent(tokenGenerator serviceaccount.TokenGenerator, r *types.TelemetryRequest) { - token, err := tokenGenerator.GenerateToken(&jwt.Claims{}, &jwt.Claims{}) - if err != nil { - log.Default.Debugf("failed to generate telemetry request signed token: %v", err) - return + if d.client.WorkspaceConfig() != nil { + eventProperties["source_type"] = d.client.WorkspaceConfig().Source.Type() + eventProperties["ide"] = d.client.WorkspaceConfig().IDE.Name + } + } + userProperties := map[string]interface{}{ + "os_name": runtime.GOOS, + "os_arch": runtime.GOARCH, + "timezone": timezone, } - - r.Token = token - marshaled, err := json.Marshal(r) - // handle potential Marshal errors if err != nil { - log.Default.Debugf("failed to json.Marshal telemetry request: %v", err) - return + eventProperties["error"] = err.Error() } - // send the telemetry data and ignore the response - client := http.Client{ - Timeout: telemetryRequestTimeout, + // Check if we're on the runner + isPro := false + wd, wdErr := os.Getwd() + if wdErr == nil { + if strings.HasPrefix(wd, "/var/lib/loft/devpod") { + isPro = true + } } - _, err = client.Post( - telemetryEndpoint, - "multipart/form-data", - bytes.NewReader(marshaled), - ) - if err != nil { - log.Default.Debugf("error sending telemetry request: %v", err) + eventType := "devpod_cli" + if isPro { + eventType = "devpod_cli_runner" } + + // build the event and record + eventPropertiesRaw, _ := json.Marshal(eventProperties) + userPropertiesRaw, _ := json.Marshal(userProperties) + d.analyticsClient.RecordEvent(client.Event{ + "event": { + "type": eventType, + "machine_id": GetMachineID(), + "properties": string(eventPropertiesRaw), + "timestamp": time.Now().Unix(), + }, + "user": { + "machine_id": GetMachineID(), + "properties": string(userPropertiesRaw), + "timestamp": time.Now().Unix(), + }, + }) } diff --git a/pkg/telemetry/helpers.go b/pkg/telemetry/helpers.go index 82b69af7a..e3e61fe9d 100644 --- a/pkg/telemetry/helpers.go +++ b/pkg/telemetry/helpers.go @@ -1,71 +1,30 @@ package telemetry import ( - "os" - "runtime" + "crypto/hmac" + "crypto/sha256" + "fmt" - "github.com/loft-sh/devpod/pkg/encoding" - "github.com/loft-sh/devpod/pkg/telemetry/types" - "github.com/loft-sh/devpod/pkg/version" - "github.com/spf13/cobra" - "github.com/spf13/pflag" + "github.com/denisbrodbeck/machineid" + "github.com/mitchellh/go-homedir" ) -const ( - UIEnvVar = "DEVPOD_UI" -) - -func (d *DefaultCollector) getInstanceProperties(command *cobra.Command, executionID string, ts int64) types.InstanceProperties { - p := types.InstanceProperties{ - Timestamp: ts, - ExecutionID: executionID, - UID: encoding.GetMachineUID(nil), - Arch: runtime.GOARCH, - OS: runtime.GOOS, - Version: getVersion(), - Flags: getFlags(command), - UI: isUIEvent(), +// GetMachineID retrieves machine ID and encodes it together with users $HOME path and +// extra key to protect privacy. Returns a hex-encoded string. +func GetMachineID() string { + id, err := machineid.ID() + if err != nil { + id = "error" } - return p -} - -func getVersion() types.Version { - return types.Version{ - Major: version.GetMajorVersion(), - Minor: version.GetMinorVersion(), - Patch: version.GetPatchVersion(), - PreRelease: version.GetPrerelease(), - Build: version.GetBuild(), - } -} - -func getFlags(command *cobra.Command) types.Flags { - if command == nil { - return types.Flags{} + // get $HOME to distinguish two users on the same machine + // will be hashed later together with the ID + home, err := homedir.Dir() + if err != nil { + home = "error" } - setFlags := []string{} - command.Flags().VisitAll(func(f *pflag.Flag) { - if f.Changed { - setFlags = append(setFlags, f.Name) - } - }) - - return types.Flags{SetFlags: setFlags} -} - -func shouldSkipCommand(cmd string) bool { - if isUIEvent() { - for _, exception := range UIEventsExceptions { - if cmd == exception { - return true - } - } - } - return false -} - -func isUIEvent() bool { - return os.Getenv(UIEnvVar) == "true" + mac := hmac.New(sha256.New, []byte(id)) + mac.Write([]byte(home)) + return fmt.Sprintf("%x", mac.Sum(nil)) } diff --git a/pkg/telemetry/key.go b/pkg/telemetry/key.go deleted file mode 100644 index 8951e4bfd..000000000 --- a/pkg/telemetry/key.go +++ /dev/null @@ -1,35 +0,0 @@ -package telemetry - -import ( - "crypto" - "crypto/ecdsa" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "fmt" -) - -func parsePrivateKey(raw []byte) (crypto.PrivateKey, error) { - block, _ := pem.Decode(raw) - if block == nil { - return nil, fmt.Errorf("empty private key block") - } - - der := block.Bytes - if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { - return key, nil - } - if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { - switch key := key.(type) { - case *rsa.PrivateKey, *ecdsa.PrivateKey: - return key, nil - default: - // its our key and its always rsa so this is fine - return nil, fmt.Errorf("found unknown private key type in PKCS#8 wrapping") - } - } - if key, err := x509.ParseECPrivateKey(der); err == nil { - return key, nil - } - return nil, fmt.Errorf("failed to parse private key") -} diff --git a/pkg/telemetry/noop.go b/pkg/telemetry/noop.go new file mode 100644 index 000000000..9b1631c5c --- /dev/null +++ b/pkg/telemetry/noop.go @@ -0,0 +1,10 @@ +package telemetry + +import "github.com/loft-sh/devpod/pkg/client" + +type noopCollector struct{} + +func (n *noopCollector) RecordCLI(err error) {} +func (n *noopCollector) SetClient(client client.BaseWorkspaceClient) {} + +func (n *noopCollector) Flush() {} diff --git a/pkg/telemetry/serviceaccount/jwt.go b/pkg/telemetry/serviceaccount/jwt.go deleted file mode 100644 index eb5eef9f9..000000000 --- a/pkg/telemetry/serviceaccount/jwt.go +++ /dev/null @@ -1,203 +0,0 @@ -/* -Copyright 2014 The Kubernetes Authors. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package serviceaccount - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "fmt" - - jose "gopkg.in/square/go-jose.v2" - "gopkg.in/square/go-jose.v2/jwt" -) - -type TokenGenerator interface { - // GenerateToken generates a token which will identify the given - // ServiceAccount. privateClaims is an interface that will be - // serialized into the JWT payload JSON encoding at the root level of - // the payload object. Public claims take precedent over private - // claims i.e. if both claims and privateClaims have an "exp" field, - // the value in claims will be used. - GenerateToken(claims *jwt.Claims, privateClaims interface{}) (string, error) -} - -// JWTTokenGenerator returns a TokenGenerator that generates signed JWT tokens, using the given privateKey. -// privateKey is a PEM-encoded byte array of a private RSA key. -func JWTTokenGenerator(iss string, privateKey interface{}) (TokenGenerator, error) { - var signer jose.Signer - var err error - switch pk := privateKey.(type) { - case *rsa.PrivateKey: - signer, err = signerFromRSAPrivateKey(pk) - if err != nil { - return nil, fmt.Errorf("could not generate signer for RSA keypair: %w", err) - } - case *ecdsa.PrivateKey: - signer, err = signerFromECDSAPrivateKey(pk) - if err != nil { - return nil, fmt.Errorf("could not generate signer for ECDSA keypair: %w", err) - } - case jose.OpaqueSigner: - signer, err = signerFromOpaqueSigner(pk) - if err != nil { - return nil, fmt.Errorf("could not generate signer for OpaqueSigner: %w", err) - } - default: - return nil, fmt.Errorf("unknown private key type %T, must be *rsa.PrivateKey, *ecdsa.PrivateKey, or jose.OpaqueSigner", privateKey) - } - - return &jwtTokenGenerator{ - iss: iss, - signer: signer, - }, nil -} - -// keyIDFromPublicKey derives a key ID non-reversibly from a public key. -// -// The Key ID is field on a given on JWTs and JWKs that help relying parties -// pick the correct key for verification when the identity party advertises -// multiple keys. -// -// Making the derivation non-reversible makes it impossible for someone to -// accidentally obtain the real key from the key ID and use it for token -// validation. -func keyIDFromPublicKey(publicKey interface{}) (string, error) { - publicKeyDERBytes, err := x509.MarshalPKIXPublicKey(publicKey) - if err != nil { - return "", fmt.Errorf("failed to serialize public key to DER format: %w", err) - } - - hasher := crypto.SHA256.New() - _, _ = hasher.Write(publicKeyDERBytes) - publicKeyDERHash := hasher.Sum(nil) - - keyID := base64.RawURLEncoding.EncodeToString(publicKeyDERHash) - - return keyID, nil -} - -func signerFromRSAPrivateKey(keyPair *rsa.PrivateKey) (jose.Signer, error) { - keyID, err := keyIDFromPublicKey(&keyPair.PublicKey) - if err != nil { - return nil, fmt.Errorf("failed to derive keyID: %w", err) - } - - // IMPORTANT: If this function is updated to support additional key sizes, - // algorithmForPublicKey in serviceaccount/openidmetadata.go must also be - // updated to support the same key sizes. Today we only support RS256. - - // Wrap the RSA keypair in a JOSE JWK with the designated key ID. - privateJWK := &jose.JSONWebKey{ - Algorithm: string(jose.RS256), - Key: keyPair, - KeyID: keyID, - Use: "sig", - } - - signer, err := jose.NewSigner( - jose.SigningKey{ - Algorithm: jose.RS256, - Key: privateJWK, - }, - nil, - ) - - if err != nil { - return nil, fmt.Errorf("failed to create signer: %w", err) - } - - return signer, nil -} - -func signerFromECDSAPrivateKey(keyPair *ecdsa.PrivateKey) (jose.Signer, error) { - var alg jose.SignatureAlgorithm - switch keyPair.Curve { - case elliptic.P256(): - alg = jose.ES256 - case elliptic.P384(): - alg = jose.ES384 - case elliptic.P521(): - alg = jose.ES512 - default: - return nil, fmt.Errorf("unknown private key curve, must be 256, 384, or 521") - } - - keyID, err := keyIDFromPublicKey(&keyPair.PublicKey) - if err != nil { - return nil, fmt.Errorf("failed to derive keyID: %w", err) - } - - // Wrap the ECDSA keypair in a JOSE JWK with the designated key ID. - privateJWK := &jose.JSONWebKey{ - Algorithm: string(alg), - Key: keyPair, - KeyID: keyID, - Use: "sig", - } - - signer, err := jose.NewSigner( - jose.SigningKey{ - Algorithm: alg, - Key: privateJWK, - }, - nil, - ) - if err != nil { - return nil, fmt.Errorf("failed to create signer: %w", err) - } - - return signer, nil -} - -func signerFromOpaqueSigner(opaqueSigner jose.OpaqueSigner) (jose.Signer, error) { - alg := jose.SignatureAlgorithm(opaqueSigner.Public().Algorithm) - - signer, err := jose.NewSigner( - jose.SigningKey{ - Algorithm: alg, - Key: &jose.JSONWebKey{ - Algorithm: string(alg), - Key: opaqueSigner, - KeyID: opaqueSigner.Public().KeyID, - Use: "sig", - }, - }, - nil, - ) - if err != nil { - return nil, fmt.Errorf("failed to create signer: %w", err) - } - - return signer, nil -} - -type jwtTokenGenerator struct { - iss string - signer jose.Signer -} - -func (j *jwtTokenGenerator) GenerateToken(claims *jwt.Claims, privateClaims interface{}) (string, error) { - // claims are applied in reverse precedence - return jwt.Signed(j.signer). - Claims(privateClaims). - Claims(claims). - Claims(&jwt.Claims{ - Issuer: j.iss, - }). - CompactSerialize() -} diff --git a/pkg/telemetry/types/types.go b/pkg/telemetry/types/types.go deleted file mode 100644 index 6acb7d9f7..000000000 --- a/pkg/telemetry/types/types.go +++ /dev/null @@ -1,66 +0,0 @@ -package types - -/* -* Keep the dependencies of this package minimal to make it easy to import - */ - -type TelemetryRequest struct { - EventType EventType `json:"type,omitempty"` - Event interface{} `json:"event,omitempty"` - InstanceProperties InstanceProperties `json:"instanceProperties,omitempty"` - Token string `json:"token,omitempty"` -} - -// Uses semver spec -type Version struct { - Major string `json:"major,omitempty"` - Minor string `json:"minor,omitempty"` - Patch string `json:"patch,omitempty"` - PreRelease string `json:"prerelease,omitempty"` - Build string `json:"build,omitempty"` -} - -type InstanceProperties struct { - Timestamp int64 `json:"timestamp,omitempty"` - ExecutionID string `json:"executionID,omitempty"` - UID string `json:"uid,omitempty"` - Arch string `json:"arch,omitempty"` - OS string `json:"os,omitempty"` - Version Version `json:"version,omitempty"` - Flags Flags `json:"flags,omitempty"` - UI bool `json:"ui"` -} - -type EventType string - -const ( - EventCommandStarted EventType = "cmdstarted" - EventCommandFinished EventType = "cmdfinished" -) - -type Event interface{} - -type CMDStartedEvent struct { - // Timestamp represents Unix timestampt in microseconds - Timestamp int64 `json:"timestamp,omitempty"` - ExecutionID string `json:"executionID,omitempty"` - Command string `json:"command,omitempty"` - Provider string `json:"provider,omitempty"` - ProviderVersion string `json:"providerVersion,omitempty"` //TODO: implement a way to get this value -} - -type CMDFinishedEvent struct { - // Time represents Unix timestampt in microseconds - Timestamp int64 `json:"timestamp,omitempty"` - ExecutionID string `json:"executionID,omitempty"` - Command string `json:"command,omitempty"` - Provider string `json:"provider,omitempty"` - ProviderVersion string `json:"providerVersion,omitempty"` //TODO: implement a way to get this value - Success bool `json:"success,omitempty"` - ProcessingTime int `json:"processingTime,omitempty"` - Errors string `json:"errors,omitempty"` -} - -type Flags struct { - SetFlags []string `json:"setFlags,omitempty"` -} diff --git a/vendor/github.com/loft-sh/analytics-client/LICENSE b/vendor/github.com/loft-sh/analytics-client/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/vendor/github.com/loft-sh/analytics-client/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/loft-sh/analytics-client/client/buffer.go b/vendor/github.com/loft-sh/analytics-client/client/buffer.go new file mode 100644 index 000000000..90660eaec --- /dev/null +++ b/vendor/github.com/loft-sh/analytics-client/client/buffer.go @@ -0,0 +1,66 @@ +package client + +import ( + "sync" +) + +func newEventBuffer(size int) *eventBuffer { + return &eventBuffer{ + bufferSize: size, + buffer: make([]Event, 0, size), + fullChan: make(chan struct{}), + } +} + +type eventBuffer struct { + m sync.Mutex + bufferSize int + buffer []Event + + fullOnce sync.Once + fullChan chan struct{} +} + +func (e *eventBuffer) Drain() []Event { + e.m.Lock() + defer e.m.Unlock() + + e.close() + return e.buffer +} + +func (e *eventBuffer) Full() <-chan struct{} { + return e.fullChan +} + +func (e *eventBuffer) IsFull() bool { + e.m.Lock() + defer e.m.Unlock() + + return len(e.buffer) >= e.bufferSize +} + +func (e *eventBuffer) Append(ev Event) bool { + e.m.Lock() + defer e.m.Unlock() + + // add to buffer if below capacity + wasAdded := false + if len(e.buffer) < e.bufferSize { + e.buffer = append(e.buffer, ev) + wasAdded = true + } + + // we drop the event here if buffer is full + if len(e.buffer) >= e.bufferSize { + e.close() + } + + return wasAdded +} + +func (e *eventBuffer) close() { + e.fullOnce.Do(func() { + close(e.fullChan) + }) +} diff --git a/vendor/github.com/loft-sh/analytics-client/client/client.go b/vendor/github.com/loft-sh/analytics-client/client/client.go new file mode 100644 index 000000000..3a343182b --- /dev/null +++ b/vendor/github.com/loft-sh/analytics-client/client/client.go @@ -0,0 +1,179 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + klog "k8s.io/klog/v2" +) + +const ( + defaultEndpoint = "https://analytics.loft.rocks/v1/insert" + + eventsCountThreshold = 100 + + maxUploadInterval = 5 * time.Minute + minUploadInterval = time.Second * 30 +) + +var Dry = false + +func NewClient() Client { + c := &client{ + endpoint: defaultEndpoint, + + buffer: newEventBuffer(eventsCountThreshold), + overflow: newEventBuffer(eventsCountThreshold), + + events: make(chan Event, 100), + httpClient: http.Client{Timeout: time.Second * 3}, + } + + // start sending events in an interval + go c.loop() + + return c +} + +type client struct { + buffer *eventBuffer + overflow *eventBuffer + droppedEvents int + bufferMutex sync.Mutex + + events chan Event + + endpoint string + + httpClient http.Client +} + +func (c *client) RecordEvent(event Event) { + c.events <- event +} + +func (c *client) Flush() { + // check if buffer is full + c.bufferMutex.Lock() + isFull := c.buffer.IsFull() + c.bufferMutex.Unlock() + + // wait for remaining events if flush was triggered without being full + if !isFull { + startTime := time.Now() + for time.Since(startTime) < time.Millisecond*500 { + time.Sleep(time.Millisecond * 10) + if len(c.events) == 0 { + break + } + } + } + + // execute upload + c.executeUpload(c.exchangeBuffer()) +} + +func (c *client) loop() { + // constantly pull events into this buffer + go func() { + for event := range c.events { + // try to write into buffer first and fallback to overflow buffer + c.bufferMutex.Lock() + if !c.buffer.Append(event) && !c.overflow.Append(event) { + c.droppedEvents++ + } + c.bufferMutex.Unlock() + } + }() + + // constantly loop + for { + // either wait until buffer is full or up to 5 minutes + startWait := time.Now() + c.bufferMutex.Lock() + fullChan := c.buffer.Full() + c.bufferMutex.Unlock() + + // wait until buffer is full or time is up + select { + case <-fullChan: + timeSinceStart := time.Since(startWait) + if timeSinceStart < minUploadInterval { + // wait the rest of the time here before proceeding + time.Sleep(minUploadInterval - timeSinceStart) + } + case <-time.After(maxUploadInterval): + } + + // flush the buffer + c.Flush() + } +} + +func (c *client) executeUpload(buffer []Event) { + if len(buffer) == 0 { + return + } + + // create request object + request := &Request{ + Data: buffer, + } + + // if dry do not send the request and instead just print it + if Dry { + // marshal request + marshaled, err := json.MarshalIndent(request, "", " ") + if err != nil { + klog.V(1).ErrorS(err, "failed to json.Marshal analytics request") + return + } + + klog.InfoS("Send analytics request", "payload", string(marshaled)) + return + } + + // marshal request + marshaled, err := json.Marshal(request) + if err != nil { + klog.V(1).ErrorS(err, "failed to json.Marshal analytics request") + return + } + + // send the telemetry data and ignore the response + resp, err := c.httpClient.Post(c.endpoint, "application/json", bytes.NewReader(marshaled)) + if err != nil { + klog.V(1).ErrorS(err, "error sending analytics request") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + out, err := io.ReadAll(resp.Body) + if err != nil { + klog.Error("error while reading the body") + return + } + klog.V(1).ErrorS(fmt.Errorf("%s%w", string(out), err), "analytics request returned non 200 status code") + } +} + +func (c *client) exchangeBuffer() []Event { + c.bufferMutex.Lock() + defer c.bufferMutex.Unlock() + + if c.droppedEvents > 0 { + klog.V(1).InfoS("events were dropped because analytics buffer was full", "events", c.droppedEvents) + } + + events := c.buffer.Drain() + c.buffer = c.overflow + c.overflow = newEventBuffer(eventsCountThreshold) + c.droppedEvents = 0 + return events +} diff --git a/vendor/github.com/loft-sh/analytics-client/client/noop.go b/vendor/github.com/loft-sh/analytics-client/client/noop.go new file mode 100644 index 000000000..c1f749ba2 --- /dev/null +++ b/vendor/github.com/loft-sh/analytics-client/client/noop.go @@ -0,0 +1,11 @@ +package client + +func NewNoopClient() Client { + return &noopClient{} +} + +type noopClient struct{} + +func (n *noopClient) RecordEvent(event Event) {} + +func (n *noopClient) Flush() {} diff --git a/vendor/github.com/loft-sh/analytics-client/client/types.go b/vendor/github.com/loft-sh/analytics-client/client/types.go new file mode 100644 index 000000000..6a5faf83f --- /dev/null +++ b/vendor/github.com/loft-sh/analytics-client/client/types.go @@ -0,0 +1,15 @@ +package client + +type Event map[string]map[string]interface{} + +type Client interface { + // RecordEvent will record a new event in the client + RecordEvent(Event) + + // Flush forces sending queued events to the server + Flush() +} + +type Request struct { + Data []Event `json:"data,omitempty"` +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 724d87da1..9503c0bea 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -633,6 +633,9 @@ github.com/loft-sh/admin-apis/pkg/licenseapi github.com/loft-sh/agentapi/v4/pkg/apis/loft/cluster github.com/loft-sh/agentapi/v4/pkg/apis/loft/cluster/v1 github.com/loft-sh/agentapi/v4/pkg/apis/loft/storage/v1 +# github.com/loft-sh/analytics-client v0.0.0-20240219162240-2f4c64b2494e +## explicit; go 1.21 +github.com/loft-sh/analytics-client/client # github.com/loft-sh/api/v4 v4.0.0-alpha.6.0.20241129074910-a24d4104d586 ## explicit; go 1.22.5 github.com/loft-sh/api/v4/pkg/apis/audit/v1