Skip to content

feat: adds device_id, device_os, and coder_desktop_version to telemetry #17086

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions coderd/workspaceagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -1652,6 +1652,8 @@ func (api *API) tailnetRPCConn(rw http.ResponseWriter, r *http.Request) {
DeviceOS: nil,
CoderDesktopVersion: nil,
}

fillCoderDesktopTelemetry(r, &connectionTelemetryEvent, api.Logger)
api.Telemetry.Report(&telemetry.Snapshot{
UserTailnetConnections: []telemetry.UserTailnetConnection{connectionTelemetryEvent},
})
Expand Down Expand Up @@ -1681,6 +1683,34 @@ func (api *API) tailnetRPCConn(rw http.ResponseWriter, r *http.Request) {
}
}

// fillCoderDesktopTelemetry fills out the provided event based on a Coder Desktop telemetry header on the request, if
// present.
func fillCoderDesktopTelemetry(r *http.Request, event *telemetry.UserTailnetConnection, logger slog.Logger) {
// Parse desktop telemetry from header if it exists
desktopTelemetryHeader := r.Header.Get(codersdk.CoderDesktopTelemetryHeader)
if desktopTelemetryHeader != "" {
var telemetryData codersdk.CoderDesktopTelemetry
if err := telemetryData.FromHeader(desktopTelemetryHeader); err == nil {
// Only set fields if they aren't empty
if telemetryData.DeviceID != "" {
event.DeviceID = &telemetryData.DeviceID
}
if telemetryData.DeviceOS != "" {
event.DeviceOS = &telemetryData.DeviceOS
}
if telemetryData.CoderDesktopVersion != "" {
event.CoderDesktopVersion = &telemetryData.CoderDesktopVersion
}
logger.Debug(r.Context(), "received desktop telemetry",
slog.F("device_id", telemetryData.DeviceID),
slog.F("device_os", telemetryData.DeviceOS),
slog.F("desktop_version", telemetryData.CoderDesktopVersion))
} else {
logger.Warn(r.Context(), "failed to parse desktop telemetry header", slog.Error(err))
}
}
}

// createExternalAuthResponse creates an ExternalAuthResponse based on the
// provider type. This is to support legacy `/workspaceagents/me/gitauth`
// which uses `Username` and `Password`.
Expand Down
170 changes: 137 additions & 33 deletions coderd/workspaceagents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import (
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
Expand Down Expand Up @@ -2135,30 +2136,21 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {

ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)

fTelemetry := newFakeTelemetryReporter(ctx, t, 200)
fTelemetry.enabled = false
firstClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
Coordinator: tailnet.NewCoordinator(logger),
TelemetryReporter: fTelemetry,
Coordinator: tailnet.NewCoordinator(logger),
})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())

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

// enable telemetry now that workspace is built; we don't care about snapshots before this.
fTelemetry.enabled = true

u, err := member.URL.Parse("/api/v2/tailnet")
require.NoError(t, err)
q := u.Query()
q.Set("version", "2.0")
u.RawQuery = q.Encode()

predialTime := time.Now()

//nolint:bodyclose // websocket package closes this for you
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: http.Header{
Expand All @@ -2173,15 +2165,6 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")

// Check telemetry
snapshot := testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
require.Len(t, snapshot.UserTailnetConnections, 1)
telemetryConnection := snapshot.UserTailnetConnections[0]
require.Equal(t, memberUser.ID.String(), telemetryConnection.UserID)
require.GreaterOrEqual(t, telemetryConnection.ConnectedAt, predialTime)
require.LessOrEqual(t, telemetryConnection.ConnectedAt, time.Now())
require.NotEmpty(t, telemetryConnection.PeerID)

rpcClient, err := tailnet.NewDRPCClient(
websocket.NetConn(ctx, wsConn, websocket.MessageBinary),
logger,
Expand Down Expand Up @@ -2229,23 +2212,135 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
NumAgents: 0,
},
})
err = stream.Close()
require.NoError(t, err)
}

