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