Add sampled command telemetry

This commit is contained in:
William Martin 2026-04-14 17:52:05 +02:00
parent 26d2302cb8
commit 18dc5e77f0
38 changed files with 3337 additions and 18 deletions

4
.gitignore vendored
View file

@ -39,3 +39,7 @@
vendor/
gh
# Test coverage artifacts
coverage.out
lcov.info

View file

@ -38,6 +38,10 @@ completions: bin/gh$(EXE)
bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish
bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh
.PHONY: lint
lint:
golangci-lint run ./...
# just convenience tasks around `go test`
.PHONY: test
test:

View file

@ -182,6 +182,15 @@ func TestWorkflows(t *testing.T) {
testscript.Run(t, testScriptParamsFor(tsEnv, "workflow"))
}
func TestTelemetry(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "telemetry"))
}
func testScriptParamsFor(tsEnv testScriptEnv, command string) testscript.Params {
var files []string
if tsEnv.script != "" {
@ -226,6 +235,8 @@ func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error {
ts.Setenv("RANDOM_STRING", randomString(10))
ts.Setenv("GH_TELEMETRY", "false")
// The sandbox overrides HOME, so git cannot find the user's global
// config. Write a minimal identity so commits inside the sandbox
// don't fail with "Author identity unknown".

View file

@ -0,0 +1,9 @@
# Telemetry log mode outputs command invocation event to stderr
env GH_PRIVATE_ENABLE_TELEMETRY=1
env GH_TELEMETRY=log
env GH_TELEMETRY_SAMPLE_RATE=100
exec gh version
stderr 'Telemetry payload:'
stderr '"type": "command_invocation"'
stderr '"command": "gh version"'

View file

@ -0,0 +1,7 @@
# The completion command should not generate a telemetry event
env GH_PRIVATE_ENABLE_TELEMETRY=1
env GH_TELEMETRY=log
env GH_TELEMETRY_SAMPLE_RATE=100
exec gh completion -s bash
! stderr 'Telemetry payload:'

View file

@ -0,0 +1,27 @@
# Extensions should not generate telemetry events
[!exec:bash] skip
env GH_PRIVATE_ENABLE_TELEMETRY=1
env GH_TELEMETRY=log
env GH_TELEMETRY_SAMPLE_RATE=100
# Create a local shell extension repository
exec git init gh-hello
cp gh-hello.sh gh-hello/gh-hello
chmod 755 gh-hello/gh-hello
exec git -C gh-hello add gh-hello
exec git -C gh-hello commit -m 'init'
# Install it locally
cd gh-hello
exec gh ext install .
cd $WORK
# Run the extension and verify no telemetry is logged
exec gh hello
stdout 'hello from extension'
! stderr 'Telemetry payload:'
-- gh-hello.sh --
#!/usr/bin/env bash
echo "hello from extension"

View file

@ -0,0 +1,14 @@
# The send-telemetry command should not itself generate a telemetry event
env GH_PRIVATE_ENABLE_TELEMETRY=1
env GH_TELEMETRY=log
env GH_TELEMETRY_SAMPLE_RATE=100
env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1
# Provide a minimal valid payload on stdin so the command can run.
# It will fail to connect but that's fine — we only care about telemetry logging.
stdin payload.json
! exec gh send-telemetry
! stderr 'Telemetry payload:'
-- payload.json --
{"events":[{"type":"test","dimensions":{},"measures":{}}]}

View file

@ -0,0 +1,8 @@
# Command completes successfully even when telemetry endpoint is unreachable
env GH_PRIVATE_ENABLE_TELEMETRY=1
env GH_TELEMETRY=enabled
env GH_TELEMETRY_SAMPLE_RATE=100
env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1
exec gh version
stdout 'gh version'

View file

@ -11,6 +11,7 @@ import (
"github.com/cli/cli/v2/internal/docs"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/pkg/cmd/root"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
@ -53,7 +54,7 @@ func run(args []string) error {
return config.NewFromString(""), nil
},
ExtensionManager: &em{},
}, "", "")
}, &telemetry.NoOpService{}, "", "")
rootCmd.InitDefaultHelpCmd()
if err := os.MkdirAll(*dir, 0755); err != nil {

3
go.mod
View file

@ -31,6 +31,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.21.4
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-version v1.9.0
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec
@ -52,6 +53,7 @@ require (
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/theupdateframework/go-tuf/v2 v2.4.1
github.com/twitchtv/twirp v8.1.3+incompatible
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/yuin/goldmark v1.8.2
github.com/zalando/go-keyring v0.2.8
@ -129,7 +131,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/certificate-transparency-go v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect

2
go.sum
View file

@ -526,6 +526,8 @@ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ
github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI=
github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4=
github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=

View file

@ -0,0 +1,289 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.4
// protoc v5.29.3
// source: observability/v1/telemetry.proto
package observability
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// TelemetryEvent represents a single telemetry event from a client application.
type TelemetryEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The client application that generated the event (e.g. "github-cli", "vscode").
App string `protobuf:"bytes,1,opt,name=app,proto3" json:"app,omitempty"`
// Required. The type of event (e.g. "usage", "lifecycle", "error").
EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"`
// Key-value string dimensions describing the event (e.g. command, os, architecture).
Dimensions map[string]string `protobuf:"bytes,3,rep,name=dimensions,proto3" json:"dimensions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// Key-value numeric measures associated with the event (e.g. duration_ms, api_calls).
Measures map[string]int64 `protobuf:"bytes,4,rep,name=measures,proto3" json:"measures,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TelemetryEvent) Reset() {
*x = TelemetryEvent{}
mi := &file_observability_v1_telemetry_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TelemetryEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TelemetryEvent) ProtoMessage() {}
func (x *TelemetryEvent) ProtoReflect() protoreflect.Message {
mi := &file_observability_v1_telemetry_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TelemetryEvent.ProtoReflect.Descriptor instead.
func (*TelemetryEvent) Descriptor() ([]byte, []int) {
return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{0}
}
func (x *TelemetryEvent) GetApp() string {
if x != nil {
return x.App
}
return ""
}
func (x *TelemetryEvent) GetEventType() string {
if x != nil {
return x.EventType
}
return ""
}
func (x *TelemetryEvent) GetDimensions() map[string]string {
if x != nil {
return x.Dimensions
}
return nil
}
func (x *TelemetryEvent) GetMeasures() map[string]int64 {
if x != nil {
return x.Measures
}
return nil
}
// RecordEventsRequest contains a batch of telemetry events.
type RecordEventsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. One or more telemetry events to record.
Events []*TelemetryEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RecordEventsRequest) Reset() {
*x = RecordEventsRequest{}
mi := &file_observability_v1_telemetry_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RecordEventsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RecordEventsRequest) ProtoMessage() {}
func (x *RecordEventsRequest) ProtoReflect() protoreflect.Message {
mi := &file_observability_v1_telemetry_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RecordEventsRequest.ProtoReflect.Descriptor instead.
func (*RecordEventsRequest) Descriptor() ([]byte, []int) {
return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{1}
}
func (x *RecordEventsRequest) GetEvents() []*TelemetryEvent {
if x != nil {
return x.Events
}
return nil
}
// RecordEventsResponse is intentionally empty.
type RecordEventsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RecordEventsResponse) Reset() {
*x = RecordEventsResponse{}
mi := &file_observability_v1_telemetry_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RecordEventsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RecordEventsResponse) ProtoMessage() {}
func (x *RecordEventsResponse) ProtoReflect() protoreflect.Message {
mi := &file_observability_v1_telemetry_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RecordEventsResponse.ProtoReflect.Descriptor instead.
func (*RecordEventsResponse) Descriptor() ([]byte, []int) {
return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{2}
}
var File_observability_v1_telemetry_proto protoreflect.FileDescriptor
var file_observability_v1_telemetry_proto_rawDesc = string([]byte{
0x0a, 0x20, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2f,
0x76, 0x31, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x12, 0x1d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65,
0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76,
0x31, 0x22, 0xf5, 0x02, 0x0a, 0x0e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45,
0x76, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x70, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x03, 0x61, 0x70, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f,
0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x76, 0x65, 0x6e,
0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x5d, 0x0a, 0x0a, 0x64, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69,
0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x63, 0x6c, 0x69, 0x65,
0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61,
0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65,
0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69,
0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x64, 0x69, 0x6d, 0x65, 0x6e, 0x73,
0x69, 0x6f, 0x6e, 0x73, 0x12, 0x57, 0x0a, 0x08, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73,
0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3b, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61,
0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c,
0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79,
0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e,
0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x1a, 0x3d, 0x0a,
0x0f, 0x44, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b,
0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x3b, 0x0a, 0x0d,
0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a,
0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05,
0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x5c, 0x0a, 0x13, 0x52, 0x65, 0x63,
0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x12, 0x45, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x2d, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e,
0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31,
0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52,
0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32,
0x87, 0x01, 0x0a, 0x0c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x41, 0x50, 0x49,
0x12, 0x77, 0x0a, 0x0c, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73,
0x12, 0x32, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e,
0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70,
0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74,
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2f, 0x63,
0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2f, 0x70, 0x6b, 0x67, 0x2f,
0x61, 0x70, 0x69, 0x2f, 0x74, 0x77, 0x69, 0x72, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76,
0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2f, 0x76, 0x31, 0x3b, 0x6f, 0x62, 0x73, 0x65, 0x72,
0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_observability_v1_telemetry_proto_rawDescOnce sync.Once
file_observability_v1_telemetry_proto_rawDescData []byte
)
func file_observability_v1_telemetry_proto_rawDescGZIP() []byte {
file_observability_v1_telemetry_proto_rawDescOnce.Do(func() {
file_observability_v1_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_observability_v1_telemetry_proto_rawDesc), len(file_observability_v1_telemetry_proto_rawDesc)))
})
return file_observability_v1_telemetry_proto_rawDescData
}
var file_observability_v1_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_observability_v1_telemetry_proto_goTypes = []any{
(*TelemetryEvent)(nil), // 0: clientappsfe.observability.v1.TelemetryEvent
(*RecordEventsRequest)(nil), // 1: clientappsfe.observability.v1.RecordEventsRequest
(*RecordEventsResponse)(nil), // 2: clientappsfe.observability.v1.RecordEventsResponse
nil, // 3: clientappsfe.observability.v1.TelemetryEvent.DimensionsEntry
nil, // 4: clientappsfe.observability.v1.TelemetryEvent.MeasuresEntry
}
var file_observability_v1_telemetry_proto_depIdxs = []int32{
3, // 0: clientappsfe.observability.v1.TelemetryEvent.dimensions:type_name -> clientappsfe.observability.v1.TelemetryEvent.DimensionsEntry
4, // 1: clientappsfe.observability.v1.TelemetryEvent.measures:type_name -> clientappsfe.observability.v1.TelemetryEvent.MeasuresEntry
0, // 2: clientappsfe.observability.v1.RecordEventsRequest.events:type_name -> clientappsfe.observability.v1.TelemetryEvent
1, // 3: clientappsfe.observability.v1.TelemetryAPI.RecordEvents:input_type -> clientappsfe.observability.v1.RecordEventsRequest
2, // 4: clientappsfe.observability.v1.TelemetryAPI.RecordEvents:output_type -> clientappsfe.observability.v1.RecordEventsResponse
4, // [4:5] is the sub-list for method output_type
3, // [3:4] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_observability_v1_telemetry_proto_init() }
func file_observability_v1_telemetry_proto_init() {
if File_observability_v1_telemetry_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_observability_v1_telemetry_proto_rawDesc), len(file_observability_v1_telemetry_proto_rawDesc)),
NumEnums: 0,
NumMessages: 5,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_observability_v1_telemetry_proto_goTypes,
DependencyIndexes: file_observability_v1_telemetry_proto_depIdxs,
MessageInfos: file_observability_v1_telemetry_proto_msgTypes,
}.Build()
File_observability_v1_telemetry_proto = out.File
file_observability_v1_telemetry_proto_goTypes = nil
file_observability_v1_telemetry_proto_depIdxs = nil
}

