diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3f90aa8c1e..1c1f1946b9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7810,6 +7810,39 @@ const docTemplate = `{ ] } }, + "/api/v2/templatebuilder/sessions": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "TemplateBuilder" + ], + "summary": "Report a template builder session event", + "operationId": "report-a-template-builder-session-event", + "parameters": [ + { + "description": "Session event", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderSessionRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/templates": { "get": { "description": "Returns a list of templates.\nBy default, only non-deprecated templates are returned.\nTo include deprecated templates, specify ` + "`" + `deprecated:true` + "`" + ` in the search query.", @@ -24747,6 +24780,56 @@ const docTemplate = `{ } } }, + "codersdk.TemplateBuilderSessionEventType": { + "type": "string", + "enum": [ + "wizard_entry", + "compose_completion" + ], + "x-enum-varnames": [ + "TemplateBuilderSessionEventWizardEntry", + "TemplateBuilderSessionEventComposeCompletion" + ] + }, + "codersdk.TemplateBuilderSessionRequest": { + "type": "object", + "required": [ + "event_type", + "session_id" + ], + "properties": { + "base_template_id": { + "type": "string" + }, + "duration_seconds": { + "type": "number" + }, + "event_type": { + "enum": [ + "wizard_entry", + "compose_completion" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateBuilderSessionEventType" + } + ] + }, + "module_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "session_id": { + "type": "string", + "format": "uuid" + }, + "success": { + "type": "boolean" + } + } + }, "codersdk.TemplateBuilderVariableType": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b958b0e4a2..78a0c965eb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6933,6 +6933,35 @@ ] } }, + "/api/v2/templatebuilder/sessions": { + "post": { + "consumes": ["application/json"], + "tags": ["TemplateBuilder"], + "summary": "Report a template builder session event", + "operationId": "report-a-template-builder-session-event", + "parameters": [ + { + "description": "Session event", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.TemplateBuilderSessionRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/templates": { "get": { "description": "Returns a list of templates.\nBy default, only non-deprecated templates are returned.\nTo include deprecated templates, specify `deprecated:true` in the search query.", @@ -22729,6 +22758,47 @@ } } }, + "codersdk.TemplateBuilderSessionEventType": { + "type": "string", + "enum": ["wizard_entry", "compose_completion"], + "x-enum-varnames": [ + "TemplateBuilderSessionEventWizardEntry", + "TemplateBuilderSessionEventComposeCompletion" + ] + }, + "codersdk.TemplateBuilderSessionRequest": { + "type": "object", + "required": ["event_type", "session_id"], + "properties": { + "base_template_id": { + "type": "string" + }, + "duration_seconds": { + "type": "number" + }, + "event_type": { + "enum": ["wizard_entry", "compose_completion"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateBuilderSessionEventType" + } + ] + }, + "module_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "session_id": { + "type": "string", + "format": "uuid" + }, + "success": { + "type": "boolean" + } + } + }, "codersdk.TemplateBuilderVariableType": { "type": "string", "enum": ["string", "number", "bool"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 158af33929..7580154610 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1704,6 +1704,7 @@ func New(options *Options) *API { r.Get("/modules", api.templateBuilderModules) r.Post("/compose", api.templateBuilderCompose) r.Post("/compose/template", api.templateBuilderCreateTemplate) + r.Post("/sessions", api.templateBuilderSession) }) } diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index bd931741f4..adc4da2d77 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/templatebuilder" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/coderd/util/namesgenerator" @@ -651,3 +652,45 @@ func (api *API) waitForProvisionerJob( } } } + +// @Summary Report a template builder session event +// @ID report-a-template-builder-session-event +// @Security CoderSessionToken +// @Accept json +// @Tags TemplateBuilder +// @Param request body codersdk.TemplateBuilderSessionRequest true "Session event" +// @Success 204 +// @Router /api/v2/templatebuilder/sessions [post] +func (api *API) templateBuilderSession(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + // Only template admins should be able to use this flow and submit + // session telemetry, matching the compose endpoint's authorization. + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceTemplate.AnyOrganization()) { + httpapi.ResourceNotFound(rw) + return + } + + var req codersdk.TemplateBuilderSessionRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + api.Telemetry.Report(&telemetry.Snapshot{ + TemplateBuilderSessions: []telemetry.TemplateBuilderSession{ + { + ID: req.SessionID, + EventType: string(req.EventType), + UserID: apiKey.UserID, + BaseTemplateID: req.BaseTemplateID, + ModuleIDs: req.ModuleIDs, + DurationSeconds: req.DurationSeconds, + Success: req.Success, + CreatedAt: dbtime.Now(), + }, + }, + }) + + rw.WriteHeader(http.StatusNoContent) +} diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go index a7939a82eb..bbac79a0ea 100644 --- a/coderd/templatebuilder_handler_test.go +++ b/coderd/templatebuilder_handler_test.go @@ -5,6 +5,7 @@ import ( "net/http" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" @@ -238,3 +239,120 @@ func TestTemplateBuilderModules(t *testing.T) { require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) } + +func TestTemplateBuilderSession(t *testing.T) { + t.Parallel() + + t.Run("WizardEntry", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{ + SessionID: uuid.New(), + EventType: codersdk.TemplateBuilderSessionEventWizardEntry, + }) + require.NoError(t, err) + }) + + t.Run("ComposeCompletion", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{ + SessionID: uuid.New(), + EventType: codersdk.TemplateBuilderSessionEventComposeCompletion, + BaseTemplateID: "docker", + ModuleIDs: []string{"code-server", "git-clone"}, + DurationSeconds: 42.5, + Success: true, + }) + require.NoError(t, err) + }) + + t.Run("MissingSessionID", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{ + EventType: codersdk.TemplateBuilderSessionEventWizardEntry, + }) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("InvalidEventType", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{ + EventType: "invalid_event", + }) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("DisabledReturns404", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.TemplateBuilder.Disabled = true + + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{ + EventType: codersdk.TemplateBuilderSessionEventWizardEntry, + }) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("MemberCannotSubmit", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + memberClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := memberClient.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{ + EventType: codersdk.TemplateBuilderSessionEventWizardEntry, + }) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/codersdk/templatebuilder.go b/codersdk/templatebuilder.go index 0bcb36ca05..a0be2872cf 100644 --- a/codersdk/templatebuilder.go +++ b/codersdk/templatebuilder.go @@ -150,6 +150,40 @@ type TemplateBuilderCreateTemplateResponse struct { Template Template `json:"template"` } +// TemplateBuilderSessionEventType enumerates the event types for +// template builder session telemetry. +type TemplateBuilderSessionEventType string + +const ( + TemplateBuilderSessionEventWizardEntry TemplateBuilderSessionEventType = "wizard_entry" + TemplateBuilderSessionEventComposeCompletion TemplateBuilderSessionEventType = "compose_completion" +) + +// TemplateBuilderSessionRequest is the request body for +// POST /api/v2/templatebuilder/sessions. +type TemplateBuilderSessionRequest struct { + SessionID uuid.UUID `json:"session_id" format:"uuid" validate:"required"` + EventType TemplateBuilderSessionEventType `json:"event_type" validate:"required,oneof=wizard_entry compose_completion"` + BaseTemplateID string `json:"base_template_id,omitempty"` + ModuleIDs []string `json:"module_ids,omitempty"` + DurationSeconds float64 `json:"duration_seconds,omitempty"` + Success bool `json:"success,omitempty"` +} + +// TemplateBuilderSession reports a template builder session event for +// telemetry purposes. +func (c *Client) TemplateBuilderSession(ctx context.Context, req TemplateBuilderSessionRequest) error { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/templatebuilder/sessions", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // TemplateBuilderCreateTemplate composes a template from a base and modules, // validates it via a provisioner import job, and creates the template. func (c *Client) TemplateBuilderCreateTemplate(ctx context.Context, req TemplateBuilderCreateTemplateRequest) (TemplateBuilderCreateTemplateResponse, error) { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8739faca02..c000a0cbff 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12614,6 +12614,52 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |-----------|---------------------------------------------------------------------------|----------|--------------|-------------| | `modules` | array of [codersdk.TemplateBuilderModule](#codersdktemplatebuildermodule) | false | | | +## codersdk.TemplateBuilderSessionEventType + +```json +"wizard_entry" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|--------------------------------------| +| `compose_completion`, `wizard_entry` | + +## codersdk.TemplateBuilderSessionRequest + +```json +{ + "base_template_id": "string", + "duration_seconds": 0, + "event_type": "wizard_entry", + "module_ids": [ + "string" + ], + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82", + "success": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|--------------------------------------------------------------------------------------|----------|--------------|-------------| +| `base_template_id` | string | false | | | +| `duration_seconds` | number | false | | | +| `event_type` | [codersdk.TemplateBuilderSessionEventType](#codersdktemplatebuildersessioneventtype) | true | | | +| `module_ids` | array of string | false | | | +| `session_id` | string | true | | | +| `success` | boolean | false | | | + +#### Enumerated Values + +| Property | Value(s) | +|--------------|--------------------------------------| +| `event_type` | `compose_completion`, `wizard_entry` | + ## codersdk.TemplateBuilderVariableType ```json diff --git a/docs/reference/api/templatebuilder.md b/docs/reference/api/templatebuilder.md index 9b23383636..57dd44239d 100644 --- a/docs/reference/api/templatebuilder.md +++ b/docs/reference/api/templatebuilder.md @@ -290,3 +290,45 @@ curl -X GET http://coder-server:8080/api/v2/templatebuilder/modules \ | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TemplateBuilderModulesResponse](schemas.md#codersdktemplatebuildermodulesresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Report a template builder session event + +### Code samples + +```sh +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/templatebuilder/sessions \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/v2/templatebuilder/sessions` + +> Body parameter + +```json +{ + "base_template_id": "string", + "duration_seconds": 0, + "event_type": "wizard_entry", + "module_ids": [ + "string" + ], + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82", + "success": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------------------------------------------------------------------------------------|----------|---------------| +| `body` | body | [codersdk.TemplateBuilderSessionRequest](schemas.md#codersdktemplatebuildersessionrequest) | true | Session event | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/api.ts b/site/src/api/api.ts index eb0f971d10..ccc082368e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2612,6 +2612,12 @@ class ApiMethods { return response.data; }; + recordTemplateBuilderSession = async ( + req: TypesGen.TemplateBuilderSessionRequest, + ): Promise => { + await this.axios.post("/api/v2/templatebuilder/sessions", req); + }; + uploadFile = async (file: File): Promise => { const response = await this.axios.post("/api/v2/files", file, { headers: { "Content-Type": file.type }, diff --git a/site/src/api/queries/templateBuilder.ts b/site/src/api/queries/templateBuilder.ts index 210d36106d..bd8dd7a84d 100644 --- a/site/src/api/queries/templateBuilder.ts +++ b/site/src/api/queries/templateBuilder.ts @@ -14,3 +14,7 @@ export const templateBuilderModules = (base?: string) => ({ export const createTemplateFromBuilder = () => ({ mutationFn: API.createTemplateFromBuilder, }); + +export const recordTemplateBuilderSession = () => ({ + mutationFn: API.recordTemplateBuilderSession, +}); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8ea30011d0..3d89b954e2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8806,6 +8806,28 @@ export interface TemplateBuilderModulesResponse { readonly modules: readonly TemplateBuilderModule[]; } +// From codersdk/templatebuilder.go +export type TemplateBuilderSessionEventType = + | "compose_completion" + | "wizard_entry"; + +export const TemplateBuilderSessionEventTypes: TemplateBuilderSessionEventType[] = + ["compose_completion", "wizard_entry"]; + +// From codersdk/templatebuilder.go +/** + * TemplateBuilderSessionRequest is the request body for + * POST /api/v2/templatebuilder/sessions. + */ +export interface TemplateBuilderSessionRequest { + readonly session_id: string; + readonly event_type: TemplateBuilderSessionEventType; + readonly base_template_id?: string; + readonly module_ids?: readonly string[]; + readonly duration_seconds?: number; + readonly success?: boolean; +} + // From codersdk/templatebuilder.go export type TemplateBuilderVariableType = "bool" | "number" | "string"; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index b8ed072757..340c5bb35d 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -1,9 +1,10 @@ -import { type FC, useEffect, useState } from "react"; +import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery } from "react-query"; import { Navigate, useNavigate, useSearchParams } from "react-router"; import { deploymentConfig } from "#/api/queries/deployment"; import { createTemplateFromBuilder, + recordTemplateBuilderSession, templateBuilderBases, } from "#/api/queries/templateBuilder"; import { Loader } from "#/components/Loader/Loader"; @@ -24,12 +25,55 @@ const TemplateBuilderPage: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const { data, error, isLoading } = useQuery(deploymentConfig()); const createMutation = useMutation(createTemplateFromBuilder()); + const sessionMutation = useMutation(recordTemplateBuilderSession()); + + // Stable session ID for the lifetime of this page mount, shared + // across wizard_entry and compose_completion telemetry events. + const sessionId = useMemo(() => crypto.randomUUID(), []); const builderDisabled = data?.config?.template_builder?.disabled ?? false; + const wizardReady = + !builderDisabled && !isLoading && permissions.createTemplates; + + // Report wizard_entry once the builder is ready and accessible. + const reportEntry = useCallback(() => { + sessionMutation.mutate({ + session_id: sessionId, + event_type: "wizard_entry", + }); + }, [sessionMutation.mutate, sessionId]); + + // Report compose_completion when the create request settles. Duration + // is captured at submit time so it measures wizard usage, not the + // create request round trip. + const reportCompletion = useCallback( + ( + state: TemplateBuilderWizardState, + success: boolean, + durationSeconds: number, + ) => { + sessionMutation.mutate({ + session_id: state.sessionId, + event_type: "compose_completion", + base_template_id: state.baseTemplateId ?? undefined, + module_ids: state.modules.map((m) => m.id), + duration_seconds: durationSeconds, + success, + }); + }, + [sessionMutation.mutate], + ); + + useEffect(() => { + if (!wizardReady) { + return; + } + reportEntry(); + }, [wizardReady, reportEntry]); const basesQuery = useQuery({ ...templateBuilderBases(), - enabled: !builderDisabled && !isLoading && permissions.createTemplates, + enabled: wizardReady, }); // ?base= is the only search param accepted on entry. It is consumed @@ -66,14 +110,20 @@ const TemplateBuilderPage: FC = () => { const handleCreate = (state: TemplateBuilderWizardState) => { const req = toCreateTemplateRequest(state); + const durationSeconds = (Date.now() - state.enteredAt) / 1000; + createMutation.mutate(req, { onSuccess: (resp) => { + reportCompletion(state, true, durationSeconds); const t = resp.template; navigate( `${getLink(linkToTemplate(t.organization_name, t.name))}/files`, { state: { justCreated: true } }, ); }, + onError: () => { + reportCompletion(state, false, durationSeconds); + }, }); }; @@ -88,6 +138,7 @@ const TemplateBuilderPage: FC = () => { createError={createMutation.error} isCreating={createMutation.isPending || createMutation.isSuccess} onClearCreateError={() => createMutation.reset()} + sessionId={sessionId} /> ); diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 12e776ed20..fa3767778e 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -62,6 +62,7 @@ interface TemplateBuilderPageViewProps { createError: Error | null; isCreating: boolean; onClearCreateError?: () => void; + sessionId: string; } export const TemplateBuilderPageView: FC = ({ @@ -72,10 +73,11 @@ export const TemplateBuilderPageView: FC = ({ createError, isCreating, onClearCreateError, + sessionId, }) => { const [state, dispatch] = useReducer( wizardReducer, - preselectedBase, + { sessionId, preselectedBase }, initWizardState, ); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/site/src/pages/TemplateBuilder/wizardState.test.ts b/site/src/pages/TemplateBuilder/wizardState.test.ts index c072f25a12..562f7cba16 100644 --- a/site/src/pages/TemplateBuilder/wizardState.test.ts +++ b/site/src/pages/TemplateBuilder/wizardState.test.ts @@ -613,17 +613,26 @@ describe("baseCustomizationDefaults", () => { describe("initWizardState", () => { it("returns the initial state without a preselected base", () => { - expect(initWizardState()).toEqual(initialWizardState); + const state = initWizardState({ sessionId: "test-session-id" }); + expect(state).toMatchObject({ + ...initialWizardState, + enteredAt: expect.any(Number), + sessionId: "test-session-id", + }); + expect(state.enteredAt).toBeGreaterThan(0); }); it("seeds base and customization defaults from a preselected base", () => { const state = initWizardState({ - id: "docker", - name: "Docker Containers", - description: "Docker", - iconUrl: "/icon/docker.png", - hasParameters: false, - hasPrerequisites: false, + sessionId: "test-session-id", + preselectedBase: { + id: "docker", + name: "Docker Containers", + description: "Docker", + iconUrl: "/icon/docker.png", + hasParameters: false, + hasPrerequisites: false, + }, }); expect(state.baseTemplateId).toBe("docker"); expect(state.selectedBase?.id).toBe("docker"); @@ -631,5 +640,6 @@ describe("initWizardState", () => { expect(state.displayName).toBe("Docker Containers"); expect(state.description).toBe("Docker"); expect(state.icon).toBe("/icon/docker.png"); + expect(state.sessionId).toBe("test-session-id"); }); }); diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 0381f24223..185851e224 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -79,6 +79,10 @@ export type TemplateBuilderWizardState = { icon: string; selectedBase: SelectedBaseMeta | null; selectedModules: SelectedModuleMeta[]; + /** Epoch millis when the wizard was entered, used for telemetry duration. */ + enteredAt: number; + /** Stable ID shared across wizard_entry and compose_completion events. */ + sessionId: string; }; export const initialWizardState: TemplateBuilderWizardState = { @@ -92,23 +96,36 @@ export const initialWizardState: TemplateBuilderWizardState = { icon: "", selectedBase: null, selectedModules: [], + enteredAt: 0, + sessionId: "", +}; + +/** Arguments for building a fresh wizard state on mount. */ +type WizardInit = { + /** Optional base template to preselect (from the ?base= param). */ + preselectedBase?: SelectedBaseMeta; + /** Stable session ID shared across telemetry events for this mount. */ + sessionId: string; }; /** - * Builds the initial wizard state, optionally preselecting a base - * template. + * Builds the initial wizard state with a fresh telemetry session, + * optionally preselecting a base template. */ -export function initWizardState( - preselectedBase?: SelectedBaseMeta, -): TemplateBuilderWizardState { - if (!preselectedBase) { - return initialWizardState; +export function initWizardState(init: WizardInit): TemplateBuilderWizardState { + const state: TemplateBuilderWizardState = { + ...initialWizardState, + enteredAt: Date.now(), + sessionId: init.sessionId, + }; + if (!init.preselectedBase) { + return state; } return { - ...initialWizardState, - baseTemplateId: preselectedBase.id, - selectedBase: preselectedBase, - ...baseCustomizationDefaults(preselectedBase), + ...state, + baseTemplateId: init.preselectedBase.id, + selectedBase: init.preselectedBase, + ...baseCustomizationDefaults(init.preselectedBase), }; }