From 9cdb1d72010e3642aa628cb94aac21385ea9e7fa Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 8 Jul 2026 20:09:17 +0000 Subject: [PATCH 01/11] feat(coderd/templatebuilder): wire up session telemetry endpoint Add POST /api/v2/templatebuilder/sessions to report template builder session events (wizard_entry, compose_completion) for telemetry. Uses direct inline reporting via api.Telemetry.Report() matching the pattern used by NetworkEvents and UserTailnetConnections. RBAC: requires policy.ActionCreate on ResourceTemplate (same as compose). No database table or migration needed; events are reported as partial telemetry snapshots. --- coderd/apidoc/docs.go | 78 +++++++++++++++++++++ coderd/apidoc/swagger.json | 66 ++++++++++++++++++ coderd/coderd.go | 1 + coderd/templatebuilder_handler.go | 41 +++++++++++ coderd/templatebuilder_handler_test.go | 97 ++++++++++++++++++++++++++ codersdk/templatebuilder.go | 33 +++++++++ docs/reference/api/schemas.md | 44 ++++++++++++ docs/reference/api/templatebuilder.md | 41 +++++++++++ site/src/api/typesGenerated.ts | 21 ++++++ 9 files changed, 422 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3f90aa8c1eb20..8bb90564ba7f9 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-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,51 @@ const docTemplate = `{ } } }, + "codersdk.TemplateBuilderSessionEventType": { + "type": "string", + "enum": [ + "wizard_entry", + "compose_completion" + ], + "x-enum-varnames": [ + "TemplateBuilderSessionEventWizardEntry", + "TemplateBuilderSessionEventComposeCompletion" + ] + }, + "codersdk.TemplateBuilderSessionRequest": { + "type": "object", + "required": [ + "event_type" + ], + "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" + } + }, + "success": { + "type": "boolean" + } + } + }, "codersdk.TemplateBuilderVariableType": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b958b0e4a2f82..f892d198eccf4 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-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,43 @@ } } }, + "codersdk.TemplateBuilderSessionEventType": { + "type": "string", + "enum": ["wizard_entry", "compose_completion"], + "x-enum-varnames": [ + "TemplateBuilderSessionEventWizardEntry", + "TemplateBuilderSessionEventComposeCompletion" + ] + }, + "codersdk.TemplateBuilderSessionRequest": { + "type": "object", + "required": ["event_type"], + "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" + } + }, + "success": { + "type": "boolean" + } + } + }, "codersdk.TemplateBuilderVariableType": { "type": "string", "enum": ["string", "number", "bool"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 158af33929c58..7580154610c87 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 bd931741f4725..b2a9d3345ed10 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,43 @@ func (api *API) waitForProvisionerJob( } } } + +// @Summary Report a template builder session event +// @ID report-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) + + 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: uuid.New(), + 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 a7939a82eb922..8ee6695349f4c 100644 --- a/coderd/templatebuilder_handler_test.go +++ b/coderd/templatebuilder_handler_test.go @@ -238,3 +238,100 @@ 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{ + 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{ + EventType: codersdk.TemplateBuilderSessionEventComposeCompletion, + BaseTemplateID: "docker", + ModuleIDs: []string{"code-server", "git-clone"}, + DurationSeconds: 42.5, + Success: true, + }) + require.NoError(t, err) + }) + + 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 0bcb36ca0579d..f7b2cbfadad87 100644 --- a/codersdk/templatebuilder.go +++ b/codersdk/templatebuilder.go @@ -150,6 +150,39 @@ 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 { + 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 8739faca02158..ab6718ee9ec29 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12614,6 +12614,50 @@ 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" + ], + "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 | | | +| `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 9b23383636a31..c4c4227fa022a 100644 --- a/docs/reference/api/templatebuilder.md +++ b/docs/reference/api/templatebuilder.md @@ -290,3 +290,44 @@ 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 + +```shell +# 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" + ], + "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/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8ea30011d01c1..e6d2da3f461d4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8806,6 +8806,27 @@ 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 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"; From 3733ade325c75b3b2478fc2a8f8058bea972b8d1 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Thu, 9 Jul 2026 13:24:35 +0000 Subject: [PATCH 02/11] docs(coderd/templatebuilder): add RBAC comment to session handler --- coderd/templatebuilder_handler.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index b2a9d3345ed10..a3f3b2745f9e7 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -665,6 +665,8 @@ 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 From 1fbaab7df7669a825f36b72634a41e2fb8f41929 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Thu, 9 Jul 2026 13:50:05 +0000 Subject: [PATCH 03/11] fix(coderd/templatebuilder): match swagger ID to summary for session endpoint --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/templatebuilder_handler.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8bb90564ba7f9..a97666cb77746 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7819,7 +7819,7 @@ const docTemplate = `{ "TemplateBuilder" ], "summary": "Report a template builder session event", - "operationId": "report-template-builder-session-event", + "operationId": "report-a-template-builder-session-event", "parameters": [ { "description": "Session event", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f892d198eccf4..246000a21454e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6938,7 +6938,7 @@ "consumes": ["application/json"], "tags": ["TemplateBuilder"], "summary": "Report a template builder session event", - "operationId": "report-template-builder-session-event", + "operationId": "report-a-template-builder-session-event", "parameters": [ { "description": "Session event", diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index a3f3b2745f9e7..a0c6aed1b1e38 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -654,7 +654,7 @@ func (api *API) waitForProvisionerJob( } // @Summary Report a template builder session event -// @ID report-template-builder-session-event +// @ID report-a-template-builder-session-event // @Security CoderSessionToken // @Accept json // @Tags TemplateBuilder From 339e69daf3bb5e0ba4e7389075fc5ee120118a9f Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Thu, 9 Jul 2026 14:06:49 +0000 Subject: [PATCH 04/11] feat(site): report template builder session telemetry from wizard Wire up the frontend to call POST /api/v2/templatebuilder/sessions: - wizard_entry: fired once when the builder page becomes ready - compose_completion: fired on create success or failure, includes base_template_id, module_ids, duration_seconds, and success flag Duration is tracked via Date.now() stored in the wizard state at initialization, then computed as elapsed seconds on create submission. --- site/src/api/api.ts | 6 ++++ site/src/api/queries/templateBuilder.ts | 4 +++ .../TemplateBuilder/TemplateBuilderPage.tsx | 33 ++++++++++++++++++- site/src/pages/TemplateBuilder/wizardState.ts | 3 ++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index eb0f971d108d0..ccc082368e160 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 210d36106d667..bd8dd7a84d3e7 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/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index b8ed0727574a0..4b058845d867d 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -4,6 +4,7 @@ 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,24 @@ const TemplateBuilderPage: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const { data, error, isLoading } = useQuery(deploymentConfig()); const createMutation = useMutation(createTemplateFromBuilder()); + const sessionMutation = useMutation(recordTemplateBuilderSession()); 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 reportSession = sessionMutation.mutate; + useEffect(() => { + if (!wizardReady) { + return; + } + reportSession({ event_type: "wizard_entry" }); + }, [wizardReady, reportSession]); 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 +79,32 @@ const TemplateBuilderPage: FC = () => { const handleCreate = (state: TemplateBuilderWizardState) => { const req = toCreateTemplateRequest(state); + const durationSeconds = (Date.now() - state.enteredAt) / 1000; + createMutation.mutate(req, { onSuccess: (resp) => { + sessionMutation.mutate({ + event_type: "compose_completion", + base_template_id: state.baseTemplateId ?? undefined, + module_ids: state.modules.map((m) => m.id), + duration_seconds: durationSeconds, + success: true, + }); const t = resp.template; navigate( `${getLink(linkToTemplate(t.organization_name, t.name))}/files`, { state: { justCreated: true } }, ); }, + onError: () => { + sessionMutation.mutate({ + event_type: "compose_completion", + base_template_id: state.baseTemplateId ?? undefined, + module_ids: state.modules.map((m) => m.id), + duration_seconds: durationSeconds, + success: false, + }); + }, }); }; diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 0381f242234fb..1a70c017c9f5b 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -79,6 +79,8 @@ export type TemplateBuilderWizardState = { icon: string; selectedBase: SelectedBaseMeta | null; selectedModules: SelectedModuleMeta[]; + /** Epoch millis when the wizard was entered, used for telemetry duration. */ + enteredAt: number; }; export const initialWizardState: TemplateBuilderWizardState = { @@ -92,6 +94,7 @@ export const initialWizardState: TemplateBuilderWizardState = { icon: "", selectedBase: null, selectedModules: [], + enteredAt: Date.now(), }; /** From 1e037435600f0379e2cbd46ec291c3a3db5f2bde Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Thu, 9 Jul 2026 14:23:46 +0000 Subject: [PATCH 05/11] feat: add session ID to template builder telemetry events Add a client-supplied session_id (UUID) that correlates wizard_entry and compose_completion events for the same wizard visit. This enables precise funnel analysis and abandonment detection in BigQuery without relying on timestamp proximity heuristics. The session ID is generated once per page mount via crypto.randomUUID() and threaded through the wizard state. The backend falls back to uuid.New() if the client omits it. --- coderd/apidoc/docs.go | 4 +++ coderd/apidoc/swagger.json | 4 +++ coderd/templatebuilder_handler.go | 7 +++- codersdk/templatebuilder.go | 1 + docs/reference/api/schemas.md | 2 ++ docs/reference/api/templatebuilder.md | 1 + site/src/api/typesGenerated.ts | 1 + .../TemplateBuilder/TemplateBuilderPage.tsx | 13 ++++++-- .../TemplateBuilderPageView.tsx | 4 ++- .../pages/TemplateBuilder/wizardState.test.ts | 24 ++++++++++---- site/src/pages/TemplateBuilder/wizardState.ts | 33 ++++++++++++------- 11 files changed, 70 insertions(+), 24 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a97666cb77746..dd11db9889684 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -24820,6 +24820,10 @@ const docTemplate = `{ "type": "string" } }, + "session_id": { + "type": "string", + "format": "uuid" + }, "success": { "type": "boolean" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 246000a21454e..dfe7c8977d652 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -22790,6 +22790,10 @@ "type": "string" } }, + "session_id": { + "type": "string", + "format": "uuid" + }, "success": { "type": "boolean" } diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index a0c6aed1b1e38..baf8a485f128c 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -677,10 +677,15 @@ func (api *API) templateBuilderSession(rw http.ResponseWriter, r *http.Request) return } + sessionID := req.SessionID + if sessionID == uuid.Nil { + sessionID = uuid.New() + } + api.Telemetry.Report(&telemetry.Snapshot{ TemplateBuilderSessions: []telemetry.TemplateBuilderSession{ { - ID: uuid.New(), + ID: sessionID, EventType: string(req.EventType), UserID: apiKey.UserID, BaseTemplateID: req.BaseTemplateID, diff --git a/codersdk/templatebuilder.go b/codersdk/templatebuilder.go index f7b2cbfadad87..1fd6727e4d5d2 100644 --- a/codersdk/templatebuilder.go +++ b/codersdk/templatebuilder.go @@ -162,6 +162,7 @@ const ( // TemplateBuilderSessionRequest is the request body for // POST /api/v2/templatebuilder/sessions. type TemplateBuilderSessionRequest struct { + SessionID uuid.UUID `json:"session_id,omitempty" format:"uuid"` 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"` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ab6718ee9ec29..002263ddbd36c 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12638,6 +12638,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W "module_ids": [ "string" ], + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82", "success": true } ``` @@ -12650,6 +12651,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `duration_seconds` | number | false | | | | `event_type` | [codersdk.TemplateBuilderSessionEventType](#codersdktemplatebuildersessioneventtype) | true | | | | `module_ids` | array of string | false | | | +| `session_id` | string | false | | | | `success` | boolean | false | | | #### Enumerated Values diff --git a/docs/reference/api/templatebuilder.md b/docs/reference/api/templatebuilder.md index c4c4227fa022a..a980f84b1630d 100644 --- a/docs/reference/api/templatebuilder.md +++ b/docs/reference/api/templatebuilder.md @@ -314,6 +314,7 @@ curl -X POST http://coder-server:8080/api/v2/templatebuilder/sessions \ "module_ids": [ "string" ], + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82", "success": true } ``` diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e6d2da3f461d4..c29f17baa16ea 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8820,6 +8820,7 @@ export const TemplateBuilderSessionEventTypes: TemplateBuilderSessionEventType[] * 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[]; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index 4b058845d867d..41aad09e46b1b 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useState } from "react"; +import { type FC, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery } from "react-query"; import { Navigate, useNavigate, useSearchParams } from "react-router"; import { deploymentConfig } from "#/api/queries/deployment"; @@ -27,6 +27,10 @@ const TemplateBuilderPage: FC = () => { 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; @@ -37,8 +41,8 @@ const TemplateBuilderPage: FC = () => { if (!wizardReady) { return; } - reportSession({ event_type: "wizard_entry" }); - }, [wizardReady, reportSession]); + reportSession({ session_id: sessionId, event_type: "wizard_entry" }); + }, [wizardReady, reportSession, sessionId]); const basesQuery = useQuery({ ...templateBuilderBases(), @@ -84,6 +88,7 @@ const TemplateBuilderPage: FC = () => { createMutation.mutate(req, { onSuccess: (resp) => { sessionMutation.mutate({ + session_id: state.sessionId, event_type: "compose_completion", base_template_id: state.baseTemplateId ?? undefined, module_ids: state.modules.map((m) => m.id), @@ -98,6 +103,7 @@ const TemplateBuilderPage: FC = () => { }, onError: () => { sessionMutation.mutate({ + session_id: state.sessionId, event_type: "compose_completion", base_template_id: state.baseTemplateId ?? undefined, module_ids: state.modules.map((m) => m.id), @@ -119,6 +125,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 12e776ed2019b..fa3767778ec51 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 c072f25a1240e..0d66bf00b4ed1 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).toEqual({ + ...initialWizardState, + enteredAt: state.enteredAt, + 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 1a70c017c9f5b..4181f5a49a48a 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -81,6 +81,8 @@ export type TemplateBuilderWizardState = { 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 = { @@ -94,24 +96,31 @@ export const initialWizardState: TemplateBuilderWizardState = { icon: "", selectedBase: null, selectedModules: [], - enteredAt: Date.now(), + enteredAt: 0, + sessionId: "", }; /** - * 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: { + sessionId: string; + preselectedBase?: SelectedBaseMeta; +}): 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), }; } From ac4b85b388395b0910b959c98893eec1388070fb Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Thu, 9 Jul 2026 14:50:52 +0000 Subject: [PATCH 06/11] fix(coderd/templatebuilder): require session_id in session telemetry requests Reject requests with a missing session_id as 400 Bad Request via the validate:"required" struct tag instead of silently fabricating a UUID server-side. A missing session ID is a client bug. --- coderd/apidoc/docs.go | 3 ++- coderd/apidoc/swagger.json | 2 +- coderd/templatebuilder_handler.go | 7 +------ coderd/templatebuilder_handler_test.go | 21 +++++++++++++++++++++ codersdk/templatebuilder.go | 2 +- docs/reference/api/schemas.md | 2 +- site/src/api/typesGenerated.ts | 2 +- 7 files changed, 28 insertions(+), 11 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index dd11db9889684..1c1f1946b9155 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -24794,7 +24794,8 @@ const docTemplate = `{ "codersdk.TemplateBuilderSessionRequest": { "type": "object", "required": [ - "event_type" + "event_type", + "session_id" ], "properties": { "base_template_id": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index dfe7c8977d652..78a0c965ebfaa 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -22768,7 +22768,7 @@ }, "codersdk.TemplateBuilderSessionRequest": { "type": "object", - "required": ["event_type"], + "required": ["event_type", "session_id"], "properties": { "base_template_id": { "type": "string" diff --git a/coderd/templatebuilder_handler.go b/coderd/templatebuilder_handler.go index baf8a485f128c..adc4da2d77c2d 100644 --- a/coderd/templatebuilder_handler.go +++ b/coderd/templatebuilder_handler.go @@ -677,15 +677,10 @@ func (api *API) templateBuilderSession(rw http.ResponseWriter, r *http.Request) return } - sessionID := req.SessionID - if sessionID == uuid.Nil { - sessionID = uuid.New() - } - api.Telemetry.Report(&telemetry.Snapshot{ TemplateBuilderSessions: []telemetry.TemplateBuilderSession{ { - ID: sessionID, + ID: req.SessionID, EventType: string(req.EventType), UserID: apiKey.UserID, BaseTemplateID: req.BaseTemplateID, diff --git a/coderd/templatebuilder_handler_test.go b/coderd/templatebuilder_handler_test.go index 8ee6695349f4c..bbac79a0ea7a0 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" @@ -251,6 +252,7 @@ func TestTemplateBuilderSession(t *testing.T) { defer cancel() err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{ + SessionID: uuid.New(), EventType: codersdk.TemplateBuilderSessionEventWizardEntry, }) require.NoError(t, err) @@ -265,6 +267,7 @@ func TestTemplateBuilderSession(t *testing.T) { defer cancel() err := client.TemplateBuilderSession(ctx, codersdk.TemplateBuilderSessionRequest{ + SessionID: uuid.New(), EventType: codersdk.TemplateBuilderSessionEventComposeCompletion, BaseTemplateID: "docker", ModuleIDs: []string{"code-server", "git-clone"}, @@ -274,6 +277,24 @@ func TestTemplateBuilderSession(t *testing.T) { 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) diff --git a/codersdk/templatebuilder.go b/codersdk/templatebuilder.go index 1fd6727e4d5d2..a0be2872cfd96 100644 --- a/codersdk/templatebuilder.go +++ b/codersdk/templatebuilder.go @@ -162,7 +162,7 @@ const ( // TemplateBuilderSessionRequest is the request body for // POST /api/v2/templatebuilder/sessions. type TemplateBuilderSessionRequest struct { - SessionID uuid.UUID `json:"session_id,omitempty" format:"uuid"` + 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"` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 002263ddbd36c..c000a0cbffed3 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12651,7 +12651,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `duration_seconds` | number | false | | | | `event_type` | [codersdk.TemplateBuilderSessionEventType](#codersdktemplatebuildersessioneventtype) | true | | | | `module_ids` | array of string | false | | | -| `session_id` | string | false | | | +| `session_id` | string | true | | | | `success` | boolean | false | | | #### Enumerated Values diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c29f17baa16ea..3d89b954e2ccb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8820,7 +8820,7 @@ export const TemplateBuilderSessionEventTypes: TemplateBuilderSessionEventType[] * POST /api/v2/templatebuilder/sessions. */ export interface TemplateBuilderSessionRequest { - readonly session_id?: string; + readonly session_id: string; readonly event_type: TemplateBuilderSessionEventType; readonly base_template_id?: string; readonly module_ids?: readonly string[]; From 1b06396bd8166015c91d4d0588305aaa9c101743 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 22 Jul 2026 21:16:21 +0000 Subject: [PATCH 07/11] fix: regenerate templatebuilder API doc and unexport unused WizardInit Regenerate docs/reference/api/templatebuilder.md so the code sample fence matches generator output (sh, not shell), fixing the gen and offlinedocs unstaged-changes checks. Drop the unused export on WizardInit to satisfy the frontend knip lint check. Co-authored-by: Coder Agent --- docs/reference/api/templatebuilder.md | 2 +- site/src/pages/TemplateBuilder/wizardState.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/reference/api/templatebuilder.md b/docs/reference/api/templatebuilder.md index a980f84b1630d..57dd44239d34d 100644 --- a/docs/reference/api/templatebuilder.md +++ b/docs/reference/api/templatebuilder.md @@ -295,7 +295,7 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ### Code samples -```shell +```sh # Example request using curl curl -X POST http://coder-server:8080/api/v2/templatebuilder/sessions \ -H 'Content-Type: application/json' \ diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 4181f5a49a48a..185851e2241a2 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -100,14 +100,19 @@ export const initialWizardState: TemplateBuilderWizardState = { 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 with a fresh telemetry session, * optionally preselecting a base template. */ -export function initWizardState(init: { - sessionId: string; - preselectedBase?: SelectedBaseMeta; -}): TemplateBuilderWizardState { +export function initWizardState(init: WizardInit): TemplateBuilderWizardState { const state: TemplateBuilderWizardState = { ...initialWizardState, enteredAt: Date.now(), From 330dc25ab54ae9b3cc6a0cf9109cf8e95cb0eb9f Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 27 Jul 2026 13:55:29 +0000 Subject: [PATCH 08/11] test(site/src/pages/TemplateBuilder): use toMatchObject in initWizardState test --- site/src/pages/TemplateBuilder/wizardState.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/TemplateBuilder/wizardState.test.ts b/site/src/pages/TemplateBuilder/wizardState.test.ts index 0d66bf00b4ed1..562f7cba16d8d 100644 --- a/site/src/pages/TemplateBuilder/wizardState.test.ts +++ b/site/src/pages/TemplateBuilder/wizardState.test.ts @@ -614,9 +614,9 @@ describe("baseCustomizationDefaults", () => { describe("initWizardState", () => { it("returns the initial state without a preselected base", () => { const state = initWizardState({ sessionId: "test-session-id" }); - expect(state).toEqual({ + expect(state).toMatchObject({ ...initialWizardState, - enteredAt: state.enteredAt, + enteredAt: expect.any(Number), sessionId: "test-session-id", }); expect(state.enteredAt).toBeGreaterThan(0); From da012a7dc0ef484c451f64844342d536110460c5 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 27 Jul 2026 13:59:24 +0000 Subject: [PATCH 09/11] refactor(site/src/pages/TemplateBuilder): dedupe session telemetry calls Drop the reportSession alias in favor of calling sessionMutation.mutate directly, and extract the duplicated compose_completion payload into a reportCompletion helper. --- .../TemplateBuilder/TemplateBuilderPage.tsx | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index 41aad09e46b1b..5b6ef1e400ec2 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -36,13 +36,15 @@ const TemplateBuilderPage: FC = () => { !builderDisabled && !isLoading && permissions.createTemplates; // Report wizard_entry once the builder is ready and accessible. - const reportSession = sessionMutation.mutate; useEffect(() => { if (!wizardReady) { return; } - reportSession({ session_id: sessionId, event_type: "wizard_entry" }); - }, [wizardReady, reportSession, sessionId]); + sessionMutation.mutate({ + session_id: sessionId, + event_type: "wizard_entry", + }); + }, [wizardReady, sessionMutation.mutate, sessionId]); const basesQuery = useQuery({ ...templateBuilderBases(), @@ -85,16 +87,20 @@ const TemplateBuilderPage: FC = () => { const req = toCreateTemplateRequest(state); const durationSeconds = (Date.now() - state.enteredAt) / 1000; + const reportCompletion = (success: boolean) => { + 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, + }); + }; + createMutation.mutate(req, { onSuccess: (resp) => { - 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: true, - }); + reportCompletion(true); const t = resp.template; navigate( `${getLink(linkToTemplate(t.organization_name, t.name))}/files`, @@ -102,14 +108,7 @@ const TemplateBuilderPage: FC = () => { ); }, onError: () => { - 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: false, - }); + reportCompletion(false); }, }); }; From 52baa7d6be26692e3544375cacb97c62a80b7186 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 27 Jul 2026 14:00:57 +0000 Subject: [PATCH 10/11] refactor(site/src/pages/TemplateBuilder): extract session telemetry helpers Extract reportEntry and reportCompletion helpers to dedupe the session telemetry payloads, replacing the reportSession alias and the repeated compose_completion mutate calls. --- .../TemplateBuilder/TemplateBuilderPage.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index 5b6ef1e400ec2..1f538e74fdd41 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useMemo, 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"; @@ -36,15 +36,19 @@ const TemplateBuilderPage: FC = () => { !builderDisabled && !isLoading && permissions.createTemplates; // Report wizard_entry once the builder is ready and accessible. - useEffect(() => { - if (!wizardReady) { - return; - } + const reportEntry = useCallback(() => { sessionMutation.mutate({ session_id: sessionId, event_type: "wizard_entry", }); - }, [wizardReady, sessionMutation.mutate, sessionId]); + }, [sessionMutation.mutate, sessionId]); + + useEffect(() => { + if (!wizardReady) { + return; + } + reportEntry(); + }, [wizardReady, reportEntry]); const basesQuery = useQuery({ ...templateBuilderBases(), From d1ee33c1eb5ecbe5afe6402b22a17b44e2bb1c58 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 27 Jul 2026 14:04:13 +0000 Subject: [PATCH 11/11] refactor(site/src/pages/TemplateBuilder): define session telemetry helpers together Move reportEntry and reportCompletion to component scope as useCallback hooks so both telemetry payloads are defined side by side. reportCompletion takes state and duration as parameters since state is only available inside handleCreate. --- .../TemplateBuilder/TemplateBuilderPage.tsx | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index 1f538e74fdd41..340c5bb35df79 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -43,6 +43,27 @@ const TemplateBuilderPage: FC = () => { }); }, [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; @@ -91,20 +112,9 @@ const TemplateBuilderPage: FC = () => { const req = toCreateTemplateRequest(state); const durationSeconds = (Date.now() - state.enteredAt) / 1000; - const reportCompletion = (success: boolean) => { - 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, - }); - }; - createMutation.mutate(req, { onSuccess: (resp) => { - reportCompletion(true); + reportCompletion(state, true, durationSeconds); const t = resp.template; navigate( `${getLink(linkToTemplate(t.organization_name, t.name))}/files`, @@ -112,7 +122,7 @@ const TemplateBuilderPage: FC = () => { ); }, onError: () => { - reportCompletion(false); + reportCompletion(state, false, durationSeconds); }, }); };