Skip to content

Commit d5e4a03

Browse files
committed
Usage: better_cat <file>
1 parent a99e063 commit d5e4a03

File tree

1 file changed

+333
-0
lines changed

1 file changed

+333
-0
lines changed

lib/telemetry/telemetry.go

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
package telemetry
2+
3+
import (
4+
"context"
5+
"runtime"
6+
"time"
7+
8+
"github.com/AlecAivazis/survey/v2"
9+
"github.com/rawnly/splash-cli/config"
10+
"github.com/sirupsen/logrus"
11+
"github.com/spf13/viper"
12+
"github.com/voxelite-ai/env"
13+
"go.opentelemetry.io/otel"
14+
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
15+
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
16+
"go.opentelemetry.io/otel/metric"
17+
"go.opentelemetry.io/otel/propagation"
18+
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
19+
"go.opentelemetry.io/otel/sdk/resource"
20+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
21+
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
22+
"go.opentelemetry.io/otel/trace"
23+
)
24+
25+
type Telemetry struct {
26+
tracer trace.Tracer
27+
meter metric.Meter
28+
Enabled bool
29+
commandCounter metric.Int64Counter
30+
executionTimer metric.Float64Histogram
31+
installationCounter metric.Int64Counter
32+
errorCounter metric.Int64Counter
33+
}
34+
35+
func New() (*Telemetry, error) {
36+
// Check if telemetry is enabled via environment variable
37+
// Default to true if not set (enabled by default)
38+
telemetryEnabled := env.Bool("SPLASH_CLI_TELEMETRY", true)
39+
40+
// Also check if user has opted out via settings
41+
userOptedOut := viper.GetBool("user_opt_out_telemetry")
42+
43+
// Only enable if not explicitly disabled and not in debug mode and user hasn't opted out
44+
enabled := telemetryEnabled && !userOptedOut && !config.IsDebug()
45+
46+
if !enabled {
47+
logrus.Debug("Telemetry disabled")
48+
return &Telemetry{Enabled: false}, nil
49+
}
50+
51+
// Create resource with service information
52+
res, err := resource.New(context.Background(),
53+
resource.WithAttributes(
54+
semconv.ServiceNameKey.String("splash-cli"),
55+
semconv.ServiceVersionKey.String(config.GetVersion()),
56+
semconv.OSNameKey.String(runtime.GOOS),
57+
semconv.OSTypeKey.String(runtime.GOARCH),
58+
),
59+
)
60+
if err != nil {
61+
logrus.WithError(err).Debug("Failed to create OTEL resource")
62+
return &Telemetry{Enabled: false}, nil
63+
}
64+
65+
// Initialize trace provider
66+
if err := initTraceProvider(res); err != nil {
67+
logrus.WithError(err).Debug("Failed to initialize trace provider")
68+
return &Telemetry{Enabled: false}, nil
69+
}
70+
71+
// Initialize metrics provider
72+
if err := initMetricsProvider(res); err != nil {
73+
logrus.WithError(err).Debug("Failed to initialize metrics provider")
74+
return &Telemetry{Enabled: false}, nil
75+
}
76+
77+
// Create tracer and meter
78+
tracer := otel.Tracer("splash-cli")
79+
meter := otel.Meter("splash-cli")
80+
81+
// Create metrics instruments
82+
commandCounter, err := meter.Int64Counter(
83+
"splash_cli_commands_total",
84+
metric.WithDescription("Total number of commands executed"),
85+
)
86+
if err != nil {
87+
logrus.WithError(err).Debug("Failed to create command counter")
88+
return &Telemetry{Enabled: false}, nil
89+
}
90+
91+
executionTimer, err := meter.Float64Histogram(
92+
"splash_cli_command_duration_seconds",
93+
metric.WithDescription("Duration of command execution in seconds"),
94+
metric.WithUnit("s"),
95+
)
96+
if err != nil {
97+
logrus.WithError(err).Debug("Failed to create execution timer")
98+
return &Telemetry{Enabled: false}, nil
99+
}
100+
101+
installationCounter, err := meter.Int64Counter(
102+
"splash_cli_installations_total",
103+
metric.WithDescription("Total number of CLI installations"),
104+
)
105+
if err != nil {
106+
logrus.WithError(err).Debug("Failed to create installation counter")
107+
return &Telemetry{Enabled: false}, nil
108+
}
109+
110+
errorCounter, err := meter.Int64Counter(
111+
"splash_cli_errors_total",
112+
metric.WithDescription("Total number of errors encountered"),
113+
)
114+
if err != nil {
115+
logrus.WithError(err).Debug("Failed to create error counter")
116+
return &Telemetry{Enabled: false}, nil
117+
}
118+
119+
return &Telemetry{
120+
tracer: tracer,
121+
meter: meter,
122+
Enabled: true,
123+
commandCounter: commandCounter,
124+
executionTimer: executionTimer,
125+
installationCounter: installationCounter,
126+
errorCounter: errorCounter,
127+
}, nil
128+
}
129+
130+
func initTraceProvider(res *resource.Resource) error {
131+
// Use fixed production endpoint
132+
endpoint := "https://api.honeycomb.io/v1/traces"
133+
134+
// Create OTLP trace exporter
135+
traceExporter, err := otlptracehttp.New(
136+
context.Background(),
137+
otlptracehttp.WithEndpoint(endpoint),
138+
)
139+
if err != nil {
140+
return err
141+
}
142+
143+
// Create trace provider
144+
traceProvider := sdktrace.NewTracerProvider(
145+
sdktrace.WithBatcher(traceExporter),
146+
sdktrace.WithResource(res),
147+
)
148+
149+
otel.SetTracerProvider(traceProvider)
150+
otel.SetTextMapPropagator(propagation.TraceContext{})
151+
152+
return nil
153+
}
154+
155+
func initMetricsProvider(res *resource.Resource) error {
156+
// Use fixed production endpoint
157+
endpoint := "https://api.honeycomb.io/v1/metrics"
158+
159+
// Create OTLP metrics exporter
160+
metricsExporter, err := otlpmetrichttp.New(
161+
context.Background(),
162+
otlpmetrichttp.WithEndpoint(endpoint),
163+
)
164+
if err != nil {
165+
return err
166+
}
167+
168+
// Create metrics provider
169+
metricsProvider := sdkmetric.NewMeterProvider(
170+
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(
171+
metricsExporter,
172+
sdkmetric.WithInterval(30*time.Second),
173+
)),
174+
sdkmetric.WithResource(res),
175+
)
176+
177+
otel.SetMeterProvider(metricsProvider)
178+
179+
return nil
180+
}
181+
182+
func (t *Telemetry) TrackCommand(ctx context.Context, commandName string, args []string) (context.Context, func()) {
183+
if !t.Enabled {
184+
return ctx, func() {}
185+
}
186+
187+
start := time.Now()
188+
189+
// Create span for command execution
190+
ctx, span := t.tracer.Start(ctx, commandName,
191+
trace.WithAttributes(
192+
semconv.ProcessCommandKey.String(commandName),
193+
semconv.ProcessCommandArgsKey.StringSlice(args),
194+
),
195+
)
196+
197+
// Record command execution
198+
t.commandCounter.Add(ctx, 1,
199+
metric.WithAttributes(
200+
semconv.ProcessCommandKey.String(commandName),
201+
),
202+
)
203+
204+
// Return cleanup function
205+
return ctx, func() {
206+
duration := time.Since(start).Seconds()
207+
208+
// Record execution time
209+
t.executionTimer.Record(ctx, duration,
210+
metric.WithAttributes(
211+
semconv.ProcessCommandKey.String(commandName),
212+
),
213+
)
214+
215+
span.End()
216+
}
217+
}
218+
219+
func (t *Telemetry) TrackInstallation(ctx context.Context) {
220+
if !t.Enabled {
221+
return
222+
}
223+
224+
// Create span for installation
225+
ctx, span := t.tracer.Start(ctx, "installation")
226+
defer span.End()
227+
228+
// Record installation
229+
t.installationCounter.Add(ctx, 1)
230+
231+
logrus.Debug("Tracked CLI installation via OTEL")
232+
}
233+
234+
func (t *Telemetry) TrackTelemetryConsent(ctx context.Context, consented bool) {
235+
if !t.Enabled {
236+
return
237+
}
238+
239+
// Create span for telemetry consent decision
240+
ctx, span := t.tracer.Start(ctx, "telemetry_consent")
241+
defer span.End()
242+
243+
// Add consent decision to span
244+
span.SetAttributes(
245+
semconv.ProcessCommandKey.String("telemetry_consent"),
246+
)
247+
248+
// Record consent decision metric
249+
consentCounter, err := t.meter.Int64Counter(
250+
"splash_cli_telemetry_consent_total",
251+
metric.WithDescription("Total number of telemetry consent decisions"),
252+
)
253+
if err != nil {
254+
logrus.WithError(err).Debug("Failed to create consent counter")
255+
return
256+
}
257+
258+
consentCounter.Add(ctx, 1,
259+
metric.WithAttributes(
260+
semconv.ProcessCommandKey.String("telemetry_consent"),
261+
),
262+
)
263+
264+
logrus.WithField("consented", consented).Debug("Tracked telemetry consent decision")
265+
}
266+
267+
func (t *Telemetry) TrackError(ctx context.Context, err error, command string) {
268+
if !t.Enabled {
269+
return
270+
}
271+
272+
// Create span for error
273+
ctx, span := t.tracer.Start(ctx, "error")
274+
defer span.End()
275+
276+
// Record error
277+
t.errorCounter.Add(ctx, 1,
278+
metric.WithAttributes(
279+
semconv.ProcessCommandKey.String(command),
280+
),
281+
)
282+
283+
// Add error details to span
284+
span.RecordError(err)
285+
286+
logrus.WithError(err).Debug("Tracked error via OTEL")
287+
}
288+
289+
func (t *Telemetry) CreateSpan(ctx context.Context, name string) (context.Context, trace.Span) {
290+
if !t.Enabled {
291+
return ctx, trace.SpanFromContext(ctx)
292+
}
293+
294+
return t.tracer.Start(ctx, name)
295+
}
296+
297+
func (t *Telemetry) PromptTelemetryConsent(ctx context.Context) bool {
298+
confirm := true
299+
prompt := &survey.Confirm{
300+
Default: true,
301+
Message: "Would you like to help us improve the app by sending anonymous usage telemetry? This helps us understand how the CLI is being used and identify areas for improvement.",
302+
}
303+
304+
if err := survey.AskOne(prompt, &confirm); err != nil {
305+
logrus.WithError(err).Debug("Failed to prompt telemetry consent")
306+
return false
307+
}
308+
309+
// Save the user's decision
310+
viper.Set("user_opt_out_telemetry", !confirm)
311+
312+
// Track the decision before potentially disabling telemetry
313+
t.TrackTelemetryConsent(ctx, confirm)
314+
315+
if !confirm {
316+
// Disable telemetry if user opted out
317+
t.Enabled = false
318+
logrus.Debug("User opted out of telemetry")
319+
} else {
320+
logrus.Debug("User consented to telemetry")
321+
}
322+
323+
return confirm
324+
}
325+
326+
func (t *Telemetry) Close() error {
327+
if !t.Enabled {
328+
return nil
329+
}
330+
331+
logrus.Debug("Closing telemetry")
332+
return nil
333+
}

0 commit comments

Comments
 (0)