mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
* 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>
309 lines
11 KiB
Go
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:])
|
|
}
|