mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +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>
359 lines
9.8 KiB
Go
359 lines
9.8 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/templatebuilder"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestTemplateBuilderBases(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("OK", 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()
|
|
|
|
resp, err := client.TemplateBuilderBases(ctx)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, resp.Bases)
|
|
require.Len(t, resp.Bases, len(templatebuilder.BaseTemplateIDs()))
|
|
|
|
basesByID := make(map[string]codersdk.TemplateBuilderBase, len(resp.Bases))
|
|
for _, b := range resp.Bases {
|
|
basesByID[b.ID] = b
|
|
}
|
|
|
|
type baseSpec struct {
|
|
id string
|
|
expectedOS string
|
|
expectedVars []string
|
|
hasVariables bool
|
|
}
|
|
|
|
specs := []baseSpec{
|
|
{
|
|
id: "docker",
|
|
expectedOS: "linux",
|
|
hasVariables: true,
|
|
expectedVars: []string{"container_image"},
|
|
},
|
|
{
|
|
id: "kubernetes",
|
|
expectedOS: "linux",
|
|
hasVariables: true,
|
|
expectedVars: []string{"container_image", "namespace", "use_kubeconfig"},
|
|
},
|
|
{
|
|
id: "aws-linux",
|
|
expectedOS: "linux",
|
|
hasVariables: false,
|
|
},
|
|
{
|
|
id: "aws-windows",
|
|
expectedOS: "windows",
|
|
hasVariables: false,
|
|
},
|
|
{
|
|
id: "gcp-windows",
|
|
expectedOS: "windows",
|
|
hasVariables: true,
|
|
expectedVars: []string{"project_id"},
|
|
},
|
|
}
|
|
|
|
for _, spec := range specs {
|
|
b, ok := basesByID[spec.id]
|
|
require.True(t, ok, "base %q missing from response", spec.id)
|
|
require.NotEmpty(t, b.Name, "base %q should have a name", spec.id)
|
|
require.NotEmpty(t, b.Icon, "base %q should have an icon", spec.id)
|
|
require.Equal(t, spec.expectedOS, b.OS, "base %q OS mismatch", spec.id)
|
|
require.NotNil(t, b.Variables, "base %q should have non-nil variables slice", spec.id)
|
|
|
|
if spec.hasVariables {
|
|
require.NotEmpty(t, b.Variables, "base %q should have variables", spec.id)
|
|
varNames := make(map[string]bool, len(b.Variables))
|
|
for _, v := range b.Variables {
|
|
varNames[v.Name] = true
|
|
}
|
|
for _, expected := range spec.expectedVars {
|
|
require.True(t, varNames[expected],
|
|
"base %q should have variable %q", spec.id, expected)
|
|
}
|
|
} else {
|
|
require.Empty(t, b.Variables, "base %q should have no variables", spec.id)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Sorted", 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()
|
|
|
|
resp, err := client.TemplateBuilderBases(ctx)
|
|
require.NoError(t, err)
|
|
|
|
for i := 1; i < len(resp.Bases); i++ {
|
|
require.LessOrEqual(t, resp.Bases[i-1].Name, resp.Bases[i].Name,
|
|
"bases should be sorted by name")
|
|
}
|
|
})
|
|
|
|
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.TemplateBuilderBases(ctx)
|
|
require.Error(t, err)
|
|
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
})
|
|
}
|
|
|
|
func TestTemplateBuilderModules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("OK", 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()
|
|
|
|
resp, err := client.TemplateBuilderModules(ctx, "")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, resp.Modules)
|
|
|
|
for _, m := range resp.Modules {
|
|
require.NotEmpty(t, m.ID)
|
|
require.NotEmpty(t, m.Version)
|
|
}
|
|
})
|
|
|
|
t.Run("FilteredByBase", 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()
|
|
|
|
resp, err := client.TemplateBuilderModules(ctx, "docker")
|
|
require.NoError(t, err)
|
|
|
|
for _, m := range resp.Modules {
|
|
if len(m.CompatibleOS) > 0 {
|
|
require.Contains(t, m.CompatibleOS, "linux",
|
|
"module %q should be compatible with linux when filtered by docker base", m.ID)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("ComputedVariablesExcluded", 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()
|
|
|
|
resp, err := client.TemplateBuilderModules(ctx, "")
|
|
require.NoError(t, err)
|
|
|
|
// The embedded code-server module has agent_id with computed=true.
|
|
// It must not appear in the API response.
|
|
var found bool
|
|
for _, m := range resp.Modules {
|
|
if m.ID == "code-server" {
|
|
found = true
|
|
for _, v := range m.Variables {
|
|
require.NotEqual(t, "agent_id", v.Name,
|
|
"computed variable agent_id must not appear in API response")
|
|
}
|
|
}
|
|
}
|
|
require.True(t, found, "code-server module must be in the catalog")
|
|
})
|
|
|
|
t.Run("UnknownBaseReturns400", 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.TemplateBuilderModules(ctx, "nonexistent")
|
|
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.TemplateBuilderModules(ctx, "")
|
|
require.Error(t, err)
|
|
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
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())
|
|
})
|
|
}
|