File diff suppressed because it is too large Load diff

View file

@ -31,6 +31,7 @@ const (
promptKey = "prompt"
preferEditorPromptKey = "prefer_editor_prompt"
spinnerKey = "spinner"
telemetryKey = "telemetry"
userKey = "user"
usersKey = "users"
versionKey = "version"
@ -169,6 +170,11 @@ func (c *cfg) Spinner(hostname string) gh.ConfigEntry {
return c.GetOrDefault(hostname, spinnerKey).Unwrap()
}
func (c *cfg) Telemetry() gh.ConfigEntry {
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
return c.GetOrDefault("", telemetryKey).Unwrap()
}
func (c *cfg) Version() o.Option[string] {
return c.get("", versionKey)
}
@ -682,6 +688,15 @@ var Options = []ConfigOption{
return c.Spinner(hostname).Value
},
},
{
Key: telemetryKey,
Description: "whether telemetry is enabled, disabled, or logging",
DefaultValue: "enabled",
AllowedValues: []string{"enabled", "disabled", "log"},
CurrentValue: func(c gh.Config, hostname string) string {
return c.Telemetry().Value
},
},
}
func HomeDirPath(subdir string) (string, error) {

View file

@ -182,3 +182,34 @@ func TestSetUserSpecificKeyNoUserPresent(t *testing.T) {
requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val)
requireNoKey(t, c.cfg, []string{hostsKey, host, usersKey})
}
func TestTelemetry(t *testing.T) {
t.Run("returns default when not configured", func(t *testing.T) {
c := newTestConfig()
entry := c.Telemetry()
require.Equal(t, "enabled", entry.Value)
require.Equal(t, gh.ConfigDefaultProvided, entry.Source)
})
t.Run("returns user configured value", func(t *testing.T) {
c := newTestConfig()
c.Set("", telemetryKey, "disabled")
entry := c.Telemetry()
require.Equal(t, "disabled", entry.Value)
require.Equal(t, gh.ConfigUserProvided, entry.Source)
})
t.Run("returns log when configured", func(t *testing.T) {
c := newTestConfig()
c.Set("", telemetryKey, "log")
entry := c.Telemetry()
require.Equal(t, "log", entry.Value)
require.Equal(t, gh.ConfigUserProvided, entry.Source)
})
}

View file

