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

File tree

11 files changed

+285
-59
lines changed

11 files changed

+285
-59
lines changed

coderd/workspaceagents.go

Lines changed: 24 additions & 0 deletions
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

Lines changed: 136 additions & 33 deletions
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

Lines changed: 26 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vpn/speaker_internal_test.go

Lines changed: 8 additions & 7 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy