Skip to content

Commit dea30ba

Browse files
committed
feat: adds device_id, device_os, and coder_desktop_version to telemetry
1 parent 081679f commit dea30ba

11 files changed

+285
-59
lines changed

coderd/workspaceagents.go

+24
Original file line numberDiff line numberDiff line change
@@ -1652,6 +1652,30 @@ func (api *API) tailnetRPCConn(rw http.ResponseWriter, r *http.Request) {
16521652
DeviceOS: nil,
16531653
CoderDesktopVersion: nil,
16541654
}
1655+
1656+
// Parse desktop telemetry from header if it exists
1657+
desktopTelemetryHeader := r.Header.Get(codersdk.CoderDesktopTelemetryHeader)
1658+
if desktopTelemetryHeader != "" {
1659+
var telemetryData codersdk.CoderDesktopTelemetry
1660+
if err := telemetryData.FromHeader(desktopTelemetryHeader); err == nil {
1661+
// Only set fields if they aren't empty
1662+
if telemetryData.DeviceID != "" {
1663+
connectionTelemetryEvent.DeviceID = &telemetryData.DeviceID
1664+
}
1665+
if telemetryData.DeviceOS != "" {
1666+
connectionTelemetryEvent.DeviceOS = &telemetryData.DeviceOS
1667+
}
1668+
if telemetryData.CoderDesktopVersion != "" {
1669+
connectionTelemetryEvent.CoderDesktopVersion = &telemetryData.CoderDesktopVersion
1670+
}
1671+
api.Logger.Debug(ctx, "received desktop telemetry",
1672+
slog.F("device_id", telemetryData.DeviceID),
1673+
slog.F("device_os", telemetryData.DeviceOS),
1674+
slog.F("desktop_version", telemetryData.CoderDesktopVersion))
1675+
} else {
1676+
api.Logger.Warn(ctx, "failed to parse desktop telemetry header", slog.Error(err))
1677+
}
1678+
}
16551679
api.Telemetry.Report(&telemetry.Snapshot{
16561680
UserTailnetConnections: []telemetry.UserTailnetConnection{connectionTelemetryEvent},
16571681
})

coderd/workspaceagents_test.go

+136-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,134 @@ 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+
for _, tc := range testCases {
2267+
t.Run(tc.name, func(t *testing.T) {
2268+
t.Parallel()
2269+
2270+
ctx := testutil.Context(t, testutil.WaitLong)
2271+
logger := testutil.Logger(t)
2272+
2273+
fTelemetry := newFakeTelemetryReporter(ctx, t, 200)
2274+
fTelemetry.enabled = false
2275+
firstClient := coderdtest.New(t, &coderdtest.Options{
2276+
Logger: &logger,
2277+
TelemetryReporter: fTelemetry,
2278+
})
2279+
firstUser := coderdtest.CreateFirstUser(t, firstClient)
2280+
member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
2281+
2282+
headers := http.Header{
2283+
"Coder-Session-Token": []string{member.SessionToken()},
2284+
}
2285+
for k, v := range tc.headers {
2286+
headers.Add(k, v)
2287+
}
2288+
2289+
// enable telemetry now that user is created.
2290+
fTelemetry.enabled = true
2291+
2292+
u, err := member.URL.Parse("/api/v2/tailnet")
2293+
require.NoError(t, err)
2294+
q := u.Query()
2295+
q.Set("version", "2.0")
2296+
u.RawQuery = q.Encode()
2297+
2298+
predialTime := time.Now()
2299+
2300+
//nolint:bodyclose // websocket package closes this for you
2301+
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
2302+
HTTPHeader: headers,
2303+
})
2304+
if err != nil {
2305+
if resp != nil && resp.StatusCode != http.StatusSwitchingProtocols {
2306+
err = codersdk.ReadBodyAsError(resp)
2307+
}
2308+
require.NoError(t, err)
2309+
}
2310+
defer wsConn.Close(websocket.StatusNormalClosure, "done")
2311+
2312+
// Check telemetry
2313+
snapshot := testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
2314+
require.Len(t, snapshot.UserTailnetConnections, 1)
2315+
telemetryConnection := snapshot.UserTailnetConnections[0]
2316+
require.Equal(t, memberUser.ID.String(), telemetryConnection.UserID)
2317+
require.GreaterOrEqual(t, telemetryConnection.ConnectedAt, predialTime)
2318+
require.LessOrEqual(t, telemetryConnection.ConnectedAt, time.Now())
2319+
require.NotEmpty(t, telemetryConnection.PeerID)
2320+
requireEqualOrBothNil(t, telemetryConnection.DeviceID, tc.expected.DeviceID)
2321+
requireEqualOrBothNil(t, telemetryConnection.DeviceOS, tc.expected.DeviceOS)
2322+
requireEqualOrBothNil(t, telemetryConnection.CoderDesktopVersion, tc.expected.CoderDesktopVersion)
2323+
2324+
beforeDisconnectTime := time.Now()
2325+
err = wsConn.Close(websocket.StatusNormalClosure, "done")
2326+
require.NoError(t, err)
2327+
2328+
snapshot = testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
2329+
require.Len(t, snapshot.UserTailnetConnections, 1)
2330+
telemetryDisconnection := snapshot.UserTailnetConnections[0]
2331+
require.Equal(t, memberUser.ID.String(), telemetryDisconnection.UserID)
2332+
require.Equal(t, telemetryConnection.ConnectedAt, telemetryDisconnection.ConnectedAt)
2333+
require.Equal(t, telemetryConnection.UserID, telemetryDisconnection.UserID)
2334+
require.Equal(t, telemetryConnection.PeerID, telemetryDisconnection.PeerID)
2335+
require.NotNil(t, telemetryDisconnection.DisconnectedAt)
2336+
require.GreaterOrEqual(t, *telemetryDisconnection.DisconnectedAt, beforeDisconnectTime)
2337+
require.LessOrEqual(t, *telemetryDisconnection.DisconnectedAt, time.Now())
2338+
requireEqualOrBothNil(t, telemetryConnection.DeviceID, tc.expected.DeviceID)
2339+
requireEqualOrBothNil(t, telemetryConnection.DeviceOS, tc.expected.DeviceOS)
2340+
requireEqualOrBothNil(t, telemetryConnection.CoderDesktopVersion, tc.expected.CoderDesktopVersion)
2341+
})
2342+
}
22492343
}
22502344

22512345
func buildWorkspaceWithAgent(
@@ -2414,3 +2508,12 @@ func (f *fakeTelemetryReporter) Enabled() bool {
24142508

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

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)