beforeDisconnectTime := time.Now()
err = wsConn.Close(websocket.StatusNormalClosure, "done")
func TestUserTailnetTelemetry(t *testing.T) {
t.Parallel()

telemetryData := &codersdk.CoderDesktopTelemetry{
DeviceOS: "Windows",
DeviceID: "device001",
CoderDesktopVersion: "0.22.1",
}
fullHeader, err := json.Marshal(telemetryData)
require.NoError(t, err)

snapshot = testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
require.Len(t, snapshot.UserTailnetConnections, 1)
telemetryDisconnection := snapshot.UserTailnetConnections[0]
require.Equal(t, memberUser.ID.String(), telemetryDisconnection.UserID)
require.Equal(t, telemetryConnection.ConnectedAt, telemetryDisconnection.ConnectedAt)
require.Equal(t, telemetryConnection.UserID, telemetryDisconnection.UserID)
require.Equal(t, telemetryConnection.PeerID, telemetryDisconnection.PeerID)
require.NotNil(t, telemetryDisconnection.DisconnectedAt)
require.GreaterOrEqual(t, *telemetryDisconnection.DisconnectedAt, beforeDisconnectTime)
require.LessOrEqual(t, *telemetryDisconnection.DisconnectedAt, time.Now())
testCases := []struct {
name string
headers map[string]string
// only used for DeviceID, DeviceOS, CoderDesktopVersion
expected telemetry.UserTailnetConnection
}{
{
name: "no header",
headers: map[string]string{},
expected: telemetry.UserTailnetConnection{},
},
{
name: "full header",
headers: map[string]string{
codersdk.CoderDesktopTelemetryHeader: string(fullHeader),
},
expected: telemetry.UserTailnetConnection{
DeviceOS: ptr.Ref("Windows"),
DeviceID: ptr.Ref("device001"),
CoderDesktopVersion: ptr.Ref("0.22.1"),
},
},
{
name: "empty header",
headers: map[string]string{
codersdk.CoderDesktopTelemetryHeader: "",
},
expected: telemetry.UserTailnetConnection{},
},
{
name: "invalid header",
headers: map[string]string{
codersdk.CoderDesktopTelemetryHeader: "{\"device_os",
},
expected: telemetry.UserTailnetConnection{},
},
}

// nolint: paralleltest // no longer need to reinitialize loop vars in go 1.22
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)

fTelemetry := newFakeTelemetryReporter(ctx, t, 200)
fTelemetry.enabled = false
firstClient := coderdtest.New(t, &coderdtest.Options{
Logger: &logger,
TelemetryReporter: fTelemetry,
})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())

headers := http.Header{
"Coder-Session-Token": []string{member.SessionToken()},
}
for k, v := range tc.headers {
headers.Add(k, v)
}

// enable telemetry now that user is created.
fTelemetry.enabled = true

u, err := member.URL.Parse("/api/v2/tailnet")
require.NoError(t, err)
q := u.Query()
q.Set("version", "2.0")
u.RawQuery = q.Encode()

predialTime := time.Now()

//nolint:bodyclose // websocket package closes this for you
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
HTTPHeader: headers,
})
if err != nil {
if resp != nil && resp.StatusCode != http.StatusSwitchingProtocols {
err = codersdk.ReadBodyAsError(resp)
}
require.NoError(t, err)
}
defer wsConn.Close(websocket.StatusNormalClosure, "done")

// Check telemetry
snapshot := testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
require.Len(t, snapshot.UserTailnetConnections, 1)
telemetryConnection := snapshot.UserTailnetConnections[0]
require.Equal(t, memberUser.ID.String(), telemetryConnection.UserID)
require.GreaterOrEqual(t, telemetryConnection.ConnectedAt, predialTime)
require.LessOrEqual(t, telemetryConnection.ConnectedAt, time.Now())
require.NotEmpty(t, telemetryConnection.PeerID)
requireEqualOrBothNil(t, telemetryConnection.DeviceID, tc.expected.DeviceID)
requireEqualOrBothNil(t, telemetryConnection.DeviceOS, tc.expected.DeviceOS)
requireEqualOrBothNil(t, telemetryConnection.CoderDesktopVersion, tc.expected.CoderDesktopVersion)

beforeDisconnectTime := time.Now()
err = wsConn.Close(websocket.StatusNormalClosure, "done")
require.NoError(t, err)

snapshot = testutil.RequireRecvCtx(ctx, t, fTelemetry.snapshots)
require.Len(t, snapshot.UserTailnetConnections, 1)
telemetryDisconnection := snapshot.UserTailnetConnections[0]
require.Equal(t, memberUser.ID.String(), telemetryDisconnection.UserID)
require.Equal(t, telemetryConnection.ConnectedAt, telemetryDisconnection.ConnectedAt)
require.Equal(t, telemetryConnection.UserID, telemetryDisconnection.UserID)
require.Equal(t, telemetryConnection.PeerID, telemetryDisconnection.PeerID)
require.NotNil(t, telemetryDisconnection.DisconnectedAt)
require.GreaterOrEqual(t, *telemetryDisconnection.DisconnectedAt, beforeDisconnectTime)
require.LessOrEqual(t, *telemetryDisconnection.DisconnectedAt, time.Now())
requireEqualOrBothNil(t, telemetryConnection.DeviceID, tc.expected.DeviceID)
requireEqualOrBothNil(t, telemetryConnection.DeviceOS, tc.expected.DeviceOS)
requireEqualOrBothNil(t, telemetryConnection.CoderDesktopVersion, tc.expected.CoderDesktopVersion)
})
}
}

