Files
multica/server/internal/analytics/events.go
Jiayuan Zhang f539fdba83 feat(onboarding): backfill prompt for missing source attribution (MUL-2796) (#3550)
* feat(onboarding): backfill prompt for users missing source attribution

Adds a one-shot popup shown after login to already-onboarded users
whose `onboarding_questionnaire.source` was never recorded — either
they completed onboarding before the source step shipped, or they
clicked Skip on it. Reuses the existing 12-option StepSource UI and
the existing `PATCH /api/me/onboarding` endpoint, so no schema or
backend changes.

Web renders it as a route at /onboarding/source (sibling of the
reserved /onboarding); desktop dispatches it as a WindowOverlay per
the Route categories rule. Submit and explicit Skip are terminal;
the close X bumps a per-user localStorage counter and stops appearing
after 3 dismissals.

Emits source_backfill_shown / submitted / skipped / dismissed PostHog
events so the funnel can be tracked separately from first-time
onboarding.

For MUL-2796.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): preserve role/use_case and respect dismiss cap in source backfill

Round-2 fixes from Emacs's review of #3550:

1. PATCH wipe: `PATCH /api/me/onboarding` replaces the JSONB column
   wholesale (server/internal/handler/onboarding.go), so sending only
   the source slots was wiping role/use_case/version for exactly the
   historical users this targets. Read user.onboarding_questionnaire,
   overlay the source fields client-side via mergedQuestionnairePatch,
   and send the full shape. 7 unit cases cover the merge semantics.

2. Legacy single-string source: pre-multi-select rows wrote
   `source: "search"` as a bare string. needsSourceBackfill now treats
   that as already answered, matching mergeQuestionnaire (views) and
   stringOrSlice.UnmarshalJSON (server). Flipped the existing test and
   added empty-string + null coverage.

3. Dismiss cap honored in callback: the web auth callback was passing
   dismissCount=0, which would force-route capped users through
   /onboarding/source on every login (the route page would bounce them
   onward, but only after a blank detour and a re-fired
   `source_backfill_shown` event). Added readSourceBackfillDismissCount
   so the callback reads the same per-user localStorage bucket the
   prompt writes to. Test asserts a count of 3 bypasses the detour.

Co-authored-by: multica-agent <github@multica.ai>

* test(onboarding): clear source-backfill dismiss counter in callback test beforeEach

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): footer hint text matches the Submit button on the backfill prompt

The Source step's hint reads "Hit Continue when you're ready" because
its commit button is "Continue". The backfill view ships a "Submit"
button instead, so the inherited hint was misleading. Add a dedicated
`source_backfill.hint_ready` key across en / zh / ko and use it here.

Caught during browser E2E in the round-2 verification stack.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): magic-code login also detours through source backfill

The round-2 fix in PR #3550 only wired the source-backfill detour
into the OAuth `/auth/callback` post-success path. Magic-code login
goes through `/login` → `handleSuccess()` which calls
`resolveLoggedInDestination()` and pushes directly to the workspace,
so those users never reach `/onboarding/source`. Caught during the
local-env demo for Jiayuan.

Add `maybeSourceBackfillDetour` to the login page and apply it in
both the already-authenticated useEffect and the post-verify-code
handler. Predicate consults the same per-user localStorage bucket
the prompt writes to, so a user who hit the close-X cap on this
browser flows straight through.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(onboarding): source backfill is a workspace-mounted modal, not a route detour

Per UAT, the prompt should overlay the workspace as a Dialog with the
workspace visible behind a dimmed backdrop — the original brief and
reference screenshot both showed a modal. PR #3550 shipped a full-window
takeover (web /onboarding/source + desktop WindowOverlay) which Jiayuan
rejected.

This commit replaces the full-window view with a Dialog-based
`<SourceBackfillModal />` mounted once inside the shared `DashboardLayout`
(packages/views/layout). The modal self-mounts: it reads
`needsSourceBackfill(user, dismissCount)` and opens itself when the
predicate flips to true; X / ESC / outside-click all bump the per-user
localStorage cap and close.

