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.
This commit is contained in:
Spike Curtis
2025-03-25 15:26:05 +04:00
committed by GitHub
parent d5557fcbf5
commit 117e4c2fe7
11 changed files with 292 additions and 59 deletions

View File

@ -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},
})
@ -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`.

View File

@ -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"
@ -2135,12 +2136,8 @@ 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,
})
firstUser := coderdtest.CreateFirstUser(t, firstClient)
member, memberUser := coderdtest.CreateAnotherUser(t, firstClient, firstUser.OrganizationID, rbac.RoleTemplateAdmin())
@ -2148,17 +2145,12 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
// 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{
@ -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,
@ -2229,9 +2212,116 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
NumAgents: 0,
},
})
err = stream.Close()
}
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)
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)
@ -2246,6 +2336,11 @@ func TestOwnedWorkspacesCoordinate(t *testing.T) {
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(
@ -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)
}

View File

@ -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"
@ -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 {

View File

@ -27,6 +27,7 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/testutil"
)

View File

@ -290,6 +290,9 @@ export interface ChangePasswordWithOneTimePasscodeRequest {
readonly one_time_passcode: string;
}
// From codersdk/client.go
export const CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry";
// From codersdk/insights.go
export interface ConnectionLatency {
readonly p50: number;

View File

@ -15,6 +15,7 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/testutil"
)
@ -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)
@ -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)
@ -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()
@ -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)
@ -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)

View File

@ -26,8 +26,10 @@ import (
"tailscale.com/wgengine/router"
"cdr.dev/slog"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/quartz"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
)
// netStatusInterval is the interval at which the tunnel sends network status updates to the manager.
@ -236,6 +238,24 @@ func (t *Tunnel) start(req *StartRequest) error {
for _, h := range req.GetHeaders() {
header.Add(h.GetName(), h.GetValue())
}
// Add desktop telemetry if any fields are provided
telemetryData := codersdk.CoderDesktopTelemetry{
DeviceID: req.GetDeviceId(),
DeviceOS: req.GetDeviceOs(),
CoderDesktopVersion: req.GetCoderDesktopVersion(),
}
if !telemetryData.IsEmpty() {
headerValue, err := json.Marshal(telemetryData)
if err == nil {
header.Set(codersdk.CoderDesktopTelemetryHeader, string(headerValue))
t.logger.Debug(t.ctx, "added desktop telemetry header",
slog.F("data", telemetryData))
} else {
t.logger.Warn(t.ctx, "failed to marshal telemetry data")
}
}
var networkingStack NetworkStack
if t.networkingStackFn != nil {
networkingStack, err = t.networkingStackFn(t, req, t.clientLogger)

View File

@ -16,10 +16,11 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"tailscale.com/util/dnsname"
"github.com/coder/quartz"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/tailnet/proto"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func newFakeClient(ctx context.Context, t *testing.T) *fakeClient {
@ -103,6 +104,9 @@ func TestTunnel_StartStop(t *testing.T) {
Headers: []*StartRequest_Header{
{Name: "X-Test-Header", Value: "test"},
},
DeviceOs: "macOS",
DeviceId: "device001",
CoderDesktopVersion: "0.24.8",
},
},
})

View File

@ -12,7 +12,11 @@ import (
// implementation of the VPN RPC protocol.
var CurrentSupportedVersions = RPCVersionList{
Versions: []RPCVersion{
{Major: 1, Minor: 0},
// 1.1 adds telemetry fields to StartRequest:
// - device_id: Coder Desktop device ID
// - device_os: Coder Desktop OS information
// - coder_desktop_version: Coder Desktop version
{Major: 1, Minor: 1},
},
}

View File

@ -957,6 +957,12 @@ type StartRequest struct {
CoderUrl string `protobuf:"bytes,2,opt,name=coder_url,json=coderUrl,proto3" json:"coder_url,omitempty"`
ApiToken string `protobuf:"bytes,3,opt,name=api_token,json=apiToken,proto3" json:"api_token,omitempty"`
Headers []*StartRequest_Header `protobuf:"bytes,4,rep,name=headers,proto3" json:"headers,omitempty"`
// Device ID from Coder Desktop
DeviceId string `protobuf:"bytes,5,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
// Device OS from Coder Desktop
DeviceOs string `protobuf:"bytes,6,opt,name=device_os,json=deviceOs,proto3" json:"device_os,omitempty"`
// Coder Desktop version
CoderDesktopVersion string `protobuf:"bytes,7,opt,name=coder_desktop_version,json=coderDesktopVersion,proto3" json:"coder_desktop_version,omitempty"`
}
func (x *StartRequest) Reset() {
@ -1019,6 +1025,27 @@ func (x *StartRequest) GetHeaders() []*StartRequest_Header {
return nil
}
func (x *StartRequest) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
func (x *StartRequest) GetDeviceOs() string {
if x != nil {
return x.DeviceOs
}
return ""
}
func (x *StartRequest) GetCoderDesktopVersion() string {
if x != nil {
return x.CoderDesktopVersion
}
return ""
}
type StartResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@ -1839,7 +1866,7 @@ var file_vpn_vpn_proto_rawDesc = []byte{
0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73,
0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65,
0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xe6, 0x01, 0x0a, 0x0c,
0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xd4, 0x02, 0x0a, 0x0c,
0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x16,
0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x64, 0x65, 0x73, 0x63,
0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x14, 0x74, 0x75,
@ -1851,25 +1878,32 @@ var file_vpn_vpn_proto_rawDesc = []byte{
0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e,
0x76, 0x70, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x52, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73,
0x1a, 0x32, 0x0a, 0x06, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61,
0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14,
0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x22, 0x4e, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73,
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12,
0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x22, 0x0d, 0x0a, 0x0b, 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x22, 0x4d, 0x0a, 0x0c, 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20,
0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1b, 0x0a,
0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6f, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x4f, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x5f, 0x64, 0x65, 0x73, 0x6b, 0x74, 0x6f, 0x70, 0x5f, 0x76, 0x65, 0x72, 0x73,
0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f, 0x70, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x32,
0x0a, 0x06, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05,
0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c,
0x75, 0x65, 0x22, 0x4e, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01,
0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a,
0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x42, 0x39, 0x5a, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f,
0x76, 0x70, 0x6e, 0xaa, 0x02, 0x17, 0x43, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x44, 0x65, 0x73, 0x6b,
0x74, 0x6f, 0x70, 0x2e, 0x56, 0x70, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
0x67, 0x65, 0x22, 0x0d, 0x0a, 0x0b, 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x22, 0x4d, 0x0a, 0x0c, 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01,
0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65,
0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x42, 0x39, 0x5a, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63,
0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x76, 0x70,
0x6e, 0xaa, 0x02, 0x17, 0x43, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f,
0x70, 0x2e, 0x56, 0x70, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
}
var (

View File

@ -185,6 +185,12 @@ message StartRequest {
string value = 2;
}
repeated Header headers = 4;
// Device ID from Coder Desktop
string device_id = 5;
// Device OS from Coder Desktop
string device_os = 6;
// Coder Desktop version
string coder_desktop_version = 7;
}
message StartResponse {