Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 117e4c2

Browse files
authored
feat: adds device_id, device_os, and coder_desktop_version to telemetry (#17086)
Records the Device ID, Device OS and Coder Desktop version to telemetry. These values are provided by the Coder Desktop client in the StartRequest method of the VPN protocol. We render them as an HTTP header to transmit to Coderd, where they are decoded and added to telemetry.
1 parent d5557fc commit 117e4c2

11 files changed

+292
-59
lines changed

coderd/workspaceagents.go

+30
Original file line numberDiff line numberDiff line change
@@ -1652,6 +1652,8 @@ func (api *API) tailnetRPCConn(rw http.ResponseWriter, r *http.Request) {
16521652
DeviceOS: nil,
16531653
CoderDesktopVersion: nil,
16541654
}
1655+
1656+
fillCoderDesktopTelemetry(r, &connectionTelemetryEvent, api.Logger)
16551657
api.Telemetry.Report(&telemetry.Snapshot{
16561658
UserTailnetConnections: []telemetry.UserTailnetConnection{connectionTelemetryEvent},
16571659
})
@@ -1681,6 +1683,34 @@ func (api *API) tailnetRPCConn(rw http.ResponseWriter, r *http.Request) {
16811683
}
16821684
}
16831685

1686+
// fillCoderDesktopTelemetry fills out the provided event based on a Coder Desktop telemetry header on the request, if
1687+
// present.
1688+
func fillCoderDesktopTelemetry(r *http.Request, event *telemetry.UserTailnetConnection, logger slog.Logger) {
1689+
// Parse desktop telemetry from header if it exists
1690+
desktopTelemetryHeader := r.Header.Get(codersdk.CoderDesktopTelemetryHeader)
1691+
if desktopTelemetryHeader != "" {
1692+
var telemetryData codersdk.CoderDesktopTelemetry
1693+
if err := telemetryData.FromHeader(desktopTelemetryHeader); err == nil {
1694+
// Only set fields if they aren't empty
1695+
if telemetryData.DeviceID != "" {
1696+
event.DeviceID = &telemetryData.DeviceID
1697+
}
1698+
if telemetryData.DeviceOS != "" {
1699+
event.DeviceOS = &telemetryData.DeviceOS
1700+
}
1701+
if telemetryData.CoderDesktopVersion != "" {
1702+
event.CoderDesktopVersion = &telemetryData.CoderDesktopVersion
1703+
}
1704+
logger.Debug(r.Context(), "received desktop telemetry",
1705+
slog.F("device_id", telemetryData.DeviceID),
1706+
slog.F("device_os", telemetryData.DeviceOS),
1707+
slog.F("desktop_version", telemetryData.CoderDesktopVersion))
1708+
} else {
1709+
logger.Warn(r.Context(), "failed to parse desktop telemetry header", slog.Error(err))
1710+
}
1711+
}
1712+
}
1713+
16841714
// createExternalAuthResponse creates an ExternalAuthResponse based on the
16851715
// provider type. This is to support legacy `/workspaceagents/me/gitauth`
16861716
// which uses `Username` and `Password`.

coderd/workspaceagents_test.go

+137-33
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import (
5151
"github.com/coder/coder/v2/coderd/jwtutils"
5252
"github.com/coder/coder/v2/coderd/rbac"
5353
"github.com/coder/coder/v2/coderd/telemetry"
54+
"github.com/coder/coder/v2/coderd/util/ptr"
5455
"github.com/coder/coder/v2/codersdk"
5556
"github.com/coder/coder/v2/codersdk/agentsdk"
5657
"github.com/coder/coder/v2/codersdk/workspacesdk"
@@ -2135,30 +2136,21 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
21352136

21362137
ctx := testutil.Context(t, testutil.WaitLong)
21372138
logger := testutil.Logger(t)
2138-
2139-
fTelemetry := newFakeTelemetryReporter(ctx, t, 200)
2140-
fTelemetry.enabled = false
21412139
firstClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
2142-
Coordinator: tailnet.NewCoordinator(logger),
2143-
TelemetryReporter: fTelemetry,
2140+
Coordinator: tailnet.NewCoordinator(logger),
21442141
})
21452142
firstUser := coderdtest.CreateFirstUser(t, firstClient)
21462143
member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
21472144

21482145
// Create a workspace with an agent
21492146
firstWorkspace := buildWorkspaceWithAgent(t, member, firstUser.OrganizationID, memberUser.ID, api.Database, api.Pubsub)
21502147

2151-
// enable telemetry now that workspace is built; we don't care about snapshots before this.
2152-
fTelemetry.enabled = true
2153-
21542148
u, err := member.URL.Parse("/api/v2/tailnet")
21552149
require.NoError(t, err)
21562150
q := u.Query()
21572151
q.Set("version", "2.0")
21582152
u.RawQuery = q.Encode()
21592153

