Skip to content

Commit 1a803fe

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

10 files changed

+337
-58
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

+58
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,61 @@ func marshal(res any) string {
352352

353353
return string(b)
354354
}
355+
356+
func TestDesktopTelemetry(t *testing.T) {
357+
t.Parallel()
358+
359+
t.Run("IsEmpty", func(t *testing.T) {
360+
t.Parallel()
361+
dt := DesktopTelemetry{}
362+
assert.True(t, dt.IsEmpty(), "empty telemetry should be empty")
363+
364+
dt = DesktopTelemetry{DeviceID: "device1"}
365+
assert.False(t, dt.IsEmpty(), "telemetry with deviceID should not be empty")
366+
367+
dt = DesktopTelemetry{DeviceOS: "macOS"}
368+
assert.False(t, dt.IsEmpty(), "telemetry with deviceOS should not be empty")
369+
370+
dt = DesktopTelemetry{CoderDesktopVersion: "1.0.0"}
371+
assert.False(t, dt.IsEmpty(), "telemetry with version should not be empty")
372+
})
373+
374+
t.Run("ToHeader", func(t *testing.T) {
375+
t.Parallel()
376+
dt := DesktopTelemetry{
377+
DeviceID: "device1",
378+
DeviceOS: "macOS",
379+
CoderDesktopVersion: "1.0.0",
380+
}
381+
header := dt.ToHeader()
382+
assert.NotEmpty(t, header, "header should not be empty")
383+
384+
// Verify we can unmarshal it back
385+
var parsedDT DesktopTelemetry
386+
err := json.Unmarshal([]byte(header), &parsedDT)
387+
require.NoError(t, err, "should unmarshal without error")
388+
assert.Equal(t, dt, parsedDT, "unmarshaled value should match original")
389+
})
390+
391+
t.Run("FromHeader", func(t *testing.T) {
392+
t.Parallel()
393+
jsonStr := `{"device_id":"device1","device_os":"macOS","coder_desktop_version":"1.0.0"}`
394+
var dt DesktopTelemetry
395+
err := dt.FromHeader(jsonStr)
396+
require.NoError(t, err, "should parse without error")
397+
assert.Equal(t, "device1", dt.DeviceID)
398+
assert.Equal(t, "macOS", dt.DeviceOS)
399+
assert.Equal(t, "1.0.0", dt.CoderDesktopVersion)
400+
401+
// Empty header
402+
dt = DesktopTelemetry{}
403+
err = dt.FromHeader("")
404+
require.NoError(t, err, "empty header should not cause an error")
405+
assert.True(t, dt.IsEmpty(), "empty header should result in empty telemetry")
406+
407+
// Invalid JSON
408+
dt = DesktopTelemetry{}
409+
err = dt.FromHeader("{invalid")
410+
require.Error(t, err, "invalid JSON should cause an error")
411+
})
412+
}

site/src/api/typesGenerated.ts

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

0 commit comments

Comments
 (0)