mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
`TemplateBuilderSession` telemetry types and telemetry-server ingestion were added in earlier PRs (#25082, coder/coder-telemetry-server#41), but no code ever produced session events. This adds the missing producer. **Backend**: `POST /api/v2/templatebuilder/sessions` reports wizard entry and compose completion events directly via `api.Telemetry.Report()`, using the same inline pattern as `NetworkEvents` and `UserTailnetConnections`. No database migration or `createSnapshot()` changes needed. RBAC requires `policy.ActionCreate` on `ResourceTemplate.AnyOrganization()`, matching the compose endpoint. **Frontend**: The template builder wizard fires `wizard_entry` on page mount and `compose_completion` on create success or failure. A client-generated session ID (UUID) correlates the two events for the same wizard visit, enabling precise funnel analysis and abandonment detection in BigQuery. Duration is tracked via `Date.now()` in the wizard state. Closes https://linear.app/codercom/issue/DEVEX-599 <details> <summary>Implementation plan</summary> ## Root Cause Analysis The DEVEX-599 ticket diagnosis suggested missing DB tables, queries, and `eg.Go` blocks. That diagnosis assumes the DB-backed periodic snapshot path is required. It is not. Investigation shows two telemetry reporting patterns in the codebase: 1. **DB-backed periodic snapshots** (`createSnapshot()` with `eg.Go` blocks): Used for durable entities like workspaces, templates, users. 2. **Direct inline reporting** (`api.Telemetry.Report(&telemetry.Snapshot{...})`): Used for ephemeral events like `NetworkEvents`, `UserTailnetConnections`, `CLIInvocations`. Template builder sessions are ephemeral events, so the direct inline reporting pattern is the correct fit. ## Backend Changes - `codersdk/templatebuilder.go`: `TemplateBuilderSessionRequest` type with `SessionID`, `EventType` enum, `TemplateBuilderSession()` client method - `coderd/coderd.go`: Route registration in `/templatebuilder` group - `coderd/templatebuilder_handler.go`: Handler with RBAC check, request validation, session ID fallback, and inline telemetry report - `coderd/templatebuilder_handler_test.go`: Tests for wizard entry, compose completion, invalid event type, disabled feature, and member RBAC rejection ## Frontend Changes - `site/src/api/api.ts`: `recordTemplateBuilderSession` API method - `site/src/api/queries/templateBuilder.ts`: React Query mutation - `site/src/pages/TemplateBuilder/wizardState.ts`: `sessionId` and `enteredAt` fields, `createWizardState()` factory for per-mount initialization - `site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx`: `sessionId` prop, `useReducer` initializer form - `site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx`: Telemetry calls for wizard entry (on mount) and compose completion (on create success/failure) </details> > 🤖 Generated by Coder Agents --------- Co-authored-by: Coder Agent <agent@coder.com>
201 lines
8.3 KiB
Go
201 lines
8.3 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TemplateBuilderVariableType enumerates the variable types
|
|
// supported by template builder module manifests.
|
|
type TemplateBuilderVariableType string
|
|
|
|
const (
|
|
TemplateBuilderVariableTypeString TemplateBuilderVariableType = "string"
|
|
TemplateBuilderVariableTypeNumber TemplateBuilderVariableType = "number"
|
|
TemplateBuilderVariableTypeBool TemplateBuilderVariableType = "bool"
|
|
)
|
|
|
|
type TemplateBuilderModuleVariable struct {
|
|
Name string `json:"name"`
|
|
Type TemplateBuilderVariableType `json:"type"`
|
|
Description string `json:"description"`
|
|
Default json.RawMessage `json:"default,omitempty"`
|
|
Required bool `json:"required"`
|
|
Sensitive bool `json:"sensitive"`
|
|
}
|
|
|
|
// TemplateBuilderModule is the API response type returned by
|
|
// GET /api/v2/templatebuilder/modules. The Version field is
|
|
// populated from the catalog manifest's PinnedVersion at serving time.
|
|
type TemplateBuilderModule struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon"`
|
|
Category string `json:"category"`
|
|
Version string `json:"version"`
|
|
CompatibleOS []string `json:"compatible_os"`
|
|
ConflictsWith []string `json:"conflicts_with"`
|
|
Variables []TemplateBuilderModuleVariable `json:"variables"`
|
|
}
|
|
|
|
// TemplateBuilderModulesResponse is the response body for listing template builder modules.
|
|
type TemplateBuilderModulesResponse struct {
|
|
Modules []TemplateBuilderModule `json:"modules"`
|
|
}
|
|
|
|
// TemplateBuilderBase is the API response type for a base template
|
|
// returned by GET /api/v2/templatebuilder/bases.
|
|
type TemplateBuilderBase struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon"`
|
|
OS string `json:"os"`
|
|
Variables []TemplateBuilderModuleVariable `json:"variables"`
|
|
Prerequisites string `json:"prerequisites"`
|
|
}
|
|
|
|
// TemplateBuilderBasesResponse is the response body for listing template builder bases.
|
|
type TemplateBuilderBasesResponse struct {
|
|
Bases []TemplateBuilderBase `json:"bases"`
|
|
}
|
|
|
|
// TemplateBuilderBases returns the list of base templates available
|
|
// in the template builder.
|
|
func (c *Client) TemplateBuilderBases(ctx context.Context) (TemplateBuilderBasesResponse, error) {
|
|
res, err := c.Request(ctx, http.MethodGet, "/api/v2/templatebuilder/bases", nil)
|
|
if err != nil {
|
|
return TemplateBuilderBasesResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return TemplateBuilderBasesResponse{}, ReadBodyAsError(res)
|
|
}
|
|
var resp TemplateBuilderBasesResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// TemplateBuilderModules returns the list of modules available for a given
|
|
// base template. If base is empty, all modules are returned.
|
|
func (c *Client) TemplateBuilderModules(ctx context.Context, base string) (TemplateBuilderModulesResponse, error) {
|
|
path := "/api/v2/templatebuilder/modules"
|
|
if base != "" {
|
|
q := url.Values{"base": {base}}
|
|
path += "?" + q.Encode()
|
|
}
|
|
res, err := c.Request(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return TemplateBuilderModulesResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return TemplateBuilderModulesResponse{}, ReadBodyAsError(res)
|
|
}
|
|
var resp TemplateBuilderModulesResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// TemplateBuilderComposeRequest is the request body for
|
|
// POST /api/v2/templatebuilder/compose.
|
|
type TemplateBuilderComposeRequest struct {
|
|
BaseTemplateID string `json:"base_template_id"`
|
|
BaseVariableValues map[string]string `json:"base_variable_values,omitempty"`
|
|
Modules []TemplateBuilderComposeModule `json:"modules"`
|
|
}
|
|
|
|
// TemplateBuilderComposeModule identifies a module and its variable
|
|
// values for the compose request.
|
|
type TemplateBuilderComposeModule struct {
|
|
ID string `json:"id"`
|
|
Variables map[string]string `json:"variables,omitempty"`
|
|
}
|
|
|
|
// TemplateBuilderCompose renders a base template with the selected
|
|
// modules and returns the resulting tar archive bytes.
|
|
func (c *Client) TemplateBuilderCompose(ctx context.Context, req TemplateBuilderComposeRequest) ([]byte, error) {
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/templatebuilder/compose", req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, ReadBodyAsError(res)
|
|
}
|
|
return io.ReadAll(res.Body)
|
|
}
|
|
|
|
// TemplateBuilderCreateTemplateRequest is the request body for
|
|
// POST /api/v2/templatebuilder/compose/template.
|
|
type TemplateBuilderCreateTemplateRequest struct {
|
|
BaseTemplateID string `json:"base_template_id"`
|
|
BaseVariableValues map[string]string `json:"base_variable_values,omitempty"`
|
|
Modules []TemplateBuilderComposeModule `json:"modules"`
|
|
OrganizationID uuid.UUID `json:"organization_id" format:"uuid" validate:"required"`
|
|
Name string `json:"name" validate:"required,template_name"`
|
|
DisplayName string `json:"display_name,omitempty" validate:"template_display_name"`
|
|
Description string `json:"description,omitempty" validate:"lt=128"`
|
|
Icon string `json:"icon,omitempty"`
|
|
ProvisionerTags map[string]string `json:"provisioner_tags,omitempty"`
|
|
}
|
|
|
|
// TemplateBuilderCreateTemplateResponse is the response body for
|
|
// POST /api/v2/templatebuilder/compose/template.
|
|
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) {
|
|
res, err := c.Request(ctx, http.MethodPost, "/api/v2/templatebuilder/compose/template", req)
|
|
if err != nil {
|
|
return TemplateBuilderCreateTemplateResponse{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusCreated {
|
|
return TemplateBuilderCreateTemplateResponse{}, ReadBodyAsError(res)
|
|
}
|
|
var resp TemplateBuilderCreateTemplateResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|