Skip to content

Commit 3968471

Browse files
authored
feat(posthog): Add Telemetry client Posthog (#781)
Signed-off-by: Javier Rodriguez <[email protected]>
1 parent c70fd6a commit 3968471

File tree

9 files changed

+309
-62
lines changed

9 files changed

+309
-62
lines changed

.github/workflows/release.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ jobs:
7575
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
7676
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
7777
COSIGN_KEY: ${{ secrets.COSIGN_KEY }}
78+
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
79+
POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }}
7880

7981
- uses: anchore/sbom-action@c6aed38a4323b393d05372c58a74c39ae8386d02 # v0.15.6
8082
with:

.goreleaser.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ builds:
2828
ldflags:
2929
- "{{ .Env.COMMON_LDFLAGS }}"
3030
- -X github.com/chainloop-dev/chainloop/app/cli/cmd.Version={{ .Version }}
31+
- -X github.com/chainloop-dev/chainloop/app/cli/cmd.posthogAPIKey={{ .Env.POSTHOG_API_KEY }}
32+
- -X github.com/chainloop-dev/chainloop/app/cli/cmd.posthogEndpoint={{ .Env.POSTHOG_ENDPOINT }}
3133
targets:
3234
- darwin_amd64
3335
- darwin_arm64

app/cli/Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ VERSION=$(shell git describe --tags --always)
33
.PHONY: build
44
# build
55
build:
6-
mkdir -p bin/ && go build -ldflags "-X github.com/chainloop-dev/chainloop/app/cli/cmd.Version=$(VERSION)" -o ./bin/chainloop ./main.go
6+
mkdir -p bin/ && go build -ldflags \
7+
"-X github.com/chainloop-dev/chainloop/app/cli/cmd.Version=$(VERSION) \
8+
-X github.com/chainloop-dev/chainloop/app/cli/cmd.posthogAPIKey=${POSTHOG_API_KEY} \
9+
-X github.com/chainloop-dev/chainloop/app/cli/cmd.posthogEndpoint=${POSTHOG_ENDPOINT}" \
10+
-o ./bin/chainloop ./main.go
711

812
.PHONY: test
913
# test

app/cli/cmd/root.go

Lines changed: 146 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@
1616
package cmd
1717

1818
import (
19-
"context"
19+
"crypto/sha256"
2020
"errors"
2121
"fmt"
2222
"os"
2323
"path/filepath"
24+
"strings"
25+
"sync"
2426

2527
"github.com/adrg/xdg"
2628
"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"
2731
"github.com/chainloop-dev/chainloop/internal/grpcconn"
2832
"github.com/golang-jwt/jwt/v4"
2933
"github.com/rs/zerolog"
@@ -53,12 +57,16 @@ const (
5357
userAudience = "user-auth.chainloop"
5458
//nolint:gosec
5559
apiTokenAudience = "api-token-auth.chainloop"
60+
// Follow the convention stated on https://consoledonottrack.com/
61+
doNotTrackEnv = "DO_NOT_TRACK"
5662
)
5763

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
6270
}
6371

6472
func NewRootCmd(l zerolog.Logger) *cobra.Command {
@@ -92,15 +100,28 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
92100

93101
actionOpts = newActionOpts(logger, conn)
94102

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+
}()
100123
}
101124

102-
cmd.SetContext(context.WithValue(cmd.Context(), AuthenticationToken{}, token))
103-
104125
return nil
105126
},
106127
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
@@ -143,6 +164,16 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
143164

144165
func init() {
145166
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
146177
}
147178

148179
func initLogger(logger zerolog.Logger) (zerolog.Logger, error) {
@@ -239,46 +270,125 @@ func loadControlplaneAuthToken(cmd *cobra.Command) (string, error) {
239270
// 3. API token
240271
// Each one of them have an associated audience claim that we use to identify the type of token. If the token is not
241272
// 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) {
243274
if token == "" {
244-
return &ParsedToken{}, nil
275+
return nil, nil
245276
}
246277

247278
// Create a parser without claims validation
248279
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
249280

250281
// Parse the token without verification
251-
parsedToken, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
282+
t, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
252283
if err != nil {
253284
return nil, err
254285
}
255286

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+
}
258293

259294
// 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
265316
}
266-
if len(aud) == 0 {
267-
return nil, nil
317+
if orgID, ok := claims["org_id"].(string); ok {
318+
pToken.orgID = orgID
268319
}
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
280332
}
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)
281369
}
282370

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)))
284394
}

