Skip to content

Commit b9b62d8

Browse files
authored
feat: add posthog telemetry (#5019)
* feat: add posthog telemetry * fix(cli): cursor agent detection * feat: use single flag map and explicit safe flag annotation * feat: add env vars signal telemetry * fix: lint add goreleaser changes * chore: group telemetry constants
1 parent 57b98b7 commit b9b62d8

32 files changed

Lines changed: 2105 additions & 27 deletions

.github/workflows/release-beta.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ jobs:
5252
env:
5353
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5454
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
55+
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
56+
POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }}
5557

5658
- run: gh release edit v${{ needs.release.outputs.new-release-version }} --draft=false --prerelease
5759
env:

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ builds:
66
flags:
77
- -trimpath
88
ldflags:
9-
- -s -w -X github.com/supabase/cli/internal/utils.Version={{.Version}} -X github.com/supabase/cli/internal/utils.SentryDsn={{ .Env.SENTRY_DSN }}
9+
- -s -w -X github.com/supabase/cli/internal/utils.Version={{.Version}} -X github.com/supabase/cli/internal/utils.SentryDsn={{ .Env.SENTRY_DSN }} -X github.com/supabase/cli/internal/utils.PostHogAPIKey={{ .Env.POSTHOG_API_KEY }} -X github.com/supabase/cli/internal/utils.PostHogEndpoint={{ .Env.POSTHOG_ENDPOINT }}
1010
env:
1111
- CGO_ENABLED=0
1212
targets:

