mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: wire up Template Builder session telemetry endpoint (#27124)
`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>
This commit is contained in:
co-authored by
Coder Agent
parent
daf655dff8
commit
51ac968d5a
Generated
+83
@@ -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": [
|
||||
|
||||
Generated
+70
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user