Removed:
- apps/web/app/(auth)/onboarding/source/page.tsx (route)
- paths.sourceBackfill (no longer needed)
- callback page detour
- login page maybeSourceBackfillDetour
- desktop WindowOverlay type "source-backfill"
- desktop navigation interception of /onboarding/source
- desktop App.tsx dispatch effect
- pageview-tracker case
- views/onboarding `SourceBackfillView` + `readSourceBackfillDismissCount` exports

Preserved (semantics unchanged):
- `needsSourceBackfill` predicate (incl. legacy single-string source coercion)
- `mergedQuestionnairePatch` so role / use_case survive Submit / Skip
- PostHog events: source_backfill_shown / submitted / skipped / dismissed
- Per-user dismiss-count cap (3) in localStorage
- en / zh / ko i18n strings

Tests:
- 7 new tests for the modal in packages/views/onboarding/source-backfill-modal.test.tsx
- Adjusted apps/web/app/auth/callback/page.test.tsx: detour tests dropped,
  one assertion remains that onboarded users with missing source land in
  the workspace (the modal handles the rest)
- Full suite: 965 tests pass, typecheck + lint clean

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): mount source-backfill modal on the desktop workspace too

Desktop's WorkspaceRouteLayout never wraps DashboardLayout, so the
previous commit's modal mount only fired for web. Regression: desktop
users were not seeing the prompt at all.

Wire the same `<SourceBackfillModal />` next to `<WelcomeAfterOnboarding />`
inside `workspace-route-layout.tsx`, with the matching
`!overlayActive` suppression so the Dialog doesn't portal-jump above
an active pre-workspace WindowOverlay (onboarding / accept-invite /
new-workspace). Same component on both platforms — single source of
truth lives in packages/views/onboarding/source-backfill-modal.tsx.

Also drop the now-stale `source-backfill detour` comment in the web
callback test fixture (Emacs nit, non-blocking).

Co-authored-by: multica-agent <github@multica.ai>

* test(desktop): assert workspace-route-layout mounts source-backfill modal

Two structural tests pinning the round-4 fix:

- `mounts SourceBackfillModal when no WindowOverlay is active` —
  guards against the regression Emacs caught (modal silently absent
  on desktop because the previous round only wired DashboardLayout).
- `suppresses SourceBackfillModal while a WindowOverlay is active` —
  mirrors the existing `!overlayActive` rule that WelcomeAfterOnboarding
  already relies on so a portal-rendered Dialog can't visually outrank
  an active pre-workspace overlay.

Mocks the SourceBackfillModal with a marker component so the test
asserts mount/unmount without depending on the modal's own predicate
gate.

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): backfill modal Other toggles off; entrance settles after 700ms

UAT round-3 follow-ups from Jiayuan:

1. **Other can't be deselected**: the modal kept a parallel
   `pendingOther` flag set to true on every Other click, and
   `IconOtherOptionCard`'s row click was guarded with
   `if (!selected) onSelect()` — so a second click neither flipped
   pendingOther nor reached the parent toggle. Drop `pendingOther`
   (the `source.includes("other")` derivation is already authoritative)
   AND add an opt-in `allowToggleOff` prop to `IconOtherOptionCard`
   that lets the row toggle when already selected. The text input
   stops click propagation so typing never deselects.

2. **Rebase + absorb GitHub channel**: rebased onto origin/main which
   added `social_github` (PR #3612). Modal's option list now mirrors
   StepSource — GitHub slotted between YouTube and Other social,
   reusing the existing `GitHubIcon`.

3. **Soft entrance**: defer the dialog open by 700ms after the user
   lands on a workspace so the underlying view paints first and the
   modal feels like an inviting prompt rather than a hard block.
   Honour `prefers-reduced-motion: reduce` (open immediately for
   users who have opted out of incidental motion).

Tests:
- New `Other toggles off on the second click instead of getting stuck`
- New `renders the GitHub channel rebased from origin/main`
- New `defers the entrance by ~700ms when the user has not opted into
  reduced motion`
- Existing tests stamp `prefers-reduced-motion: reduce` in beforeEach
  so the dialog opens synchronously and they don't need to drive
  fake timers.

Full suite passes (969 tests).

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): backfill modal opens reliably + Other deselects via icon area

