feat: add onboarding info fields to first user setup (#23989)

Add optional demographic and newsletter preference fields to the setup
page: business use (yes/no), industry type, organization size, and two
newsletter toggles (marketing, release/security updates).

The new data flows through telemetry via a FirstUserOnboarding struct in
the snapshot payload, sent once when the first user is created. The
telemetry-server and BigQuery schema changes are required separately to
persist this data.

---------

Co-authored-by: default <davidiii@fraley.us>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeremy Ruppel
2026-04-03 09:52:52 -04:00
committed by GitHub
co-authored by default Claude Sonnet 4.6
parent 53482adc2d
commit da3c46b557
9 changed files with 549 additions and 15 deletions
+112
View File
@@ -14565,6 +14565,59 @@ const docTemplate = `{
}
}
},
"codersdk.CreateFirstUserOnboardingInfo": {
"type": "object",
"properties": {
"industry_type": {
"enum": [
"Technology",
"Financial Services",
"Healthcare",
"Government",
"Education",
"Retail",
"Manufacturing",
"Media",
"Telecom",
"Energy",
"Transportation",
"Consulting",
"Non-Profit",
"Other"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.IndustryType"
}
]
},
"is_business": {
"type": "boolean"
},
"newsletter_marketing": {
"type": "boolean"
},
"newsletter_releases": {
"type": "boolean"
},
"org_size": {
"enum": [
"Just me",
"2-10",
"11-50",
"51-200",
"201-1000",
"1001-5000",
"5000+"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.OrgSizeRange"
}
]
}
}
},
"codersdk.CreateFirstUserRequest": {
"type": "object",
"required": [
@@ -14579,6 +14632,9 @@ const docTemplate = `{
"name": {
"type": "string"
},
"onboarding_info": {
"$ref": "#/definitions/codersdk.CreateFirstUserOnboardingInfo"
},
"password": {
"type": "string"
},
@@ -16430,6 +16486,41 @@ const docTemplate = `{
}
}
},
"codersdk.IndustryType": {
"type": "string",
"enum": [
"Technology",
"Financial Services",
"Healthcare",
"Government",
"Education",
"Retail",
"Manufacturing",
"Media",
"Telecom",
"Energy",
"Transportation",
"Consulting",
"Non-Profit",
"Other"
],
"x-enum-varnames": [
"IndustryTypeTechnology",
"IndustryTypeFinancial",
"IndustryTypeHealthcare",
"IndustryTypeGovernment",
"IndustryTypeEducation",
"IndustryTypeRetail",
"IndustryTypeManufacturing",
"IndustryTypeMedia",
"IndustryTypeTelecom",
"IndustryTypeEnergy",
"IndustryTypeTransportation",
"IndustryTypeConsulting",
"IndustryTypeNonProfit",
"IndustryTypeOther"
]
},
"codersdk.InsightsReportInterval": {
"type": "string",
"enum": [
@@ -17616,6 +17707,27 @@ const docTemplate = `{
"OptionTypeListString"
]
},
"codersdk.OrgSizeRange": {
"type": "string",
"enum": [
"Just me",
"2-10",
"11-50",
"51-200",
"201-1000",
"1001-5000",
"5000+"
],
"x-enum-varnames": [
"OrgSizeRangeJustMe",
"OrgSizeRange2To10",
"OrgSizeRange11To50",
"OrgSizeRange51To200",
"OrgSizeRange201To1K",
"OrgSizeRange1KTo5K",
"OrgSizeRange5KPlus"
]
},
"codersdk.Organization": {
"type": "object",
"required": [
+112
View File
@@ -13105,6 +13105,59 @@
}
}
},
"codersdk.CreateFirstUserOnboardingInfo": {
"type": "object",
"properties": {
"industry_type": {
"enum": [
"Technology",
"Financial Services",
"Healthcare",
"Government",
"Education",
"Retail",
"Manufacturing",
"Media",
"Telecom",
"Energy",
"Transportation",
"Consulting",
"Non-Profit",
"Other"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.IndustryType"
}
]
},
"is_business": {
"type": "boolean"
},
"newsletter_marketing": {
"type": "boolean"
},
"newsletter_releases": {
"type": "boolean"
},
"org_size": {
"enum": [
"Just me",
"2-10",
"11-50",
"51-200",
"201-1000",
"1001-5000",
"5000+"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.OrgSizeRange"
}
]
}
}
},
"codersdk.CreateFirstUserRequest": {
"type": "object",
"required": ["email", "password", "username"],
@@ -13115,6 +13168,9 @@
"name": {
"type": "string"
},
"onboarding_info": {
"$ref": "#/definitions/codersdk.CreateFirstUserOnboardingInfo"
},
"password": {
"type": "string"
},
@@ -14914,6 +14970,41 @@
}
}
},
"codersdk.IndustryType": {
"type": "string",
"enum": [
"Technology",
"Financial Services",
"Healthcare",
"Government",
"Education",
"Retail",
"Manufacturing",
"Media",
"Telecom",
"Energy",
"Transportation",
"Consulting",
"Non-Profit",
"Other"
],
"x-enum-varnames": [
"IndustryTypeTechnology",
"IndustryTypeFinancial",
"IndustryTypeHealthcare",
"IndustryTypeGovernment",
"IndustryTypeEducation",
"IndustryTypeRetail",
"IndustryTypeManufacturing",
"IndustryTypeMedia",
"IndustryTypeTelecom",
"IndustryTypeEnergy",
"IndustryTypeTransportation",
"IndustryTypeConsulting",
"IndustryTypeNonProfit",
"IndustryTypeOther"
]
},
"codersdk.InsightsReportInterval": {
"type": "string",
"enum": ["day", "week"],
@@ -16038,6 +16129,27 @@
"OptionTypeListString"
]
},
"codersdk.OrgSizeRange": {
"type": "string",
"enum": [
"Just me",
"2-10",
"11-50",
"51-200",
"201-1000",
"1001-5000",
"5000+"
],
"x-enum-varnames": [
"OrgSizeRangeJustMe",
"OrgSizeRange2To10",
"OrgSizeRange11To50",
"OrgSizeRange51To200",
"OrgSizeRange201To1K",
"OrgSizeRange1KTo5K",
"OrgSizeRange5KPlus"
]
},
"codersdk.Organization": {
"type": "object",
"required": ["created_at", "id", "is_default", "updated_at"],
+13
View File
@@ -1502,6 +1502,7 @@ type Snapshot struct {
PrebuiltWorkspaces []PrebuiltWorkspace `json:"prebuilt_workspaces"`
AIBridgeInterceptionsSummaries []AIBridgeInterceptionsSummary `json:"aibridge_interceptions_summaries"`
BoundaryUsageSummary *BoundaryUsageSummary `json:"boundary_usage_summary"`
FirstUserOnboarding *FirstUserOnboarding `json:"first_user_onboarding"`
}
// Deployment contains information about the host running Coder.
@@ -1551,6 +1552,18 @@ type User struct {
LoginType string `json:"login_type,omitempty"`
}
// FirstUserOnboarding contains optional demographic and newsletter
// preference data collected during first user setup. This is sent
// once when the first user is created. Pointer fields distinguish an
// explicit answer from a skipped question.
type FirstUserOnboarding struct {
IsBusiness *bool `json:"is_business"`
IndustryType string `json:"industry_type"`
OrgSize string `json:"org_size"`
NewsletterMarketing *bool `json:"newsletter_marketing"`
NewsletterReleases *bool `json:"newsletter_releases"`
}
type Group struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
+19 -1
View File
@@ -281,8 +281,26 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) {
telemetryUser := telemetry.ConvertUser(user)
// Send the initial users email address!
telemetryUser.Email = &user.Email
// Only populate onboarding data when the client actually sent it. A nil
// OnboardingInfo means the request came from an older client, the CLI, or
// the OIDC flow — not from a user who answered "no" to every question.
//
// Note: newsletter consent (NewsletterMarketing) is bundled here alongside
// product-analytics fields. These may have different legal bases under GDPR
// and should be separated in a follow-up once the legal posture is clarified.
var onboarding *telemetry.FirstUserOnboarding
if createUser.OnboardingInfo != nil {
onboarding = &telemetry.FirstUserOnboarding{
IsBusiness: createUser.OnboardingInfo.IsBusiness,
IndustryType: string(createUser.OnboardingInfo.IndustryType),
OrgSize: string(createUser.OnboardingInfo.OrgSize),
NewsletterMarketing: createUser.OnboardingInfo.NewsletterMarketing,
NewsletterReleases: createUser.OnboardingInfo.NewsletterReleases,
}
}
api.Telemetry.Report(&telemetry.Snapshot{
Users: []telemetry.User{telemetryUser},
Users: []telemetry.User{telemetryUser},
FirstUserOnboarding: onboarding,
})
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateFirstUserResponse{
+87
View File
@@ -116,6 +116,93 @@ func TestFirstUser(t *testing.T) {
})
}
func TestFirstUser_OnboardingTelemetry(t *testing.T) {
t.Parallel()
t.Run("OnboardingInfoFlowsToSnapshot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
fTelemetry := newFakeTelemetryReporter(ctx, t, 10)
client := coderdtest.New(t, &coderdtest.Options{
TelemetryReporter: fTelemetry,
})
isBusiness := true
wantMarketing := false
wantReleases := true
_, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: "admin@coder.com",
Username: "admin",
Password: "SomeSecurePassword!",
OnboardingInfo: &codersdk.CreateFirstUserOnboardingInfo{
IsBusiness: &isBusiness,
IndustryType: codersdk.IndustryTypeTechnology,
OrgSize: codersdk.OrgSizeRange51To200,
NewsletterMarketing: &wantMarketing,
NewsletterReleases: &wantReleases,
},
})
require.NoError(t, err)
snapshot := testutil.TryReceive(ctx, t, fTelemetry.snapshots)
require.NotNil(t, snapshot.FirstUserOnboarding)
require.NotNil(t, snapshot.FirstUserOnboarding.IsBusiness)
require.True(t, *snapshot.FirstUserOnboarding.IsBusiness)
require.Equal(t, string(codersdk.IndustryTypeTechnology), snapshot.FirstUserOnboarding.IndustryType)
require.Equal(t, string(codersdk.OrgSizeRange51To200), snapshot.FirstUserOnboarding.OrgSize)
require.NotNil(t, snapshot.FirstUserOnboarding.NewsletterMarketing)
require.False(t, *snapshot.FirstUserOnboarding.NewsletterMarketing)
require.NotNil(t, snapshot.FirstUserOnboarding.NewsletterReleases)
require.True(t, *snapshot.FirstUserOnboarding.NewsletterReleases)
})
t.Run("NilWhenOnboardingInfoOmitted", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
fTelemetry := newFakeTelemetryReporter(ctx, t, 10)
client := coderdtest.New(t, &coderdtest.Options{
TelemetryReporter: fTelemetry,
})
_, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: "admin@coder.com",
Username: "admin",
Password: "SomeSecurePassword!",
// No OnboardingInfo — simulates old CLI or OIDC flow.
})
require.NoError(t, err)
snapshot := testutil.TryReceive(ctx, t, fTelemetry.snapshots)
require.Nil(t, snapshot.FirstUserOnboarding)
})
t.Run("EmptyOnboardingInfoIsNonNilWithZeroFields", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
fTelemetry := newFakeTelemetryReporter(ctx, t, 10)
client := coderdtest.New(t, &coderdtest.Options{
TelemetryReporter: fTelemetry,
})
_, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: "admin@coder.com", Username: "admin",
Password: "SomeSecurePassword!",
OnboardingInfo: &codersdk.CreateFirstUserOnboardingInfo{},
})
require.NoError(t, err)
snapshot := testutil.TryReceive(ctx, t, fTelemetry.snapshots)
require.NotNil(t, snapshot.FirstUserOnboarding,
"non-nil OnboardingInfo must produce non-nil telemetry")
require.Nil(t, snapshot.FirstUserOnboarding.IsBusiness,
"nil *bool must stay nil, not become false")
require.Nil(t, snapshot.FirstUserOnboarding.NewsletterMarketing)
require.Nil(t, snapshot.FirstUserOnboarding.NewsletterReleases)
require.Empty(t, snapshot.FirstUserOnboarding.IndustryType)
require.Empty(t, snapshot.FirstUserOnboarding.OrgSize)
})
}
func TestPostLogin(t *testing.T) {
t.Parallel()
t.Run("InvalidUser", func(t *testing.T) {
+56 -6
View File
@@ -125,12 +125,13 @@ type LicensorTrialRequest struct {
}
type CreateFirstUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Name string `json:"name" validate:"user_real_name"`
Password string `json:"password" validate:"required"`
Trial bool `json:"trial"`
TrialInfo CreateFirstUserTrialInfo `json:"trial_info"`
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Name string `json:"name" validate:"user_real_name"`
Password string `json:"password" validate:"required"`
Trial bool `json:"trial"`
TrialInfo CreateFirstUserTrialInfo `json:"trial_info"`
OnboardingInfo *CreateFirstUserOnboardingInfo `json:"onboarding_info,omitempty"`
}
type CreateFirstUserTrialInfo struct {
@@ -143,6 +144,55 @@ type CreateFirstUserTrialInfo struct {
Developers string `json:"developers"`
}
// OrgSizeRange represents a bucketed headcount range for an organization.
// These values are fixed by agreement with the frontend and the telemetry
// schema — do not change them without coordinating both sides.
type OrgSizeRange string
const (
OrgSizeRangeJustMe OrgSizeRange = "Just me"
OrgSizeRange2To10 OrgSizeRange = "2-10"
OrgSizeRange11To50 OrgSizeRange = "11-50"
OrgSizeRange51To200 OrgSizeRange = "51-200"
OrgSizeRange201To1K OrgSizeRange = "201-1000"
OrgSizeRange1KTo5K OrgSizeRange = "1001-5000"
OrgSizeRange5KPlus OrgSizeRange = "5000+"
)
// IndustryType represents the industry vertical an organization operates in.
// These values are fixed by agreement with the frontend and the telemetry
// schema — do not change them without coordinating both sides.
type IndustryType string
const (
IndustryTypeTechnology IndustryType = "Technology"
IndustryTypeFinancial IndustryType = "Financial Services"
IndustryTypeHealthcare IndustryType = "Healthcare"
IndustryTypeGovernment IndustryType = "Government"
IndustryTypeEducation IndustryType = "Education"
IndustryTypeRetail IndustryType = "Retail"
IndustryTypeManufacturing IndustryType = "Manufacturing"
IndustryTypeMedia IndustryType = "Media"
IndustryTypeTelecom IndustryType = "Telecom"
IndustryTypeEnergy IndustryType = "Energy"
IndustryTypeTransportation IndustryType = "Transportation"
IndustryTypeConsulting IndustryType = "Consulting"
IndustryTypeNonProfit IndustryType = "Non-Profit"
IndustryTypeOther IndustryType = "Other"
)
// CreateFirstUserOnboardingInfo contains optional demographic and
// newsletter preference data collected during first user setup.
// Pointer fields allow an explicit "no" answer to be distinguished
// from a skipped question, which matters for the telemetry schema.
type CreateFirstUserOnboardingInfo struct {
IsBusiness *bool `json:"is_business"`
IndustryType IndustryType `json:"industry_type" validate:"omitempty,oneof=Technology 'Financial Services' Healthcare Government Education Retail Manufacturing Media Telecom Energy Transportation Consulting Non-Profit Other"`
OrgSize OrgSizeRange `json:"org_size" validate:"omitempty,oneof='Just me' 2-10 11-50 51-200 201-1000 1001-5000 5000+"`
NewsletterMarketing *bool `json:"newsletter_marketing"`
NewsletterReleases *bool `json:"newsletter_releases"`
}
// CreateFirstUserResponse contains IDs for newly created user info.
type CreateFirstUserResponse struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
+73 -8
View File
@@ -2246,12 +2246,48 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `password` | string | true | | |
| `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. |
## codersdk.CreateFirstUserOnboardingInfo
```json
{
"industry_type": "Technology",
"is_business": true,
"newsletter_marketing": true,
"newsletter_releases": true,
"org_size": "Just me"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------------|------------------------------------------------|----------|--------------|-------------|
| `industry_type` | [codersdk.IndustryType](#codersdkindustrytype) | false | | |
| `is_business` | boolean | false | | |
| `newsletter_marketing` | boolean | false | | |
| `newsletter_releases` | boolean | false | | |
| `org_size` | [codersdk.OrgSizeRange](#codersdkorgsizerange) | false | | |
#### Enumerated Values
| Property | Value(s) |
|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `industry_type` | `Consulting`, `Education`, `Energy`, `Financial Services`, `Government`, `Healthcare`, `Manufacturing`, `Media`, `Non-Profit`, `Other`, `Retail`, `Technology`, `Telecom`, `Transportation` |
| `org_size` | `1001-5000`, `11-50`, `2-10`, `201-1000`, `5000+`, `51-200`, `Just me` |
## codersdk.CreateFirstUserRequest
```json
{
"email": "string",
"name": "string",
"onboarding_info": {
"industry_type": "Technology",
"is_business": true,
"newsletter_marketing": true,
"newsletter_releases": true,
"org_size": "Just me"
},
"password": "string",
"trial": true,
"trial_info": {
@@ -2269,14 +2305,15 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------|------------------------------------------------------------------------|----------|--------------|-------------|
| `email` | string | true | | |
| `name` | string | false | | |
| `password` | string | true | | |
| `trial` | boolean | false | | |
| `trial_info` | [codersdk.CreateFirstUserTrialInfo](#codersdkcreatefirstusertrialinfo) | false | | |
| `username` | string | true | | |
| Name | Type | Required | Restrictions | Description |
|-------------------|----------------------------------------------------------------------------------|----------|--------------|-------------|
| `email` | string | true | | |
| `name` | string | false | | |
| `onboarding_info` | [codersdk.CreateFirstUserOnboardingInfo](#codersdkcreatefirstuseronboardinginfo) | false | | |
| `password` | string | true | | |
| `trial` | boolean | false | | |
| `trial_info` | [codersdk.CreateFirstUserTrialInfo](#codersdkcreatefirstusertrialinfo) | false | | |
| `username` | string | true | | |
## codersdk.CreateFirstUserResponse
@@ -5128,6 +5165,20 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
| `label` | string | false | | |
| `url` | string | false | | |
## codersdk.IndustryType
```json
"Technology"
```
### Properties
#### Enumerated Values
| Value(s) |
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Consulting`, `Education`, `Energy`, `Financial Services`, `Government`, `Healthcare`, `Manufacturing`, `Media`, `Non-Profit`, `Other`, `Retail`, `Technology`, `Telecom`, `Transportation` |
## codersdk.InsightsReportInterval
```json
@@ -6359,6 +6410,20 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|--------------------------------------------|
| `bool`, `list(string)`, `number`, `string` |
## codersdk.OrgSizeRange
```json
"Just me"
```
### Properties
#### Enumerated Values
| Value(s) |
|------------------------------------------------------------------------|
| `1001-5000`, `11-50`, `2-10`, `201-1000`, `5000+`, `51-200`, `Just me` |
## codersdk.Organization
```json
+7
View File
@@ -245,6 +245,13 @@ curl -X POST http://coder-server:8080/api/v2/users/first \
{
"email": "string",
"name": "string",
"onboarding_info": {
"industry_type": "Technology",
"is_business": true,
"newsletter_marketing": true,
"newsletter_releases": true,
"org_size": "Just me"
},
"password": "string",
"trial": true,
"trial_info": {
+70
View File
@@ -2383,6 +2383,21 @@ export interface CreateChatRequest {
readonly labels?: Record<string, string>;
}
// From codersdk/users.go
/**
* CreateFirstUserOnboardingInfo contains optional demographic and
* newsletter preference data collected during first user setup.
* Pointer fields allow an explicit "no" answer to be distinguished
* from a skipped question, which matters for the telemetry schema.
*/
export interface CreateFirstUserOnboardingInfo {
readonly is_business: boolean | null;
readonly industry_type: IndustryType;
readonly org_size: OrgSizeRange;
readonly newsletter_marketing: boolean | null;
readonly newsletter_releases: boolean | null;
}
// From codersdk/users.go
export interface CreateFirstUserRequest {
readonly email: string;
@@ -2391,6 +2406,7 @@ export interface CreateFirstUserRequest {
readonly password: string;
readonly trial: boolean;
readonly trial_info: CreateFirstUserTrialInfo;
readonly onboarding_info?: CreateFirstUserOnboardingInfo;
}
// From codersdk/users.go
@@ -3844,6 +3860,40 @@ export const InboxNotificationFallbackIconTemplate = "DEFAULT_ICON_TEMPLATE";
// From codersdk/inboxnotification.go
export const InboxNotificationFallbackIconWorkspace = "DEFAULT_ICON_WORKSPACE";
// From codersdk/users.go
export type IndustryType =
| "Consulting"
| "Education"
| "Energy"
| "Financial Services"
| "Government"
| "Healthcare"
| "Manufacturing"
| "Media"
| "Non-Profit"
| "Other"
| "Retail"
| "Technology"
| "Telecom"
| "Transportation";
export const IndustryTypes: IndustryType[] = [
"Consulting",
"Education",
"Energy",
"Financial Services",
"Government",
"Healthcare",
"Manufacturing",
"Media",
"Non-Profit",
"Other",
"Retail",
"Technology",
"Telecom",
"Transportation",
];
// From codersdk/insights.go
export type InsightsReportInterval = "day" | "week";
@@ -4786,6 +4836,26 @@ export const OptionTypes: OptionType[] = [
"string",
];
// From codersdk/users.go
export type OrgSizeRange =
| "11-50"
| "1001-5000"
| "201-1000"
| "2-10"
| "51-200"
| "5000+"
| "Just me";
export const OrgSizeRanges: OrgSizeRange[] = [
"11-50",
"1001-5000",
"201-1000",
"2-10",
"51-200",
"5000+",
"Just me",
];
// From codersdk/organizations.go
/**
* Organization is the JSON representation of a Coder organization.