@ -61,6 +61,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock {
mock.BrowserFunc = func(hostname string) gh.ConfigEntry {
return cfg.Browser(hostname)
}
mock.TelemetryFunc = func() gh.ConfigEntry {
return cfg.Telemetry()
}
mock.ColorLabelsFunc = func(hostname string) gh.ConfigEntry {
return cfg.ColorLabels(hostname)
}

View file

@ -57,6 +57,8 @@ type Config interface {
PreferEditorPrompt(hostname string) ConfigEntry
// Spinner returns the configured spinner setting, optionally scoped by host.
Spinner(hostname string) ConfigEntry
// Telemetry returns the configured telemetry setting, ignoring host scoping since telemetry is a global setting.
Telemetry() ConfigEntry
// Aliases provides persistent storage and modification of command aliases.
Aliases() AliasConfig

View file

@ -0,0 +1,27 @@
package ghtelemetry
type Dimensions map[string]string
type Measures map[string]int64
type Event struct {
Type string
Dimensions Dimensions
Measures Measures
}
type EventRecorder interface {
Record(event Event)
}
type CommandRecorder interface {
EventRecorder
SetSampleRate(rate int)
}
type Service interface {
CommandRecorder
Flush()
}
const SAMPLE_ALL = 100

View file

@ -4,9 +4,10 @@
package ghmock
import (
"sync"
"github.com/cli/cli/v2/internal/gh"
o "github.com/cli/cli/v2/pkg/option"
"sync"
)
// Ensure, that ConfigMock does implement gh.Config.
@ -70,6 +71,9 @@ var _ gh.Config = &ConfigMock{}
// SpinnerFunc: func(hostname string) gh.ConfigEntry {
// panic("mock out the Spinner method")
// },
// TelemetryFunc: func() gh.ConfigEntry {
// panic("mock out the Telemetry method")
// },
// VersionFunc: func() o.Option[string] {
// panic("mock out the Version method")
// },
@ -134,6 +138,9 @@ type ConfigMock struct {
// SpinnerFunc mocks the Spinner method.
SpinnerFunc func(hostname string) gh.ConfigEntry
// TelemetryFunc mocks the Telemetry method.
TelemetryFunc func() gh.ConfigEntry
// VersionFunc mocks the Version method.
VersionFunc func() o.Option[string]
@ -227,6 +234,9 @@ type ConfigMock struct {
// Hostname is the hostname argument value.
Hostname string
}
// Telemetry holds details about calls to the Telemetry method.
Telemetry []struct {
}
// Version holds details about calls to the Version method.
Version []struct {
}
@ -251,6 +261,7 @@ type ConfigMock struct {
lockPrompt sync.RWMutex
lockSet sync.RWMutex
lockSpinner sync.RWMutex
lockTelemetry sync.RWMutex
lockVersion sync.RWMutex
lockWrite sync.RWMutex
}
@ -796,6 +807,33 @@ func (mock *ConfigMock) SpinnerCalls() []struct {
return calls
}
// Telemetry calls TelemetryFunc.
func (mock *ConfigMock) Telemetry() gh.ConfigEntry {
if mock.TelemetryFunc == nil {
panic("ConfigMock.TelemetryFunc: method is nil but Config.Telemetry was just called")
}
callInfo := struct {
}{}
mock.lockTelemetry.Lock()
mock.calls.Telemetry = append(mock.calls.Telemetry, callInfo)
mock.lockTelemetry.Unlock()
return mock.TelemetryFunc()
}
// TelemetryCalls gets all the calls that were made to Telemetry.
// Check the length with:
//
// len(mockedConfig.TelemetryCalls())
func (mock *ConfigMock) TelemetryCalls() []struct {
} {
var calls []struct {
}
mock.lockTelemetry.RLock()
calls = mock.calls.Telemetry
mock.lockTelemetry.RUnlock()
return calls
}
// Version calls VersionFunc.
func (mock *ConfigMock) Version() o.Option[string] {
if mock.VersionFunc == nil {

View file

@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@ -19,6 +20,8 @@ import (
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/config/migration"
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/internal/update"
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmd/root"
@ -48,16 +51,57 @@ func Main() exitCode {
cmdFactory := factory.New(buildVersion, string(agents.Detect()))
stderr := cmdFactory.IOStreams.ErrOut
ctx := context.Background()
cfg, err := cmdFactory.Config()
if err != nil {
fmt.Fprintf(stderr, "failed to load config: %s\n", err)
return exitError
}
if cfg, err := cmdFactory.Config(); err == nil {
var m migration.MultiAccount
if err := cfg.Migrate(m); err != nil {
fmt.Fprintln(stderr, err)
additionalCommonDimensions := ghtelemetry.Dimensions{
"version": strings.TrimPrefix(buildVersion, "v"),
"is_tty": strconv.FormatBool(cmdFactory.IOStreams.IsStdoutTTY()),
"agent": string(agents.Detect()),
}
var telemetryService ghtelemetry.Service
if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" {
telemetryService = &telemetry.NoOpService{}
} else {
telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value)
switch telemetryState {
case telemetry.Disabled:
telemetryService = &telemetry.NoOpService{}
case telemetry.Logged:
telemetryService = telemetry.NewService(
telemetry.LogFlusher(cmdFactory.IOStreams.ErrOut, cmdFactory.IOStreams.ColorEnabled()),
telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions),
)
case telemetry.Enabled:
sampleRate := 1
if v, err := strconv.Atoi(os.Getenv("GH_TELEMETRY_SAMPLE_RATE")); err == nil && v >= 0 && v <= 100 {
sampleRate = v
}
additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate)
telemetryService = telemetry.NewService(
telemetry.GitHubFlusher(cmdFactory.Executable()),
telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions),
telemetry.WithSampleRate(sampleRate),
)
default:
fmt.Fprintf(stderr, "invalid telemetry configuration: %q\n", cfg.Telemetry().Value)
return exitError
}
}
defer telemetryService.Flush()
var m migration.MultiAccount
if err := cfg.Migrate(m); err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
ctx := context.Background()
updateCtx, updateCancel := context.WithCancel(ctx)
defer updateCancel()
updateMessageChan := make(chan *update.ReleaseInfo)
@ -90,7 +134,7 @@ func Main() exitCode {
cobra.MousetrapHelpText = ""
}
rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
rootCmd, err := root.NewCmdRoot(cmdFactory, telemetryService, buildVersion, buildDate)
if err != nil {
fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
return exitError

View file

@ -0,0 +1,12 @@
//go:build !windows
package telemetry
import "syscall"
// detachAttrs returns SysProcAttr configured to place the child in its own
// process group so that terminal signals delivered to the parent's group
// (SIGINT, SIGHUP) are not forwarded to the child.
func detachAttrs() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setpgid: true}
}

View file

@ -0,0 +1,16 @@
//go:build windows
package telemetry
import (
"syscall"
"golang.org/x/sys/windows"
)
// detachAttrs returns SysProcAttr configured to place the child in its own
// process group so that console signals (Ctrl+C) delivered to the parent's
// group are not forwarded to the child.
func detachAttrs() *syscall.SysProcAttr {
return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS}
}

View file

@ -0,0 +1,13 @@
package telemetry
import "github.com/cli/cli/v2/internal/gh/ghtelemetry"
type EventRecorderSpy struct {
Events []ghtelemetry.Event
}
func (r *EventRecorderSpy) Record(event ghtelemetry.Event) {
r.Events = append(r.Events, event)
}
func (r *EventRecorderSpy) Flush() {}

View file

@ -0,0 +1,384 @@
// Package telemetry provides best-effort usage telemetry for gh commands.
package telemetry
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
"github.com/cli/cli/v2/pkg/jsoncolor"
"github.com/google/uuid"
"github.com/mgutz/ansi"
)
const deviceIDFileName = "device-id"
// stateDirFunc returns the state directory path. Can be replaced in tests.
var stateDirFunc = config.StateDir
// deviceIDFunc returns a per-user device identifier stored in the state directory.
// It generates and persists a UUID on first call. Can be replaced in tests.
var deviceIDFunc = getOrCreateDeviceID
func getOrCreateDeviceID() (string, error) {
stateDir := stateDirFunc()
idPath := filepath.Join(stateDir, deviceIDFileName)
data, err := os.ReadFile(idPath)
if err == nil {
return strings.TrimSpace(string(data)), nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", err
}
id := uuid.New().String()
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return "", err
}
// Write the ID to a temp file in the same directory, then hard-link it
// to the target path. os.Link fails atomically if the target already
// exists, so exactly one concurrent caller wins. Losers read the
// winner's ID. The temp file is always cleaned up.
tmpFile, err := os.CreateTemp(stateDir, deviceIDFileName+".tmp.*")
if err != nil {
return "", err
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.WriteString(id); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return "", err
}
if err := tmpFile.Close(); err != nil {
os.Remove(tmpPath)
return "", err
}
linkErr := os.Link(tmpPath, idPath)
os.Remove(tmpPath)
if linkErr != nil {
// Another caller won — read their ID.
data, readErr := os.ReadFile(idPath)
if readErr != nil {
return "", linkErr
}
return strings.TrimSpace(string(data)), nil
}
return id, nil
}
var falseyValues = []string{"", "0", "false", "no", "disabled", "off"}
// lookupEnvFunc wraps os.LookupEnv. Can be replaced in tests.
var lookupEnvFunc = os.LookupEnv
type TelemetryState string
const (
Enabled TelemetryState = "enabled"
Disabled TelemetryState = "disabled"
Logged TelemetryState = "log"
)
// ParseTelemetryState determines the telemetry state based on environment variables and configuration values.
// The GH_TELEMETRY environment variable takes precedence, followed by DO_NOT_TRACK, then the configuration value.
// Recognized values for GH_TELEMETRY and config are "enabled", "disabled", "log", or any falsey value (e.g. "0", "false", "no") to disable telemetry.
func ParseTelemetryState(configValue string) TelemetryState {
// GH_TELEMETRY env var takes highest precedence
if envVal, ok := lookupEnvFunc("GH_TELEMETRY"); ok {
envVal = strings.TrimSpace(strings.ToLower(envVal))
// If falsey, telemetry is disabled.
if slices.Contains(falseyValues, envVal) {
return Disabled
}
// If logged, telemetry is logged instead of sent.
if envVal == "log" {
return Logged
}
// Any other value (including "enabled") is treated as enabled.
return Enabled
}
// DO_NOT_TRACK takes precedence over config
if envVal, ok := lookupEnvFunc("DO_NOT_TRACK"); ok {
envVal = strings.TrimSpace(strings.ToLower(envVal))
if envVal == "1" || envVal == "true" {
return Disabled
}
}
// Then check the config values with the same rules.
configValue = strings.TrimSpace(strings.ToLower(configValue))
if slices.Contains(falseyValues, configValue) {
return Disabled
}
if configValue == "log" {
return Logged
}
return Enabled
}
type telemetryServiceOpts struct {
additionalDimensions ghtelemetry.Dimensions
sampleRate int
}
type telemetryServiceOption func(*telemetryServiceOpts)
// WithAdditionalCommonDimensions allows setting additional common dimensions that will be included with every telemetry event recorded by the service.
func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) telemetryServiceOption {
return func(s *telemetryServiceOpts) {
maps.Copy(s.additionalDimensions, dimensions)
}
}
// WithSampleRate allows setting a sample rate (0-100) for telemetry events. Events recorded with the Unsampled option will be sent regardless of the sample rate.
// Sampling is based on invocation ID, so an entire invocation will be included or excluded as a whole. This ensures that related events are not split between sampled and unsampled,
// which could lead to incomplete data and incorrect assumptions.
func WithSampleRate(rate int) telemetryServiceOption {
return func(s *telemetryServiceOpts) {
s.sampleRate = rate
}
}
// 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.
var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemetryPayload) {
return func(payload SendTelemetryPayload) {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return
}
header := "Telemetry payload:"
if colorEnabled {
header = ansi.Color(header, "cyan+b")
}
fmt.Fprintf(log, "%s\n", header)
if colorEnabled {
_ = jsoncolor.Write(log, bytes.NewReader(payloadBytes), " ")
} else {
var indented bytes.Buffer
_ = json.Indent(&indented, payloadBytes, "", " ")
fmt.Fprintln(log, indented.String())
}
}
}
// GitHubFlusher returns a flush function that sends telemetry payloads to a child `gh send-telemetry` process. This is used for the "enabled" telemetry mode.
var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) {
return func(payload SendTelemetryPayload) {
SpawnSendTelemetry(executable, payload)
}
}
// NewService creates a new telemetry service with the provided flush function and options.
func NewService(flusher func(SendTelemetryPayload), opts ...telemetryServiceOption) ghtelemetry.Service {
telemetryServiceOpts := telemetryServiceOpts{
additionalDimensions: make(ghtelemetry.Dimensions),
}
for _, opt := range opts {
opt(&telemetryServiceOpts)
}
deviceID, err := deviceIDFunc()
if err != nil {
deviceID = "<unknown>"
}
invocationID := uuid.NewString()
var commonDimensions = ghtelemetry.Dimensions{
"device_id": deviceID,
"invocation_id": invocationID,
"os": runtime.GOOS,
"architecture": runtime.GOARCH,
}
maps.Copy(commonDimensions, telemetryServiceOpts.additionalDimensions)
hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID))
sampleBucket := hash[0] % 100
s := &service{
flush: flusher,
commonDimensions: commonDimensions,
sampleRate: telemetryServiceOpts.sampleRate,
sampleBucket: sampleBucket,
}
return s
}
type recordedEvent struct {
event ghtelemetry.Event
recordedAt time.Time
}
type service struct {
mu sync.RWMutex
flush func(payload SendTelemetryPayload)
previouslyCalled bool
commonDimensions ghtelemetry.Dimensions
sampleRate int
sampleBucket byte
events []recordedEvent
}
func (s *service) Record(event ghtelemetry.Event) {
s.mu.Lock()
defer s.mu.Unlock()
s.events = append(s.events, recordedEvent{event: event, recordedAt: time.Now()})
}
func (s *service) SetSampleRate(rate int) {
s.mu.Lock()
defer s.mu.Unlock()
s.sampleRate = rate
}
func (s *service) Flush() {
// This shouldn't really be required since flush should only be called once, but just in case...
s.mu.Lock()
defer s.mu.Unlock()
if s.previouslyCalled {
return
}
s.previouslyCalled = true
if len(s.events) == 0 {
return
}
if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate {
return
}
payload := SendTelemetryPayload{
Events: make([]PayloadEvent, len(s.events)),
}
for i, recorded := range s.events {
dimensions := map[string]string{
"timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"),
}
maps.Copy(dimensions, s.commonDimensions)
maps.Copy(dimensions, recorded.event.Dimensions)
payload.Events[i] = PayloadEvent{
Type: recorded.event.Type,
Dimensions: dimensions,
Measures: recorded.event.Measures,
}
}
s.flush(payload)
}
// maxPayloadSize is a safety limit for the telemetry payload written to the
// child process stdin pipe. This bounds the data transferred to a reasonable
// size and avoids blocking on pipe buffer capacity (typically 16-64 KB).
const maxPayloadSize = 16 * 1024
// PayloadEvent represents a single telemetry event in the wire format.
type PayloadEvent struct {
Type string `json:"type"`
Dimensions map[string]string `json:"dimensions,omitempty"`
Measures map[string]int64 `json:"measures,omitempty"`
}
type SendTelemetryPayload struct {
Events []PayloadEvent `json:"events"`
}
// SpawnSendTelemetry spawns a detached subprocess to send telemetry.
// The payload is written to the child's stdin via a pipe so that it is not
// visible to other users through process argument inspection (e.g. ps aux).
// The parent writes the full payload and closes the pipe before returning,
// so no long-lived pipe is needed and the parent can exit immediately.
//
// Note: the payload is bounded by maxPayloadSize (16 KB). On macOS the
// default pipe buffer is also 16 KB, so in theory a write could block
// briefly if the child hasn't started reading yet. In practice the child
// is already running after cmd.Start(), so this is unlikely.
//
// All errors are silently ignored since telemetry is best-effort.
func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return
}
if len(payloadBytes) > maxPayloadSize {
return
}
cmd := exec.Command(executable, "send-telemetry")
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
// Set the working directory to a stable directory elsewhere so that the subprocess doesn't
// hold a reference to the parent's current working directory, avoiding any weirdness around
// deleting the parent process's current working directory while the child is still running.
cmd.Dir = os.TempDir()
// Configure the child process to be detached from the parent so that it can continue running
// after the parent exits, and so that it doesn't receive any signals sent to the parent.
cmd.SysProcAttr = detachAttrs()
// Get the write end of the stdin pipe before starting.
stdin, err := cmd.StdinPipe()
if err != nil {
return
}
if err := cmd.Start(); err != nil {
_ = stdin.Close()
return
}
// Write the payload synchronously into the kernel pipe buffer, then close
// the pipe to signal EOF. The child reads the complete payload from stdin.
_, _ = stdin.Write(payloadBytes)
_ = stdin.Close()
// Release resources associated with the child process since we will never Wait for it.
_ = cmd.Process.Release()
}
type NoOpService struct{}
func (s *NoOpService) Record(event ghtelemetry.Event) {}
func (s *NoOpService) SetSampleRate(rate int) {}
func (s *NoOpService) Flush() {}