func buildWorkspaceWithAgent(
Expand Down Expand Up @@ -2414,3 +2509,12 @@ func (f *fakeTelemetryReporter) Enabled() bool {

// Close implements the telemetry.Reporter interface.
func (*fakeTelemetryReporter) Close() {}

func requireEqualOrBothNil[T any](t testing.TB, a, b *T) {
t.Helper()
if a != nil && b != nil {
require.Equal(t, *a, *b)
return
}
require.Equal(t, a, b)
}
Comment on lines +2513 to +2520
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: potentially useful tesutil candidate?

26 changes: 26 additions & 0 deletions codersdk/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ const (
// only.
CLITelemetryHeader = "Coder-CLI-Telemetry"

// CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry
// fields, including device ID, OS, and Desktop version.
CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry"

// ProvisionerDaemonPSK contains the authentication pre-shared key for an external provisioner daemon
ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK"

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

var _ error = (*ValidationError)(nil)

// CoderDesktopTelemetry represents the telemetry data sent from Coder Desktop clients.
// @typescript-ignore CoderDesktopTelemetry
type CoderDesktopTelemetry struct {
DeviceID string `json:"device_id"`
DeviceOS string `json:"device_os"`
CoderDesktopVersion string `json:"coder_desktop_version"`
}

// FromHeader parses the desktop telemetry from the provided header value.
// Returns nil if the header is empty or if parsing fails.
func (t *CoderDesktopTelemetry) FromHeader(headerValue string) error {
if headerValue == "" {
return nil
}
return json.Unmarshal([]byte(headerValue), t)
}

// IsEmpty returns true if all fields in the telemetry data are empty.
func (t *CoderDesktopTelemetry) IsEmpty() bool {
return t.DeviceID == "" && t.DeviceOS == "" && t.CoderDesktopVersion == ""
}

// IsConnectionError is a convenience function for checking if the source of an
// error is due to a 'connection refused', 'no such host', etc.
func IsConnectionError(err error) bool {
Expand Down
1 change: 1 addition & 0 deletions codersdk/client_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (

"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"

"github.com/coder/coder/v2/testutil"
)

Expand Down
3 changes: 3 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 8 additions & 7 deletions vpn/speaker_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"

"github.com/coder/coder/v2/testutil"
)

Expand Down Expand Up @@ -47,7 +48,7 @@ func TestSpeaker_RawPeer(t *testing.T) {
errCh <- err
}()

expectedHandshake := "codervpn tunnel 1.0\n"
expectedHandshake := "codervpn tunnel 1.1\n"

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

expectedHandshake := "codervpn tunnel 1.0\n"
expectedHandshake := "codervpn tunnel 1.1\n"

b := make([]byte, 256)
n, err := mp.Read(b)
Expand All @@ -177,12 +178,12 @@ func TestSpeaker_HandshakeInvalid(t *testing.T) {
for _, tc := range []struct {
name, handshake string
}{
{name: "preamble", handshake: "ssh manager 1.0\n"},
{name: "preamble", handshake: "ssh manager 1.1\n"},
{name: "2components", handshake: "ssh manager\n"},
{name: "newmajors", handshake: "codervpn manager 2.0,3.0\n"},
{name: "0version", handshake: "codervpn 0.1 manager\n"},
{name: "unknown_role", handshake: "codervpn 1.0 supervisor\n"},
{name: "unexpected_role", handshake: "codervpn 1.0 tunnel\n"},
{name: "unknown_role", handshake: "codervpn 1.1 supervisor\n"},
{name: "unexpected_role", handshake: "codervpn 1.1 tunnel\n"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
Expand All @@ -208,7 +209,7 @@ func TestSpeaker_HandshakeInvalid(t *testing.T) {
_, err = mp.Write([]byte(tc.handshake))
require.NoError(t, err)

expectedHandshake := "codervpn tunnel 1.0\n"
expectedHandshake := "codervpn tunnel 1.1\n"
b := make([]byte, 256)
n, err := mp.Read(b)
require.NoError(t, err)
Expand Down Expand Up @@ -246,7 +247,7 @@ func TestSpeaker_CorruptMessage(t *testing.T) {
errCh <- err
}()

expectedHandshake := "codervpn tunnel 1.0\n"
expectedHandshake := "codervpn tunnel 1.1\n"

b := make([]byte, 256)
n, err := mp.Read(b)
Expand Down
Loading
Loading