Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ All notable changes to this project will be documented in this file.

## Unreleased

- feat/telemetry send anonymous telemetry usage by @mabd-dev

## [1.3.8] - 2026-04-27

## [1.3.8-alpha] - 2026-04-27

### Added

Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,5 +175,27 @@ Each step overrides the one before it
- [ ] Show branches with their states on each repo


## Telemetry

reposcan collects anonymous usage data to help understand how the tool is used and improve it over time.
You'll see a one-time notice about this on first run.

What is collected:
- `os` — operating system (linux, windows, darwin)
- `arch` — device cpu architecture
- `tool-version` — tool version being used
- `ci` — whether the tool is running in a CI environment

and other tool specific cli-flags like `filter`, `output_format`, `repo_count`

Nothing personal is collected — no usernames, tokens, or file paths.
Events are sent to a [mixpanel](https://mixpanel.com/home/) (a third-party analytics service) and visible only to the maintainer.


### Disable telemetry

Add `--no-telemetry` when running the command. Or in `~/.config/reposcan/config.toml` add `no-telemetry = true` at the top of the file (check [sample.toml](sample/config.toml))


## 🤝 Contributing
PRs, bug reports, and feature requests are welcome.
13 changes: 10 additions & 3 deletions cmd/reposcan/flags_read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func TestReadFlags_AppliesAllFlags(t *testing.T) {
cmd.Flags().String("json-output-path", "", "")
cmd.Flags().IntP("max-workers", "w", 8, "")
cmd.Flags().BoolP("debug", "", false, "")
cmd.Flags().BoolP("no-telemetry", "", false, "")
// cmd.Flags().StringP("colorscheme", "", "", "")

args := []string{
Expand All @@ -31,7 +32,8 @@ func TestReadFlags_AppliesAllFlags(t *testing.T) {
"-f", "all",
"--json-output-path", "/tmp/out",
"-w", "16",
"--debug", "true",
"--debug=true",
"--no-telemetry=false",
// "--colorscheme", "something",
}
cmd.SetArgs(args)
Expand Down Expand Up @@ -66,7 +68,10 @@ func TestReadFlags_AppliesAllFlags(t *testing.T) {
t.Fatalf("max workers not applied: %d", cfg.MaxWorkers)
}
if cfg.Debug != true {
t.Fatalf("debugnot applied: %t", cfg.Debug)
t.Fatalf("debug not applied: %t", cfg.Debug)
}
if cfg.NoTelemetry != false {
t.Fatalf("telemetry not applied: %t", cfg.NoTelemetry)
}
Comment thread
mabd-dev marked this conversation as resolved.
}

Expand All @@ -92,6 +97,7 @@ func TestReadTableOutput_SwitchToInteractiveOutput(t *testing.T) {
cmd.Flags().String("json-output-path", "", "")
cmd.Flags().IntP("max-workers", "w", 8, "")
cmd.Flags().BoolP("debug", "", false, "")
cmd.Flags().BoolP("no-telemetry", "", false, "")
// cmd.Flags().StringP("colorscheme", "", "", "")

args := []string{
Expand All @@ -103,7 +109,8 @@ func TestReadTableOutput_SwitchToInteractiveOutput(t *testing.T) {
"-f", "all",
"--json-output-path", "/tmp/out",
"-w", "16",
"--debug", "true",
"--debug=true",
"--no-telemetry=false",
// "--colorscheme", "something",
}
cmd.SetArgs(args)
Expand Down
1 change: 1 addition & 0 deletions cmd/reposcan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func init() {
RootCmd.PersistentFlags().String("json-output-path", configs.Output.JSONPath, "Write scan report JSON files to this directory (optional)")
RootCmd.PersistentFlags().IntP("max-workers", "w", configs.MaxWorkers, "Number of concurrent git checks")
RootCmd.PersistentFlags().BoolP("debug", "", configs.Debug, "Enable/Disable debug mode")
RootCmd.PersistentFlags().BoolP("no-telemetry", "", configs.NoTelemetry, "Enable/Disable sending telemetry")
// RootCmd.PersistentFlags().StringP("colorscheme", "", configs.Output.ColorSchemeName, "Used only if 'output' is 'interactive'")

RootCmd.AddCommand(versionCmd)
Expand Down
12 changes: 12 additions & 0 deletions cmd/reposcan/rootCmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/mabd-dev/reposcan/internal/render/file"
"github.com/mabd-dev/reposcan/internal/render/stdout"
"github.com/mabd-dev/reposcan/internal/render/tui"
"github.com/mabd-dev/reposcan/internal/telemetry"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -72,6 +73,7 @@ var RootCmd = &cobra.Command{
// - max-workers (-w) : number of concurrent git checks
// - debug (--debug) : enable/disable debug mode
// - colorscheme (--colorscheme) : enable/disable debug mode
// - no-telemetry : enable/disable sending telemetry data
func readFlags(cmd *cobra.Command, configs *config.Config) error {
// Read roots flags
roots, err := cmd.Flags().GetStringArray("root")
Expand Down Expand Up @@ -138,12 +140,22 @@ func readFlags(cmd *cobra.Command, configs *config.Config) error {
}
(*configs).Debug = debug

noTelemetry, err := cmd.Flags().GetBool("no-telemetry")
if err != nil {
return err
}
(*configs).NoTelemetry = noTelemetry

return nil
}

func run(configs config.Config) error {
report := internal.GenerateScanReport(configs)

if !configs.NoTelemetry {
telemetry.Send(mixpanelToken, configs.Debug, configs.Only, configs.Output.Type, len(report.RepoStates))
}

switch configs.Output.Type {
case config.OutputJson:
err := stdout.RenderScanReportAsJson(report)
Expand Down
5 changes: 5 additions & 0 deletions docs/cli-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ This document explains each CLI flag, its equivalent `config.toml` field, what i
- Config: `debug = true/false`
- Description: Enable/disable logging mode. Log file will be in `~/.config/reposcan/logs/`
- Example: `--debug=false` or `--debug` same as `--debug=true`

- `--no-telemetry true/false`
- Config: `no-telemetry = true/false`
- Description: Enable/disable sending telemetry data
- Example: `--no-telemetry=true` or `--no-telemetry`
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/mattn/go-runewidth v0.0.16
github.com/mixpanel/mixpanel-go v1.2.1
github.com/muesli/reflow v0.3.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
Expand Down
3 changes: 2 additions & 1 deletion internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ type Config struct {
MaxWorkers int `toml:"maxWorkers"`

// Debug if true, enable logging to a file in [DefaultLogFileDir]
Debug bool `toml:"debug"`
Debug bool `toml:"debug"`
NoTelemetry bool `toml:"no-telemetry"`

Version int `toml:"version"`
}
Expand Down
204 changes: 204 additions & 0 deletions internal/telemetry/telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Package telemetry used to send telemetry usage to mixpanel
package telemetry

import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"

"github.com/google/uuid"
"github.com/mabd-dev/reposcan/internal"
"github.com/mabd-dev/reposcan/internal/analytics"
"github.com/mabd-dev/reposcan/internal/config"
"github.com/mabd-dev/reposcan/internal/logger"
)

var (
toolName = "reposcan"
telemtryFileName = "telemetry.json"
userConfigDir = os.UserConfigDir
newAnalyticsService = analytics.New
stdout io.Writer = os.Stdout
)

type Telemetry struct {
UUID string `json:"uuid"`
Warned bool `json:"warned"`
}

// Send send telemetry usage to analytics service
func Send(
token string,
debug bool,
filter config.OnlyFilter,
outputFormat config.OutputFormat,
repoCount int,
) {
isCI := os.Getenv("CI") != ""
if isCI {
sendTelemetry(token, debug, filter, outputFormat, repoCount, isCI)
return
}
Comment thread
mabd-dev marked this conversation as resolved.
Comment thread
mabd-dev marked this conversation as resolved.

filePath, err := getTelemetryFilePath()
if err != nil {
logger.Error("Failed to get telemetry file path, error=%v", err.Error())
return
}
telemetry, err := getOrCreateTelemetry(filePath)
if err != nil {
return
}

if !telemetry.Warned {
fmt.Fprintln(stdout, "reposcan collects anonymous usage telemetry to help improve the tool.")
fmt.Fprintln(stdout, "No personal data or file paths are collected.")
fmt.Fprintln(stdout, "To disable: pass --no-telemetry")
fmt.Fprintln(stdout, "More info: https://github.com/mabd-dev/reposcan#telemetry")

telemetry.Warned = true
writeTelemetry(filePath, telemetry)
}

sendTelemetry(token, debug, filter, outputFormat, repoCount, isCI)
}

func sendTelemetry(
token string,
debug bool,
filter config.OnlyFilter,
outputFormat config.OutputFormat,
repoCount int,
isCI bool,
) {
analyticsService := newAnalyticsService(token, debug)

err := analyticsService.Send("usage", map[string]any{
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"ci": isCI,
"filter": filter,
"output_format": outputFormat,
"repo_count": repoCount,
"tool-version": internal.VERSION,
"project": toolName,
})
Comment thread
mabd-dev marked this conversation as resolved.

if err != nil {
logger.Error("Failed to send analytics, error")
}
}

func getOrCreateTelemetry(filePath string) (Telemetry, error) {
exists, err := fileExists(filePath)
if err != nil {
return Telemetry{}, err
}
Comment thread
mabd-dev marked this conversation as resolved.

if exists {
telemetry, err := readTelemetry(filePath)
if err != nil {
logger.Error("Failed to read telemetry, error=%v", err.Error())
return Telemetry{}, err
}
return telemetry, nil
}

telemetry := Telemetry{
UUID: uuid.New().String(),
Warned: false,
}
writeTelemetry(filePath, telemetry)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 writeTelemetry error silently discarded — UUID lost on every run

writeTelemetry returns an error, but the return value is ignored here and again at line 65 in Send. If writing the file fails (e.g. a permissions issue on the config dir), no UUID is ever persisted: every subsequent invocation calls getOrCreateTelemetry, finds no file, generates a fresh UUID, fails to write it, and repeats — so all events are sent with different, ephemeral UUIDs, making it impossible to deduplicate users in analytics. Separately, the write at line 65 persisting Warned = true also drops its error, so the consent notice is reprinted on every run whenever the config dir is not writable. Both call sites should check and at least log the returned error.

return telemetry, nil
}

func getTelemetryFilePath() (string, error) {
configDir, err := userConfigDir()
if err != nil {
return "", err
}

dir := filepath.Join(configDir, toolName)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}

filePath := filepath.Join(dir, telemtryFileName)
return filePath, nil
}

func readTelemetry(filepath string) (Telemetry, error) {
data, err := os.ReadFile(filepath)
if err != nil {
return Telemetry{}, err
}

var telemetry Telemetry
if err := json.Unmarshal(data, &telemetry); err != nil {
return Telemetry{}, err
}

return telemetry, nil
}

func writeTelemetry(filePath string, telemetry Telemetry) error {
data, err := json.MarshalIndent(telemetry, "", " ")
if err != nil {
return err
}

path, err := expandPath(filePath)
if err != nil {
return err
}

return os.WriteFile(path, data, 0o644)
}

// FileExists checks if a file exists at the given path.
// Returns (true, nil) if the file exists,
// (false, nil) if it does not exist,
// or (false, err) if an error other than "not exist" occurs.
func fileExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
// file already exists, do nothing
return true, nil
}

if errors.Is(err, os.ErrNotExist) {
// file not found
return false, nil
}

return false, err
}

// expandPath expands a filesystem path that may start with '~' into an
// absolute path using the current user's home directory.
//
// Examples:
//
// expandPath("~/Documents/file.txt") -> "/Users/someone/Documents/file.txt"
// expandPath("/tmp/file.txt") -> "/tmp/file.txt"
//
// Only a leading '~' is expanded. If the path does not start with '~',
// it is returned unchanged.
//
// Returns the expanded absolute path or an error if the home directory
// cannot be determined.
func expandPath(path string) (string, error) {
if strings.HasPrefix(path, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, path[1:]), nil
}
return path, nil
}
Loading