Files
multica/server/internal/analytics/events.go
Naiyuan Qing d6e7824ff1 feat(feedback): in-app feedback flow + Help launcher (#1546)
* feat(feedback): add in-app feedback flow and Help launcher

Replaces the duplicated bottom-sidebar user popover and "What's new" links
with a single Help menu (Docs / Feedback / Change log) pinned to the
sidebar footer. Feedback opens a rich-text modal that POSTs to a new
/api/feedback endpoint; submissions land in a dedicated feedback table
with per-user hourly rate limiting (10/hr) to deter spam without adding
middleware infrastructure. User identity (avatar + name + email) moves
into the workspace dropdown header so the sidebar is no longer visually
redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(feedback): harden submit path and cap request body

- Read editor markdown via ref at submit time instead of debounced state,
  so ⌘+Enter immediately after typing doesn't drop the last keystrokes.
- Block submission while images are still uploading; toast prompts the
  user to wait instead of silently sending markdown with blob: URLs
  that get stripped.
- Cap /api/feedback request body at 64 KiB via MaxBytesReader so an
  authenticated client can't bloat the metadata JSONB column with an
  oversized url field.
- Add Go handler tests covering happy path, empty-message rejection,
  and the hourly rate limit boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analytics): instrument feedback funnel

Adds two events pairing frontend intent with backend conversion so we
can compute a completion rate for the in-app Feedback modal:

- `feedback_opened` (frontend) — fires once on FeedbackModal mount.
  Source is currently always "help_menu" but the type is a union so
  future entry points have to extend it explicitly. Workspace id is
  attached when present.
- `feedback_submitted` (backend) — fires from CreateFeedback after the
  DB insert succeeds and the hourly rate-limit check has passed.
  Message content itself is never sent to PostHog; the event carries
  a coarse length bucket (0-100 / 100-500 / 500-2000 / 2000+), an
  image-presence flag, and the client platform / version pulled from
  X-Client-* headers via middleware.ClientMetadataFromContext.

Affects no existing funnel; seeds a new Feedback funnel for product
triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:35:55 +08:00

309 lines
11 KiB
Go

