diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 917052d93a6..f515fa9fff7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3389,6 +3389,39 @@ const docTemplate = `{ ] } }, + "/api/v2/deployment/premium-funnel-events": { + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Report a premium paywall click", + "operationId": "report-a-premium-paywall-click", + "parameters": [ + { + "description": "Premium funnel event", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PremiumFunnelEventRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/deployment/ssh": { "get": { "produces": [ @@ -19894,6 +19927,11 @@ const docTemplate = `{ "phone_number" ], "properties": { + "attribution_id": { + "description": "AttributionID is the ID of the cta_click funnel event that led here, so\nthat a signup can be joined back to the paywall that produced it.", + "type": "string", + "format": "uuid" + }, "company_name": { "type": "string", "maxLength": 100, @@ -19936,6 +19974,14 @@ const docTemplate = `{ "maxLength": 20, "minLength": 7, "example": "+14155552671" + }, + "source": { + "description": "Source is the premium paywall the request came from, for telemetry. It\nis not forwarded to the licensor. Omit it to report \"direct\".", + "allOf": [ + { + "$ref": "#/definitions/codersdk.PremiumFunnelSource" + } + ] } } }, @@ -23750,6 +23796,85 @@ const docTemplate = `{ } } }, + "codersdk.PremiumFunnelEventRequest": { + "type": "object", + "required": [ + "id", + "source", + "variant" + ], + "properties": { + "id": { + "description": "ID identifies this click, and doubles as the attribution token that a\nlater trial signup reports.", + "type": "string", + "format": "uuid" + }, + "source": { + "$ref": "#/definitions/codersdk.PremiumFunnelSource" + }, + "variant": { + "$ref": "#/definitions/codersdk.PremiumFunnelVariant" + } + } + }, + "codersdk.PremiumFunnelSource": { + "type": "string", + "enum": [ + "aibridge_session_threads", + "aibridge_sessions", + "ai_gateway_keys", + "ai_governance", + "appearance", + "audit_log", + "browser_only", + "connection_log", + "custom_roles", + "groups", + "idp_org_sync", + "idp_sync", + "multiple_organizations", + "observability", + "provisioner_keys", + "provisioners", + "template_permissions", + "workspace_proxies", + "direct" + ], + "x-enum-varnames": [ + "PremiumFunnelSourceAIBridgeSessionThreads", + "PremiumFunnelSourceAIBridgeSessions", + "PremiumFunnelSourceAIGatewayKeys", + "PremiumFunnelSourceAIGovernance", + "PremiumFunnelSourceAppearance", + "PremiumFunnelSourceAuditLog", + "PremiumFunnelSourceBrowserOnly", + "PremiumFunnelSourceConnectionLog", + "PremiumFunnelSourceCustomRoles", + "PremiumFunnelSourceGroups", + "PremiumFunnelSourceIdpOrgSync", + "PremiumFunnelSourceIdpSync", + "PremiumFunnelSourceMultipleOrganizations", + "PremiumFunnelSourceObservability", + "PremiumFunnelSourceProvisionerKeys", + "PremiumFunnelSourceProvisioners", + "PremiumFunnelSourceTemplatePermissions", + "PremiumFunnelSourceWorkspaceProxies", + "PremiumFunnelSourceDirect" + ] + }, + "codersdk.PremiumFunnelVariant": { + "type": "string", + "enum": [ + "premium", + "small", + "ai_governance" + ], + "x-enum-varnames": [ + "PremiumFunnelVariantPremium", + "PremiumFunnelVariantSmall", + "PremiumFunnelVariantAIGovernance" + ] + }, "codersdk.Preset": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d1733e59cc8..6e4c74d90b0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3008,6 +3008,35 @@ ] } }, + "/api/v2/deployment/premium-funnel-events": { + "post": { + "consumes": ["application/json"], + "tags": ["Enterprise"], + "summary": "Report a premium paywall click", + "operationId": "report-a-premium-paywall-click", + "parameters": [ + { + "description": "Premium funnel event", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PremiumFunnelEventRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/deployment/ssh": { "get": { "produces": ["application/json"], @@ -18011,6 +18040,11 @@ "phone_number" ], "properties": { + "attribution_id": { + "description": "AttributionID is the ID of the cta_click funnel event that led here, so\nthat a signup can be joined back to the paywall that produced it.", + "type": "string", + "format": "uuid" + }, "company_name": { "type": "string", "maxLength": 100, @@ -18053,6 +18087,14 @@ "maxLength": 20, "minLength": 7, "example": "+14155552671" + }, + "source": { + "description": "Source is the premium paywall the request came from, for telemetry. It\nis not forwarded to the licensor. Omit it to report \"direct\".", + "allOf": [ + { + "$ref": "#/definitions/codersdk.PremiumFunnelSource" + } + ] } } }, @@ -21743,6 +21785,77 @@ } } }, + "codersdk.PremiumFunnelEventRequest": { + "type": "object", + "required": ["id", "source", "variant"], + "properties": { + "id": { + "description": "ID identifies this click, and doubles as the attribution token that a\nlater trial signup reports.", + "type": "string", + "format": "uuid" + }, + "source": { + "$ref": "#/definitions/codersdk.PremiumFunnelSource" + }, + "variant": { + "$ref": "#/definitions/codersdk.PremiumFunnelVariant" + } + } + }, + "codersdk.PremiumFunnelSource": { + "type": "string", + "enum": [ + "aibridge_session_threads", + "aibridge_sessions", + "ai_gateway_keys", + "ai_governance", + "appearance", + "audit_log", + "browser_only", + "connection_log", + "custom_roles", + "groups", + "idp_org_sync", + "idp_sync", + "multiple_organizations", + "observability", + "provisioner_keys", + "provisioners", + "template_permissions", + "workspace_proxies", + "direct" + ], + "x-enum-varnames": [ + "PremiumFunnelSourceAIBridgeSessionThreads", + "PremiumFunnelSourceAIBridgeSessions", + "PremiumFunnelSourceAIGatewayKeys", + "PremiumFunnelSourceAIGovernance", + "PremiumFunnelSourceAppearance", + "PremiumFunnelSourceAuditLog", + "PremiumFunnelSourceBrowserOnly", + "PremiumFunnelSourceConnectionLog", + "PremiumFunnelSourceCustomRoles", + "PremiumFunnelSourceGroups", + "PremiumFunnelSourceIdpOrgSync", + "PremiumFunnelSourceIdpSync", + "PremiumFunnelSourceMultipleOrganizations", + "PremiumFunnelSourceObservability", + "PremiumFunnelSourceProvisionerKeys", + "PremiumFunnelSourceProvisioners", + "PremiumFunnelSourceTemplatePermissions", + "PremiumFunnelSourceWorkspaceProxies", + "PremiumFunnelSourceDirect" + ] + }, + "codersdk.PremiumFunnelVariant": { + "type": "string", + "enum": ["premium", "small", "ai_governance"], + "x-enum-varnames": [ + "PremiumFunnelVariantPremium", + "PremiumFunnelVariantSmall", + "PremiumFunnelVariantAIGovernance" + ] + }, "codersdk.Preset": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index ff6dfe0e845..4a5efba16c4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1578,6 +1578,7 @@ func New(options *Options) *API { r.Get("/config", api.deploymentValues) r.Get("/stats", api.deploymentStats) r.Get("/ssh", api.sshConfig) + r.Post("/premium-funnel-events", api.postPremiumFunnelEvent) }) r.Route("/experiments", func(r chi.Router) { r.Use(apiKeyMiddleware) diff --git a/coderd/premiumfunnel.go b/coderd/premiumfunnel.go new file mode 100644 index 00000000000..943a160fd55 --- /dev/null +++ b/coderd/premiumfunnel.go @@ -0,0 +1,75 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Report a premium paywall click +// @ID report-a-premium-paywall-click +// @Security CoderSessionToken +// @Accept json +// @Tags Enterprise +// @Param request body codersdk.PremiumFunnelEventRequest true "Premium funnel event" +// @Success 204 +// @Router /api/v2/deployment/premium-funnel-events [post] +func (api *API) postPremiumFunnelEvent(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + // Reports are authorized rather than trusted: a few paywalls show their + // call to action to everyone, so a user who cannot read licenses can click + // one, and their click must not enter the funnel. + if !api.Authorize(r, policy.ActionRead, rbac.ResourceLicense) { + httpapi.ResourceNotFound(rw) + return + } + + var req codersdk.PremiumFunnelEventRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + var validations []codersdk.ValidationError + if !req.Source.Valid() { + validations = append(validations, codersdk.ValidationError{ + Field: "source", + Detail: "Unknown premium funnel source.", + }) + } + if !req.Variant.Valid() { + validations = append(validations, codersdk.ValidationError{ + Field: "variant", + Detail: "Unknown premium paywall variant.", + }) + } + if len(validations) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid premium funnel event.", + Validations: validations, + }) + return + } + + api.Telemetry.Report(&telemetry.Snapshot{ + PremiumFunnelEvents: []telemetry.PremiumFunnelEvent{ + { + ID: req.ID, + EventType: telemetry.PremiumFunnelEventCTAClick, + Source: string(req.Source), + Variant: string(req.Variant), + UserID: apiKey.UserID, + CreatedAt: dbtime.Now(), + }, + }, + }) + + rw.WriteHeader(http.StatusNoContent) +} diff --git a/coderd/premiumfunnel_test.go b/coderd/premiumfunnel_test.go new file mode 100644 index 00000000000..8d50b0505eb --- /dev/null +++ b/coderd/premiumfunnel_test.go @@ -0,0 +1,103 @@ +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/telemetry" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestPremiumFunnelEvent(t *testing.T) { + t.Parallel() + + t.Run("CTAClick", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + fTelemetry := newFakeTelemetryReporter(ctx, t, 10) + client := coderdtest.New(t, &coderdtest.Options{ + TelemetryReporter: fTelemetry, + }) + _ = coderdtest.CreateFirstUser(t, client) + + eventID := uuid.New() + err := client.ReportPremiumFunnelEvent(ctx, codersdk.PremiumFunnelEventRequest{ + ID: eventID, + Source: codersdk.PremiumFunnelSourceAuditLog, + Variant: codersdk.PremiumFunnelVariantAIGovernance, + }) + require.NoError(t, err) + + event := receivePremiumFunnelEvent(ctx, t, fTelemetry) + require.Equal(t, eventID, event.ID) + require.Equal(t, telemetry.PremiumFunnelEventCTAClick, event.EventType) + require.Equal(t, string(codersdk.PremiumFunnelSourceAuditLog), event.Source) + require.Equal(t, string(codersdk.PremiumFunnelVariantAIGovernance), event.Variant) + // Only trial signups carry an attribution. + require.Equal(t, uuid.Nil, event.AttributionID) + require.NotEqual(t, uuid.Nil, event.UserID) + require.False(t, event.CreatedAt.IsZero()) + }) + + t.Run("UnknownSource", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + err := client.ReportPremiumFunnelEvent(ctx, codersdk.PremiumFunnelEventRequest{ + ID: uuid.New(), + Source: "/organizations/acme-corp/idp-sync", + Variant: codersdk.PremiumFunnelVariantPremium, + }) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "source", sdkErr.Validations[0].Field) + }) + + t.Run("MemberCannotReport", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + memberClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + + err := memberClient.ReportPremiumFunnelEvent(ctx, codersdk.PremiumFunnelEventRequest{ + ID: uuid.New(), + Source: codersdk.PremiumFunnelSourceAppearance, + Variant: codersdk.PremiumFunnelVariantPremium, + }) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} + +// receivePremiumFunnelEvent drains snapshots until one carries a funnel event. +// Creating the first user reports its own snapshots on the same channel. +func receivePremiumFunnelEvent(ctx context.Context, t *testing.T, reporter *fakeTelemetryReporter) telemetry.PremiumFunnelEvent { + t.Helper() + + for { + snapshot := testutil.TryReceive(ctx, t, reporter.snapshots) + if len(snapshot.PremiumFunnelEvents) > 0 { + require.Len(t, snapshot.PremiumFunnelEvents, 1) + return snapshot.PremiumFunnelEvents[0] + } + } +} diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 28e69a248b3..495c4f49b87 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -1640,6 +1640,7 @@ type Snapshot struct { ChatDiffStatusSummary *ChatDiffStatusSummary `json:"chat_diff_status_summary"` UserSecretsSummary *UserSecretsSummary `json:"user_secrets_summary"` TemplateBuilderSessions []TemplateBuilderSession `json:"template_builder_sessions"` + PremiumFunnelEvents []PremiumFunnelEvent `json:"premium_funnel_events"` } // Deployment contains information about the host running Coder. @@ -2677,6 +2678,28 @@ type TemplateBuilderSession struct { CreatedAt time.Time `json:"created_at"` } +// Steps of the premium trial funnel. These are set by coderd rather than the +// client so that a conversion cannot be forged. +const ( + PremiumFunnelEventCTAClick = "cta_click" + PremiumFunnelEventTrialSignup = "trial_signup" +) + +// PremiumFunnelEvent tracks a single step of the premium trial funnel: a +// paywall call to action being clicked, or a trial license being issued. +// AttributionID carries the ID of the cta_click event that led to a trial +// signup, so signups can be joined back to the paywall that produced them; it +// is the nil UUID for clicks and for trials started without a paywall. +type PremiumFunnelEvent struct { + ID uuid.UUID `json:"id"` + EventType string `json:"event_type"` + Source string `json:"source"` + Variant string `json:"variant"` + AttributionID uuid.UUID `json:"attribution_id"` + UserID uuid.UUID `json:"user_id"` + CreatedAt time.Time `json:"created_at"` +} + func ConvertAIBridgeInterceptionsSummary(endTime time.Time, provider, model, client string, summary database.CalculateAIBridgeInterceptionsTelemetrySummaryRow) AIBridgeInterceptionsSummary { return AIBridgeInterceptionsSummary{ ID: uuid.New(), diff --git a/codersdk/licenses.go b/codersdk/licenses.go index b2099f388d7..6c160e45f4a 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -41,6 +41,12 @@ type CreateTrialLicenseRequest struct { CompanyName string `json:"company_name" validate:"required,min=2,max=100" example:"Acme Corp" minLength:"2" maxLength:"100"` Country string `json:"country" validate:"required" example:"United States"` Developers string `json:"developers" validate:"required"` + // Source is the premium paywall the request came from, for telemetry. It + // is not forwarded to the licensor. Omit it to report "direct". + Source PremiumFunnelSource `json:"source,omitempty"` + // AttributionID is the ID of the cta_click funnel event that led here, so + // that a signup can be joined back to the paywall that produced it. + AttributionID uuid.UUID `json:"attribution_id,omitempty" format:"uuid"` } type License struct { diff --git a/codersdk/premiumfunnel.go b/codersdk/premiumfunnel.go new file mode 100644 index 00000000000..f2901406e6a --- /dev/null +++ b/codersdk/premiumfunnel.go @@ -0,0 +1,118 @@ +package codersdk + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +// PremiumFunnelSource identifies the premium feature whose paywall the user +// interacted with. This is a fixed enum rather than a page URL because paywall +// routes embed organization and template names. +type PremiumFunnelSource string + +const ( + PremiumFunnelSourceAIBridgeSessionThreads PremiumFunnelSource = "aibridge_session_threads" + PremiumFunnelSourceAIBridgeSessions PremiumFunnelSource = "aibridge_sessions" + PremiumFunnelSourceAIGatewayKeys PremiumFunnelSource = "ai_gateway_keys" + PremiumFunnelSourceAIGovernance PremiumFunnelSource = "ai_governance" + PremiumFunnelSourceAppearance PremiumFunnelSource = "appearance" + PremiumFunnelSourceAuditLog PremiumFunnelSource = "audit_log" + PremiumFunnelSourceBrowserOnly PremiumFunnelSource = "browser_only" + PremiumFunnelSourceConnectionLog PremiumFunnelSource = "connection_log" + PremiumFunnelSourceCustomRoles PremiumFunnelSource = "custom_roles" + PremiumFunnelSourceGroups PremiumFunnelSource = "groups" + PremiumFunnelSourceIdpOrgSync PremiumFunnelSource = "idp_org_sync" + PremiumFunnelSourceIdpSync PremiumFunnelSource = "idp_sync" + PremiumFunnelSourceMultipleOrganizations PremiumFunnelSource = "multiple_organizations" + PremiumFunnelSourceObservability PremiumFunnelSource = "observability" + PremiumFunnelSourceProvisionerKeys PremiumFunnelSource = "provisioner_keys" + PremiumFunnelSourceProvisioners PremiumFunnelSource = "provisioners" + PremiumFunnelSourceTemplatePermissions PremiumFunnelSource = "template_permissions" + PremiumFunnelSourceWorkspaceProxies PremiumFunnelSource = "workspace_proxies" + // PremiumFunnelSourceDirect is reported when a trial is requested without + // passing through a paywall, such as from the sidebar or a bookmark. It + // keeps "arrived without attribution" distinguishable from missing data. + PremiumFunnelSourceDirect PremiumFunnelSource = "direct" +) + +// PremiumFunnelSources returns every valid funnel source. +func PremiumFunnelSources() []PremiumFunnelSource { + return []PremiumFunnelSource{ + PremiumFunnelSourceAIBridgeSessionThreads, + PremiumFunnelSourceAIBridgeSessions, + PremiumFunnelSourceAIGatewayKeys, + PremiumFunnelSourceAIGovernance, + PremiumFunnelSourceAppearance, + PremiumFunnelSourceAuditLog, + PremiumFunnelSourceBrowserOnly, + PremiumFunnelSourceConnectionLog, + PremiumFunnelSourceCustomRoles, + PremiumFunnelSourceGroups, + PremiumFunnelSourceIdpOrgSync, + PremiumFunnelSourceIdpSync, + PremiumFunnelSourceMultipleOrganizations, + PremiumFunnelSourceObservability, + PremiumFunnelSourceProvisionerKeys, + PremiumFunnelSourceProvisioners, + PremiumFunnelSourceTemplatePermissions, + PremiumFunnelSourceWorkspaceProxies, + PremiumFunnelSourceDirect, + } +} + +// Valid reports whether the source is a known funnel source. +func (s PremiumFunnelSource) Valid() bool { + for _, source := range PremiumFunnelSources() { + if s == source { + return true + } + } + return false +} + +// PremiumFunnelVariant identifies which paywall presentation was rendered. +type PremiumFunnelVariant string + +const ( + PremiumFunnelVariantPremium PremiumFunnelVariant = "premium" + PremiumFunnelVariantSmall PremiumFunnelVariant = "small" + PremiumFunnelVariantAIGovernance PremiumFunnelVariant = "ai_governance" +) + +// Valid reports whether the variant is a known paywall presentation. +func (v PremiumFunnelVariant) Valid() bool { + switch v { + case PremiumFunnelVariantPremium, PremiumFunnelVariantSmall, PremiumFunnelVariantAIGovernance: + return true + default: + return false + } +} + +// PremiumFunnelEventRequest is the request body for +// POST /api/v2/deployment/premium-funnel-events. +type PremiumFunnelEventRequest struct { + // ID identifies this click, and doubles as the attribution token that a + // later trial signup reports. + ID uuid.UUID `json:"id" format:"uuid" validate:"required"` + Source PremiumFunnelSource `json:"source" validate:"required"` + Variant PremiumFunnelVariant `json:"variant" validate:"required"` +} + +// ReportPremiumFunnelEvent reports a click on a premium paywall call to action +// for telemetry purposes. It is a no-op on deployments with telemetry +// disabled. Trial signups are reported by coderd when a license is actually +// issued, so a client cannot forge a conversion. +func (c *Client) ReportPremiumFunnelEvent(ctx context.Context, req PremiumFunnelEventRequest) error { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/deployment/premium-funnel-events", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index f7930b1c246..6fb958ea3c9 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -549,6 +549,43 @@ curl -X GET http://coder-server:8080/api/v2/connectionlog?limit=0 \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Report a premium paywall click + +### Code samples + +```sh +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/deployment/premium-funnel-events \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/v2/deployment/premium-funnel-events` + +> Body parameter + +```json +{ + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "source": "aibridge_session_threads", + "variant": "premium" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------|----------|----------------------| +| `body` | body | [codersdk.PremiumFunnelEventRequest](schemas.md#codersdkpremiumfunneleventrequest) | true | Premium funnel 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). + ## Get entitlements ### Code samples @@ -1354,6 +1391,7 @@ curl -X POST http://coder-server:8080/api/v2/licenses/trial \ ```json { + "attribution_id": "6f882a62-1d45-46e1-b5f4-0abfea127010", "company_name": "Acme Corp", "country": "United States", "developers": "string", @@ -1361,7 +1399,8 @@ curl -X POST http://coder-server:8080/api/v2/licenses/trial \ "first_name": "Jane", "job_title": "Engineering Manager", "last_name": "Doe", - "phone_number": "+14155552671" + "phone_number": "+14155552671", + "source": "aibridge_session_threads" } ``` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4e944c71bbe..d8bbcbd6afe 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5422,6 +5422,7 @@ This is required on creation to enable a user-flow of validating a template work ```json { + "attribution_id": "6f882a62-1d45-46e1-b5f4-0abfea127010", "company_name": "Acme Corp", "country": "United States", "developers": "string", @@ -5429,22 +5430,25 @@ This is required on creation to enable a user-flow of validating a template work "first_name": "Jane", "job_title": "Engineering Manager", "last_name": "Doe", - "phone_number": "+14155552671" + "phone_number": "+14155552671", + "source": "aibridge_session_threads" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------|--------|----------|--------------|-------------| -| `company_name` | string | true | | | -| `country` | string | true | | | -| `developers` | string | true | | | -| `email` | string | true | | | -| `first_name` | string | true | | | -| `job_title` | string | true | | | -| `last_name` | string | true | | | -| `phone_number` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|------------------|--------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `attribution_id` | string | false | | Attribution ID is the ID of the cta_click funnel event that led here, so that a signup can be joined back to the paywall that produced it. | +| `company_name` | string | true | | | +| `country` | string | true | | | +| `developers` | string | true | | | +| `email` | string | true | | | +| `first_name` | string | true | | | +| `job_title` | string | true | | | +| `last_name` | string | true | | | +| `phone_number` | string | true | | | +| `source` | [codersdk.PremiumFunnelSource](#codersdkpremiumfunnelsource) | false | | Source is the premium paywall the request came from, for telemetry. It is not forwarded to the licensor. Omit it to report "direct". | ## codersdk.CreateUserRequestWithOrgs @@ -10642,6 +10646,52 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-------------------------|---------|----------|--------------|-------------| | `reconciliation_paused` | boolean | false | | | +## codersdk.PremiumFunnelEventRequest + +```json +{ + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "source": "aibridge_session_threads", + "variant": "premium" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|----------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------| +| `id` | string | true | | ID identifies this click, and doubles as the attribution token that a later trial signup reports. | +| `source` | [codersdk.PremiumFunnelSource](#codersdkpremiumfunnelsource) | true | | | +| `variant` | [codersdk.PremiumFunnelVariant](#codersdkpremiumfunnelvariant) | true | | | + +## codersdk.PremiumFunnelSource + +```json +"aibridge_session_threads" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_keys`, `ai_governance`, `aibridge_session_threads`, `aibridge_sessions`, `appearance`, `audit_log`, `browser_only`, `connection_log`, `custom_roles`, `direct`, `groups`, `idp_org_sync`, `idp_sync`, `multiple_organizations`, `observability`, `provisioner_keys`, `provisioners`, `template_permissions`, `workspace_proxies` | + +## codersdk.PremiumFunnelVariant + +```json +"premium" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-------------------------------------| +| `ai_governance`, `premium`, `small` | + ## codersdk.Preset ```json diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index e51f1286a87..64bfa4860c8 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -27,8 +27,10 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/enterprise/trialer" @@ -213,6 +215,26 @@ func (api *API) postTrialLicense(rw http.ResponseWriter, r *http.Request) { }) return } + + // Reported here rather than from the browser so that a conversion is only + // counted when a license was actually issued. + source := req.Source + if !source.Valid() { + source = codersdk.PremiumFunnelSourceDirect + } + api.Telemetry.Report(&telemetry.Snapshot{ + PremiumFunnelEvents: []telemetry.PremiumFunnelEvent{ + { + ID: uuid.New(), + EventType: telemetry.PremiumFunnelEventTrialSignup, + Source: string(source), + AttributionID: req.AttributionID, + UserID: httpmw.APIKey(r).UserID, + CreatedAt: dbtime.Now(), + }, + }, + }) + httpapi.Write(ctx, rw, http.StatusCreated, lic) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7c220b56ca4..b9dfee28f8c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2464,6 +2464,12 @@ class ApiMethods { return response.data; }; + reportPremiumFunnelEvent = async ( + req: TypesGen.PremiumFunnelEventRequest, + ): Promise => { + await this.axios.post("/api/v2/deployment/premium-funnel-events", req); + }; + getReplicas = async (): Promise => { const response = await this.axios.get("/api/v2/replicas"); return response.data; diff --git a/site/src/api/queries/premiumFunnel.ts b/site/src/api/queries/premiumFunnel.ts new file mode 100644 index 00000000000..faa36edde36 --- /dev/null +++ b/site/src/api/queries/premiumFunnel.ts @@ -0,0 +1,5 @@ +import { API } from "#/api/api"; + +export const reportPremiumFunnelEvent = () => ({ + mutationFn: API.reportPremiumFunnelEvent, +}); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 171ad711f37..aa6f9279cde 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4242,6 +4242,16 @@ export interface CreateTrialLicenseRequest { readonly company_name: string; readonly country: string; readonly developers: string; + /** + * Source is the premium paywall the request came from, for telemetry. It + * is not forwarded to the licensor. Omit it to report "direct". + */ + readonly source?: PremiumFunnelSource; + /** + * AttributionID is the ID of the cta_click funnel event that led here, so + * that a signup can be joined back to the paywall that produced it. + */ + readonly attribution_id?: string; } // From codersdk/chats.go @@ -7375,6 +7385,74 @@ export interface PrebuildsSettings { */ export const PrebuildsSystemUserID = "c42fdf75-3097-471c-8c33-fb52454d81c0"; +// From codersdk/premiumfunnel.go +/** + * PremiumFunnelEventRequest is the request body for + * POST /api/v2/deployment/premium-funnel-events. + */ +export interface PremiumFunnelEventRequest { + /** + * ID identifies this click, and doubles as the attribution token that a + * later trial signup reports. + */ + readonly id: string; + readonly source: PremiumFunnelSource; + readonly variant: PremiumFunnelVariant; +} + +// From codersdk/premiumfunnel.go +export type PremiumFunnelSource = + | "aibridge_session_threads" + | "aibridge_sessions" + | "ai_gateway_keys" + | "ai_governance" + | "appearance" + | "audit_log" + | "browser_only" + | "connection_log" + | "custom_roles" + | "direct" + | "groups" + | "idp_org_sync" + | "idp_sync" + | "multiple_organizations" + | "observability" + | "provisioner_keys" + | "provisioners" + | "template_permissions" + | "workspace_proxies"; + +export const PremiumFunnelSources: PremiumFunnelSource[] = [ + "aibridge_session_threads", + "aibridge_sessions", + "ai_gateway_keys", + "ai_governance", + "appearance", + "audit_log", + "browser_only", + "connection_log", + "custom_roles", + "direct", + "groups", + "idp_org_sync", + "idp_sync", + "multiple_organizations", + "observability", + "provisioner_keys", + "provisioners", + "template_permissions", + "workspace_proxies", +]; + +// From codersdk/premiumfunnel.go +export type PremiumFunnelVariant = "ai_governance" | "premium" | "small"; + +export const PremiumFunnelVariants: PremiumFunnelVariant[] = [ + "ai_governance", + "premium", + "small", +]; + // From codersdk/presets.go export interface Preset { readonly ID: string; diff --git a/site/src/components/Paywall/Paywall.tsx b/site/src/components/Paywall/Paywall.tsx index a13ffb3386c..b3bc048b5f0 100644 --- a/site/src/components/Paywall/Paywall.tsx +++ b/site/src/components/Paywall/Paywall.tsx @@ -24,6 +24,7 @@ export type PaywallProps = React.ComponentProps<"div"> & { compact?: boolean; canViewPremium: boolean; features?: string[]; + onCTAClick?: () => void; }; export const Paywall = ({ diff --git a/site/src/components/Paywall/PaywallAIGovernance.tsx b/site/src/components/Paywall/PaywallAIGovernance.tsx index 94b85f286a9..f9d9aa79b03 100644 --- a/site/src/components/Paywall/PaywallAIGovernance.tsx +++ b/site/src/components/Paywall/PaywallAIGovernance.tsx @@ -1,4 +1,5 @@ import { PaywallSmall } from "#/components/Paywall/PaywallSmall"; +import type { PaywallProps } from "./Paywall"; type PaywallAIGovernanceVariant = "governance" | "sessions"; @@ -26,12 +27,13 @@ const PAYWALL_AIGOVERNANCE_COPY: Record< }, }; -type PaywallAIGovernanceProps = { +type PaywallAIGovernanceProps = Pick & { variant?: PaywallAIGovernanceVariant; }; const PaywallAIGovernance = ({ variant = "governance", + onCTAClick, }: PaywallAIGovernanceProps) => { const { description, features } = PAYWALL_AIGOVERNANCE_COPY[variant]; @@ -41,6 +43,7 @@ const PaywallAIGovernance = ({ canViewPremium description={description} features={features} + onCTAClick={onCTAClick} /> ); }; diff --git a/site/src/components/Paywall/PaywallPremium.tsx b/site/src/components/Paywall/PaywallPremium.tsx index a480efa66ad..9ce01278a65 100644 --- a/site/src/components/Paywall/PaywallPremium.tsx +++ b/site/src/components/Paywall/PaywallPremium.tsx @@ -56,6 +56,7 @@ const PaywallPremium = ({ canViewPremium, className, features = PREMIUM_FEATURES, + onCTAClick, ...props }: PaywallProps) => { return ( @@ -71,7 +72,11 @@ const PaywallPremium = ({ {PREMIUM_DEFAULT_HERO}

{DEFAULT_HERO_SUBTITLE}

{canViewPremium ? ( - + Start trial for free ) : ( diff --git a/site/src/components/Paywall/PaywallSmall.tsx b/site/src/components/Paywall/PaywallSmall.tsx index 5833f3c518d..fd879081011 100644 --- a/site/src/components/Paywall/PaywallSmall.tsx +++ b/site/src/components/Paywall/PaywallSmall.tsx @@ -23,6 +23,7 @@ const PaywallSmall = ({ canViewPremium, className, features = PREMIUM_FEATURES, + onCTAClick, ...props }: PaywallProps) => { return ( @@ -61,7 +62,11 @@ const PaywallSmall = ({ ))} {canViewPremium ? ( - + Start trial for free ) : ( diff --git a/site/src/modules/paywall/PremiumPaywall.stories.tsx b/site/src/modules/paywall/PremiumPaywall.stories.tsx new file mode 100644 index 00000000000..d863869b283 --- /dev/null +++ b/site/src/modules/paywall/PremiumPaywall.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import { PremiumPaywall } from "./PremiumPaywall"; +import { readPremiumFunnelAttribution } from "./premiumFunnelAttribution"; + +const meta: Meta = { + title: "modules/paywall/PremiumPaywall", + component: PremiumPaywall, + args: { + source: "appearance", + message: "Appearance", + description: "You need a Premium license to use this feature.", + canViewPremium: true, + }, + beforeEach: () => { + sessionStorage.clear(); + spyOn(API, "reportPremiumFunnelEvent").mockResolvedValue(); + }, +}; + +export default meta; +type Story = StoryObj; + +export const ReportsCTAClickWithAttribution: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByRole("link", { name: "Start trial for free" }), + ); + + // The stored token must match the reported click so a later trial signup + // joins back to this event and not to a stale one. + const attribution = readPremiumFunnelAttribution(); + await expect(attribution?.source).toBe("appearance"); + await waitFor(() => + expect(API.reportPremiumFunnelEvent).toHaveBeenCalledWith({ + id: attribution?.id, + source: "appearance", + variant: "premium", + }), + ); + }, +}; + +// Users who cannot view licenses get guidance instead of a call to action, so +// they have nothing to click and nothing to report. +export const NoCTAWithoutPermission: Story = { + args: { canViewPremium: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByText(/contact your deployment administrator/i), + ).toBeVisible(); + await expect(API.reportPremiumFunnelEvent).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/modules/paywall/PremiumPaywall.tsx b/site/src/modules/paywall/PremiumPaywall.tsx new file mode 100644 index 00000000000..c62d51f987c --- /dev/null +++ b/site/src/modules/paywall/PremiumPaywall.tsx @@ -0,0 +1,29 @@ +import type { FC } from "react"; +import { useMutation } from "react-query"; +import { reportPremiumFunnelEvent } from "#/api/queries/premiumFunnel"; +import type { PremiumFunnelSource } from "#/api/typesGenerated"; +import type { PaywallProps } from "#/components/Paywall/Paywall"; +import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; +import { trackPremiumFunnelClick } from "./premiumFunnelAttribution"; + +type PremiumPaywallProps = Omit & { + source: PremiumFunnelSource; +}; + +/** + * The full page premium paywall, wired to conversion telemetry. Prefer this + * over PaywallPremium so every surface is attributable. + */ +export const PremiumPaywall: FC = ({ + source, + ...paywallProps +}) => { + const { mutate: reportClick } = useMutation(reportPremiumFunnelEvent()); + + return ( + reportClick(trackPremiumFunnelClick(source, "premium"))} + /> + ); +}; diff --git a/site/src/modules/paywall/PremiumPaywallAIGovernance.stories.tsx b/site/src/modules/paywall/PremiumPaywallAIGovernance.stories.tsx new file mode 100644 index 00000000000..1f4f7fba5c8 --- /dev/null +++ b/site/src/modules/paywall/PremiumPaywallAIGovernance.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import { PremiumPaywallAIGovernance } from "./PremiumPaywallAIGovernance"; + +const meta: Meta = { + title: "modules/paywall/PremiumPaywallAIGovernance", + component: PremiumPaywallAIGovernance, + args: { + source: "ai_governance", + }, + beforeEach: () => { + sessionStorage.clear(); + spyOn(API, "reportPremiumFunnelEvent").mockResolvedValue(); + }, +}; + +export default meta; +type Story = StoryObj; + +export const ReportsCTAClick: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByRole("link", { name: "Start trial for free" }), + ); + + await waitFor(() => + expect(API.reportPremiumFunnelEvent).toHaveBeenCalledWith({ + id: expect.any(String), + source: "ai_governance", + variant: "ai_governance", + }), + ); + }, +}; + +// The three AI surfaces share one component, so the source must distinguish +// them. +export const AIBridgeSessions: Story = { + args: { source: "aibridge_sessions", variant: "sessions" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByRole("link", { name: "Start trial for free" }), + ); + + await waitFor(() => + expect(API.reportPremiumFunnelEvent).toHaveBeenCalledWith( + expect.objectContaining({ source: "aibridge_sessions" }), + ), + ); + }, +}; diff --git a/site/src/modules/paywall/PremiumPaywallAIGovernance.tsx b/site/src/modules/paywall/PremiumPaywallAIGovernance.tsx new file mode 100644 index 00000000000..e6c07010d75 --- /dev/null +++ b/site/src/modules/paywall/PremiumPaywallAIGovernance.tsx @@ -0,0 +1,32 @@ +import type { ComponentProps, FC } from "react"; +import { useMutation } from "react-query"; +import { reportPremiumFunnelEvent } from "#/api/queries/premiumFunnel"; +import type { PremiumFunnelSource } from "#/api/typesGenerated"; +import { PaywallAIGovernance } from "#/components/Paywall/PaywallAIGovernance"; +import { trackPremiumFunnelClick } from "./premiumFunnelAttribution"; + +type PremiumPaywallAIGovernanceProps = Omit< + ComponentProps, + "onCTAClick" +> & { + source: PremiumFunnelSource; +}; + +/** + * The AI gateway paywall, wired to conversion telemetry. Prefer this over + * PaywallAIGovernance so every surface is attributable. + */ +export const PremiumPaywallAIGovernance: FC< + PremiumPaywallAIGovernanceProps +> = ({ source, ...paywallProps }) => { + const { mutate: reportClick } = useMutation(reportPremiumFunnelEvent()); + + return ( + + reportClick(trackPremiumFunnelClick(source, "ai_governance")) + } + /> + ); +}; diff --git a/site/src/modules/paywall/PremiumPaywallSmall.stories.tsx b/site/src/modules/paywall/PremiumPaywallSmall.stories.tsx new file mode 100644 index 00000000000..97e4e865ba6 --- /dev/null +++ b/site/src/modules/paywall/PremiumPaywallSmall.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import { PremiumPaywallSmall } from "./PremiumPaywallSmall"; + +const meta: Meta = { + title: "modules/paywall/PremiumPaywallSmall", + component: PremiumPaywallSmall, + args: { + source: "custom_roles", + message: "Custom Roles", + description: "You need a Premium license to use this feature.", + canViewPremium: true, + }, + beforeEach: () => { + sessionStorage.clear(); + spyOn(API, "reportPremiumFunnelEvent").mockResolvedValue(); + }, +}; + +export default meta; +type Story = StoryObj; + +export const ReportsCTAClick: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByRole("link", { name: "Start trial for free" }), + ); + + await waitFor(() => + expect(API.reportPremiumFunnelEvent).toHaveBeenCalledWith({ + id: expect.any(String), + source: "custom_roles", + variant: "small", + }), + ); + }, +}; diff --git a/site/src/modules/paywall/PremiumPaywallSmall.tsx b/site/src/modules/paywall/PremiumPaywallSmall.tsx new file mode 100644 index 00000000000..d93675934b1 --- /dev/null +++ b/site/src/modules/paywall/PremiumPaywallSmall.tsx @@ -0,0 +1,29 @@ +import type { FC } from "react"; +import { useMutation } from "react-query"; +import { reportPremiumFunnelEvent } from "#/api/queries/premiumFunnel"; +import type { PremiumFunnelSource } from "#/api/typesGenerated"; +import type { PaywallProps } from "#/components/Paywall/Paywall"; +import { PaywallSmall } from "#/components/Paywall/PaywallSmall"; +import { trackPremiumFunnelClick } from "./premiumFunnelAttribution"; + +type PremiumPaywallSmallProps = Omit & { + source: PremiumFunnelSource; +}; + +/** + * The inline premium paywall, wired to conversion telemetry. Prefer this over + * PaywallSmall so every surface is attributable. + */ +export const PremiumPaywallSmall: FC = ({ + source, + ...paywallProps +}) => { + const { mutate: reportClick } = useMutation(reportPremiumFunnelEvent()); + + return ( + reportClick(trackPremiumFunnelClick(source, "small"))} + /> + ); +}; diff --git a/site/src/modules/paywall/premiumFunnelAttribution.test.ts b/site/src/modules/paywall/premiumFunnelAttribution.test.ts new file mode 100644 index 00000000000..ee2dc385c11 --- /dev/null +++ b/site/src/modules/paywall/premiumFunnelAttribution.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { CreateTrialLicenseRequest } from "#/api/typesGenerated"; +import { + clearPremiumFunnelAttribution, + readPremiumFunnelAttribution, + storePremiumFunnelAttribution, + trackPremiumFunnelClick, + withPremiumFunnelAttribution, +} from "./premiumFunnelAttribution"; + +const attribution = { + id: "b3f1b3a0-0f5a-4f6a-9c1a-4f2f9b0a7c11", + source: "appearance", + createdAt: 1_000_000, +} as const; + +describe("premiumFunnelAttribution", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("round trips a stored attribution", () => { + storePremiumFunnelAttribution(attribution); + + expect(readPremiumFunnelAttribution(attribution.createdAt)).toEqual( + attribution, + ); + }); + + it("returns undefined when nothing is stored", () => { + expect(readPremiumFunnelAttribution()).toBeUndefined(); + }); + + it("discards an attribution older than the TTL", () => { + storePremiumFunnelAttribution(attribution); + + const pastTTL = attribution.createdAt + 30 * 60 * 1000 + 1; + expect(readPremiumFunnelAttribution(pastTTL)).toBeUndefined(); + expect(readPremiumFunnelAttribution(attribution.createdAt)).toBeUndefined(); + }); + + it("keeps an attribution inside the TTL", () => { + storePremiumFunnelAttribution(attribution); + + const withinTTL = attribution.createdAt + 29 * 60 * 1000; + expect(readPremiumFunnelAttribution(withinTTL)).toEqual(attribution); + }); + + it("discards malformed storage contents", () => { + sessionStorage.setItem("coder.premiumFunnelAttribution", "not json"); + + expect(readPremiumFunnelAttribution()).toBeUndefined(); + }); + + it("discards contents missing required fields", () => { + sessionStorage.setItem( + "coder.premiumFunnelAttribution", + JSON.stringify({ id: attribution.id }), + ); + + expect(readPremiumFunnelAttribution()).toBeUndefined(); + }); + + it("discards a source that is not a known paywall", () => { + sessionStorage.setItem( + "coder.premiumFunnelAttribution", + JSON.stringify({ ...attribution, source: "marketing_email" }), + ); + + expect(readPremiumFunnelAttribution(attribution.createdAt)).toBeUndefined(); + }); + + it("clears a stored attribution", () => { + storePremiumFunnelAttribution(attribution); + clearPremiumFunnelAttribution(); + + expect(readPremiumFunnelAttribution(attribution.createdAt)).toBeUndefined(); + }); +}); + +describe("trackPremiumFunnelClick", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + // The trial signup is joined to the click on this ID, so the reported event + // and the stored attribution must carry the same one. + it("stores the attribution it reports", () => { + const event = trackPremiumFunnelClick("audit_log", "small"); + + expect(event).toEqual({ + id: expect.any(String), + source: "audit_log", + variant: "small", + }); + expect(readPremiumFunnelAttribution()?.id).toBe(event.id); + }); +}); + +describe("withPremiumFunnelAttribution", () => { + const request: CreateTrialLicenseRequest = { + email: "admin@coder.com", + first_name: "Ada", + last_name: "Lovelace", + phone_number: "+14155552671", + job_title: "Platform Engineer", + company_name: "Coder", + country: "United States", + developers: "51 - 100", + }; + + beforeEach(() => { + sessionStorage.clear(); + }); + + it("tags the request with the stored attribution", () => { + expect(withPremiumFunnelAttribution(request, attribution)).toEqual({ + ...request, + source: "appearance", + attribution_id: attribution.id, + }); + }); + + it("reports direct when no attribution is stored", () => { + expect(withPremiumFunnelAttribution(request)).toEqual({ + ...request, + source: "direct", + attribution_id: undefined, + }); + }); +}); diff --git a/site/src/modules/paywall/premiumFunnelAttribution.ts b/site/src/modules/paywall/premiumFunnelAttribution.ts new file mode 100644 index 00000000000..3f8bae44b62 --- /dev/null +++ b/site/src/modules/paywall/premiumFunnelAttribution.ts @@ -0,0 +1,103 @@ +import * as Yup from "yup"; +import type { + CreateTrialLicenseRequest, + PremiumFunnelEventRequest, + PremiumFunnelSource, + PremiumFunnelVariant, +} from "#/api/typesGenerated"; +import { PremiumFunnelSources } from "#/api/typesGenerated"; +import { generateUUID } from "#/utils/random"; + +const STORAGE_KEY = "coder.premiumFunnelAttribution"; + +/** + * How long a stored attribution stays valid. Without an expiry, a token left + * behind by an abandoned click would attribute an unrelated trial signup made + * much later in the same tab. + */ +const TTL_MS = 30 * 60 * 1000; + +type PremiumFunnelAttribution = { + /** ID of the cta_click event that produced this attribution. */ + id: string; + source: PremiumFunnelSource; + createdAt: number; +}; + +const attributionSchema: Yup.ObjectSchema = + Yup.object({ + id: Yup.string().required(), + source: Yup.string() + .oneOf(PremiumFunnelSources) + .required(), + createdAt: Yup.number().required(), + }); + +/** + * Records which paywall sent the user to the premium page. sessionStorage is + * used instead of a query parameter so the URL stays clean, and instead of + * router state so the attribution survives a page refresh. + */ +export const storePremiumFunnelAttribution = ( + attribution: PremiumFunnelAttribution, +): void => { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(attribution)); +}; + +export const clearPremiumFunnelAttribution = (): void => { + sessionStorage.removeItem(STORAGE_KEY); +}; + +/** + * Records a click on a paywall call to action and returns the event to report. + * The event ID doubles as the attribution token, so the trial signup that + * follows can be joined back to this click. + */ +export const trackPremiumFunnelClick = ( + source: PremiumFunnelSource, + variant: PremiumFunnelVariant, +): PremiumFunnelEventRequest => { + const id = generateUUID(); + storePremiumFunnelAttribution({ id, source, createdAt: Date.now() }); + return { id, source, variant }; +}; + +/** + * Reads the stored attribution, discarding anything expired or malformed. + */ +export const readPremiumFunnelAttribution = ( + now: number = Date.now(), +): PremiumFunnelAttribution | undefined => { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) { + return undefined; + } + + let attribution: PremiumFunnelAttribution; + try { + attribution = attributionSchema.validateSync(JSON.parse(raw)); + } catch { + clearPremiumFunnelAttribution(); + return undefined; + } + + if (now - attribution.createdAt > TTL_MS) { + clearPremiumFunnelAttribution(); + return undefined; + } + return attribution; +}; + +/** + * Tags a trial request with the paywall that produced it. Requests made + * without a stored attribution report "direct", so that arriving without a + * paywall stays distinguishable from missing data. + */ +export const withPremiumFunnelAttribution = ( + request: CreateTrialLicenseRequest, + attribution = readPremiumFunnelAttribution(), +): CreateTrialLicenseRequest => ({ + ...request, + source: attribution?.source ?? "direct", + attribution_id: attribution?.id, +}); diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx index 534925a5dd3..5498b7efc9c 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx @@ -5,7 +5,6 @@ import { PaginationContainer, type PaginationResult, } from "#/components/PaginationWidget/PaginationContainer"; -import { PaywallAIGovernance } from "#/components/Paywall/PaywallAIGovernance"; import { Table, TableBody, @@ -21,6 +20,7 @@ import { TooltipProvider, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { PremiumPaywallAIGovernance } from "#/modules/paywall/PremiumPaywallAIGovernance"; import { DATE_FORMAT, formatDateTime } from "#/utils/time"; import { AIBridgeSetupAlert } from "../AIBridgeSetupAlert"; import { ListSessionsFilter } from "./ListSessionsFilter"; @@ -64,7 +64,12 @@ export const ListSessionsPageView: FC = ({ onSessionRowClick, }) => { if (!isAISessionsEntitled) { - return ; + return ( + + ); } if (!isAISessionsEnabled) { diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx index 0a02cedbfe4..34cee48ce77 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx @@ -6,13 +6,13 @@ import type { } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { Loader } from "#/components/Loader/Loader"; -import { PaywallAIGovernance } from "#/components/Paywall/PaywallAIGovernance"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { PremiumPaywallAIGovernance } from "#/modules/paywall/PremiumPaywallAIGovernance"; import { AIBridgeSetupAlert } from "../AIBridgeSetupAlert"; import { SessionSummaryTable } from "./SessionSummaryTable"; import { SessionTimeline } from "./SessionTimeline/SessionTimeline"; @@ -62,7 +62,12 @@ export const SessionThreadsPageView: FC = ({ onBackClicked, }) => { if (!isAISessionsEntitled) { - return ; + return ( + + ); } if (!isAISessionsEnabled) { diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx index 9708010aaa7..1fb0b918a4e 100644 --- a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx @@ -3,7 +3,6 @@ import type { FC } from "react"; import type { AIGatewayKey } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; -import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { SettingsHeader, SettingsHeaderDescription, @@ -20,6 +19,7 @@ import { } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { PremiumPaywall } from "#/modules/paywall/PremiumPaywall"; import type { Permissions } from "#/modules/permissions"; import { docs } from "#/utils/docs"; import { relativeTime } from "#/utils/time"; @@ -70,7 +70,8 @@ export const GatewayKeysPageView: FC = ({ {showPaywall && ( - = ({ ) : ( - = ({ ) : ( - ) : ( - + )} diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index 1308d694d0c..89a9ccd9180 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -10,7 +10,6 @@ import { } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; import { IconField } from "#/components/IconField/IconField"; -import { PaywallPremium } from "#/components/Paywall/PaywallPremium"; import { SettingsHeader, SettingsHeaderDescription, @@ -18,6 +17,7 @@ import { SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; +import { PremiumPaywall } from "#/modules/paywall/PremiumPaywall"; import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; import { AnnouncementBannerSettings } from "./AnnouncementBannerSettings"; @@ -61,7 +61,8 @@ export const AppearanceSettingsPageView: FC< {!isEntitled ? ( - { {!isIdpSyncEnabled ? ( - o.name === "Audit Logs Retention")} /> ) : ( - { isSubmitting={trialMutation.isPending} error={trialMutation.error} onSubmit={(request) => { - trialMutation.mutate(request, { + trialMutation.mutate(withPremiumFunnelAttribution(request), { onSuccess: () => { + clearPremiumFunnelAttribution(); navigate("/deployment/licenses?success=true"); }, onError: (error) => { diff --git a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx index 188f329c92b..a8e95ffd29b 100644 --- a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx @@ -5,13 +5,13 @@ import { DisabledBadge, EnabledBadge, } from "#/components/Badges/Badges"; -import { PaywallSmall } from "#/components/Paywall/PaywallSmall"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderDocsLink, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { PremiumPaywallSmall } from "#/modules/paywall/PremiumPaywallSmall"; import { deploymentGroupHasParent, useDeploymentOptions, @@ -76,7 +76,8 @@ export const SecuritySettingsPageView: FC = ({ {featureBrowserOnlyEnabled ? : } {!featureBrowserOnlyEnabled ? ( - = ({ }) => { if (!groupsEnabled) { return ( - {!isEntitled ? (
- = ({ return (
{!isCustomRolesEnabled && ( - { {!isIdpSyncEnabled ? ( - {showPaywall ? ( - { {!isTemplateRBACEnabled ? ( - = ({ {showPaywall ? ( -