View file

@ -0,0 +1,624 @@
package telemetry
import (
"bytes"
"errors"
"maps"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func stubStateDir(dir string) func() {
orig := stateDirFunc
stateDirFunc = func() string { return dir }
return func() { stateDirFunc = orig }
}
func stubDeviceID(id string) func() {
orig := deviceIDFunc
deviceIDFunc = func() (string, error) { return id, nil }
return func() { deviceIDFunc = orig }
}
func stubDeviceIDError(err error) func() {
orig := deviceIDFunc
deviceIDFunc = func() (string, error) { return "", err }
return func() { deviceIDFunc = orig }
}
func stubLookupEnv(fn func(string) (string, bool)) func() {
orig := lookupEnvFunc
lookupEnvFunc = fn
return func() { lookupEnvFunc = orig }
}
// newService is a test helper that constructs the internal service struct
// directly, bypassing the config/env parsing of NewService but still
// resolving common dimensions like device_id and invocation_id.
func newService(flusher func(SendTelemetryPayload), additionalDimensions ghtelemetry.Dimensions) *service {
deviceID, err := deviceIDFunc()
if err != nil {
deviceID = "<unknown>"
}
commonDimensions := ghtelemetry.Dimensions{
"device_id": deviceID,
"invocation_id": uuid.NewString(),
}
maps.Copy(commonDimensions, additionalDimensions)
return &service{
flush: flusher,
commonDimensions: commonDimensions,
}
}
func TestGetOrCreateDeviceID(t *testing.T) {
t.Run("creates new ID on first call", func(t *testing.T) {
tmpDir := t.TempDir()
t.Cleanup(stubStateDir(tmpDir))
id, err := getOrCreateDeviceID()
require.NoError(t, err)
require.NotEmpty(t, id)
data, err := os.ReadFile(filepath.Join(tmpDir, deviceIDFileName))
require.NoError(t, err)
assert.Equal(t, id, string(data))
})
t.Run("returns same ID on subsequent calls", func(t *testing.T) {
tmpDir := t.TempDir()
t.Cleanup(stubStateDir(tmpDir))
id1, err := getOrCreateDeviceID()
require.NoError(t, err)
id2, err := getOrCreateDeviceID()
require.NoError(t, err)
assert.Equal(t, id1, id2)
})
t.Run("trims whitespace from stored ID", func(t *testing.T) {
tmpDir := t.TempDir()
t.Cleanup(stubStateDir(tmpDir))
err := os.WriteFile(filepath.Join(tmpDir, deviceIDFileName), []byte(" some-device-id\n"), 0o600)
require.NoError(t, err)
id, err := getOrCreateDeviceID()
require.NoError(t, err)
assert.Equal(t, "some-device-id", id)
})
t.Run("returns error for non-ErrNotExist read failures", func(t *testing.T) {
tmpDir := t.TempDir()
t.Cleanup(stubStateDir(tmpDir))
// Create device-id as a directory so ReadFile fails with a non-ErrNotExist error.
err := os.Mkdir(filepath.Join(tmpDir, deviceIDFileName), 0o755)
require.NoError(t, err)
_, err = getOrCreateDeviceID()
require.Error(t, err)
assert.False(t, errors.Is(err, os.ErrNotExist))
})
t.Run("creates state directory if missing", func(t *testing.T) {
tmpDir := t.TempDir()
nestedDir := filepath.Join(tmpDir, "nested", "state")
t.Cleanup(stubStateDir(nestedDir))
id, err := getOrCreateDeviceID()
require.NoError(t, err)
require.NotEmpty(t, id)
data, err := os.ReadFile(filepath.Join(nestedDir, deviceIDFileName))
require.NoError(t, err)
assert.Equal(t, id, string(data))
})
t.Run("concurrent callers converge on the same ID", func(t *testing.T) {
tmpDir := t.TempDir()
t.Cleanup(stubStateDir(tmpDir))
const goroutines = 10
ids := make([]string, goroutines)
errs := make([]error, goroutines)
var wg sync.WaitGroup
wg.Add(goroutines)
for i := range goroutines {
go func() {
defer wg.Done()
ids[i], errs[i] = getOrCreateDeviceID()
}()
}
wg.Wait()
for i := range goroutines {
require.NoError(t, errs[i])
}
for i := 1; i < goroutines; i++ {
assert.Equal(t, ids[0], ids[i], "goroutine %d returned a different ID", i)
}
})
}
func TestParseTelemetryState(t *testing.T) {
envSet := func(val string) func(string) (string, bool) {
return func(string) (string, bool) { return val, true }
}
envUnset := func(string) (string, bool) { return "", false }
// envMap allows setting multiple environment variables for testing DO_NOT_TRACK + GH_TELEMETRY interactions.
envMap := func(m map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
val, ok := m[key]
return val, ok
}
}
tests := []struct {
name string
lookupEnv func(string) (string, bool)
configValue string
want TelemetryState
}{
{
name: "env unset, config empty string disables",
lookupEnv: envUnset,
configValue: "",
want: Disabled,
},
{
name: "env unset, config enabled",
lookupEnv: envUnset,
configValue: "enabled",
want: Enabled,
},
{
name: "env unset, config disabled",
lookupEnv: envUnset,
configValue: "disabled",
want: Disabled,
},
{
name: "env unset, config log",
lookupEnv: envUnset,
configValue: "log",
want: Logged,
},
{
name: "env unset, config false",
lookupEnv: envUnset,
configValue: "false",
want: Disabled,
},
{
name: "env unset, config any truthy value",
lookupEnv: envUnset,
configValue: "anything",
want: Enabled,
},
{
name: "env enabled takes precedence over config disabled",
lookupEnv: envSet("enabled"),
configValue: "disabled",
want: Enabled,
},
{
name: "env disabled takes precedence over config enabled",
lookupEnv: envSet("disabled"),
configValue: "enabled",
want: Disabled,
},
{
name: "env log takes precedence over config enabled",
lookupEnv: envSet("log"),
configValue: "enabled",
want: Logged,
},
{
name: "env false disables",
lookupEnv: envSet("false"),
configValue: "enabled",
want: Disabled,
},
{
name: "env empty string disables",
lookupEnv: envSet(""),
configValue: "enabled",
want: Disabled,
},
{
name: "env any truthy value enables",
lookupEnv: envSet("yes"),
configValue: "disabled",
want: Enabled,
},
{
name: "env FALSE (uppercase) disables",
lookupEnv: envSet("FALSE"),
configValue: "enabled",
want: Disabled,
},
{
name: "env LOG (uppercase) logs",
lookupEnv: envSet("LOG"),
configValue: "enabled",
want: Logged,
},
{
name: "env value with whitespace is trimmed",
lookupEnv: envSet(" false "),
configValue: "enabled",
want: Disabled,
},
{
name: "DO_NOT_TRACK=1 disables telemetry",
lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}),
configValue: "enabled",
want: Disabled,
},
{
name: "DO_NOT_TRACK=true disables telemetry",
lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "true"}),
configValue: "enabled",
want: Disabled,
},
{
name: "DO_NOT_TRACK=TRUE disables telemetry (case insensitive)",
lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "TRUE"}),
configValue: "enabled",
want: Disabled,
},
{
name: "DO_NOT_TRACK=0 does not disable telemetry",
lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "0"}),
configValue: "enabled",
want: Enabled,
},
{
name: "DO_NOT_TRACK with whitespace is trimmed",
lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": " 1 "}),
configValue: "enabled",
want: Disabled,
},
{
name: "GH_TELEMETRY takes precedence over DO_NOT_TRACK",
lookupEnv: envMap(map[string]string{"GH_TELEMETRY": "enabled", "DO_NOT_TRACK": "1"}),
configValue: "",
want: Enabled,
},
{
name: "DO_NOT_TRACK takes precedence over config",
lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}),
configValue: "log",
want: Disabled,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Cleanup(stubLookupEnv(tt.lookupEnv))
got := ParseTelemetryState(tt.configValue)
assert.Equal(t, tt.want, got)
})
}
}
func TestNewServiceLogModeFlushesToWriter(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var buf bytes.Buffer
svc := NewService(LogFlusher(&buf, false))
svc.Record(ghtelemetry.Event{
Type: "test_event",
Dimensions: map[string]string{"key": "value"},
})
svc.Flush()
output := buf.String()
assert.Contains(t, output, "Telemetry payload:")
assert.Contains(t, output, "test_event")
assert.Contains(t, output, `"key"`)
assert.Contains(t, output, `"value"`)
}
func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var buf bytes.Buffer
svc := NewService(LogFlusher(&buf, true))
svc.Record(ghtelemetry.Event{Type: "color_event"})
svc.Flush()
output := buf.String()
assert.Contains(t, output, "color_event")
// Verify ANSI color codes are present in the output
assert.Contains(t, output, "\033[", "expected ANSI escape sequences when color is enabled")
}
func TestServiceDeviceIDFallback(t *testing.T) {
t.Cleanup(stubDeviceIDError(errors.New("no device id")))
var captured SendTelemetryPayload
svc := newService(func(p SendTelemetryPayload) { captured = p }, nil)
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
require.Len(t, captured.Events, 1)
assert.Equal(t, "<unknown>", captured.Events[0].Dimensions["device_id"])
}
func TestServiceFlush(t *testing.T) {
t.Run("does nothing when no events recorded", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
called := false
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
svc.Flush()
assert.False(t, called, "flusher should not be called with no events")
})
t.Run("flushes events with merged dimensions", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var captured SendTelemetryPayload
svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"version": "2.45.0"})
svc.Record(ghtelemetry.Event{
Type: "command_invocation",
Dimensions: map[string]string{"command": "gh pr list"},
Measures: map[string]int64{"duration_ms": 150},
})
svc.Flush()
require.Len(t, captured.Events, 1)
event := captured.Events[0]
assert.Equal(t, "command_invocation", event.Type)
assert.Equal(t, "gh pr list", event.Dimensions["command"])
assert.Equal(t, "2.45.0", event.Dimensions["version"])
assert.Equal(t, "test-device", event.Dimensions["device_id"])
assert.NotEmpty(t, event.Dimensions["timestamp"])
assert.NotEmpty(t, event.Dimensions["invocation_id"])
assert.Equal(t, int64(150), event.Measures["duration_ms"])
})
t.Run("flushes multiple events", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var captured SendTelemetryPayload
svc := newService(func(p SendTelemetryPayload) { captured = p }, nil)
svc.Record(ghtelemetry.Event{Type: "event1"})
svc.Record(ghtelemetry.Event{Type: "event2"})
svc.Flush()
require.Len(t, captured.Events, 2)
assert.Equal(t, "event1", captured.Events[0].Type)
assert.Equal(t, "event2", captured.Events[1].Type)
})
t.Run("is idempotent", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
callCount := 0
svc := newService(func(SendTelemetryPayload) { callCount++ }, nil)
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
svc.Flush()
svc.Flush()
assert.Equal(t, 1, callCount, "flusher should only be called once")
})
t.Run("event dimensions override common dimensions", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var captured SendTelemetryPayload
svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"shared": "common"})
svc.Record(ghtelemetry.Event{
Type: "test",
Dimensions: map[string]string{"shared": "event-level"},
})
svc.Flush()
require.Len(t, captured.Events, 1)
// Event dimensions are copied last via maps.Copy, so they override common
assert.Equal(t, "event-level", captured.Events[0].Dimensions["shared"])
})
t.Run("timestamps reflect record time not flush time", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var captured SendTelemetryPayload
svc := newService(func(p SendTelemetryPayload) { captured = p }, nil)
svc.Record(ghtelemetry.Event{Type: "early"})
time.Sleep(50 * time.Millisecond)
svc.Record(ghtelemetry.Event{Type: "late"})
svc.Flush()
require.Len(t, captured.Events, 2)
ts1 := captured.Events[0].Dimensions["timestamp"]
ts2 := captured.Events[1].Dimensions["timestamp"]
require.NotEmpty(t, ts1)
require.NotEmpty(t, ts2)
t1, err := time.Parse("2006-01-02T15:04:05.000Z", ts1)
require.NoError(t, err)
t2, err := time.Parse("2006-01-02T15:04:05.000Z", ts2)
require.NoError(t, err)
assert.True(t, t2.After(t1), "second event timestamp %s should be after first %s", ts2, ts1)
})
}
func TestServiceSampling(t *testing.T) {
t.Run("sampleRate 0 sends all events", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var captured SendTelemetryPayload
svc := newService(func(p SendTelemetryPayload) { captured = p }, nil)
svc.sampleRate = 0
svc.sampleBucket = 99
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
require.Len(t, captured.Events, 1)
})
t.Run("sampleRate 100 sends all events regardless of bucket", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var captured SendTelemetryPayload
svc := newService(func(p SendTelemetryPayload) { captured = p }, nil)
svc.sampleRate = 100
svc.sampleBucket = 99
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
require.Len(t, captured.Events, 1)
})
t.Run("bucket below sampleRate sends events", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var captured SendTelemetryPayload
svc := newService(func(p SendTelemetryPayload) { captured = p }, nil)
svc.sampleRate = 50
svc.sampleBucket = 49 // below rate, should be included
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
require.Len(t, captured.Events, 1)
})
t.Run("bucket at sampleRate drops events", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
called := false
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
svc.sampleRate = 50
svc.sampleBucket = 50 // at rate boundary, should be excluded
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
assert.False(t, called, "flusher should not be called when bucket >= sampleRate")
})
t.Run("bucket above sampleRate drops events", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
called := false
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
svc.sampleRate = 1
svc.sampleBucket = 50
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
assert.False(t, called, "flusher should not be called when bucket >= sampleRate")
})
t.Run("SetSampleRate changes flush behavior", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
called := false
svc := newService(func(SendTelemetryPayload) { called = true }, nil)
svc.sampleBucket = 50
// Initially rate=0, which sends everything
svc.SetSampleRate(10) // Now bucket=50 >= rate=10, should drop
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
assert.False(t, called, "flusher should not be called after SetSampleRate reduced the rate")
})
t.Run("WithSampleRate option sets rate on construction", func(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
called := false
svc := NewService(func(SendTelemetryPayload) { called = true }, WithSampleRate(1))
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
// We can't control the bucket from NewService, so we just verify
// the service was created without error and Flush doesn't panic.
// The actual sampling behavior is tested via direct struct manipulation above.
_ = called
})
}
func TestWithAdditionalCommonDimensions(t *testing.T) {
t.Cleanup(stubDeviceID("test-device"))
var captured SendTelemetryPayload
svc := NewService(
func(p SendTelemetryPayload) { captured = p },
WithAdditionalCommonDimensions(ghtelemetry.Dimensions{
"version": "2.45.0",
"agent": "none",
}),
)
svc.Record(ghtelemetry.Event{Type: "test"})
svc.Flush()
require.Len(t, captured.Events, 1)
assert.Equal(t, "2.45.0", captured.Events[0].Dimensions["version"])
assert.Equal(t, "none", captured.Events[0].Dimensions["agent"])
// Standard common dimensions should also be present
assert.Equal(t, "test-device", captured.Events[0].Dimensions["device_id"])
assert.NotEmpty(t, captured.Events[0].Dimensions["invocation_id"])
assert.NotEmpty(t, captured.Events[0].Dimensions["os"])
assert.NotEmpty(t, captured.Events[0].Dimensions["architecture"])
}
func TestNoOpService(t *testing.T) {
svc := &NoOpService{}
// All methods should be safe to call without panicking
svc.Record(ghtelemetry.Event{Type: "test"})
svc.SetSampleRate(50)
svc.Flush()
}
func TestSpawnSendTelemetryRejectsOversizedPayload(t *testing.T) {
// Build a payload larger than maxPayloadSize (16KB)
largeDimensions := map[string]string{
"data": strings.Repeat("x", maxPayloadSize),
}
payload := SendTelemetryPayload{
Events: []PayloadEvent{
{Type: "test", Dimensions: largeDimensions},
},
}
// This should not panic or spawn a process - it silently returns.
// We can't easily assert the subprocess wasn't started, but we verify
// the function doesn't crash.
SpawnSendTelemetry("/nonexistent/binary", payload)
}

