|
16 | 16 | package cmd |
17 | 17 |
|
18 | 18 | import ( |
19 | | - "context" |
| 19 | + "crypto/sha256" |
20 | 20 | "errors" |
21 | 21 | "fmt" |
22 | 22 | "os" |
23 | 23 | "path/filepath" |
| 24 | + "strings" |
| 25 | + "sync" |
24 | 26 |
|
25 | 27 | "github.com/adrg/xdg" |
26 | 28 | "github.com/chainloop-dev/chainloop/app/cli/internal/action" |
| 29 | + "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry" |
| 30 | + "github.com/chainloop-dev/chainloop/app/cli/internal/telemetry/posthog" |
27 | 31 | "github.com/chainloop-dev/chainloop/internal/grpcconn" |
28 | 32 | "github.com/golang-jwt/jwt/v4" |
29 | 33 | "github.com/rs/zerolog" |
@@ -53,12 +57,16 @@ const ( |
53 | 57 | userAudience = "user-auth.chainloop" |
54 | 58 | //nolint:gosec |
55 | 59 | apiTokenAudience = "api-token-auth.chainloop" |
| 60 | + // Follow the convention stated on https://consoledonottrack.com/ |
| 61 | + doNotTrackEnv = "DO_NOT_TRACK" |
56 | 62 | ) |
57 | 63 |
|
58 | | -type AuthenticationToken struct{} |
59 | | -type ParsedToken struct { |
60 | | - ID string |
61 | | - Type string |
| 64 | +var telemetryWg sync.WaitGroup |
| 65 | + |
| 66 | +type parsedToken struct { |
| 67 | + id string |
| 68 | + orgID string |
| 69 | + tokenType string |
62 | 70 | } |
63 | 71 |
|
64 | 72 | func NewRootCmd(l zerolog.Logger) *cobra.Command { |
@@ -92,15 +100,28 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { |
92 | 100 |
|
93 | 101 | actionOpts = newActionOpts(logger, conn) |
94 | 102 |
|
95 | | - // For telemetry reasons we parse the token to know the type of token is being used when executing the CLI |
96 | | - // Once we have the token type we can send it to the telemetry service by injecting it on the context |
97 | | - token, err := parseToken(apiToken) |
98 | | - if err != nil { |
99 | | - logger.Debug().Err(err).Msg("parsing token for telemetry") |
| 103 | + if !isTelemetryDisabled() { |
| 104 | + logger.Debug().Msg("Telemetry enabled, to disable it use DO_NOT_TRACK=1") |
| 105 | + |
| 106 | + telemetryWg.Add(1) |
| 107 | + go func() { |
| 108 | + defer telemetryWg.Done() |
| 109 | + |
| 110 | + // For telemetry reasons we parse the token to know the type of token is being used when executing the CLI |
| 111 | + // Once we have the token type we can send it to the telemetry service by injecting it on the context |
| 112 | + token, err := parseToken(apiToken) |
| 113 | + if err != nil { |
| 114 | + logger.Debug().Err(err).Msg("parsing token for telemetry") |
| 115 | + return |
| 116 | + } |
| 117 | + |
| 118 | + err = recordCommand(cmd, token) |
| 119 | + if err != nil { |
| 120 | + logger.Debug().Err(err).Msg("sending command to telemetry") |
| 121 | + } |
| 122 | + }() |
100 | 123 | } |
101 | 124 |
|
102 | | - cmd.SetContext(context.WithValue(cmd.Context(), AuthenticationToken{}, token)) |
103 | | - |
104 | 125 | return nil |
105 | 126 | }, |
106 | 127 | PersistentPostRunE: func(cmd *cobra.Command, args []string) error { |
@@ -143,6 +164,16 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { |
143 | 164 |
|
144 | 165 | func init() { |
145 | 166 | cobra.OnInitialize(initConfigFile) |
| 167 | + // Using the cobra.OnFinalize because the hooks don't work on error |
| 168 | + cobra.OnFinalize(func() { |
| 169 | + // In some cases the command is faster than the telemetry, in that case we wait |
| 170 | + telemetryWg.Wait() |
| 171 | + }) |
| 172 | +} |
| 173 | + |
| 174 | +// isTelemetryDisabled checks if the telemetry is disabled by the user or if we are running a development version |
| 175 | +func isTelemetryDisabled() bool { |
| 176 | + return os.Getenv(doNotTrackEnv) == "1" || os.Getenv(doNotTrackEnv) == "true" || Version == devVersion |
146 | 177 | } |
147 | 178 |
|
148 | 179 | func initLogger(logger zerolog.Logger) (zerolog.Logger, error) { |
@@ -239,46 +270,125 @@ func loadControlplaneAuthToken(cmd *cobra.Command) (string, error) { |
239 | 270 | // 3. API token |
240 | 271 | // Each one of them have an associated audience claim that we use to identify the type of token. If the token is not |
241 | 272 | // present, nor we cannot match it with one of the expected audience, return nil. |
242 | | -func parseToken(token string) (*ParsedToken, error) { |
| 273 | +func parseToken(token string) (*parsedToken, error) { |
243 | 274 | if token == "" { |
244 | | - return &ParsedToken{}, nil |
| 275 | + return nil, nil |
245 | 276 | } |
246 | 277 |
|
247 | 278 | // Create a parser without claims validation |
248 | 279 | parser := jwt.NewParser(jwt.WithoutClaimsValidation()) |
249 | 280 |
|
250 | 281 | // Parse the token without verification |
251 | | - parsedToken, _, err := parser.ParseUnverified(token, jwt.MapClaims{}) |
| 282 | + t, _, err := parser.ParseUnverified(token, jwt.MapClaims{}) |
252 | 283 | if err != nil { |
253 | 284 | return nil, err |
254 | 285 | } |
255 | 286 |
|
256 | | - // Extract claims |
257 | | - claims := parsedToken.Claims.(jwt.MapClaims) |
| 287 | + // Extract generic claims otherwise, we would have to parse |
| 288 | + // the token again to get the claims for each type |
| 289 | + claims, ok := t.Claims.(jwt.MapClaims) |
| 290 | + if !ok { |
| 291 | + return nil, nil |
| 292 | + } |
258 | 293 |
|
259 | 294 | // Get the audience claim |
260 | | - if val, ok := claims["aud"]; ok && val != nil { |
261 | | - // Chainloop tokens have only one audience in an array |
262 | | - aud, ok := val.([]interface{}) |
263 | | - if !ok { |
264 | | - return nil, nil |
| 295 | + val, ok := claims["aud"] |
| 296 | + if !ok || val == nil { |
| 297 | + return nil, nil |
| 298 | + } |
| 299 | + |
| 300 | + // Ensure audience is an array of interfaces |
| 301 | + // Chainloop only has one audience per token |
| 302 | + aud, ok := val.([]interface{}) |
| 303 | + if !ok || len(aud) == 0 { |
| 304 | + return nil, nil |
| 305 | + } |
| 306 | + |
| 307 | + // Initialize parsedToken |
| 308 | + pToken := &parsedToken{} |
| 309 | + |
| 310 | + // Determine the type of token based on the audience. |
| 311 | + switch aud[0].(string) { |
| 312 | + case apiTokenAudience: |
| 313 | + pToken.tokenType = "api-token" |
| 314 | + if tokenID, ok := claims["jti"].(string); ok { |
| 315 | + pToken.id = tokenID |
265 | 316 | } |
266 | | - if len(aud) == 0 { |
267 | | - return nil, nil |
| 317 | + if orgID, ok := claims["org_id"].(string); ok { |
| 318 | + pToken.orgID = orgID |
268 | 319 | } |
269 | | - |
270 | | - switch aud[0].(string) { |
271 | | - case apiTokenAudience: |
272 | | - return &ParsedToken{Type: "api-token"}, nil |
273 | | - case userAudience: |
274 | | - userID := claims["user_id"].(string) |
275 | | - return &ParsedToken{Type: "user", ID: userID}, nil |
276 | | - case robotAccountAudience: |
277 | | - return &ParsedToken{Type: "robot-account"}, nil |
278 | | - default: |
279 | | - return nil, nil |
| 320 | + case userAudience: |
| 321 | + pToken.tokenType = "user" |
| 322 | + if userID, ok := claims["user_id"].(string); ok { |
| 323 | + pToken.id = userID |
| 324 | + } |
| 325 | + case robotAccountAudience: |
| 326 | + pToken.tokenType = "robot-account" |
| 327 | + if tokenID, ok := claims["jti"].(string); ok { |
| 328 | + pToken.id = tokenID |
| 329 | + } |
| 330 | + if orgID, ok := claims["org_id"].(string); ok { |
| 331 | + pToken.orgID = orgID |
280 | 332 | } |
| 333 | + default: |
| 334 | + return nil, nil |
| 335 | + } |
| 336 | + |
| 337 | + return pToken, nil |
| 338 | +} |
| 339 | + |
| 340 | +var ( |
| 341 | + // Posthog API key and endpoint to be injected in the build process |
| 342 | + posthogAPIKey = "" |
| 343 | + posthogEndpoint = "" |
| 344 | +) |
| 345 | + |
| 346 | +// recordCommand sends the command to the telemetry service |
| 347 | +func recordCommand(executedCmd *cobra.Command, authInfo *parsedToken) error { |
| 348 | + telemetryClient, err := posthog.NewClient(posthogAPIKey, posthogEndpoint) |
| 349 | + if err != nil { |
| 350 | + logger.Debug().Err(err).Msgf("creating telemetry client: %v", err) |
| 351 | + } |
| 352 | + |
| 353 | + cmdTracker := telemetry.NewCommandTracker(telemetryClient) |
| 354 | + tags := telemetry.Tags{ |
| 355 | + "cli_version": Version, |
| 356 | + "cp_url_hash": hashControlPlaneURL(), |
| 357 | + "chainloop_source": "cli", |
| 358 | + } |
| 359 | + |
| 360 | + // It tries to extract the token from the context and add it to the tags. If it fails, it will ignore it. |
| 361 | + if authInfo != nil { |
| 362 | + tags["token_type"] = authInfo.tokenType |
| 363 | + tags["user_id"] = authInfo.id |
| 364 | + tags["org_id"] = authInfo.orgID |
| 365 | + } |
| 366 | + |
| 367 | + if err = cmdTracker.Track(executedCmd.Context(), extractCmdLineFromCommand(executedCmd), tags); err != nil { |
| 368 | + return fmt.Errorf("sending event: %w", err) |
281 | 369 | } |
282 | 370 |
|
283 | | - return nil, nil |
| 371 | + return nil |
| 372 | +} |
| 373 | + |
| 374 | +// extractCmdLineFromCommand returns the full command hierarchy as a string from a cobra.Command |
| 375 | +func extractCmdLineFromCommand(cmd *cobra.Command) string { |
| 376 | + var cmdHierarchy []string |
| 377 | + currentCmd := cmd |
| 378 | + // While the current command is not the root command, keep iteration. |
| 379 | + // This is done to get the full hierarchy of the command and remove the root command from the hierarchy. |
| 380 | + for currentCmd.Use != "chainloop" { |
| 381 | + cmdHierarchy = append([]string{currentCmd.Use}, cmdHierarchy...) |
| 382 | + currentCmd = currentCmd.Parent() |
| 383 | + } |
| 384 | + |
| 385 | + cmdLine := strings.Join(cmdHierarchy, " ") |
| 386 | + return cmdLine |
| 387 | +} |
| 388 | + |
| 389 | +// hashControlPlaneURL returns a hash of the control plane URL |
| 390 | +func hashControlPlaneURL() string { |
| 391 | + url := viper.GetString("control-plane.API") |
| 392 | + |
| 393 | + return fmt.Sprintf("%x", sha256.Sum256([]byte(url))) |
284 | 394 | } |
0 commit comments