Skip to content

Commit 3a6d4de

Browse files
Merge pull request cli#13255 from cli/wm/log-no-events
Log when there is no telemetry
2 parents 92e812b + 0467ed4 commit 3a6d4de

7 files changed

Lines changed: 93 additions & 31 deletions

File tree

acceptance/testdata/telemetry/no-telemetry-for-completion.txtar

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ env GH_TELEMETRY=log
44
env GH_TELEMETRY_SAMPLE_RATE=100
55

66
exec gh completion -s bash
7-
! stderr 'Telemetry payload:'
7+
stderr 'Telemetry payload: none'

acceptance/testdata/telemetry/no-telemetry-for-extension.txtar

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ cd $WORK
2222
# Run the extension and verify no telemetry is logged
2323
exec gh hello
2424
stdout 'hello from extension'
25-
! stderr 'Telemetry payload:'
25+
stderr 'Telemetry payload: none'
2626

2727
-- gh-hello.sh --
2828
#!/usr/bin/env bash

acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ env GH_TELEMETRY_SAMPLE_RATE=100
55
env GH_ENTERPRISE_TOKEN=fake-enterprise-token
66

77
exec gh version
8-
! stderr 'Telemetry payload:'
8+
stderr 'Telemetry payload: none'

acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1
88
# It will fail to connect but that's fine — we only care about telemetry logging.
99
stdin payload.json
1010
! exec gh send-telemetry
11-
! stderr 'Telemetry payload:'
11+
stderr 'Telemetry payload: none'
1212

1313
-- payload.json --
1414
{"events":[{"type":"test","dimensions":{},"measures":{}}]}

internal/ghcmd/cmd.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,31 @@ func Main() exitCode {
8282
case cfgErr != nil:
8383
// Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe.
8484
telemetryService = &telemetry.NoOpService{}
85-
case os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg):
86-
telemetryService = &telemetry.NoOpService{}
8785
default:
8886
telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value)
87+
telemetryDisabled := os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg)
88+
8989
switch telemetryState {
9090
case telemetry.Disabled:
9191
telemetryService = &telemetry.NoOpService{}
9292
case telemetry.Logged:
93+
// Always construct the real service in log mode so that the log
94+
// flusher runs and surfaces an explicit "Telemetry payload: none"
95+
// marker when no events will be sent. This gives the user an
96+
// observable signal that telemetry is wired up even when their
97+
// context (e.g. GHES) causes events to be dropped.
9398
telemetryService = telemetry.NewService(
9499
telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()),
95100
telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions),
96101
)
102+
if telemetryDisabled {
103+
telemetryService.Disable()
104+
}
97105
case telemetry.Enabled:
106+
if telemetryDisabled {
107+
telemetryService = &telemetry.NoOpService{}
108+
break
109+
}
98110
sampleRate := 1
99111
if v, err := strconv.Atoi(os.Getenv("GH_TELEMETRY_SAMPLE_RATE")); err == nil && v >= 0 && v <= 100 {
100112
sampleRate = v

internal/telemetry/telemetry.go

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package telemetry
33

44
import (
55
"bytes"
6+
"encoding/binary"
67
"encoding/json"
78
"errors"
89
"fmt"
@@ -166,17 +167,24 @@ func WithSampleRate(rate int) telemetryServiceOption {
166167
}
167168

168169
// LogFlusher returns a flush function that writes telemetry payloads to the provided log writer. This is used for the "log" telemetry mode, which is intended for debugging and development.
170+
// When there are no events to report (for example the command opted out of telemetry, the user is on GHES, or no events were recorded), a "Telemetry payload: none" marker is written so that the absence of events is observable.
169171
var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemetryPayload) {
170172
return func(payload SendTelemetryPayload) {
173+
header := "Telemetry payload:"
174+
if colorEnabled {
175+
header = ansi.Color(header, "cyan+b")
176+
}
177+
178+
if len(payload.Events) == 0 {
179+
fmt.Fprintf(log, "%s none\n", header)
180+
return
181+
}
182+
171183
payloadBytes, err := json.Marshal(payload)
172184
if err != nil {
173185
return
174186
}
175187

176-
header := "Telemetry payload:"
177-
if colorEnabled {
178-
header = ansi.Color(header, "cyan+b")
179-
}
180188
fmt.Fprintf(log, "%s\n", header)
181189

182190
if colorEnabled {
@@ -190,8 +198,12 @@ var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemet
190198
}
191199

192200
// GitHubFlusher returns a flush function that sends telemetry payloads to a child `gh send-telemetry` process. This is used for the "enabled" telemetry mode.
201+
// Empty payloads are dropped without spawning a subprocess.
193202
var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) {
194203
return func(payload SendTelemetryPayload) {
204+
if len(payload.Events) == 0 {
205+
return
206+
}
195207
SpawnSendTelemetry(executable, payload)
196208
}
197209
}
@@ -221,7 +233,7 @@ func NewService(flusher func(SendTelemetryPayload), opts ...telemetryServiceOpti
221233
maps.Copy(commonDimensions, telemetryServiceOpts.additionalDimensions)
222234

223235
hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID))
224-
sampleBucket := hash[0] % 100
236+
sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100)
225237