View file

@ -31,5 +31,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(authTokenCmd.NewCmdToken(f, nil))
cmd.AddCommand(authSwitchCmd.NewCmdSwitch(f, nil))
cmdutil.DisableTelemetryForSubcommands(cmd)
return cmd
}

View file

@ -93,6 +93,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
cmdutil.DisableAuthCheck(cmd)
cmdutil.StringEnumFlag(cmd, &shellType, "shell", "s", "", []string{"bash", "zsh", "fish", "powershell"}, "Shell type")
cmdutil.DisableTelemetry(cmd)
return cmd
}

View file

@ -104,6 +104,7 @@ func Test_listRun(t *testing.T) {
accessible_colors=disabled
accessible_prompter=disabled
spinner=enabled
telemetry=enabled
`),
},
}

View file

@ -8,6 +8,7 @@ import (
"time"
"github.com/cli/cli/v2/internal/update"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
@ -26,7 +27,7 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex
checkExtensionReleaseInfo = checkForExtensionUpdate
}
return &cobra.Command{
cmd := &cobra.Command{
Use: ext.Name(),
Short: fmt.Sprintf("Extension %s", ext.Name()),
// PreRun handles looking up whether extension has a latest version only when the command is ran.
@ -73,12 +74,14 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex
// This is being handled in non-blocking default as there is no context to cancel like in gh update checks.
}
},
GroupID: "extension",
Annotations: map[string]string{
"skipAuthCheck": "true",
},
GroupID: "extension",
DisableFlagParsing: true,
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.DisableTelemetry(cmd)
return cmd
}
func checkForExtensionUpdate(em extensions.ExtensionManager, ext extensions.Extension) (*update.ReleaseInfo, error) {

View file

@ -6,6 +6,7 @@ import (
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
@ -74,7 +75,7 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) {
ExtensionManager: em,
}
cmd, err := NewCmdRoot(f, "", "")
cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "", "")
require.NoError(t, err)
// Verify skipped extensions (should find core command registered, not extension)

View file

@ -7,6 +7,7 @@ import (
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
@ -74,7 +75,7 @@ func TestKramdownCompatibleDocs(t *testing.T) {
},
}
cmd, err := NewCmdRoot(f, "N/A", "")
cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "N/A", "")
require.NoError(t, err)
var walk func(*cobra.Command)

View file

@ -117,6 +117,12 @@ var HelpTopics = []helpTopic{
%[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are
more compatible with speech synthesis and braille screen readers.
%[1]sGH_TELEMETRY%[1]s: set to %[1]slog%[1]s to print telemetry data to standard error instead of sending it.
Set to %[1]sfalse%[1]s or %[1]s0%[1]s to disable telemetry that would have been printed when set to %[1]slog%[1]s.
%[1]sDO_NOT_TRACK%[1]s: set to %[1]strue%[1]s or %[1]s1%[1]s to disable telemetry that would have been printed
when %[1]sGH_TELEMETRY%[1]s is set to %[1]slog%[1]s. %[1]sGH_TELEMETRY%[1]s takes precedence if both are set.
%[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with
a textual progress indicator.
`, "`"),