cmd/branches.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ var (
201201
func init() {
202202
branchFlags := branchesCmd.PersistentFlags()
203203
branchFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
204+
markFlagTelemetrySafe(branchFlags.Lookup("project-ref"))
204205
createFlags := branchCreateCmd.Flags()
205206
createFlags.Var(&region, "region", "Select a region to deploy the branch database.")
206207
createFlags.Var(&size, "size", "Select a desired instance size for the branch database.")

cmd/functions.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ var (
138138

139139
func init() {
140140
functionsListCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
141+
markFlagTelemetrySafe(functionsListCmd.Flags().Lookup("project-ref"))
141142
functionsDeleteCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
143+
markFlagTelemetrySafe(functionsDeleteCmd.Flags().Lookup("project-ref"))
142144
deployFlags := functionsDeployCmd.Flags()
143145
deployFlags.BoolVar(&useApi, "use-api", false, "Bundle functions server-side without using Docker.")
144146
deployFlags.BoolVar(&useDocker, "use-docker", true, "Use Docker to bundle functions.")
@@ -150,6 +152,7 @@ func init() {
150152
deployFlags.BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
151153
deployFlags.BoolVar(&prune, "prune", false, "Delete Functions that exist in Supabase project but not locally.")
152154
deployFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
155+
markFlagTelemetrySafe(deployFlags.Lookup("project-ref"))
153156
deployFlags.StringVar(&importMapPath, "import-map", "", "Path to import map file.")
154157
functionsServeCmd.Flags().BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.")
155158
functionsServeCmd.Flags().StringVar(&envFilePath, "env-file", "", "Path to an env file to be populated to the Function environment.")
@@ -162,6 +165,7 @@ func init() {
162165
cobra.CheckErr(functionsServeCmd.Flags().MarkHidden("all"))
163166
downloadFlags := functionsDownloadCmd.Flags()
164167
downloadFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
168+
markFlagTelemetrySafe(downloadFlags.Lookup("project-ref"))
165169
downloadFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.")
166170
downloadFlags.BoolVar(&useApi, "use-api", false, "Unbundle functions server-side without using Docker.")
167171
downloadFlags.BoolVar(&useDocker, "use-docker", true, "Use Docker to unbundle functions client-side.")

cmd/gen.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ func init() {
149149
typeFlags.Bool("linked", false, "Generate types from the linked project.")
150150
typeFlags.String("db-url", "", "Generate types from a database url.")
151151
typeFlags.StringVar(&flags.ProjectRef, "project-id", "", "Generate types from a project ID.")
152+
markFlagTelemetrySafe(typeFlags.Lookup("project-id"))
152153
genTypesCmd.MarkFlagsMutuallyExclusive("local", "linked", "project-id", "db-url")
153154
typeFlags.Var(&lang, "lang", "Output language of the generated types.")
154155
typeFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.")
@@ -162,6 +163,7 @@ func init() {
162163
genCmd.AddCommand(genTypesCmd)
163164
keyFlags := genKeysCmd.Flags()
164165
keyFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
166+
markFlagTelemetrySafe(keyFlags.Lookup("project-ref"))
165167
keyFlags.StringSliceVar(&override, "override-name", []string{}, "Override specific variable names.")
166168
genCmd.AddCommand(genKeysCmd)
167169
signingKeyFlags := genSigningKeyCmd.Flags()

cmd/link.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ var (
4949
func init() {
5050
linkFlags := linkCmd.Flags()
5151
linkFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
52+
markFlagTelemetrySafe(linkFlags.Lookup("project-ref"))
5253
linkFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.")
5354
linkFlags.BoolVar(&skipPooler, "skip-pooler", false, "Use direct connection instead of pooler.")
5455
// For some reason, BindPFlag only works for StringVarP instead of StringP

cmd/migration.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ func init() {
131131
// Build squash command
132132
squashFlags := migrationSquashCmd.Flags()
133133
squashFlags.StringVar(&migrationVersion, "version", "", "Squash up to the specified version.")
134+
markFlagTelemetrySafe(squashFlags.Lookup("version"))
134135
squashFlags.String("db-url", "", "Squashes migrations of the database specified by the connection string (must be percent-encoded).")
135136
squashFlags.Bool("linked", false, "Squashes the migration history of the linked project.")
136137
squashFlags.Bool("local", true, "Squashes the migration history of the local database.")

cmd/projects.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ func init() {
134134
createFlags.BoolVarP(&interactive, "interactive", "i", true, "Enables interactive mode.")
135135
cobra.CheckErr(createFlags.MarkHidden("interactive"))
136136
createFlags.StringVar(&orgId, "org-id", "", "Organization ID to create the project in.")
137+
markFlagTelemetrySafe(createFlags.Lookup("org-id"))
137138
createFlags.StringVar(&dbPassword, "db-password", "", "Database password of the project.")
138139
createFlags.Var(&region, "region", "Select a region close to you for the best performance.")
139140
createFlags.String("plan", "", "Select a plan that suits your needs.")
@@ -143,6 +144,7 @@ func init() {
143144

144145
apiKeysFlags := projectsApiKeysCmd.Flags()
145146
apiKeysFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
147+
markFlagTelemetrySafe(apiKeysFlags.Lookup("project-ref"))
146148

147149
// Add commands to root
148150
projectsCmd.AddCommand(projectsCreateCmd)

cmd/root.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/spf13/cobra"
1818
"github.com/spf13/viper"
1919
"github.com/supabase/cli/internal/debug"
20+
"github.com/supabase/cli/internal/telemetry"
2021
"github.com/supabase/cli/internal/utils"
2122
"github.com/supabase/cli/internal/utils/flags"
2223
"golang.org/x/mod/semver"
@@ -122,6 +123,24 @@ var (
122123
fmt.Fprintln(os.Stderr, cmd.Root().Short)
123124
fmt.Fprintf(os.Stderr, "Using profile: %s (%s)\n", utils.CurrentProfile.Name, utils.CurrentProfile.ProjectHost)
124125
}
126+
isTTY := telemetryIsTTY()
127+
isCI := telemetryIsCI()
128+
isAgent := telemetryIsAgent()
129+
envSignals := telemetryEnvSignals()
130+
service, err := telemetry.NewService(fsys, telemetry.Options{
131+
Now: time.Now,
132+
IsTTY: isTTY,
133+
IsCI: isCI,
134+
IsAgent: isAgent,
135+
EnvSignals: envSignals,
136+
CLIName: utils.Version,
137+
})
138+
if err != nil {
139+
fmt.Fprintln(utils.GetDebugLogger(), err)
140+
} else {
141+
ctx = telemetry.WithService(ctx, service)
142+
}
143+
ctx = telemetry.WithCommandContext(ctx, commandAnalyticsContext(cmd))
125144
cmd.SetContext(ctx)
126145
// Setup sentry last to ignore errors from parsing cli flags
127146
apiHost, err := url.Parse(utils.GetSupabaseAPIHost())
@@ -137,11 +156,26 @@ var (
137156

138157
func Execute() {
139158
defer recoverAndExit()
140-
if err := rootCmd.Execute(); err != nil {
159+
startedAt := time.Now()
160+
executedCmd, err := rootCmd.ExecuteC()
161+
if executedCmd != nil {
162+
if service := telemetry.FromContext(executedCmd.Context()); service != nil {
163+
_ = service.Capture(executedCmd.Context(), telemetry.EventCommandExecuted, map[string]any{
164+
telemetry.PropExitCode: exitCode(err),
165+
telemetry.PropDurationMs: time.Since(startedAt).Milliseconds(),
166+
}, nil)
167+
_ = service.Close()
168+
}
169+
}
170+
if err != nil {
141171
panic(err)
142172
}
143173
// Check upgrade last because --version flag is initialised after execute
144-
version, err := checkUpgrade(rootCmd.Context(), afero.NewOsFs())
174+
ctx := rootCmd.Context()
175+
if executedCmd != nil {
176+
ctx = executedCmd.Context()
177+
}
178+
version, err := checkUpgrade(ctx, afero.NewOsFs())
145179
if err != nil {
146180
fmt.Fprintln(utils.GetDebugLogger(), err)
147181
}
@@ -153,6 +187,13 @@ func Execute() {
153187
}
154188
}
155189

190+
func exitCode(err error) int {
191+
if err != nil {
192+
return 1
193+
}
194+
return 0
195+
}
196+
156197
func checkUpgrade(ctx context.Context, fsys afero.Fs) (string, error) {
157198
if shouldFetchRelease(fsys) {
158199
version, err := utils.GetLatestRelease(ctx)

cmd/root_analytics.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"sort"
6+
"strconv"
7+
"strings"
8+
9+
"github.com/google/uuid"
10+
"github.com/spf13/cobra"
11+
"github.com/spf13/pflag"
12+
"github.com/supabase/cli/internal/telemetry"
13+
"github.com/supabase/cli/internal/utils"
14+
"github.com/supabase/cli/internal/utils/agent"
15+
"golang.org/x/term"
16+
)
17+
18+
const (
19+
telemetrySafeValueAnnotation = "supabase.com/telemetry-safe-value"
20+
redactedTelemetryValue = "<redacted>"
21+
maxTelemetryEnvValueLength = 80
22+
)
23+
24+
func commandAnalyticsContext(cmd *cobra.Command) telemetry.CommandContext {
25+
return telemetry.CommandContext{
26+
RunID: uuid.NewString(),
27+
Command: commandName(cmd),
28+
Flags: changedFlagValues(cmd),
29+
}
30+
}
31+
32+
func commandName(cmd *cobra.Command) string {
33+
path := strings.TrimSpace(cmd.CommandPath())
34+
rootName := strings.TrimSpace(cmd.Root().Name())
35+
if path == rootName || path == "" {
36+
return rootName
37+
}
38+
return strings.TrimSpace(strings.TrimPrefix(path, rootName))
39+
}
40+
41+
func changedFlagValues(cmd *cobra.Command) map[string]any {
42+
flags := changedFlags(cmd)
43+
if len(flags) == 0 {
44+
return nil
45+
}
46+
values := make(map[string]any, len(flags))
47+
for _, flag := range flags {
48+
values[flag.Name] = telemetryFlagValue(flag)
49+
}
50+
return values
51+
}
52+
53+
func changedFlags(cmd *cobra.Command) []*pflag.Flag {
54+
seen := make(map[string]struct{})
55+
var result []*pflag.Flag
56+
collect := func(flags *pflag.FlagSet) {
57+
if flags == nil {
58+
return
59+
}
60+
flags.Visit(func(flag *pflag.Flag) {
61+
if _, ok := seen[flag.Name]; ok {
62+
return
63+
}
64+
seen[flag.Name] = struct{}{}
65+
result = append(result, flag)
66+
})
67+
}
68+
for current := cmd; current != nil; current = current.Parent() {
69+
collect(current.PersistentFlags())
70+
}
71+
collect(cmd.Flags())
72+
sort.Slice(result, func(i, j int) bool {
73+
return result[i].Name < result[j].Name
74+
})
75+
return result
76+
}
77+
78+
func markFlagTelemetrySafe(flag *pflag.Flag) {
79+
if flag == nil {
80+
return
81+
}
82+
if flag.Annotations == nil {
83+
flag.Annotations = map[string][]string{}
84+
}
85+
flag.Annotations[telemetrySafeValueAnnotation] = []string{"true"}
86+
}
87+
88+
func telemetryFlagValue(flag *pflag.Flag) any {
89+
if flag == nil {
90+
return nil
91+
}
92+
if isTelemetrySafeFlag(flag) || isBooleanFlag(flag) || isEnumFlag(flag) {
93+
return actualTelemetryFlagValue(flag)
94+
}
95+
return redactedTelemetryValue
96+
}
97+
98+
func isTelemetrySafeFlag(flag *pflag.Flag) bool {
99+
if flag == nil || flag.Annotations == nil {
100+
return false
101+
}
102+
values, ok := flag.Annotations[telemetrySafeValueAnnotation]
103+
return ok && len(values) > 0 && values[0] == "true"
104+
}
105+
106+
func isBooleanFlag(flag *pflag.Flag) bool {
107+
return flag != nil && flag.Value.Type() == "bool"
108+
}
109+
110+
func isEnumFlag(flag *pflag.Flag) bool {
111+
if flag == nil {
112+
return false
113+
}
114+
_, ok := flag.Value.(*utils.EnumFlag)
115+
return ok
116+
}
117+
118+
func actualTelemetryFlagValue(flag *pflag.Flag) any {
119+
if isBooleanFlag(flag) {
120+
value, err := strconv.ParseBool(flag.Value.String())
121+
if err == nil {
122+
return value
123+
}
124+
}
125+
return flag.Value.String()
126+
}
127+
128+
func telemetryIsCI() bool {
129+
return os.Getenv("CI") != "" ||
130+
os.Getenv("GITHUB_ACTIONS") != "" ||
131+
os.Getenv("BUILDKITE") != "" ||
132+
os.Getenv("TF_BUILD") != "" ||
133+
os.Getenv("JENKINS_URL") != "" ||
134+
os.Getenv("GITLAB_CI") != ""
135+
}
136+
137+
func telemetryIsTTY() bool {
138+
return term.IsTerminal(int(os.Stdout.Fd())) //nolint:gosec // G115: stdout fd is a small int on supported platforms
139+
}
140+
141+
func telemetryIsAgent() bool {
142+
return agent.IsAgent()
143+
}
144+
145+
func telemetryEnvSignals() map[string]any {
146+
return envSignals(telemetry.EnvSignalPresenceKeys[:], telemetry.EnvSignalValueKeys[:])
147+
}
148+
149+
func envSignals(presenceKeys []string, valueKeys []string) map[string]any {
150+
signals := make(map[string]any, len(presenceKeys)+len(valueKeys))
151+
for _, key := range presenceKeys {
152+
if hasTelemetryEnvValue(key) {
153+
signals[key] = true
154+
}
155+
}
156+
for _, key := range valueKeys {
157+
if value := telemetryEnvValue(key); value != "" {
158+
signals[key] = value
159+
}
160+
}
161+
if len(signals) == 0 {
162+
return nil
163+
}
164+
return signals
165+
}
166+
167+
func hasTelemetryEnvValue(key string) bool {
168+
return strings.TrimSpace(os.Getenv(key)) != ""
169+
}
170+
171+
func telemetryEnvValue(key string) string {
172+
value := strings.TrimSpace(os.Getenv(key))
173+
if value == "" {
174+
return ""
175+
}
176+
if len(value) > maxTelemetryEnvValueLength {
177+
return value[:maxTelemetryEnvValueLength]
178+
}
179+
return value
180+
}

0 commit comments

Comments
 (0)