2160-
predialTime := time.Now()
2161-
21622154
//nolint:bodyclose // websocket package closes this for you
21632155
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
21642156
HTTPHeader: http.Header{
@@ -2173,15 +2165,6 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
21732165
}
21742166
defer wsConn.Close(websocket.StatusNormalClosure, "done")
21752167

2176-
// Check telemetry
2177-
snapshot := testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
2178-
require.Len(t, snapshot.UserTailnetConnections, 1)
2179-
telemetryConnection := snapshot.UserTailnetConnections[0]
2180-
require.Equal(t, memberUser.ID.String(), telemetryConnection.UserID)
2181-
require.GreaterOrEqual(t, telemetryConnection.ConnectedAt, predialTime)
2182-
require.LessOrEqual(t, telemetryConnection.ConnectedAt, time.Now())
2183-
require.NotEmpty(t, telemetryConnection.PeerID)
2184-
21852168
rpcClient, err := tailnet.NewDRPCClient(
21862169
websocket.NetConn(ctx, wsConn, websocket.MessageBinary),
21872170
logger,
@@ -2229,23 +2212,135 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
22292212
NumAgents: 0,
22302213
},
22312214
})
2232-
err = stream.Close()
2233-
require.NoError(t, err)
2215+
}
22342216

2235-
beforeDisconnectTime := time.Now()
2236-
err = wsConn.Close(websocket.StatusNormalClosure, "done")
2217+
func TestUserTailnetTelemetry(t *testing.T) {
2218+
t.Parallel()
2219+
2220+
telemetryData := &codersdk.CoderDesktopTelemetry{
2221+
DeviceOS: "Windows",
2222+
DeviceID: "device001",
2223+
CoderDesktopVersion: "0.22.1",
2224+
}
2225+
fullHeader, err := json.Marshal(telemetryData)
22372226
require.NoError(t, err)
22382227

2239-
snapshot = testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
2240-
require.Len(t, snapshot.UserTailnetConnections, 1)
2241-
telemetryDisconnection := snapshot.UserTailnetConnections[0]
2242-
require.Equal(t, memberUser.ID.String(), telemetryDisconnection.UserID)
2243-
require.Equal(t, telemetryConnection.ConnectedAt, telemetryDisconnection.ConnectedAt)
2244-
require.Equal(t, telemetryConnection.UserID, telemetryDisconnection.UserID)
2245-
require.Equal(t, telemetryConnection.PeerID, telemetryDisconnection.PeerID)
2246-
require.NotNil(t, telemetryDisconnection.DisconnectedAt)
2247-
require.GreaterOrEqual(t, *telemetryDisconnection.DisconnectedAt, beforeDisconnectTime)
2248-
require.LessOrEqual(t, *telemetryDisconnection.DisconnectedAt, time.Now())
2228+
testCases := []struct {
2229+
name string
2230+
headers map[string]string
2231+
// only used for DeviceID, DeviceOS, CoderDesktopVersion
2232+
expected telemetry.UserTailnetConnection
2233+
}{
2234+
{
2235+
name: "no header",
2236+
headers: map[string]string{},
2237+
expected: telemetry.UserTailnetConnection{},
2238+
},
2239+
{
2240+
name: "full header",
2241+
headers: map[string]string{
2242+
codersdk.CoderDesktopTelemetryHeader: string(fullHeader),
2243+
},
2244+
expected: telemetry.UserTailnetConnection{
2245+
DeviceOS: ptr.Ref("Windows"),
2246+
DeviceID: ptr.Ref("device001"),
2247+
CoderDesktopVersion: ptr.Ref("0.22.1"),
2248+
},
2249+
},
2250+
{
2251+
name: "empty header",
2252+
headers: map[string]string{
2253+
codersdk.CoderDesktopTelemetryHeader: "",
2254+
},
2255+
expected: telemetry.UserTailnetConnection{},
2256+
},
2257+
{
2258+
name: "invalid header",
2259+
headers: map[string]string{
2260+
codersdk.CoderDesktopTelemetryHeader: "{\"device_os",
2261+
},
2262+
expected: telemetry.UserTailnetConnection{},
2263+
},
2264+
}
2265+
2266+
// nolint: paralleltest // no longer need to reinitialize loop vars in go 1.22
2267+
for _, tc := range testCases {
2268+
t.Run(tc.name, func(t *testing.T) {
2269+
t.Parallel()
2270+
2271+
ctx := testutil.Context(t, testutil.WaitLong)
2272+
logger := testutil.Logger(t)
2273+
2274+
fTelemetry := newFakeTelemetryReporter(ctx, t, 200)
2275+
fTelemetry.enabled = false
2276+
firstClient := coderdtest.New(t, &coderdtest.Options{
2277+
Logger: &logger,
2278+
TelemetryReporter: fTelemetry,
2279+
})
2280+
firstUser := coderdtest.CreateFirstUser(t, firstClient)
2281+
member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
2282+
2283+
headers := http.Header{
2284+
"Coder-Session-Token": []string{member.SessionToken()},
2285+
}
2286+
for k, v := range tc.headers {
2287+
headers.Add(k, v)
2288+
}
2289+
2290+
// enable telemetry now that user is created.
2291+
fTelemetry.enabled = true
2292+
2293+
u, err := member.URL.Parse("/api/v2/tailnet")
2294+
require.NoError(t, err)
2295+
q := u.Query()
2296+
q.Set("version", "2.0")
2297+
u.RawQuery = q.Encode()
2298+
2299+
predialTime := time.Now()
2300+
2301+
//nolint:bodyclose // websocket package closes this for you
2302+
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
2303+
HTTPHeader: headers,
2304+
})
2305+
if err != nil {
2306+
if resp != nil && resp.StatusCode != http.StatusSwitchingProtocols {
2307+
err = codersdk.ReadBodyAsError(resp)
2308+
}
2309+
require.NoError(t, err)
2310+
}
2311+
defer wsConn.Close(websocket.StatusNormalClosure, "done")
2312+
2313+
// Check telemetry
2314+
snapshot := testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
2315+
require.Len(t, snapshot.UserTailnetConnections, 1)
2316+
telemetryConnection := snapshot.UserTailnetConnections[0]
2317+
require.Equal(t, memberUser.ID.String(), telemetryConnection.UserID)
2318+
require.GreaterOrEqual(t, telemetryConnection.ConnectedAt, predialTime)
2319+
require.LessOrEqual(t, telemetryConnection.ConnectedAt, time.Now())
2320+
require.NotEmpty(t, telemetryConnection.PeerID)
2321+
requireEqualOrBothNil(t, telemetryConnection.DeviceID, tc.expected.DeviceID)
2322+
requireEqualOrBothNil(t, telemetryConnection.DeviceOS, tc.expected.DeviceOS)
2323+
requireEqualOrBothNil(t, telemetryConnection.CoderDesktopVersion, tc.expected.CoderDesktopVersion)
2324+
2325+
beforeDisconnectTime := time.Now()
2326+
err = wsConn.Close(websocket.StatusNormalClosure, "done")
2327+
require.NoError(t, err)
2328+
2329+
snapshot = testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
2330+
require.Len(t, snapshot.UserTailnetConnections, 1)
2331+
telemetryDisconnection := snapshot.UserTailnetConnections[0]
2332+
require.Equal(t, memberUser.ID.String(), telemetryDisconnection.UserID)
2333+
require.Equal(t, telemetryConnection.ConnectedAt, telemetryDisconnection.ConnectedAt)
2334+
require.Equal(t, telemetryConnection.UserID, telemetryDisconnection.UserID)
2335+
require.Equal(t, telemetryConnection.PeerID, telemetryDisconnection.PeerID)
2336+
require.NotNil(t, telemetryDisconnection.DisconnectedAt)
2337+
require.GreaterOrEqual(t, *telemetryDisconnection.DisconnectedAt, beforeDisconnectTime)
2338+
require.LessOrEqual(t, *telemetryDisconnection.DisconnectedAt, time.Now())
2339+
requireEqualOrBothNil(t, telemetryConnection.DeviceID, tc.expected.DeviceID)
2340+
requireEqualOrBothNil(t, telemetryConnection.DeviceOS, tc.expected.DeviceOS)
2341+
requireEqualOrBothNil(t, telemetryConnection.CoderDesktopVersion, tc.expected.CoderDesktopVersion)
2342+
})
2343+
}
22492344
}
22502345