View file

@ -6,6 +6,7 @@ import (
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
accessibilityCmd "github.com/cli/cli/v2/pkg/cmd/accessibility"
actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions"
agentTaskCmd "github.com/cli/cli/v2/pkg/cmd/agent-task"
@ -38,6 +39,7 @@ import (
runCmd "github.com/cli/cli/v2/pkg/cmd/run"
searchCmd "github.com/cli/cli/v2/pkg/cmd/search"
secretCmd "github.com/cli/cli/v2/pkg/cmd/secret"
sendTelemetryCmd "github.com/cli/cli/v2/pkg/cmd/send-telemetry"
skillsCmd "github.com/cli/cli/v2/pkg/cmd/skills"
sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key"
statusCmd "github.com/cli/cli/v2/pkg/cmd/status"
@ -58,7 +60,7 @@ func (ae *AuthError) Error() string {
return ae.err.Error()
}
func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, error) {
func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, version, buildDate string) (*cobra.Command, error) {
io := f.IOStreams
cfg, err := f.Config()
if err != nil {
@ -88,6 +90,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
}
return &AuthError{}
}
return nil
},
}
@ -153,6 +156,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
cmd.AddCommand(statusCmd.NewCmdStatus(f, nil))
cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil))
cmd.AddCommand(licensesCmd.NewCmdLicenses(f))
cmd.AddCommand(sendTelemetryCmd.NewCmdSendTelemetry(f))
// below here at the commands that require the "intelligent" BaseRepo resolver
repoResolvingCmdFactory := *f
@ -244,6 +248,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.RecordTelemetryForSubcommands(cmd, telemetry)
// The reference command produces paged output that displays information on every other command.
// Therefore, we explicitly set the Long text and HelpFunc here after all other commands are registered.

