Skip to content

Commit e52070e

Browse files
Merge pull request cli#13205 from cli/wm/record-official-extension-telemetry
Record official extension telemetry
2 parents 1b236f2 + c50fb79 commit e52070e

7 files changed

Lines changed: 182 additions & 2 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
# Extensions should not generate telemetry events
1+
# Third-party extensions must not generate telemetry events, since the
2+
# extension command name can be a user-authored identifier (e.g. an
3+
# organization or project name).
24
[!exec:bash] skip
35

46
env GH_PRIVATE_ENABLE_TELEMETRY=1
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Official extension stubs (the hidden commands suggesting installation of
2+
# GitHub-owned extensions) are safe to report via telemetry: their command
3+
# names come from a fixed, hard-coded registry and do not contain any
4+
# user-authored identifiers.
5+
6+
env GH_PRIVATE_ENABLE_TELEMETRY=1
7+
env GH_TELEMETRY=log
8+
env GH_TELEMETRY_SAMPLE_RATE=100
9+
10+
# `stack` is registered in extensions.OfficialExtensions. Since no real
11+
# extension is installed, the hidden stub runs and, in a non-TTY session,
12+
# prints install instructions without prompting.
13+
exec gh stack
14+
stderr 'gh extension install github/gh-stack'
15+
16+
# The stub invocation records a command_invocation event for the stub's
17+
# command path.
18+
stderr 'Telemetry payload:'
19+
stderr '"type": "command_invocation"'
20+
stderr '"command": "gh stack"'

pkg/cmd/root/extension.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,14 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex
7979
}
8080

8181
cmdutil.DisableAuthCheck(cmd)
82-
cmdutil.DisableTelemetry(cmd)
82+
// Extensions are user-installed and their names can be arbitrary
83+
// (potentially including sensitive identifiers such as project or
84+
// organization names), so we must not record telemetry for them by
85+
// default. Official GitHub-owned extensions are a known, fixed set and
86+
// can safely contribute their command name to telemetry.
87+
if !extensions.IsOfficial(ext.Name(), ext.Owner()) {
88+
cmdutil.DisableTelemetry(cmd)
89+
}
8390

8491
return cmd
8592
}

pkg/cmd/root/extension_registration_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) {
5757
NameFunc: func() string {
5858
return extName
5959
},
60+
OwnerFunc: func() string {
61+
return ""
62+
},
6063
})
6164
}
6265

pkg/cmd/root/extension_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ func TestNewCmdExtension_Updates(t *testing.T) {
144144
NameFunc: func() string {
145145
return tt.extName
146146
},
147+
OwnerFunc: func() string {
148+
return ""
149+
},
147150
UpdateAvailableFunc: func() bool {
148151
return tt.extUpdateAvailable
149152
},
@@ -199,6 +202,9 @@ func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) {
199202
NameFunc: func() string {
200203
return "major-update"
201204
},
205+
OwnerFunc: func() string {
206+
return ""
207+
},
202208
UpdateAvailableFunc: func() bool {
203209
return true
204210
},
@@ -234,3 +240,60 @@ func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) {
234240
t.Fatal("extension update check should have exited")
235241
}
236242
}
243+
244+
func TestNewCmdExtension_TelemetryEnabledForOfficialExtensions(t *testing.T) {
245+
tests := []struct {
246+
name string
247+
extName string
248+
extOwner string
249+
wantTelemetryOff bool
250+
}{
251+
{
252+
name: "official extension records telemetry",
253+
extName: "stack",
254+
extOwner: "github",
255+
wantTelemetryOff: false,
256+
},
257+
{
258+
name: "official name with third-party owner disables telemetry",
259+
extName: "stack",
260+
extOwner: "williammartin",
261+
wantTelemetryOff: true,
262+
},
263+
{
264+
name: "official name with empty owner disables telemetry",
265+
extName: "stack",
266+
extOwner: "",
267+
wantTelemetryOff: true,
268+
},
269+
{
270+
name: "official extension name with mixed case disables telemetry",
271+
extName: "STACK",
272+
extOwner: "github",
273+
wantTelemetryOff: true,
274+
},
275+
{
276+
name: "third-party extension disables telemetry",
277+
extName: "my-custom-ext",
278+
extOwner: "someone",
279+
wantTelemetryOff: true,
280+
},
281+
}
282+
283+
for _, tt := range tests {
284+
t.Run(tt.name, func(t *testing.T) {
285+
ios, _, _, _ := iostreams.Test()
286+
em := &extensions.ExtensionManagerMock{}
287+
ext := &extensions.ExtensionMock{
288+
NameFunc: func() string { return tt.extName },
289+
OwnerFunc: func() string { return tt.extOwner },
290+
}
291+
292+
cmd := root.NewCmdExtension(ios, em, ext, func(extensions.ExtensionManager, extensions.Extension) (*update.ReleaseInfo, error) {
293+
return nil, nil
294+
})
295+
296+
assert.Equal(t, tt.wantTelemetryOff, cmd.Annotations["telemetry"] == "disabled")
297+
})
298+
}
299+
}