Three follow-up fixes after live UAT:

1. Strict-mode regression on entrance delay: the gate ref was being
   stamped when the effect *scheduled* the timer, so React Strict
   Mode's double-invoke cleared the first timer and then bailed on
   the second pass because the ref was already set, leaving the
   dialog forever closed. Stamp the ref only inside the timer
   callback (or synchronously when reduced-motion is on) so the
   second strict pass starts a fresh timer.

2. Other deselect: dropping `pendingOther` wasn't enough — the input
   that replaces the label when Other is selected was previously
   stopping click propagation, so a re-click on the row never
   reached the toggle. Remove `e.stopPropagation()` and instead let
   the row's onClick ignore clicks whose target IS the input
   (typing / focusing the input still doesn't deselect; clicks on
   the icon, padding, or border do).

3. Tests: drive the Other re-click via Playwright `click({position:
   {x:24,y:24}})` so the click lands on the icon area instead of the
   center of the input, matching real-user behaviour.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(onboarding): source picker is single-select primary source

Per Jiayuan's call after the survey of HDYHAU UX in PLG SaaS (Linear /
Vercel / Loom / Notion / Webflow / Stripe / Figma / Cursor / PostHog
mostly skip the question entirely; where it's asked the documented
default — Fairing / Recast / HockeyStack / Ruler Analytics — is to
capture the primary source so channel weights sum to 100% and ROI
math is defensible).

Modal + StepSource both pivot from multi-select to single-select
radio. Server schema is intentionally untouched: `source` stays
`string[]` for back-compat with v2 multi-select rows; the client
always sends a one-element array. Zero migration, zero data loss.

Frontend:
- `source-backfill-modal.tsx`: state pivots from a multi-element
  `source: Source[]` to a single `pickedSlug` derived from
  `source[0]`; click handler replaces the array instead of toggling.
  Cards switch to `mode="radio"`, the fieldset gets `role="radiogroup"`,
  the now-redundant `pendingOther` and `allowToggleOff` opt-in go
  away — radio mode means no toggle-off, so the original UAT bug
  ("Other can't be deselected") is structurally impossible.
- `step-source.tsx`: drop the `multiSelect` prop so it routes
  through `step-question.tsx`'s existing radio path (same one
  StepRole already uses). Picking a second option replaces the
  first; switching away from Other clears `source_other` so a stale
  value can't leak.
- `icon-option-card.tsx`: revert the `allowToggleOff` plumbing.

Tests:
- `source-backfill-modal.test.tsx`: drop the multi-select toggle-off
  assertion; add "picking a second option replaces the first" with
  explicit radio-role queries.
- `step-source.test.tsx`: rewrite multi-select tests as single-select
  (no more "stacks several picks" / "toggle off" cases); add
  "switching away from Other clears source_other".

Full suite (970 tests) green, typecheck + lint clean.

Co-authored-by: multica-agent <github@multica.ai>

* docs(onboarding): refresh stale multi-select comments around source

Comment-only follow-up to the single-select refactor in d14f9d09f.
Five docblocks still described `source` as multi-select; they now
correctly say single-select and explain the array shape is kept
purely for v2 back-compat with the JSONB column.

- packages/core/onboarding/types.ts — QuestionnaireAnswers docblock
- packages/core/onboarding/store.ts — PostHog mirror comment
- packages/views/onboarding/steps/step-question.tsx — header docblock,
  canContinue branch, and footer-hint comment (Source moves from the
  multi-select side to the single-select side; Use case stays as the
  remaining multi-select consumer)