22512346
func buildWorkspaceWithAgent(
@@ -2414,3 +2509,12 @@ func (f *fakeTelemetryReporter) Enabled() bool {
24142509

24152510
// Close implements the telemetry.Reporter interface.
24162511
func (*fakeTelemetryReporter) Close() {}
2512+
2513+
func requireEqualOrBothNil[T any](t testing.TB, a, b *T) {
2514+
t.Helper()
2515+
if a != nil && b != nil {
2516+
require.Equal(t, *a, *b)
2517+
return
2518+
}
2519+
require.Equal(t, a, b)
2520+
}

codersdk/client.go

+26
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ const (
7676
// only.
7777
CLITelemetryHeader = "Coder-CLI-Telemetry"
7878

79+
// CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry
80+
// fields, including device ID, OS, and Desktop version.
81+
CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry"
82+
7983
// ProvisionerDaemonPSK contains the authentication pre-shared key for an external provisioner daemon
8084
ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK"
8185

@@ -523,6 +527,28 @@ func (e ValidationError) Error() string {
523527

524528
var _ error = (*ValidationError)(nil)
525529

530+
// CoderDesktopTelemetry represents the telemetry data sent from Coder Desktop clients.
531+
// @typescript-ignore CoderDesktopTelemetry
532+
type CoderDesktopTelemetry struct {
533+
DeviceID string `json:"device_id"`
534+
DeviceOS string `json:"device_os"`
535+
CoderDesktopVersion string `json:"coder_desktop_version"`
536+
}
537+
538+
// FromHeader parses the desktop telemetry from the provided header value.
539+
// Returns nil if the header is empty or if parsing fails.
540+
func (t *CoderDesktopTelemetry) FromHeader(headerValue string) error {
541+
if headerValue == "" {
542+
return nil
543+
}
544+
return json.Unmarshal([]byte(headerValue), t)
545+
}
546+
547+
// IsEmpty returns true if all fields in the telemetry data are empty.
548+
func (t *CoderDesktopTelemetry) IsEmpty() bool {
549+
return t.DeviceID == "" && t.DeviceOS == "" && t.CoderDesktopVersion == ""
550+
}
551+
526552
// IsConnectionError is a convenience function for checking if the source of an
527553
// error is due to a 'connection refused', 'no such host', etc.
528554
func IsConnectionError(err error) bool {

codersdk/client_internal_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727

2828
"cdr.dev/slog"
2929
"cdr.dev/slog/sloggers/sloghuman"
30+
3031
"github.com/coder/coder/v2/testutil"
3132
)
3233

site/src/api/typesGenerated.ts

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vpn/speaker_internal_test.go

+8-7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"cdr.dev/slog"
1717
"cdr.dev/slog/sloggers/slogtest"
18+
1819
"github.com/coder/coder/v2/testutil"
1920
)
2021

@@ -47,7 +48,7 @@ func TestSpeaker_RawPeer(t *testing.T) {
4748
errCh <- err
4849
}()
4950

50-
expectedHandshake := "codervpn tunnel 1.0\n"
51+
expectedHandshake := "codervpn tunnel 1.1\n"
5152

5253
b := make([]byte, 256)
5354
n, err := mp.Read(b)
@@ -155,7 +156,7 @@ func TestSpeaker_OversizeHandshake(t *testing.T) {
155156
errCh <- err
156157
}()
157158

158-
expectedHandshake := "codervpn tunnel 1.0\n"
159+
expectedHandshake := "codervpn tunnel 1.1\n"
159160

160161
b := make([]byte, 256)
161162
n, err := mp.Read(b)
@@ -177,12 +178,12 @@ func TestSpeaker_HandshakeInvalid(t *testing.T) {
177178
for _, tc := range []struct {
178179
name, handshake string
179180
}{
180-
{name: "preamble", handshake: "ssh manager 1.0\n"},
181+
{name: "preamble", handshake: "ssh manager 1.1\n"},
181182
{name: "2components", handshake: "ssh manager\n"},
182183
{name: "newmajors", handshake: "codervpn manager 2.0,3.0\n"},
183184
{name: "0version", handshake: "codervpn 0.1 manager\n"},
184-
{name: "unknown_role", handshake: "codervpn 1.0 supervisor\n"},
185-
{name: "unexpected_role", handshake: "codervpn 1.0 tunnel\n"},
185+
{name: "unknown_role", handshake: "codervpn 1.1 supervisor\n"},
186+
{name: "unexpected_role", handshake: "codervpn 1.1 tunnel\n"},
186187
} {
187188
t.Run(tc.name, func(t *testing.T) {
188189
t.Parallel()
@@ -208,7 +209,7 @@ func TestSpeaker_HandshakeInvalid(t *testing.T) {
208209
_, err = mp.Write([]byte(tc.handshake))
209210
require.NoError(t, err)
210211

211-
expectedHandshake := "codervpn tunnel 1.0\n"
212+
expectedHandshake := "codervpn tunnel 1.1\n"
212213
b := make([]byte, 256)
213214
n, err := mp.Read(b)
214215
require.NoError(t, err)
@@ -246,7 +247,7 @@ func TestSpeaker_CorruptMessage(t *testing.T) {
246247
errCh <- err
247248
}()
248249

249-
expectedHandshake := "codervpn tunnel 1.0\n"
250+
expectedHandshake := "codervpn tunnel 1.1\n"
250251

251252
b := make([]byte, 256)
252253
n, err := mp.Read(b)

0 commit comments

Comments
 (0)