Add sampled command telemetry
This commit is contained in:
parent
26d2302cb8
commit
18dc5e77f0
38 changed files with 3337 additions and 18 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -39,3 +39,7 @@
|
|||
|
||||
vendor/
|
||||
gh
|
||||
|
||||
# Test coverage artifacts
|
||||
coverage.out
|
||||
lcov.info
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
9
acceptance/testdata/telemetry/command-invocation.txtar
vendored
Normal file
9
acceptance/testdata/telemetry/command-invocation.txtar
vendored
Normal 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"'
|
||||
7
acceptance/testdata/telemetry/no-telemetry-for-completion.txtar
vendored
Normal file
7
acceptance/testdata/telemetry/no-telemetry-for-completion.txtar
vendored
Normal 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:'
|
||||
27
acceptance/testdata/telemetry/no-telemetry-for-extension.txtar
vendored
Normal file
27
acceptance/testdata/telemetry/no-telemetry-for-extension.txtar
vendored
Normal 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"
|
||||
14
acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar
vendored
Normal file
14
acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar
vendored
Normal 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":{}}]}
|
||||
8
acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar
vendored
Normal file
8
acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar
vendored
Normal 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'
|
||||
|
|
@ -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
3
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
289
internal/barista/observability/telemetry.pb.go
Normal file
289
internal/barista/observability/telemetry.pb.go
Normal 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
|
||||
}
|
||||
1117
internal/barista/observability/telemetry.twirp.go
Normal file
1117
internal/barista/observability/telemetry.twirp.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
internal/gh/ghtelemetry/telemetry.go
Normal file
27
internal/gh/ghtelemetry/telemetry.go
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
internal/telemetry/detach_unix.go
Normal file
12
internal/telemetry/detach_unix.go
Normal 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}
|
||||
}
|
||||
16
internal/telemetry/detach_windows.go
Normal file
16
internal/telemetry/detach_windows.go
Normal 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}
|
||||
}
|
||||
13
internal/telemetry/fake.go
Normal file
13
internal/telemetry/fake.go
Normal 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() {}
|
||||
384
internal/telemetry/telemetry.go
Normal file
384
internal/telemetry/telemetry.go
Normal 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() {}
|
||||
624
internal/telemetry/telemetry_test.go
Normal file
624
internal/telemetry/telemetry_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ func Test_listRun(t *testing.T) {
|
|||
accessible_colors=disabled
|
||||
accessible_prompter=disabled
|
||||
spinner=enabled
|
||||
telemetry=enabled
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`, "`"),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
135
pkg/cmd/send-telemetry/send_telemetry.go
Normal file
135
pkg/cmd/send-telemetry/send_telemetry.go
Normal 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,
|
||||
}
|
||||
}
|
||||
226
pkg/cmd/send-telemetry/send_telemetry_test.go
Normal file
226
pkg/cmd/send-telemetry/send_telemetry_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
66
pkg/cmdutil/telemetry.go
Normal 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"
|
||||
}
|
||||
168
pkg/cmdutil/telemetry_test.go
Normal file
168
pkg/cmdutil/telemetry_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue