Skip to content

Commit 58fa1b3

Browse files
Add analytics tracking (#87)
1 parent f29d2f7 commit 58fa1b3

20 files changed

Lines changed: 1077 additions & 32 deletions

CLAUDE.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,72 @@ After making changes to commands, tools, configuration, or flags, always check a
192192

193193
Keep these files in sync with the actual implementation. When adding a new flag, config option, or command, update all relevant documentation files.
194194

195+
### Analytics Tracking
196+
197+
Tiger CLI tracks usage analytics to help improve the product. Analytics are automatically tracked using middleware - you typically don't need to add tracking code manually when adding new commands or MCP tools.
198+
199+
#### Automatic Tracking via Middleware
200+
201+
**CLI Commands** - All commands are automatically wrapped with analytics middleware in `internal/tiger/cmd/root.go:134`
202+
203+
This middleware:
204+
- Automatically tracks all CLI commands with event name like `"Run tiger service create"`
205+
- Captures elapsed time for each command
206+
- Tracks all user-provided flags (excluding sensitive ones like passwords and keys)
207+
- Records success/failure status and error messages
208+
- Uses `analytics.TryInit()` to gracefully handle cases where credentials aren't available
209+
210+
**MCP Tools** - All MCP tool calls are automatically tracked via middleware in `internal/tiger/mcp/server.go:102`
211+
212+
This middleware:
213+
- Automatically tracks all MCP tool calls with event name like `"Call service_create tool"`
214+
- Extracts and tracks tool arguments (excluding sensitive fields like passwords, queries, parameters)
215+
- Records success/failure status and error messages
216+
- Also tracks resource reads and prompt requests
217+
218+
#### Event Naming Conventions
219+
220+
The middleware follows these automatic naming conventions:
221+
222+
**CLI Commands:** `"Run tiger <command> <subcommand>"`
223+
- Example: `"Run tiger service create"`, `"Run tiger db connection-string"`
224+
225+
**MCP Tools:** `"Call <tool_name> tool"`
226+
- Example: `"Call service_create tool"`, `"Call db_execute_query tool"`
227+
228+
**MCP Resources:** `"Read proxied resource"`
229+
- Includes the `resource_uri` property
230+
231+
**MCP Prompts:** `"Get <prompt_name> prompt"`
232+
- Example: `"Get setup_hypertable prompt"`, `"Get migrate_to_hypertables prompt"`
233+
234+
#### Excluding Sensitive Data
235+
236+
The middleware automatically excludes sensitive fields using a centralized ignore list in `internal/tiger/analytics/analytics.go`.
237+
238+
**Current ignore list:**
239+
- `password` - User passwords
240+
- `new_password` - New passwords for updates
241+
- `public_key` - API public keys
242+
- `secret_key` - API secret keys
243+
- `project_id` - Project identifiers
244+
- `query` - SQL queries (may contain sensitive data)
245+
- `parameters` - SQL parameters (may contain sensitive data)
246+
247+
**IMPORTANT:** When adding new commands or MCP tools, review whether they introduce new sensitive flags, input parameters, or positional arguments:
248+
249+
1. **For sensitive flags or MCP tool parameters:** Add the field name to the `ignore` list in `internal/tiger/analytics/analytics.go`
250+
- Note: Flag names with dashes (like `public-key`) should be added with underscores (`public_key`) to the ignore list
251+
252+
2. **For positional arguments:** Currently, all positional arguments are tracked automatically. If a command is added that accepts sensitive data as a positional argument (not as a flag), you must either:
253+
- Refactor to use a flag instead
254+
- Add filtering logic in `wrapCommandsWithAnalytics()` in `internal/tiger/cmd/root.go` to sanitize or omit the args from tracking
255+
256+
**Common sensitive fields to watch for:**
257+
- Credentials: API keys, tokens, passwords, secret keys
258+
- User data: SQL queries, connection strings, personal information
259+
- Security-related: Private keys, certificates, encryption keys
260+
195261
## Architecture Overview
196262

197263
Tiger CLI is a Go-based command-line interface for managing Tiger, the modern database cloud. The architecture follows standard Go CLI patterns using Cobra and Viper.
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package analytics
2+
3+
import (
4+
"context"
5+
"os"
6+
"runtime"
7+
"slices"
8+
"strconv"
9+
"strings"
10+
"time"
11+
12+
"github.com/spf13/pflag"
13+
"github.com/timescale/tiger-cli/internal/tiger/api"
14+
"github.com/timescale/tiger-cli/internal/tiger/config"
15+
"github.com/timescale/tiger-cli/internal/tiger/logging"
16+
"go.uber.org/zap"
17+
)
18+
19+
// A list of properties that should never be recorded in analytics events.
20+
// Used to filter flags and MCP tool call parameters from automatic tracking.
21+
// Note that dashes in flag names are converted to underscores before being
22+
// checked against this list.
23+
var ignore = []string{
24+
"public_key",
25+
"secret_key",
26+
"project_id",
27+
"password",
28+
"new_password",
29+
"query",
30+
"parameters",
31+
}
32+
33+
type Analytics struct {
34+
config *config.Config
35+
projectID string
36+
client *api.ClientWithResponses
37+
}
38+
39+
// New initializes a new [Analytics] instance.
40+
func New(cfg *config.Config, client *api.ClientWithResponses, projectID string) *Analytics {
41+
return &Analytics{
42+
config: cfg,
43+
projectID: projectID,
44+
client: client,
45+
}
46+
}
47+
48+
// TryInit tries to load credentials to initialize an [Analytics]
49+
// instance. It returns an instance with a nil client if credentials do not
50+
// exist or it otherwise fails to create a new client. This function is
51+
// intended to be used when the caller does not otherwise need an API client to
52+
// function, but would use one if available to track analytics events.
53+
// Otherwise, call NewAnalytics directly.
54+
func TryInit(cfg *config.Config) *Analytics {
55+
apiKey, projectID, err := config.GetCredentials()
56+
if err != nil {
57+
return New(cfg, nil, "")
58+
}
59+
60+
client, err := api.NewTigerClient(cfg, apiKey)
61+
if err != nil {
62+
return New(cfg, nil, projectID)
63+
}
64+
65+
return New(cfg, client, projectID)
66+
}
67+
68+
// Option is a function that modifies analytics event properties. Options are
69+
// passed to Track and Identify methods to customize the data sent with events.
70+
type Option func(properties map[string]any)
71+
72+
// Property creates an Option that adds a single key-value pair to the event
73+
// properties. This is useful for adding custom analytics data that isn't
74+
// covered by other Option functions.
75+
func Property(key string, value any) Option {
76+
return func(properties map[string]any) {
77+
properties[key] = value
78+
}
79+
}
80+
81+
// Map creates an Option that adds all key-value pairs from a map to the event
82+
// properties. Keys specified in the ignore list are skipped.
83+
//
84+
// This is useful for including arbitrary map data (like MCP tool arguments) in
85+
// analytics events without manually specifying each field.
86+
func Map(m map[string]any) Option {
87+
return func(properties map[string]any) {
88+
for key, value := range m {
89+
if slices.Contains(ignore, key) {
90+
continue
91+
}
92+
properties[key] = value
93+
}
94+
}
95+
}
96+
97+
// flagNameReplacer converts flag names from kebab-case to snake_case for
98+
// consistent property naming in analytics events.
99+
var flagNameReplacer = strings.NewReplacer("-", "_")
100+
101+
// FlagSet creates an Option that adds all flags that were explicitly set by
102+
// the user (via Visit). Flag names are converted from kebab-case to snake_case
103+
// (e.g., "no-wait" becomes "no_wait"). Flags in the ignore list are skipped.
104+
//
105+
// This is useful for tracking which flags users actually use when running commands.
106+
func FlagSet(flagSet *pflag.FlagSet) Option {
107+
return func(properties map[string]any) {
108+
flagSet.Visit(func(flag *pflag.Flag) {
109+
key := flagNameReplacer.Replace(flag.Name)
110+
if slices.Contains(ignore, key) {
111+
return
112+
}
113+
properties[key] = flag.Value.String()
114+
})
115+
}
116+
}
117+
118+
// Error creates an Option that adds success and error information to event
119+
// properties. If err is nil, sets success: true. If err is not nil, sets
120+
// success: false and includes the error message.
121+
//
122+
// This is commonly used at the end of command execution to track whether
123+
// operations succeeded or failed, and what errors occurred.
124+
func Error(err error) Option {
125+
return func(properties map[string]any) {
126+
if err != nil {
127+
properties["success"] = false
128+
properties["error"] = err.Error()
129+
} else {
130+
properties["success"] = true
131+
}
132+
}
133+
}
134+
135+
// Identify associates the provided properties with the user for the sake of
136+
// analytics. It automatically includes common properties like ProjectID. The
137+
// identification is only sent if the client is initialized and analytics are
138+
// enabled in the config, otherwise it is skipped.
139+
func (a *Analytics) Identify(event string, options ...Option) {
140+
// Create properties map with default/common properties
141+
properties := map[string]any{}
142+
if a.projectID != "" {
143+
properties["project_id"] = a.projectID
144+
}
145+
146+
// Merge in user-provided properties (they can override common properties if needed)
147+
for _, option := range options {
148+
option(properties)
149+
}
150+
151+
logger := logging.GetLogger().With(
152+
zap.Any("properties", properties),
153+
)
154+
155+
// Check if analytics is disabled
156+
if !a.enabled() {
157+
logger.Debug("Analytics identify skipped (analytics disabled)")
158+
return
159+
}
160+
161+
// Check for cases where the client was not initialized
162+
// (e.g. because API credentials are not available)
163+
if a.client == nil {
164+
logger.Debug("Analytics identify skipped (client not initialized)")
165+
return
166+
}
167+
168+
// Set a 5 second timeout for tracking analytics events. We intentionally
169+
// use context.Background() here so we can track events even if a command
170+
// times out or is canceled.
171+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
172+
defer cancel()
173+
174+
// Send the event
175+
resp, err := a.client.PostAnalyticsIdentifyWithResponse(ctx, api.PostAnalyticsIdentifyJSONRequestBody{
176+
Properties: &properties,
177+
})
178+
if err != nil {
179+
// Log error but don't fail the operation - analytics should never block user actions
180+
logger.Debug("Failed to send analytics identify", zap.Error(err))
181+
return
182+
}
183+
184+
if resp.JSON200 == nil || resp.JSON200.Status == nil {
185+
logger.Debug("Failed to retrieve response from analytics endpoint")
186+
return
187+
}
188+
189+
logger.Debug("Analytics identify sent", zap.String("status", *resp.JSON200.Status))
190+
}
191+
192+
// Track sends an analytics event with the provided event name and properties.
193+
// It automatically includes common properties like ProjectID, OS, and
194+
// architecture. Events are only sent if the client is initialized and
195+
// analytics are enabled in the config, otherwise they are skipped.
196+
func (a *Analytics) Track(event string, options ...Option) {
197+
// Create properties map with default/common properties
198+
properties := map[string]any{
199+
"source": "cli",
200+
"version": config.Version,
201+
"os": runtime.GOOS,
202+
"arch": runtime.GOARCH,
203+
}
204+
if a.projectID != "" {
205+
properties["project_id"] = a.projectID
206+
}
207+
208+
// Merge in user-provided properties (they can override common properties if needed)
209+
for _, option := range options {
210+
option(properties)
211+
}
212+
213+
logger := logging.GetLogger().With(
214+
zap.String("event", event),
215+
zap.Any("properties", properties),
216+
)
217+
218+
// Check if analytics is disabled
219+
if !a.enabled() {
220+
logger.Debug("Analytics event skipped (analytics disabled)")
221+
return
222+
}
223+
224+
// Check for cases where the client was not initialized
225+
// (e.g. because API credentials are not available)
226+
if a.client == nil {
227+
logger.Debug("Analytics event skipped (client not initialized)")
228+
return
229+
}
230+
231+
// Set a 5 second timeout for tracking analytics events. We intentionally
232+
// use context.Background() here so we can track events even if a command
233+
// times out or is canceled.
234+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
235+
defer cancel()
236+
237+
// Send the event
238+
resp, err := a.client.PostAnalyticsTrackWithResponse(ctx, api.PostAnalyticsTrackJSONRequestBody{
239+
Event: event,
240+
Properties: &properties,
241+
})
242+
if err != nil {
243+
// Log error but don't fail the operation - analytics should never block user actions
244+
logger.Debug("Failed to send analytics event", zap.Error(err))
245+
return
246+
}
247+
248+
if resp.JSON200 == nil || resp.JSON200.Status == nil {
249+
logger.Debug("Failed to retrieve response from analytics endpoint")
250+
return
251+
}
252+
253+
logger.Debug("Analytics event sent", zap.String("status", *resp.JSON200.Status))
254+
}
255+
256+
func (a *Analytics) enabled() bool {
257+
if envVarIsTrue("DO_NOT_TRACK") ||
258+
envVarIsTrue("NO_TELEMETRY") ||
259+
envVarIsTrue("DISABLE_TELEMETRY") {
260+
return false
261+
}
262+
263+
return a.config.Analytics
264+
}
265+
266+
func envVarIsTrue(envVar string) bool {
267+
b, _ := strconv.ParseBool(os.Getenv(envVar))
268+
return b
269+
}

0 commit comments

Comments
 (0)