View file

@ -0,0 +1,135 @@
package sendtelemetry
import (
"cmp"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"time"
"github.com/cli/cli/v2/internal/barista/observability"
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
const defaultTelemetryEndpointURL = "https://cafe.github.com"
type SendTelemetryOptions struct {
TelemetryEndpointURL string
PayloadJSON string
HTTPUnixSocket string
}
func NewCmdSendTelemetry(f *cmdutil.Factory) *cobra.Command {
return newCmdSendTelemetry(f, nil)
}
func newCmdSendTelemetry(f *cmdutil.Factory, runF func(*SendTelemetryOptions) error) *cobra.Command {
cmd := &cobra.Command{
Use: "send-telemetry",
Short: "Send telemetry event to GitHub",
Hidden: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
payloadJSON, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("reading payload from stdin: %w", err)
}
if len(payloadJSON) == 0 {
return fmt.Errorf("no payload provided on stdin")
}
opts := &SendTelemetryOptions{
TelemetryEndpointURL: cmp.Or(os.Getenv("GH_TELEMETRY_ENDPOINT_URL"), defaultTelemetryEndpointURL),
PayloadJSON: string(payloadJSON),
// This is a best effort to use a Unix Socket if configured. In most cases, if there is one configured
// it will be at the global level. However, since the telemetry service is not related to a specific host, we can't
// know that the socket we choose will work.
HTTPUnixSocket: cfg.HTTPUnixSocket("").Value,
}
if runF != nil {
return runF(opts)
}
return runSendTelemetry(cmd.Context(), opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.DisableTelemetry(cmd)
return cmd
}
func runSendTelemetry(ctx context.Context, opts *SendTelemetryOptions) error {
httpClient := &http.Client{
Timeout: 2 * time.Second,
Transport: &userAgentTransport{
base: handleUnixDomainSocket(opts.HTTPUnixSocket),
userAgent: fmt.Sprintf("GitHub CLI %s", build.Version),
},
}
client := observability.NewTelemetryAPIProtobufClient(opts.TelemetryEndpointURL, httpClient)
var payload telemetry.SendTelemetryPayload
if err := json.Unmarshal([]byte(opts.PayloadJSON), &payload); err != nil {
return fmt.Errorf("parsing payload JSON: %w", err)
}
if len(payload.Events) == 0 {
return nil
}
events := make([]*observability.TelemetryEvent, len(payload.Events))
for i, event := range payload.Events {
events[i] = &observability.TelemetryEvent{
App: "github-cli",
EventType: event.Type,
Dimensions: event.Dimensions,
Measures: event.Measures,
}
}
_, err := client.RecordEvents(ctx, &observability.RecordEventsRequest{
Events: events,
})
return err
}
type userAgentTransport struct {
base http.RoundTripper
userAgent string
}
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", t.userAgent)
return t.base.RoundTrip(req)
}
func handleUnixDomainSocket(socketPath string) http.RoundTripper {
if socketPath == "" {
return http.DefaultTransport
}
dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
}
return &http.Transport{
DialContext: dialContext,
DisableKeepAlives: true,
}
}

View file

@ -0,0 +1,226 @@
package sendtelemetry
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/cli/cli/v2/internal/barista/observability"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockTelemetryAPI struct {
request *observability.RecordEventsRequest
err error
}
func (m *mockTelemetryAPI) RecordEvents(_ context.Context, req *observability.RecordEventsRequest) (*observability.RecordEventsResponse, error) {
m.request = req
return &observability.RecordEventsResponse{}, m.err
}
func TestNewCmdSendTelemetry(t *testing.T) {
tests := []struct {
name string
stdin string
env map[string]string
wantOpts SendTelemetryOptions
wantErr string
}{
{
name: "reads payload from stdin",
stdin: `{"events":[{"type":"usage","dimensions":{"command":"gh pr list"}}]}`,
wantOpts: SendTelemetryOptions{
TelemetryEndpointURL: defaultTelemetryEndpointURL,
PayloadJSON: `{"events":[{"type":"usage","dimensions":{"command":"gh pr list"}}]}`,
},
},
{
name: "uses GH_TELEMETRY_ENDPOINT_URL env var",
stdin: `{"events":[]}`,
env: map[string]string{"GH_TELEMETRY_ENDPOINT_URL": "https://custom.endpoint"},
wantOpts: SendTelemetryOptions{
TelemetryEndpointURL: "https://custom.endpoint",
PayloadJSON: `{"events":[]}`,
},
},
{
name: "defaults endpoint when env var not set",
stdin: `{}`,
wantOpts: SendTelemetryOptions{
TelemetryEndpointURL: defaultTelemetryEndpointURL,
PayloadJSON: `{}`,
},
},
{
name: "errors on empty stdin",
stdin: "",
wantErr: "no payload provided on stdin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for k, v := range tt.env {
t.Setenv(k, v)
}
ios, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: ios,
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
}
var gotOpts *SendTelemetryOptions
cmd := newCmdSendTelemetry(f, func(opts *SendTelemetryOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{})
cmd.SetIn(strings.NewReader(tt.stdin))
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_, err := cmd.ExecuteC()
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
require.NotNil(t, gotOpts)
assert.Equal(t, tt.wantOpts.TelemetryEndpointURL, gotOpts.TelemetryEndpointURL)
assert.Equal(t, tt.wantOpts.PayloadJSON, gotOpts.PayloadJSON)
})
}
}
func TestRunSendTelemetry(t *testing.T) {
tests := []struct {
name string
payload telemetry.SendTelemetryPayload
serverErr error
wantErr bool
assertFunc func(t *testing.T, req *observability.RecordEventsRequest)
}{
{
name: "posts single event to endpoint",
payload: telemetry.SendTelemetryPayload{
Events: []telemetry.PayloadEvent{
{
Type: "command_invocation",
Dimensions: map[string]string{
"command": "gh pr create",
"device_id": "abc123",
"os": "darwin",
},
Measures: map[string]int64{"duration_ms": 150},
},
},
},
assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) {
t.Helper()
require.Len(t, req.Events, 1)
event := req.Events[0]
assert.Equal(t, "github-cli", event.App)
assert.Equal(t, "command_invocation", event.EventType)
assert.Equal(t, "gh pr create", event.Dimensions["command"])
assert.Equal(t, "abc123", event.Dimensions["device_id"])
assert.Equal(t, "darwin", event.Dimensions["os"])
},
},
{
name: "posts multiple events in single batch request",
payload: telemetry.SendTelemetryPayload{
Events: []telemetry.PayloadEvent{
{Type: "event1", Dimensions: map[string]string{"a": "1"}},
{Type: "event2", Dimensions: map[string]string{"b": "2"}},
},
},
assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) {
t.Helper()
require.Len(t, req.Events, 2)
assert.Equal(t, "1", req.Events[0].Dimensions["a"])
assert.Equal(t, "2", req.Events[1].Dimensions["b"])
assert.Equal(t, "github-cli", req.Events[0].App)
assert.Equal(t, "event1", req.Events[0].EventType)
assert.Equal(t, "github-cli", req.Events[1].App)
assert.Equal(t, "event2", req.Events[1].EventType)
},
},
{
name: "empty events list produces no request",
payload: telemetry.SendTelemetryPayload{
Events: []telemetry.PayloadEvent{},
},
assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) {
t.Helper()
assert.Nil(t, req)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock := &mockTelemetryAPI{err: tt.serverErr}
handler := observability.NewTelemetryAPIServer(mock)
server := httptest.NewServer(handler)
defer server.Close()
opts := &SendTelemetryOptions{
TelemetryEndpointURL: server.URL,
PayloadJSON: mustMarshal(t, tt.payload),
}
err := runSendTelemetry(context.Background(), opts)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.assertFunc != nil {
tt.assertFunc(t, mock.request)
}
})
}
}
func TestRunSendTelemetryInvalidPayload(t *testing.T) {
err := runSendTelemetry(context.Background(), &SendTelemetryOptions{
TelemetryEndpointURL: "http://localhost:0",
PayloadJSON: "not-json",
})
require.Error(t, err)
}
func TestRunSendTelemetryServerError(t *testing.T) {
mock := &mockTelemetryAPI{err: assert.AnError}
handler := observability.NewTelemetryAPIServer(mock)
server := httptest.NewServer(handler)
defer server.Close()
err := runSendTelemetry(context.Background(), &SendTelemetryOptions{
TelemetryEndpointURL: server.URL,
PayloadJSON: `{"events":[{"type":"test","dimensions":{"a":"1"}}]}`,
})
require.Error(t, err)
}
func mustMarshal(t *testing.T, v any) string {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
return string(data)
}

