Skip to content

Commit 571bb1c

Browse files
committed
Log when there is no telemetry
1 parent e52070e commit 571bb1c

7 files changed

Lines changed: 90 additions & 30 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: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,24 @@ func WithSampleRate(rate int) telemetryServiceOption {
166166
}
167167

168168
// 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.
169+
// 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.
169170
var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemetryPayload) {
170171
return func(payload SendTelemetryPayload) {
172+
header := "Telemetry payload:"
173+
if colorEnabled {
174+
header = ansi.Color(header, "cyan+b")
175+
}
176+
177+
if len(payload.Events) == 0 {
178+
fmt.Fprintf(log, "%s none\n", header)
179+
return
180+
}
181+
171182
payloadBytes, err := json.Marshal(payload)
172183
if err != nil {
173184
return
174185
}
175186

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

182189
if colorEnabled {
@@ -190,8 +197,12 @@ var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemet
190197
}
191198

192199
// GitHubFlusher returns a flush function that sends telemetry payloads to a child `gh send-telemetry` process. This is used for the "enabled" telemetry mode.
200+
// Empty payloads are dropped without spawning a subprocess.
193201
var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) {
194202
return func(payload SendTelemetryPayload) {
203+
if len(payload.Events) == 0 {
204+
return
205+
}
195206
SpawnSendTelemetry(executable, payload)
196207
}
197208
}
@@ -278,28 +289,29 @@ func (s *service) Flush() {
278289
s.mu.Lock()
279290
defer s.mu.Unlock()
280291

281-
if s.disabled {
282-
return
283-
}
284-
285292
if s.previouslyCalled {
286293
return
287294
}
288295
s.previouslyCalled = true
289296

290-
if len(s.events) == 0 {
297+
if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate {
291298
return
292299
}
293300

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

298310
payload := SendTelemetryPayload{
299-
Events: make([]PayloadEvent, len(s.events)),
311+
Events: make([]PayloadEvent, len(events)),
300312
}
301313

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

internal/telemetry/telemetry_test.go

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,22 @@ 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+
})
368+
}
369+
354370
func TestServiceDeviceIDFallback(t *testing.T) {
355371
t.Cleanup(stubDeviceIDError(errors.New("no device id")))
356372

@@ -365,14 +381,19 @@ func TestServiceDeviceIDFallback(t *testing.T) {
365381
}
366382

367383
func TestServiceFlush(t *testing.T) {
368-
t.Run("does nothing when no events recorded", func(t *testing.T) {
384+
t.Run("calls flusher with empty payload when no events recorded", func(t *testing.T) {
369385
t.Cleanup(stubDeviceID("test-device"))
370386

387+
var captured SendTelemetryPayload
371388
called := false
372-
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
389+
svc := newService(func(p SendTelemetryPayload) {
390+
called = true
391+
captured = p
392+
}, nil)
373393
svc.Flush()
374394

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

378399
t.Run("flushes events with merged dimensions", func(t *testing.T) {
@@ -599,45 +620,60 @@ func TestWithAdditionalCommonDimensions(t *testing.T) {
599620
}
600621

601622
func TestServiceDisable(t *testing.T) {
602-
t.Run("prevents flush from sending events", func(t *testing.T) {
623+
t.Run("drops recorded events from flushed payload", func(t *testing.T) {
603624
t.Cleanup(stubDeviceID("test-device"))
604625

626+
var captured SendTelemetryPayload
605627
called := false
606-
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
628+
svc := newService(func(p SendTelemetryPayload) {
629+
called = true
630+
captured = p
631+
}, nil)
607632

608633
svc.Record(ghtelemetry.Event{Type: "test"})
609634
svc.Disable()
610635
svc.Flush()
611636

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

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

644+
var captured SendTelemetryPayload
618645
called := false
619-
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
646+
svc := newService(func(p SendTelemetryPayload) {
647+
called = true
648+
captured = p
649+
}, nil)
620650

621651
svc.Record(ghtelemetry.Event{Type: "event1"})
622652
svc.Record(ghtelemetry.Event{Type: "event2"})
623653
svc.Record(ghtelemetry.Event{Type: "event3"})
624654
svc.Disable()
625655
svc.Flush()
626656

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

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

664+
var captured SendTelemetryPayload
633665
called := false
634-
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
666+
svc := newService(func(p SendTelemetryPayload) {
667+
called = true
668+
captured = p
669+
}, nil)
635670

636671
svc.Disable()
637672
svc.Record(ghtelemetry.Event{Type: "test"})
638673
svc.Flush()
639674

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

0 commit comments

Comments
 (0)