226238
s := &service{
227239
flush: flusher,
@@ -278,28 +290,29 @@ func (s *service) Flush() {
278290
s.mu.Lock()
279291
defer s.mu.Unlock()
280292

281-
if s.disabled {
282-
return
283-
}
284-
285293
if s.previouslyCalled {
286294
return
287295
}
288296
s.previouslyCalled = true
289297

290-
if len(s.events) == 0 {
298+
if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate {
291299
return
292300
}
293301

294-
if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate {
295-
return
302+
// When the service has been disabled mid-invocation (e.g. an enterprise host
303+
// was contacted), discard any recorded events. We still call the flusher
304+
// with an empty payload so that the log-mode flusher can surface the
305+
// absence of telemetry rather than leaving the user staring at silence.
306+
events := s.events
307+
if s.disabled {
308+
events = nil
296309
}
297310

298311
payload := SendTelemetryPayload{
299-
Events: make([]PayloadEvent, len(s.events)),
312+
Events: make([]PayloadEvent, len(events)),
300313
}
301314

302-
for i, recorded := range s.events {
315+
for i, recorded := range events {
303316
dimensions := map[string]string{
304317
"timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"),
305318
}

internal/telemetry/telemetry_test.go

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,23 @@ func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) {
351351
assert.Contains(t, output, "\033[", "expected ANSI escape sequences when color is enabled")
352352
}
353353

354+
func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) {
355+
t.Run("no color", func(t *testing.T) {
356+
var buf bytes.Buffer
357+
LogFlusher(&buf, false)(SendTelemetryPayload{})
358+
assert.Equal(t, "Telemetry payload: none\n", buf.String())
359+
})
360+
361+
t.Run("with color", func(t *testing.T) {
362+
var buf bytes.Buffer
363+
LogFlusher(&buf, true)(SendTelemetryPayload{})
364+
output := buf.String()
365+
assert.Contains(t, output, "Telemetry payload:")
366+
assert.Contains(t, output, "none")
367+
assert.Contains(t, output, "\x1b") // ANSI escape char for color codes
368+
})
369+
}
370+
354371
func TestServiceDeviceIDFallback(t *testing.T) {
355372
t.Cleanup(stubDeviceIDError(errors.New("no device id")))
356373

@@ -365,14 +382,19 @@ func TestServiceDeviceIDFallback(t *testing.T) {
365382
}
366383

367384
func TestServiceFlush(t *testing.T) {
368-
t.Run("does nothing when no events recorded", func(t *testing.T) {
385+
t.Run("calls flusher with empty payload when no events recorded", func(t *testing.T) {
369386
t.Cleanup(stubDeviceID("test-device"))
370387

388+
var captured SendTelemetryPayload
371389
called := false
372-
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
390+
svc := newService(func(p SendTelemetryPayload) {
391+
called = true
392+
captured = p
393+
}, nil)
373394
svc.Flush()
374395

375-
assert.False(t, called, "flusher should not be called with no events")
396+
assert.True(t, called, "flusher should be called even with no events so log mode can surface the absence")
397+
assert.Empty(t, captured.Events, "payload should have no events")
376398
})
377399

378400
t.Run("flushes events with merged dimensions", func(t *testing.T) {
@@ -599,45 +621,60 @@ func TestWithAdditionalCommonDimensions(t *testing.T) {
599621
}
600622

601623
func TestServiceDisable(t *testing.T) {
602-
t.Run("prevents flush from sending events", func(t *testing.T) {
624+
t.Run("drops recorded events from flushed payload", func(t *testing.T) {
603625
t.Cleanup(stubDeviceID("test-device"))
604626

627+
var captured SendTelemetryPayload
605628
called := false
606-
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
629+
svc := newService(func(p SendTelemetryPayload) {
630+
called = true
631+
captured = p
632+
}, nil)
607633

608634
svc.Record(ghtelemetry.Event{Type: "test"})
609635
svc.Disable()
610636
svc.Flush()
611637

612-
assert.False(t, called, "flusher should not be called after Disable()")
638+
assert.True(t, called, "flusher should still be called so log mode can surface the absence of events")
639+
assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()")
613640
})
614641

615-
t.Run("prevents flush even with multiple recorded events", func(t *testing.T) {
642+
t.Run("drops events even with multiple recorded events", func(t *testing.T) {
616643
t.Cleanup(stubDeviceID("test-device"))
617644

645+
var captured SendTelemetryPayload
618646
called := false
619-
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
647+
svc := newService(func(p SendTelemetryPayload) {
648+
called = true
649+
captured = p
650+
}, nil)
620651

621652
svc.Record(ghtelemetry.Event{Type: "event1"})
622653
svc.Record(ghtelemetry.Event{Type: "event2"})
623654
svc.Record(ghtelemetry.Event{Type: "event3"})
624655
svc.Disable()
625656
svc.Flush()
626657

627-
assert.False(t, called, "flusher should not be called after Disable()")
658+
assert.True(t, called, "flusher should still be called")
659+
assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()")
628660
})
629661

630662
t.Run("can be called before any events are recorded", func(t *testing.T) {
631663
t.Cleanup(stubDeviceID("test-device"))
632664

665+
var captured SendTelemetryPayload
633666
called := false
634-
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
667+
svc := newService(func(p SendTelemetryPayload) {
668+
called = true
669+
captured = p
670+
}, nil)
635671

636672
svc.Disable()
637673
svc.Record(ghtelemetry.Event{Type: "test"})
638674
svc.Flush()
639675

640-
assert.False(t, called, "flusher should not be called when disabled before recording")
676+
assert.True(t, called, "flusher should still be called")
677+
assert.Empty(t, captured.Events, "events recorded after Disable() should be dropped")
641678
})
642679
}
643680

0 commit comments

Comments
 (0)