View file

@ -13,8 +13,9 @@ func NewCmdVersion(f *cmdutil.Factory, version, buildDate string) *cobra.Command
cmd := &cobra.Command{
Use: "version",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprint(f.IOStreams.Out, cmd.Root().Annotations["versionInfo"])
return nil
},
}

66
pkg/cmdutil/telemetry.go Normal file
View file

@ -0,0 +1,66 @@
package cmdutil
import (
"slices"
"strings"
"github.com/cli/cli/v2/internal/gh/ghtelemetry"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) {
if isTelemetryDisabled(cmd) {
return
}
if cmd.RunE == nil {
return
}
currentRunE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
runErr := currentRunE(cmd, args)
var flags []string
cmd.Flags().Visit(func(f *pflag.Flag) {
flags = append(flags, f.Name)
})
slices.Sort(flags)
telemetry.Record(ghtelemetry.Event{
Type: "command_invocation",
Dimensions: map[string]string{
"command": cmd.CommandPath(),
"flags": strings.Join(flags, ","),
},
})
return runErr
}
}
func RecordTelemetryForSubcommands(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) {
for _, c := range cmd.Commands() {
RecordTelemetry(c, telemetry)
RecordTelemetryForSubcommands(c, telemetry)
}
}
func DisableTelemetry(cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations["telemetry"] = "disabled"
}
func DisableTelemetryForSubcommands(cmd *cobra.Command) {
for _, c := range cmd.Commands() {
DisableTelemetry(c)
DisableTelemetryForSubcommands(c)
}
}
func isTelemetryDisabled(cmd *cobra.Command) bool {
return cmd.Annotations["telemetry"] == "disabled"
}

View file

@ -0,0 +1,168 @@
package cmdutil_test
import (
"fmt"
"testing"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRecordTelemetry(t *testing.T) {
t.Run("records command path and flags", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
cmd := &cobra.Command{
Use: "list",
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
cmd.Flags().Bool("web", false, "")
cmd.Flags().String("repo", "", "")
parent := &cobra.Command{Use: "pr"}
root := &cobra.Command{Use: "gh"}
root.AddCommand(parent)
parent.AddCommand(cmd)
cmdutil.RecordTelemetry(cmd, recorder)
require.NoError(t, cmd.Flags().Set("web", "true"))
require.NoError(t, cmd.Flags().Set("repo", "cli/cli"))
require.NoError(t, cmd.RunE(cmd, nil))
require.Len(t, recorder.Events, 1)
event := recorder.Events[0]
assert.Equal(t, "command_invocation", event.Type)
assert.Equal(t, "gh pr list", event.Dimensions["command"])
assert.Equal(t, "repo,web", event.Dimensions["flags"])
})
t.Run("is a no-op when original RunE is nil", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
cmd := &cobra.Command{Use: "test"}
cmdutil.RecordTelemetry(cmd, recorder)
assert.Nil(t, cmd.RunE, "RunE should remain nil when it was nil before")
assert.Empty(t, recorder.Events, "no telemetry should be recorded")
})
t.Run("propagates error from original RunE", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
expectedErr := fmt.Errorf("something went wrong")
cmd := &cobra.Command{
Use: "fail",
RunE: func(cmd *cobra.Command, args []string) error { return expectedErr },
}
cmdutil.RecordTelemetry(cmd, recorder)
err := cmd.RunE(cmd, nil)
assert.ErrorIs(t, err, expectedErr)
// Telemetry is still recorded even on error
require.Len(t, recorder.Events, 1)
assert.Equal(t, "command_invocation", recorder.Events[0].Type)
})
t.Run("flags are sorted alphabetically", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
cmd := &cobra.Command{
Use: "test",
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
cmd.Flags().Bool("zebra", false, "")
cmd.Flags().Bool("alpha", false, "")
cmd.Flags().Bool("middle", false, "")
cmdutil.RecordTelemetry(cmd, recorder)
require.NoError(t, cmd.Flags().Set("zebra", "true"))
require.NoError(t, cmd.Flags().Set("alpha", "true"))
require.NoError(t, cmd.Flags().Set("middle", "true"))
require.NoError(t, cmd.RunE(cmd, nil))
require.Len(t, recorder.Events, 1)
assert.Equal(t, "alpha,middle,zebra", recorder.Events[0].Dimensions["flags"])
})
t.Run("no flags set records empty flags string", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
cmd := &cobra.Command{
Use: "test",
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
cmd.Flags().Bool("unused", false, "")
cmdutil.RecordTelemetry(cmd, recorder)
require.NoError(t, cmd.RunE(cmd, nil))
require.Len(t, recorder.Events, 1)
assert.Equal(t, "", recorder.Events[0].Dimensions["flags"])
})
t.Run("skips commands with telemetry disabled", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
cmd := &cobra.Command{
Use: "internal",
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
cmdutil.DisableTelemetry(cmd)
cmdutil.RecordTelemetry(cmd, recorder)
require.NoError(t, cmd.RunE(cmd, nil))
assert.Empty(t, recorder.Events, "telemetry should not be recorded for disabled commands")
})
}
func TestRecordTelemetryForSubcommands(t *testing.T) {
t.Run("instruments nested subcommands", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
root := &cobra.Command{Use: "gh"}
parent := &cobra.Command{Use: "pr"}
child := &cobra.Command{
Use: "list",
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
root.AddCommand(parent)
parent.AddCommand(child)
cmdutil.RecordTelemetryForSubcommands(root, recorder)
require.NoError(t, child.RunE(child, nil))
require.Len(t, recorder.Events, 1)
assert.Equal(t, "command_invocation", recorder.Events[0].Type)
assert.Equal(t, "gh pr list", recorder.Events[0].Dimensions["command"])
})
t.Run("skips subcommands with nil RunE", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
root := &cobra.Command{Use: "gh"}
child := &cobra.Command{Use: "help"} // no RunE
root.AddCommand(child)
cmdutil.RecordTelemetryForSubcommands(root, recorder)
assert.Nil(t, child.RunE, "nil RunE should remain nil")
})
t.Run("skips subcommands with telemetry disabled", func(t *testing.T) {
recorder := &telemetry.EventRecorderSpy{}
root := &cobra.Command{Use: "gh"}
child := &cobra.Command{
Use: "send-telemetry",
RunE: func(cmd *cobra.Command, args []string) error { return nil },
}
cmdutil.DisableTelemetry(child)
root.AddCommand(child)
cmdutil.RecordTelemetryForSubcommands(root, recorder)
require.NoError(t, child.RunE(child, nil))
assert.Empty(t, recorder.Events, "disabled commands should not record telemetry")
})
}