- server/internal/handler/onboarding.go — questionnaireAnswers docblock
  and the stringOrSlice fall-back comment (the column "going multi-
  select" is no longer the current state; rename to "pre-array shape")
- server/internal/analytics/events.go — OnboardingQuestionnaireSubmitted
  docblock

No behaviour changes. Tests + Go build still green.

Co-authored-by: multica-agent <github@multica.ai>

* i18n(onboarding): add ja translations for source-backfill keys

The Japanese locale landed on main (PR #3538) after this branch
started, so my source-backfill round-2 keys (`common.close`,
`source_backfill.eyebrow / lede / submit / hint_ready`) never made
it into ja and the parity test fails in CI. Add them now with
translations that match the en/zh-Hans/ko wording and tone.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 10:07:36 +02:00

683 lines
23 KiB
Go

package analytics
import "strings"
// Event names. Keep in sync with docs/analytics.md.
const (
EventSignup = "signup"
EventWorkspaceCreated = "workspace_created"
EventRuntimeRegistered = "runtime_registered"
EventRuntimeReady = "runtime_ready"
EventRuntimeFailed = "runtime_failed"
EventRuntimeOffline = "runtime_offline"
EventIssueExecuted = "issue_executed"
EventIssueCreated = "issue_created"
EventChatMessageSent = "chat_message_sent"
EventAgentTaskQueued = "agent_task_queued"
EventAgentTaskDispatched = "agent_task_dispatched"
EventAgentTaskStarted = "agent_task_started"
EventAgentTaskCompleted = "agent_task_completed"
EventAgentTaskFailed = "agent_task_failed"
EventAgentTaskCancelled = "agent_task_cancelled"
EventAutopilotRunStarted = "autopilot_run_started"
EventAutopilotRunCompleted = "autopilot_run_completed"
EventAutopilotRunFailed = "autopilot_run_failed"
EventTeamInviteSent = "team_invite_sent"
EventTeamInviteAccepted = "team_invite_accepted"
EventOnboardingStarted = "onboarding_started"
EventOnboardingQuestionnaireSubmit = "onboarding_questionnaire_submitted"
EventAgentCreated = "agent_created"
EventOnboardingCompleted = "onboarding_completed"
EventCloudWaitlistJoined = "cloud_waitlist_joined"
EventFeedbackSubmitted = "feedback_submitted"
EventContactSalesSubmitted = "contact_sales_submitted"
)
const EventSchemaVersion = 2
const (
SourceOnboarding = "onboarding"
SourceManual = "manual"
SourceChat = "chat"
SourceAutopilot = "autopilot"
SourceAPI = "api"
)
// CoreProperties are the shared join and segmentation fields used by the
// canonical PostHog events. Empty values are omitted, except is_demo which is
// always stamped so dashboards can filter demo data without sparse-property
// edge cases.
type CoreProperties struct {
UserID string
WorkspaceID string
AgentID string
TaskID string
IssueID string
ChatSessionID string
AutopilotRunID string
Source string
RuntimeMode string
Provider string
IsDemo bool
}
type TaskContext = CoreProperties
// 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
OnboardingPathInviteAccept = "invite_accept" // accepted at least one invitation from /invitations
OnboardingPathUnknown = "unknown" // fallback when the server can't derive the path
)
// 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,
Properties: withCoreProperties(nil, CoreProperties{
UserID: userID,
WorkspaceID: workspaceID,
Source: SourceManual,
}),
}
}
// 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, daemonID, 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: withCoreProperties(map[string]any{
"runtime_id": runtimeID,
"daemon_id": daemonID,
"provider": provider,
"runtime_mode": "local",
"runtime_version": runtimeVersion,
"cli_version": cliVersion,
}, CoreProperties{
UserID: ownerID,
WorkspaceID: workspaceID,
Source: SourceManual,
RuntimeMode: "local",
Provider: provider,
}),
}
}
func RuntimeReady(ownerID, workspaceID, runtimeID, daemonID, provider string, readyDurationMS int64) Event {
distinct := ownerID
if distinct == "" {
distinct = "workspace:" + workspaceID
}
props := map[string]any{
"runtime_id": runtimeID,
"daemon_id": daemonID,
}
if readyDurationMS > 0 {
props["ready_duration_ms"] = readyDurationMS
}
return Event{
Name: EventRuntimeReady,
DistinctID: distinct,
WorkspaceID: workspaceID,
Properties: withCoreProperties(props, CoreProperties{
UserID: ownerID,
WorkspaceID: workspaceID,
Source: SourceManual,
RuntimeMode: "local",
Provider: provider,
}),
}
}
func RuntimeFailed(ownerID, workspaceID, daemonID, provider, failureReason, errorType string, recoverable bool) Event {
distinct := ownerID
if distinct == "" && workspaceID != "" {
distinct = "workspace:" + workspaceID
}
return Event{
Name: EventRuntimeFailed,
DistinctID: distinct,
WorkspaceID: workspaceID,
Properties: withCoreProperties(map[string]any{
"daemon_id": daemonID,
"failure_reason": failureReason,
"error_type": errorType,
"recoverable": recoverable,
}, CoreProperties{
UserID: ownerID,
WorkspaceID: workspaceID,
Source: SourceManual,
RuntimeMode: "local",
Provider: provider,
}),
}
}
func RuntimeOffline(ownerID, workspaceID, runtimeID, daemonID, provider string) Event {
distinct := ownerID
if distinct == "" {
distinct = "workspace:" + workspaceID
}
return Event{
Name: EventRuntimeOffline,
DistinctID: distinct,
WorkspaceID: workspaceID,
Properties: withCoreProperties(map[string]any{
"runtime_id": runtimeID,
"daemon_id": daemonID,
}, CoreProperties{
UserID: ownerID,
WorkspaceID: workspaceID,
Source: SourceManual,
RuntimeMode: "local",
Provider: provider,
}),
}
}
// 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, taskID, agentID, source, runtimeMode, provider string, taskDurationMS int64) Event {
return Event{
Name: EventIssueExecuted,
DistinctID: actorID,
WorkspaceID: workspaceID,
Properties: withCoreProperties(map[string]any{
"issue_id": issueID,
"task_id": taskID,
"agent_id": agentID,
"task_duration_ms": taskDurationMS,
"duration_ms": taskDurationMS,
}, CoreProperties{
UserID: nonAgentUserID(actorID),
WorkspaceID: workspaceID,
AgentID: agentID,
TaskID: taskID,
IssueID: issueID,
Source: source,
RuntimeMode: runtimeMode,
Provider: provider,
}),
}
}
func IssueCreated(actorID, workspaceID, issueID, agentID, taskID, autopilotRunID, source string) Event {
return Event{
Name: EventIssueCreated,
DistinctID: actorID,
WorkspaceID: workspaceID,
Properties: withCoreProperties(nil, CoreProperties{
UserID: nonAgentUserID(actorID),
WorkspaceID: workspaceID,
AgentID: agentID,
TaskID: taskID,
IssueID: issueID,
AutopilotRunID: autopilotRunID,
Source: source,
}),
}
}
func ChatMessageSent(userID, workspaceID, chatSessionID, taskID, agentID, runtimeMode, provider string) Event {
return Event{
Name: EventChatMessageSent,
DistinctID: userID,
WorkspaceID: workspaceID,
Properties: withCoreProperties(nil, CoreProperties{
UserID: userID,
WorkspaceID: workspaceID,
AgentID: agentID,
TaskID: taskID,
ChatSessionID: chatSessionID,
Source: SourceChat,
RuntimeMode: runtimeMode,
Provider: provider,
}),
}
}
func AgentTaskQueued(ctx TaskContext) Event {
return agentTaskEvent(EventAgentTaskQueued, ctx, nil)
}
func AgentTaskDispatched(ctx TaskContext) Event {
return agentTaskEvent(EventAgentTaskDispatched, ctx, nil)
}
func AgentTaskStarted(ctx TaskContext) Event {
return agentTaskEvent(EventAgentTaskStarted, ctx, nil)
}
func AgentTaskCompleted(ctx TaskContext, durationMS int64) Event {
return agentTaskEvent(EventAgentTaskCompleted, ctx, map[string]any{
"duration_ms": durationMS,
})
}
func AgentTaskFailed(ctx TaskContext, durationMS int64, failureReason, errorType string, willRetry bool) Event {
return agentTaskEvent(EventAgentTaskFailed, ctx, map[string]any{
"duration_ms": durationMS,
"failure_reason": failureReason,
"error_type": errorType,
"will_retry": willRetry,
})
}
func AgentTaskCancelled(ctx TaskContext, durationMS int64) Event {
return agentTaskEvent(EventAgentTaskCancelled, ctx, map[string]any{
"duration_ms": durationMS,
})
}
// AutopilotAssignee describes the autopilot's configured target. agent_id is
// always the agent that will actually execute the work (the squad leader for
// squad autopilots) so funnels grouping by agent stay consistent. assignee_*
// fields record the original configuration so reports can tell a solo-agent
// autopilot apart from a squad one without joining back to the autopilot row.
type AutopilotAssignee struct {
AgentID string // executing agent — leader for squad autopilots
AssigneeType string // "agent" or "squad"
SquadID string // empty when AssigneeType != "squad"
}
func AutopilotRunStarted(actorID, workspaceID, autopilotID, runID string, assignee AutopilotAssignee, triggerSource string) Event {
return autopilotRunEvent(EventAutopilotRunStarted, actorID, workspaceID, autopilotID, runID, assignee, triggerSource, nil)
}
func AutopilotRunCompleted(actorID, workspaceID, autopilotID, runID string, assignee AutopilotAssignee, triggerSource string, durationMS int64) Event {
return autopilotRunEvent(EventAutopilotRunCompleted, actorID, workspaceID, autopilotID, runID, assignee, triggerSource, map[string]any{
"duration_ms": durationMS,
})
}
func AutopilotRunFailed(actorID, workspaceID, autopilotID, runID string, assignee AutopilotAssignee, triggerSource, failureReason, errorType string, willRetry bool, durationMS int64) Event {
return autopilotRunEvent(EventAutopilotRunFailed, actorID, workspaceID, autopilotID, runID, assignee, triggerSource, map[string]any{
"duration_ms": durationMS,
"failure_reason": failureReason,
"error_type": errorType,
"will_retry": willRetry,
})
}
// 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 "at least one slot
// unresolved" to "every slot has either an answer or a skip marker".
// The handler drives this transition — we emit from PatchOnboarding so
// the single emission site stays honest even if the frontend retries.
//
// `useCase` is multi-select (users can pick several); `source` is
// single-select (primary acquisition channel) but kept as a slice
// for back-compat with v2 multi-select rows — single-element in
// current data. `role` stays single-select. Empty slice = no answer
// (skip is captured separately via the *Skipped booleans).
//
// The three answers are also mirrored into person properties via $set
// so cohorting by source / role / use_case works across every event
// on the same user without re-joining back to the DB. PostHog accepts
// array property values; breakdowns on a multi-value property treat
// each element as a separate group.
//
// `*Skipped` booleans capture per-question skip intent. `*HasOther`
// are presence booleans for the free-text "other" override; 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 string, source []string, role string, useCase []string, sourceSkipped, roleSkipped, useCaseSkipped, sourceHasOther, roleHasOther, useCaseHasOther bool) Event {
// Normalize nil slices to [] so PostHog property values are stable
// (avoids null vs [] mixing in property type inference).
if source == nil {
source = []string{}
}
if useCase == nil {
useCase = []string{}
}
return Event{
Name: EventOnboardingQuestionnaireSubmit,
DistinctID: userID,
Properties: withCoreProperties(map[string]any{
"source": source,
"role": role,
"use_case": useCase,
"source_skipped": sourceSkipped,
"role_skipped": roleSkipped,
"use_case_skipped": useCaseSkipped,
"source_has_other": sourceHasOther,
"role_has_other": roleHasOther,
"use_case_has_other": useCaseHasOther,
}, CoreProperties{
UserID: userID,
Source: SourceOnboarding,
}),
Set: map[string]any{
"source": source,
"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, runtimeMode, template string, isFirstAgentInWorkspace bool) Event {
return Event{
Name: EventAgentCreated,
DistinctID: actorID,
WorkspaceID: workspaceID,
Properties: withCoreProperties(map[string]any{
"agent_id": agentID,
"provider": provider,
"runtime_mode": runtimeMode,
"template": template,
"is_first_agent_in_workspace": isFirstAgentInWorkspace,
}, CoreProperties{
UserID: actorID,
WorkspaceID: workspaceID,
AgentID: agentID,
Source: SourceManual,
RuntimeMode: runtimeMode,
Provider: provider,
}),
}
}
// 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, workspaceID, completionPath, onboardedAt string, joinedCloudWaitlist bool) Event {
return Event{
Name: EventOnboardingCompleted,
DistinctID: userID,
WorkspaceID: workspaceID,
Properties: withCoreProperties(map[string]any{
"completion_path": completionPath,
"joined_cloud_waitlist": joinedCloudWaitlist,
}, CoreProperties{
UserID: userID,
WorkspaceID: workspaceID,
Source: SourceOnboarding,
}),
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: withCoreProperties(map[string]any{
"has_reason": hasReason,
}, CoreProperties{
UserID: userID,
Source: SourceOnboarding,
}),
}
}
// 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: withCoreProperties(props, CoreProperties{
UserID: userID,
WorkspaceID: workspaceID,
Source: "ops_feedback",
}),
}
}
// ContactSalesSubmitted fires after a contact-sales inquiry is recorded.
// The form is public and unauthenticated, so DistinctID is empty (PostHog
// will treat it as an anonymous event). We carry the coarse company size,
// country, and intended use case so sales / marketing can split inbound
// volume without having to query the operational DB.
func ContactSalesSubmitted(inquiryID, companySize, countryRegion, useCase string, hasGoals bool) Event {
props := map[string]any{
"inquiry_id": inquiryID,
"company_size": companySize,
"country_region": countryRegion,
"use_case": useCase,
"has_goals": hasGoals,
}
return Event{
Name: EventContactSalesSubmitted,
DistinctID: inquiryID,
Properties: withCoreProperties(props, CoreProperties{
Source: "marketing_contact_sales",
}),
}
}
func agentTaskEvent(name string, ctx TaskContext, extra map[string]any) Event {
props := withCoreProperties(extra, CoreProperties(ctx))
return Event{
Name: name,
DistinctID: distinctID(ctx.UserID, ctx.WorkspaceID, ctx.AgentID),
WorkspaceID: ctx.WorkspaceID,
Properties: props,
}
}
func autopilotRunEvent(name, actorID, workspaceID, autopilotID, runID string, assignee AutopilotAssignee, triggerSource string, extra map[string]any) Event {
if extra == nil {
extra = map[string]any{}
}
extra["trigger_source"] = triggerSource
props := withCoreProperties(extra, CoreProperties{
UserID: nonAgentUserID(actorID),
WorkspaceID: workspaceID,
AgentID: assignee.AgentID,
AutopilotRunID: runID,
Source: SourceAutopilot,
})
props["autopilot_id"] = autopilotID
if assignee.AssigneeType != "" {
props["assignee_type"] = assignee.AssigneeType
}
if assignee.SquadID != "" {
props["squad_id"] = assignee.SquadID
}
return Event{
Name: name,
DistinctID: actorID,
WorkspaceID: workspaceID,
Properties: props,
}
}
func withCoreProperties(props map[string]any, core CoreProperties) map[string]any {
if props == nil {
props = map[string]any{}
}
if core.UserID != "" {
props["user_id"] = core.UserID
}
if core.AgentID != "" {
props["agent_id"] = core.AgentID
}
if core.TaskID != "" {
props["task_id"] = core.TaskID
}
if core.IssueID != "" {
props["issue_id"] = core.IssueID
}
if core.ChatSessionID != "" {
props["chat_session_id"] = core.ChatSessionID
}
if core.AutopilotRunID != "" {
props["autopilot_run_id"] = core.AutopilotRunID
}
if core.Source != "" {
props["source"] = core.Source
}
if core.RuntimeMode != "" {
props["runtime_mode"] = core.RuntimeMode
}
if core.Provider != "" {
props["provider"] = core.Provider
}
props["is_demo"] = core.IsDemo
return props
}
func distinctID(userID, workspaceID, agentID string) string {
if userID != "" {
return userID
}
// Synthetic PostHog distinct IDs are namespace-prefixed; user UUIDs are not.
if agentID != "" {
return "agent:" + agentID
}
if workspaceID != "" {
return "workspace:" + workspaceID
}
return ""
}
func nonAgentUserID(distinct string) string {
if distinct == "" || strings.Contains(distinct, ":") {
return ""
}
return distinct
}
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:])
}