pkg/extensions/official.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package extensions
22

33
import (
4+
"strings"
5+
46
"github.com/cli/cli/v2/internal/ghrepo"
57
)
68

@@ -24,3 +26,28 @@ var OfficialExtensions = []OfficialExtension{
2426
{Name: "aw", Owner: "github", Repo: "gh-aw"},
2527
{Name: "stack", Owner: "github", Repo: "gh-stack"},
2628
}
29+
30+
// IsOfficial reports whether the given extension command name and owner
31+
// match an entry in the OfficialExtensions registry. Owner must be
32+
// checked alongside name because a user may have installed a third-party
33+
// extension that happens to share a name with one of ours (e.g.
34+
// `someuser/gh-stack` predates `github/gh-stack` becoming official).
35+
// Owner will be empty for local extensions, in which case the extension
36+
// is treated as non-official.
37+
//
38+
// Comparison is case-sensitive: on case-sensitive filesystems a user can
39+
// install a private extension whose name differs only in casing (e.g.
40+
// `gh-STACK`), and we must not treat that as official. Owner comparison
41+
// is case-insensitive because GitHub usernames and organization names
42+
// are themselves case-insensitive.
43+
func IsOfficial(name, owner string) bool {
44+
if owner == "" {
45+
return false
46+
}
47+
for _, ext := range OfficialExtensions {
48+
if ext.Name == name && strings.EqualFold(ext.Owner, owner) {
49+
return true
50+
}
51+
}
52+
return false
53+
}

pkg/extensions/official_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,61 @@ func TestOfficialExtension_Repository(t *testing.T) {
1313
assert.Equal(t, "gh-stack", repo.RepoName())
1414
assert.Equal(t, "github.com", repo.RepoHost())
1515
}
16+
17+
func TestIsOfficial(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
extName string
21+
extOwner string
22+
want bool
23+
}{
24+
{
25+
name: "known official extension matches",
26+
extName: "stack",
27+
extOwner: "github",
28+
want: true,
29+
},
30+
{
31+
name: "official name with different owner is not official",
32+
extName: "stack",
33+
extOwner: "williammartin",
34+
want: false,
35+
},
36+
{
37+
name: "official name with empty owner is not official",
38+
extName: "stack",
39+
extOwner: "",
40+
want: false,
41+
},
42+
{
43+
name: "owner comparison is case-insensitive",
44+
extName: "stack",
45+
extOwner: "GitHub",
46+
want: true,
47+
},
48+
{
49+
name: "mixed-case name does not match",
50+
extName: "STACK",
51+
extOwner: "github",
52+
want: false,
53+
},
54+
{
55+
name: "unknown name is not official",
56+
extName: "not-a-real-extension",
57+
extOwner: "github",
58+
want: false,
59+
},
60+
{
61+
name: "empty name is not official",
62+
extName: "",
63+
extOwner: "github",
64+
want: false,
65+
},
66+
}
67+
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
assert.Equal(t, tt.want, IsOfficial(tt.extName, tt.extOwner))
71+
})
72+
}
73+
}

0 commit comments

Comments
 (0)