app/cli/cmd/root_test.go

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,38 +25,68 @@ func TestParseToken(t *testing.T) {
2525
tests := []struct {
2626
name string
2727
token string
28-
want ParsedToken
28+
want *parsedToken
2929
}{
3030
{
3131
name: "robot account",
32-
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdfaWQiOiI5M2QwMjI3NS04NTNjLTRhZDYtOWQ2MC04ZjU2MmIxMjNmZDIiLCJ3b3JrZmxvd19pZCI6IjM1ZTZkOGIwLWE0OGYtNDFmYS05YmU3LWQ1OTM5YjJkZGUyNiIsImlzcyI6ImNwLmNoYWlubG9vcCIsImF1ZCI6WyJhdHRlc3RhdGlvbnMuY2hhaW5sb29wIl0sImp0aSI6IjQ4YWVhMWNiLTk5MGUtNDM2OS1hOTFhLTczNTIzNzk1NjhiNSJ9.aYPAPK-AauJpxRGEapV2ejwxhyhmFej6S79Ni-xJ4Q0",
33-
want: ParsedToken{
34-
Type: "robot-account",
32+
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdfaWQiOiJkZGRiODIwMS1lYWI2LTRlNjEtOTIwMS1mMTJiNDdjMDE4OTIiLCJ3b3JrZmxvd19pZCI6ImY0NmIzMDc5LTMwOGYtNGIwNC1hYjYwLTY3NDNmOGUzMGQzMyIsImlzcyI6ImNwLmNoYWlubG9vcCIsImF1ZCI6WyJhdHRlc3RhdGlvbnMuY2hhaW5sb29wIl0sImp0aSI6IjkzODI5NDE1LTA1ODYtNDFkYS05NTJkLTkyYTRjNDk1ZWEyMCJ9.3Af2C62PiODbEknhv47j0LHLuAgWLqvTrfmIzFjwPCM",
33+
want: &parsedToken{
34+
id: "93829415-0586-41da-952d-92a4c495ea20",
35+
tokenType: "robot-account",
36+
orgID: "dddb8201-eab6-4e61-9201-f12b47c01892",
3537
},
3638
},
3739
{
3840
name: "user account",
3941
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYmMwYjIxOTktY2E4NS00MmFiLWE4NTctMDQyZTljMTA5ZDQzIiwiaXNzIjoiY3AuY2hhaW5sb29wIiwiYXVkIjpbInVzZXItYXV0aC5jaGFpbmxvb3AiXSwiZXhwIjoxNzE1OTM1MjUwfQ.ounYshGtagtYQsVIzNeE0ztVYRXrmjFSpdmaTF4QvyY",
40-
want: ParsedToken{
41-
ID: "bc0b2199-ca85-42ab-a857-042e9c109d43",
42-
Type: "user",
42+
want: &parsedToken{
43+
id: "bc0b2199-ca85-42ab-a857-042e9c109d43",
44+
tokenType: "user",
4345
},
4446
},
4547
{
4648
name: "api token",
47-
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjcC5jaGFpbmxvb3AiLCJhdWQiOlsiYXBpLXRva2VuLWF1dGguY2hhaW5sb29wIl0sImp0aSI6IjE0NTQ5MzUwLTFjNGItNDdlYi05NDZkLWY2MjJhZWYyMDk0MyJ9.BPzuNxSwx10h22fJ3ocAOEIjsq9OOlk9p8fSoCwqSmM",
48-
want: ParsedToken{
49-
Type: "api-token",
49+
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdfaWQiOiJkZGRiODIwMS1lYWI2LTRlNjEtOTIwMS1mMTJiNDdjMDE4OTIiLCJpc3MiOiJjcC5jaGFpbmxvb3AiLCJhdWQiOlsiYXBpLXRva2VuLWF1dGguY2hhaW5sb29wIl0sImp0aSI6IjRiMGYwZGQ0LTQ1MzgtNDI2OS05MmE5LWFiNWIwZmNlMDI1OCJ9.yMgsoe4CcqYoNp0xtrvvSGj1Y74HeqxoxS5sw8pdnQ8",
50+
want: &parsedToken{
51+
id: "4b0f0dd4-4538-4269-92a9-ab5b0fce0258",
52+
tokenType: "api-token",
53+
orgID: "dddb8201-eab6-4e61-9201-f12b47c01892",
5054
},
5155
},
56+
{
57+
name: "old api token (without orgID)",
58+
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjcC5jaGFpbmxvb3AiLCJhdWQiOlsiYXBpLXRva2VuLWF1dGguY2hhaW5sb29wIl0sImp0aSI6ImQ0ZTBlZTVlLTk3MTMtNDFkMi05ZmVhLTBiZGIxNDAzMzA4MSJ9.IOd3JIHPwfo9ihU20kvRwLIQJcQtTvp-ajlGqlCD4Es",
59+
want: &parsedToken{
60+
id: "d4e0ee5e-9713-41d2-9fea-0bdb14033081",
61+
tokenType: "api-token",
62+
},
63+
},
64+
{
65+
name: "old robot account",
66+
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdfaWQiOiI5M2QwMjI3NS04NTNjLTRhZDYtOWQ2MC04ZjU2MmIxMjNmZDIiLCJ3b3JrZmxvd19pZCI6IjM1ZTZkOGIwLWE0OGYtNDFmYS05YmU3LWQ1OTM5YjJkZGUyNiIsImlzcyI6ImNwLmNoYWlubG9vcCIsImF1ZCI6WyJhdHRlc3RhdGlvbnMuY2hhaW5sb29wIl0sImp0aSI6ImUxZDUzOGQxLWI2MWQtNGY4MC1iZWQzLWM3MGE1NzRlOWI2NiJ9.81WltZtOno26oNydJ-YtHRwAIEmD2B4RgChhsS4yYVk",
67+
want: &parsedToken{
68+
id: "e1d538d1-b61d-4f80-bed3-c70a574e9b66",
69+
tokenType: "robot-account",
70+
orgID: "93d02275-853c-4ad6-9d60-8f562b123fd2",
71+
},
72+
},
73+
{
74+
name: "totally random token",
75+
token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE3MTYxOTE2ODQsImV4cCI6MTc0NzcyNzY4NCwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.5UnBivwkQCG4qWgi-gWkJ-Dsd7-A9G_EVEvswODc7Kk",
76+
},
5277
}
5378
for _, tt := range tests {
5479
t.Run(tt.name, func(t *testing.T) {
5580
got, err := parseToken(tt.token)
5681
assert.NoError(t, err)
82+
if tt.want == nil {
83+
assert.Nil(t, got)
84+
return
85+
}
5786

58-
assert.Equal(t, tt.want.ID, got.ID)
59-
assert.Equal(t, tt.want.Type, got.Type)
87+
assert.Equal(t, tt.want.id, got.id)
88+
assert.Equal(t, tt.want.tokenType, got.tokenType)
89+
assert.Equal(t, tt.want.orgID, got.orgID)
6090
})
6191
}
6292
}

0 commit comments

Comments
 (0)