package analytics
import "strings"
// Event names. Keep in sync with docs/analytics.md.
const (
EventSignup = "signup"
EventWorkspaceCreated = "workspace_created"
EventRuntimeRegistered = "runtime_registered"
EventIssueExecuted = "issue_executed"
EventTeamInviteSent = "team_invite_sent"
EventTeamInviteAccepted = "team_invite_accepted"
EventOnboardingQuestionnaireSubmit = "onboarding_questionnaire_submitted"
EventAgentCreated = "agent_created"
EventOnboardingCompleted = "onboarding_completed"
EventCloudWaitlistJoined = "cloud_waitlist_joined"
EventStarterContentDecided = "starter_content_decided"
EventFeedbackSubmitted = "feedback_submitted"
)
// Onboarding completion paths. Keep in sync with docs/analytics.md.
const (
OnboardingPathFull = "full" // reached first_issue end of flow
OnboardingPathRuntimeSkipped = "runtime_skipped" // completed without connecting a runtime
OnboardingPathCloudWaitlist = "cloud_waitlist" // completed via cloud waitlist soft exit
OnboardingPathSkipExisting = "skip_existing" // "I've done this before" from welcome
OnboardingPathUnknown = "unknown" // fallback when the server can't derive the path
)
// Starter content branches. Matches the server-authoritative decision in
// ImportStarterContent (hasAgent ? agent_guided : self_serve). DismissStarter
// carries the same branch so acceptance rates split cleanly.
const (
StarterContentBranchAgentGuided = "agent_guided"
StarterContentBranchSelfServe = "self_serve"
)
// Platform is used as the "platform" event property so funnels can split by
// web / desktop / cli. Request-path events use PlatformServer as a fallback
// when the caller is a server-originating action (e.g. auto-created user);
// otherwise the frontend passes the real platform via a header / body field
// in later iterations.
const (
PlatformServer = "server"
PlatformWeb = "web"
PlatformDesktop = "desktop"
PlatformCLI = "cli"
)
// Signup builds the signup event. signupSource is populated from the
// frontend's stored UTM/referrer cookie if present; leave empty otherwise.
func Signup(userID, email, signupSource string) Event {
return Event{
Name: EventSignup,
DistinctID: userID,
Properties: map[string]any{
"email_domain": emailDomain(email),
"signup_source": signupSource,
},
SetOnce: map[string]any{
"email": email,
"signup_source": signupSource,
},
}
}
// WorkspaceCreated builds the workspace_created event. "Is this the user's
// first workspace?" is deliberately not stamped here — it's derived in
// PostHog by checking whether the user has a prior workspace_created event.
func WorkspaceCreated(userID, workspaceID string) Event {
return Event{
Name: EventWorkspaceCreated,
DistinctID: userID,
WorkspaceID: workspaceID,
}
}
// RuntimeRegistered fires on the first time a (workspace, daemon, provider)
// triple is upserted. The handler uses a `xmax = 0` flag returned from the
// upsert query to distinguish inserts from updates — heartbeats and repeat
// registrations never emit this event.
//
// ownerID may be empty when the daemon authenticates via a daemon token
// (no user context); downstream funnels that need per-user attribution
// fall back to `workspace_id` as the grouping key.
func RuntimeRegistered(ownerID, workspaceID, runtimeID, provider, runtimeVersion, cliVersion string) Event {
distinct := ownerID
if distinct == "" {
// A per-workspace synthetic id keeps PostHog from merging unrelated
// daemon registrations across workspaces under a single "anonymous"
// person. It's stable within a workspace so repeat heartbeats (which
// don't emit anyway) would at least group correctly.
distinct = "workspace:" + workspaceID
}
return Event{
Name: EventRuntimeRegistered,
DistinctID: distinct,
WorkspaceID: workspaceID,
Properties: map[string]any{
"runtime_id": runtimeID,
"provider": provider,
"runtime_version": runtimeVersion,
"cli_version": cliVersion,
},
}
}
// IssueExecuted fires at most once per issue lifetime — on the first task
// completion that flips `issues.first_executed_at` from NULL via an atomic
// UPDATE. Retries, re-assignments, and comment-triggered follow-ups never
// re-emit, which is what keeps the ≥1/≥2/≥5/≥10 funnel buckets honest.
//
// Deliberately not stamped here: the workspace's Nth-issue ordinal.
// Computing it at emit time is not atomic (two concurrent first-completions
// both read count=1, both emit n=1), and PostHog derives the same number
// exactly at query time from the event stream.
func IssueExecuted(actorID, workspaceID, issueID string, taskDurationMS int64) Event {
return Event{
Name: EventIssueExecuted,
DistinctID: actorID,
WorkspaceID: workspaceID,
Properties: map[string]any{
"issue_id": issueID,
"task_duration_ms": taskDurationMS,
},
}
}
// TeamInviteSent fires when a workspace admin creates an invitation.
// inviteMethod is "email" for now; future non-email invite flows can pass
// their own value to keep this stable.
func TeamInviteSent(inviterID, workspaceID, invitedEmail, inviteMethod string) Event {
return Event{
Name: EventTeamInviteSent,
DistinctID: inviterID,
WorkspaceID: workspaceID,
Properties: map[string]any{
"invited_email_domain": emailDomain(invitedEmail),
"invite_method": inviteMethod,
},
}
}
// TeamInviteAccepted fires when the invitee accepts and joins the workspace.
// daysSinceInvite lets us segment fast-acceptance (warm) from long-tail
// acceptance (someone dug through old email).
func TeamInviteAccepted(inviteeID, workspaceID string, daysSinceInvite int64) Event {
return Event{
Name: EventTeamInviteAccepted,
DistinctID: inviteeID,
WorkspaceID: workspaceID,
Properties: map[string]any{
"days_since_invite": daysSinceInvite,
},
}
}
// OnboardingQuestionnaireSubmitted fires the first time a user's
// `user.onboarding_questionnaire` transitions from empty (or partial) to
// all three answers present. The handler drives this transition — we
// emit from PatchOnboarding so the single emission site stays honest
// even if the frontend retries.
//
// The three answers are also mirrored into person properties via $set
// so cohorting by role / use_case / team_size works across every event
// on the same user without re-joining back to the DB.
//
// teamSizeOther / roleOther / useCaseOther are presence booleans only —
// the free-text content is kept in the DB for product research but not
// broadcast via analytics (PII risk + low cardinality ask).
func OnboardingQuestionnaireSubmitted(userID, teamSize, role, useCase string, teamSizeOther, roleOther, useCaseOther bool) Event {
return Event{
Name: EventOnboardingQuestionnaireSubmit,
DistinctID: userID,
Properties: map[string]any{
"team_size": teamSize,
"role": role,
"use_case": useCase,
"team_size_has_other": teamSizeOther,
"role_has_other": roleOther,
"use_case_has_other": useCaseOther,
},
Set: map[string]any{
"team_size": teamSize,
"role": role,
"use_case": useCase,
},
}
}
// AgentCreated fires whenever a new agent is added to a workspace — not
// just inside onboarding. `isFirstAgentInWorkspace` lets the funnel
// isolate the Step 4 signal from later agent additions.
//
// template is the template slug the frontend used to seed the agent
// (e.g. "coding", "planning", "writing", "assistant") — empty when the
// caller didn't come from a template picker.
func AgentCreated(actorID, workspaceID, agentID, provider, template string, isFirstAgentInWorkspace bool) Event {
return Event{
Name: EventAgentCreated,
DistinctID: actorID,
WorkspaceID: workspaceID,
Properties: map[string]any{
"agent_id": agentID,
"provider": provider,
"template": template,
"is_first_agent_in_workspace": isFirstAgentInWorkspace,
},
}
}
// OnboardingCompleted fires from CompleteOnboarding. `completionPath`
// is derived server-side from the state the user arrived in (see the
// OnboardingPath* constants above). `joinedCloudWaitlist` is true when
// the user submitted the waitlist form at any point during the flow —
// it's orthogonal to `completion_path`; a user may submit the form and
// still pick CLI, so we keep both signals.
//
// onboardedAt is an RFC3339 timestamp set $set_once on the person so
// "onboarded before date X" cohorts are queryable directly from
// person_properties without re-emitting per-event.
func OnboardingCompleted(userID, completionPath, onboardedAt string, joinedCloudWaitlist bool) Event {
return Event{
Name: EventOnboardingCompleted,
DistinctID: userID,
Properties: map[string]any{
"completion_path": completionPath,
"joined_cloud_waitlist": joinedCloudWaitlist,
},
SetOnce: map[string]any{
"onboarded_at": onboardedAt,
},
}
}
// CloudWaitlistJoined fires when a user submits the Step 3 cloud
// waitlist form. `hasReason` is a presence bool — the free-text reason
// stays in the DB for product research.
func CloudWaitlistJoined(userID string, hasReason bool) Event {
return Event{
Name: EventCloudWaitlistJoined,
DistinctID: userID,
Properties: map[string]any{
"has_reason": hasReason,
},
}
}
// StarterContentDecided fires on the atomic NULL -> terminal state
// transition in both ImportStarterContent and DismissStarterContent.
// branch carries agent_guided / self_serve for BOTH decisions — the
// dismiss handler resolves it from the current ListAgents state so
// acceptance rates split cleanly by branch.
func StarterContentDecided(userID, workspaceID, decision, branch string) Event {
return Event{
Name: EventStarterContentDecided,
DistinctID: userID,
WorkspaceID: workspaceID,
Properties: map[string]any{
"decision": decision,
"branch": branch,
},
}
}
// FeedbackSubmitted fires after a feedback row is successfully inserted.
// The raw message is stored in the DB and never broadcast — we only emit a
// coarse length bucket, an image-presence flag, and the client platform /
// version so support can segment without leaking content.
func FeedbackSubmitted(userID, workspaceID string, messageLen int, hasImages bool, platform, appVersion string) Event {
props := map[string]any{
"message_length_bucket": feedbackLengthBucket(messageLen),
"has_images": hasImages,
}
if platform != "" {
props["platform"] = platform
}
if appVersion != "" {
props["app_version"] = appVersion
}
return Event{
Name: EventFeedbackSubmitted,
DistinctID: userID,
WorkspaceID: workspaceID,
Properties: props,
}
}
func feedbackLengthBucket(n int) string {
switch {
case n < 100:
return "0-100"
case n < 500:
return "100-500"
case n < 2000:
return "500-2000"
default:
return "2000+"
}
}
func emailDomain(email string) string {
at := strings.LastIndex(email, "@")
if at < 0 || at == len(email)-1 {
return ""
}
return strings.ToLower(email[at+1:])
}