11 Commits

Author SHA1 Message Date
Bohan Jiang
2e50df9a6a perf(analytics): report $pageview at section granularity, drop web query-string churn (#3813)
capturePageview now section-normalizes the path (strip query/hash, collapse
UUID and issue-key resource segments) and dedupes consecutive same-section
views, so navigating between issues/agents/etc. no longer fires a billed
PostHog event per resource. The web tracker keys on pathname only (not
searchParams), removing ~17% pure query-string-churn pageviews and keeping
OAuth code/state out of $current_url.

MUL-3081

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:33:32 +08:00
Bohan Jiang
b92c3fbc93 chore(analytics): stop shipping operational events to PostHog (MUL-2967) (#3720)
* chore(analytics): stop shipping operational events to PostHog (MUL-2967)

Operational / execution-lifecycle telemetry dominated PostHog event volume
and drove the bill: runtime_offline alone was ~54% of ~22.6M events/mo, and
~99% of events were billed at the higher identified-event rate. These signals
already have Prometheus counters (Grafana), so the PostHog copies were
redundant cost.

- Add analytics.IsMetricsOnly; metrics.RecordEvent now skips the PostHog
  Capture for runtime_* and autopilot_run_* while still incrementing their
  Prometheus counter (their analytics.Event constructors are retained to feed
  the metric label set via IncForEvent).
- Remove the agent_task_* PostHog path entirely: drop captureTaskEvent and the
  AgentTask* constructors/constants. Their Prometheus side is unchanged via the
  typed BusinessMetrics.RecordTask* methods. Also remove the now-dead
  taskDurationMS / willRetryTask helpers.
- Update the pairing lint test (no agent_task allow-list, no naked-Capture
  exception), add a RecordEvent skip test + IsMetricsOnly test, and update
  docs/analytics.md (taxonomy, per-event banners, reconciliation).

Product/funnel events (signup, onboarding, issue_created, issue_executed,
chat_message_sent, agent_created, autopilot_created, etc.) are unchanged and
still ship to PostHog.

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

* docs(analytics): correct agent_task Prometheus metric contract (MUL-2967)

Address PR review: the agent_task_* "Prometheus-only" banner claimed the old
PostHog event properties (task_id, agent_id, duration_ms, error_type,
will_retry, ...) were the metric label set. They are not — the real labels are
only source/runtime_mode/provider/terminal_status/failure_reason.

- Replace the agent_task_* sections with the actual metric names and labels
  (multica_agent_task_*; see business.go / labels.go), and explain that
  completed/failed/cancelled are terminal_status values on
  multica_agent_task_terminal_total, with wall-clock in the *_seconds
  histograms.
- Tighten the runtime_*/autopilot_run_* banners so id properties aren't
  mistaken for labels.
- Drop the stale AgentTask allow-list reference from the pairing lint test
  header comment.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 00:48:17 +08:00
Bohan Jiang
7984606eed feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493) (#2988)
* feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493)

Adds a public `/contact-sales` marketing page with a needs-discovery form
modelled on the design reference attached to MUL-2493 — first/last name,
business email (with free-provider rejection), company name + size,
country/region, intended use case, and a free-text goals field, plus the
two consent checkboxes from the reference.

Submissions hit a new public `POST /api/contact-sales` endpoint with
per-IP rate limiting (Redis-backed via the existing RateLimit middleware,
configurable through `RATE_LIMIT_CONTACT_SALES`) and a per-email hourly
cap so a single business address can't be used as a flood channel after
one valid pass. The inquiry is stored in a new `contact_sales_inquiry`
table; analytics fires a `contact_sales_submitted` PostHog event with
only the closed-enum dimensions (size, country, use case) — the free-text
goals stay in the DB and are never broadcast.

The page is linked from the landing header (md+) and the footer's Company
column, in both English and Simplified Chinese. The reserved-slug list is
updated so a workspace named `contact-sales` can't shadow the route.

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

* fix(landing): canonicalize business email and tighten contact-sales form (MUL-2493)

- Parse the submitted email with net/mail and run the free-email
  block-list against the canonical addr.Address, so a display-name
  form like `Ada <ada@gmail.com>` can no longer slip past the gate
  (the raw string had domain `gmail.com>`, which wasn't blocked).
  Adds regression tests covering the display-name bypass and the
  canonicalization helper.
- Drop noValidate from the contact-sales form so the browser's
  native required / email / select checks fire before submit;
  the JS-side free-email warning still runs as a UX guard.
- Update success copy ("respond within three business days") in
  EN and ZH plus the page metadata.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:22:36 +08:00
Jiayuan Zhang
591e47842d refactor(onboarding): remove starter-content kit; unify install-runtime issue across mark-onboarded paths (MUL-2438) (#2884)
* refactor(onboarding): remove starter-content kit, unify install-runtime issue across mark-onboarded paths (MUL-2438)

Drops the post-onboarding ImportStarterContent / DismissStarterContent
flow (handler + routes + StarterContentPrompt + templates + locale
strings + analytics event). The bug — web onboarding seeding 6+ starter
issues without a runtime — only existed through that path; with it gone
the source disappears.

The "install a runtime" issue from BootstrapOnboardingNoRuntime is now
the canonical no-runtime onboarding seed. The title/description and a
LockAndFindActiveDuplicate-deduped seeder move to
handler/no_runtime_issue.go, and CompleteOnboarding / CreateWorkspace /
AcceptInvitation seed it whenever the workspace has no runtime yet, so
every mark-onboarded entry point lands the user on a concrete next
step.

starter_content_state column is kept and continues to be claimed as
'imported' in all five entry points so older desktop builds (which
still render the legacy dialog on NULL) don't surface it to accounts
created after this change.

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

* fix(onboarding): backfill starter_content_state for in-window NULL users (MUL-2438)

054 only covered pre-feature users. Anyone onboarded between then and the
starter-content kit removal could still sit at NULL, and old desktop
clients gate the legacy StarterContentPrompt on `starter_content_state
IS NULL`. The import/dismiss routes are gone, so leaving these rows NULL
would surface a dialog whose buttons 404. Mark them 'imported' to match
the new helper's claim semantics.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 18:37:48 +02:00
Multica Eve
46eed3b298 Add task dispatched analytics event (#2310)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 14:11:20 +08:00
Multica Eve
ce00e05169 Add canonical PostHog core metrics events (#2302)
* Add canonical PostHog core metrics events

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

* Address analytics review feedback

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

* Tighten analytics review follow-ups

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 13:12:00 +08:00
Naiyuan Qing
6c72c71e3e feat(analytics): add onboarding_runtime_detected event on desktop Step 3 (#1553)
Answers "did the user have an AI CLI installed locally when they hit
Step 3" — currently unanswerable from the existing funnel because the
bundled daemon fails to register at all when zero CLIs are on PATH, so
`runtime_registered` is silent on that cohort. Splits the 40% of
`completion_path=runtime_skipped` into "had CLIs, skipped anyway" vs "no
CLIs available, had no choice" — the two cases need opposite product
fixes.

Fires once per Step 3 mount in `step-runtime-connect.tsx` (desktop
only), when the scanning phase resolves — either immediately on first
runtime registration or after the 5 s empty timeout. Reports
`runtime_count`, `online_count`, sorted `providers`, convenience
booleans (`has_claude` / `has_codex` / `has_cursor`), and `detect_ms`.
Also writes `has_any_cli` + `detected_cli_count` via `$set` as cohort
signals.

Not emitted from the web Step 3 (`step-platform-fork.tsx`) — web users
don't run the bundled daemon, so their runtime list can reflect
daemons on other machines and would corrupt the
"CLI installed locally" signal.

Refs MUL-1250.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:56:55 +08:00
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
Naiyuan Qing
5335edd50d feat(web): /download page + desktop promotion across landing, login, onboarding (#1500)
* docs(download): add redesign plan and copy positioning source of truth

Captures motivation (Desktop is Multica's native form; CLI is a
distinct scenario for servers/remote boxes, not a Desktop fallback),
four-step execution plan, and every touchpoint's current-vs-new
copy in EN + ZH. Subsequent UI steps read strings from the
positioning doc instead of inventing them inline.

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

* feat(web): /download page with OS auto-detection

New landing-group route that serves as the single canonical download
destination. Auto-detects OS + arch via navigator.userAgentData
(Chromium) with UA-string fallback, then surfaces the matching
Desktop installer as the primary CTA. All platforms stay visible
below, plus a CLI section (positioned for servers / remote boxes /
headless setups, not as a lightweight Desktop) and a Cloud waitlist.

Version + asset URLs come from api.github.com/repos/.../releases/latest
with Vercel ISR (revalidate=300) so every release automatically
propagates — no manual redeploy. Optional GITHUB_TOKEN env var lifts
the 60/hr unauthenticated rate limit for local dev. Failure
degrades cleanly to "Version unavailable" + a link to GitHub
releases.

Also points landing hero + footer Download links at /download
(previously pointed at the GitHub releases page directly), and
re-exports CloudWaitlistExpand from @multica/views/onboarding so
the new Cloud section can reuse the existing form.

Intel Mac has no binary today (electron-builder targets mac arm64
only); the page is honest about it and routes Intel users to CLI.

i18n copy sourced verbatim from docs/download-positioning.md.

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

* feat(onboarding): rewrite Step 3 fork + web Welcome Desktop CTA

Welcome screen now self-segments: on web (runtimeInstructions
present), the primary CTA is "Download Desktop" with a benefit-led
subtitle ("Desktop bundles the runtime — nothing to install.
Continue on web to connect your own CLI.") that lets developers
with their own CLI recognize their path while guiding everyone
else toward the desktop app. Desktop branch drops the "3 minutes"
estimate in favor of the aha promise. Download button is a real
<a href> link so middle-click / copy-link / screen readers all
behave correctly.

Step 3 fork drops the stale isMac gate — Windows / Linux binaries
now ship, the macOS-only muted card was a lie. The single Desktop
card now routes to /download (not GitHub releases directly) so
users land on the auto-detect page. CLI card is reframed around
its real scenario (servers, remote dev boxes, headless) rather
than posing as a lightweight Desktop, and the CLI dialog's stall
tier redirects users to Desktop instead of Cloud waitlist when
the daemon never registers — Desktop is the genuine retreat.

cli-install-instructions gets a one-liner acknowledging the CLI's
server use case, mirroring the card copy.

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

* feat(web,auth): desktop promotion on login + solid landing hero download

LoginPage accepts a new `extra?: ReactNode` slot rendered below the
Google button. The web shell injects a hardcoded-EN "Prefer the
desktop app? Download →" nudge there — catching users at their
lowest-investment moment, before they've typed an email. Desktop's
login wrapper omits the slot (a download prompt inside the app
would be absurd), so only the web surface renders it.

Copy is English-only for now because the /login route sits outside
the landing group's LocaleProvider. Lifting locale detection into
the root layout would force every page dynamic and kill the Router
Cache — a trade-off not worth two strings. The `auth.login.extra*`
i18n keys added during Step 2 are removed for the same reason:
they're dead code without a LocaleProvider wrapping login.

Landing hero "Download Desktop" upgrades from ghost to solid and
swaps its handwritten monitor SVG for lucide-react's Download
icon. Both hero CTAs are now solid-weighted — the icon + distinct
label differentiates them. href already points to /download from
the Step 2 landing nav pass.

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

* fix(web/download): anchor dark LandingHeader with relative wrapper

LandingHeader's dark variant uses `absolute top-0 inset-x-0`, which
only reads correctly when wrapped by a positioned ancestor — see
multica-landing.tsx:14 for the canonical pattern. Without the
wrapper the header escaped to the initial containing block and
appeared fixed as users scrolled the page.

Also drops the <main> element around the body sections for
consistency with the rest of the landing group (neither
multica-landing nor about-page-client wraps in <main>).

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

* fix(landing/hero): keep Download Desktop as ghost to preserve CTA hierarchy

Upgrading to solid alongside the existing "Start free trial" CTA
killed the primary / secondary distinction — both buttons were
white on dark, competing for attention. Revert to ghost so the
conversion CTA (trial) stays the visual primary. The lucide
Download icon swap stays (cleaner than the handwritten monitor
SVG).

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

* test(onboarding): update platform-fork assertions for /download route

The Desktop card in Step 3 now opens the new /download page instead
of GitHub releases, and the post-click feedback text changed to
match ("Continuing on the download page…" in place of "Downloading
Multica…"). Update the expectations and drop the isMac navigator
stub that was only needed when the component had a macOS-only
primary branch.

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

* Merge origin/main into NevilleQingNY/download-redesign

Main added onboarding funnel analytics (#1489) that captures
`is_mac` as a dimension for each Step 3 path selection. This
branch had removed the `isMac` state because the UI no longer
branches on it (Windows / Linux desktop builds ship now). Git
auto-merged the two diffs into a file that referenced a deleted
variable.

Reintroduce `isMac` as a lazy client-only computation scoped to
analytics capture only — the UI stays platform-agnostic. Handlers
fire client-side so SSR safety isn't needed; a plain const reads
navigator on first render.

typecheck passes across all 6 packages; all 166 views tests
green.

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

* feat(analytics): instrument download funnel across 5 surfaces + /download

Closes the gap left by PR #1489: onboarding analytics captured Step
3 path selection but missed the four surfaces that advertise the
desktop app earlier in the funnel (landing hero, landing footer,
login, Welcome), and the /download page itself had zero coverage —
so we could see the last-mile path but not the top-of-funnel entry
nor the page-to-installer conversion.

Three new events, wired via `@multica/core/analytics`:

1. `download_intent_expressed` fires on any CTA pointing at
   /download. `source` splits the five surfaces cleanly; every
   authenticated emission also writes `platform_preference=desktop`
   on the person (same convention Step 3 already uses).

2. `download_page_viewed` fires once per /download mount after OS
   detect resolves. Carries `detected_os`, `detected_arch`,
   `detect_confident` (Chromium userAgentData vs UA fallback), and
   `version_available` so the Safari-on-Mac arm64-default cohort
   and GitHub-rate-limited degraded sessions are each isolable.
   Also $set_once's `first_detected_os/arch` on the person so every
   downstream event gains a platform dimension without re-emitting.

3. `download_initiated` fires on every installer click — Hero's
   primary CTA and each All Platforms matrix row. `primary_cta`
   splits hero-recommended from manual picks; `matched_detect`
   quantifies detect accuracy from the single event (no cross-join
   to download_page_viewed needed).

Augments the existing `onboarding_runtime_path_selected` with a
`source: "step3"` property — literal today, reserved for future
surfaces reusing the same event name. `is_mac` kept for
backward-compat with PR #1489's dashboards; the new events use
`detected_os` + `detected_arch` instead.

New `setPersonPropertiesOnce` wire helper in
`packages/core/analytics/download.ts` for `$set_once` — mirrors
the backend's `Event.SetOnce` semantics.

docs/analytics.md update lands in the follow-up commit.

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

* docs(analytics): document download_intent_expressed / page_viewed / initiated

Adds the three new download-funnel events to the frontend-only
section. Also notes the semantic shift on
onboarding_runtime_path_selected: its `path: "download_desktop"`
now signals Step 3 path choice, not actual download start —
download_intent_expressed is the new canonical "user expressed
intent to download desktop" signal across surfaces.

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-22 17:25:01 +08:00
Bohan Jiang
936df59fa1 feat(analytics): instrument onboarding funnel (MUL-1250) (#1489)
* feat(analytics): capture onboarding funnel events + person-property $set

Closes the visibility gap introduced by the Onboarding relaunch: the
five new steps between signup and workspace_created were invisible to
PostHog, and we couldn't see Step 3 web-fork drop-off, cloud waitlist
intent, or starter-content acceptance at all.

Server-side events (see docs/analytics.md for full contracts):
- onboarding_questionnaire_submitted — fires once when all three
  answers first land; also $set's role/use_case/team_size on the
  person so every subsequent event is cohortable
- agent_created — not onboarding-specific; is_first_agent_in_workspace
  isolates the Step 4 signal
- onboarding_completed — fires on the actual NULL → timestamp flip
  with completion_path (full / runtime_skipped / cloud_waitlist /
  skip_existing / unknown) + joined_cloud_waitlist
- cloud_waitlist_joined — sizes hosted-runtime interest
- starter_content_decided — imported vs dismissed, split by
  agent_guided / self_serve branch on both sides

Also adds Event.Set (→ PostHog $set) alongside the existing SetOnce so
the same events can carry mutable cohort signals without a separate
identify round-trip.

* feat(analytics): wire frontend onboarding events + completion_path

- captureEvent / setPersonProperties helpers in @multica/core/analytics,
  with the same pre-init buffering as identify/pageview so config races
  don't drop step transitions
- onboarding_runtime_path_selected fires from step-platform-fork for
  the three web-fork choices (download desktop / CLI / cloud waitlist),
  plus platform_preference on person properties for downstream splits
- completeOnboarding now takes an OnboardingCompletionPath; the
  onboarding shell derives full / runtime_skipped / cloud_waitlist
  from runtime + waitlist state (lifted to the shell so StepFirstIssue
  can see both), and handleWelcomeSkip passes skip_existing
- saveQuestionnaire mirrors team_size/role/use_case into person
  properties via $set so every event on this user becomes cohortable
- StepAgent sends the template slug, StarterContentPrompt passes
  workspace_id on dismiss so the server can mirror the branch label

* docs(analytics): document onboarding funnel events + $set person properties
2026-04-22 16:28:08 +08:00
devv-eve
637bdc8eb3 feat(analytics): full PostHog pipeline + 6 funnel events (MUL-1122) (#1367)
* feat(analytics): add PostHog client with async batch shipping

Introduces server/internal/analytics, the shipping layer for the product
funnel defined in docs/analytics.md. Capture is non-blocking — events are
enqueued into a bounded channel and a background worker batches them to
PostHog's /batch/ endpoint. A broken backend drops events rather than
blocking request handlers.

Local dev and self-hosted instances run a noop client until the operator
sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created
emission land in the follow-up commit so this change is independently
reviewable.

* feat(server): emit signup and workspace_created analytics events

Wires analytics.Client through handler.New and main, then emits the first
two funnel events:

- signup fires from findOrCreateUser (which now reports isNew), covering
  both the verification-code and Google OAuth entry points — a single
  emission site guarantees Google signups aren't missed.
- workspace_created fires after the CreateWorkspace transaction commits,
  with is_first_workspace computed from a post-commit ListWorkspaces count
  so we can distinguish fresh-user activation from returning-user
  expansion.

Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of
MUL-1122; runtime_registered and issue_executed follow in later PRs per
the plan.

* refactor(analytics): drop is_first_workspace from workspace_created

Stamping "is this the user's first workspace?" at emit time races under
concurrent CreateWorkspace requests: two transactions committing close
together can both read a post-commit count greater than one and both emit
false. Fixing it at the SQL layer requires a schema change we don't want in
PR 1.

PostHog answers the same question exactly from the event stream (funnel on
"first time user does X" / cohort on $initial_event), so removing the
property loses no information and makes the emit side race-free.

* docs(analytics): document self-host safety defaults

Spell out why self-hosted instances never ship events upstream by default
(empty POSTHOG_API_KEY → noop client) and explain how operators can point
at their own PostHog project without any code change.

* feat(analytics): emit runtime_registered, issue_executed, team_invite_*

Three server-side funnel events, all gated on first-time state transitions
so retries and re-runs don't inflate the WAW buckets:

- runtime_registered fires from DaemonRegister when UpsertAgentRuntime
  reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats
  and re-registrations stay silent.
- issue_executed fires from CompleteTask after an atomic
  UPDATE issue SET first_executed_at = now() WHERE id = $1 AND
  first_executed_at IS NULL flips the column for the first time. Retries,
  re-assignments, and comment-triggered follow-up tasks hit the WHERE
  clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10
  buckets filter without extra queries.
- team_invite_sent fires from CreateInvitation and team_invite_accepted
  from AcceptInvitation, closing the expansion funnel.

Adds a 050 migration for issue.first_executed_at plus a partial index so
the workspace-scoped executed-count query doesn't scan the never-executed
tail.

* feat(config): surface PostHog key via /api/config

Extends AppConfig with posthog_key / posthog_host sourced from env on
every request (so operators can rotate the key via secret refresh without
a restart). Reading the key off the server — rather than baking it into
the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances
inherit the blank key automatically and never ship events upstream.

* feat(analytics): wire posthog-js identify + UTM capture on the client

Adds @multica/core/analytics — a thin wrapper around posthog-js that owns
attribution capture and identity merge. Posthog-js config comes from
/api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server
returns an empty key automatically run the SDK inert.

captureSignupSource stamps a multica_signup_source cookie with UTM params
and the referrer's origin (never the full referrer — that can leak OAuth
code/state in the callback URL). The backend signup event reads this
cookie on new-user creation.

Identity flows:
- auth-initializer fires identify() right after getMe() resolves, on both
  cookie and token paths. A getConfig/getMe race is handled by buffering
  a pending identify inside the analytics module and flushing it once
  initAnalytics finishes.
- auth store calls identify() on verifyCode / loginWithGoogle /
  loginWithToken and resetAnalytics() on logout so the next login merges
  cleanly without bleeding events.

* docs(analytics): describe runtime_registered, issue_executed, invite events

Fills in the schema for the remaining funnel events. Captures the
design commentary that belongs next to the contract rather than in a PR
description — in particular why issue_executed uses the atomic
first_executed_at flip instead of counting task-terminal events, and why
runtime_registered relies on xmax = 0 rather than a query-then-write.

* fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed

Computing the workspace's Nth-issue ordinal at emit time is not atomic
under concurrent first-completions — two transactions can both run
MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and
both observe count=1 before either has committed, so both events go out
stamped as n=1. Serialising it would mean a per-workspace advisory lock
or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly
at query time via row_number() partitioned by workspace_id, so the
emit-time property adds risk without adding information.

Removes the property from analytics.IssueExecuted, deletes the unused
CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial
index stays — any future workspace-scoped executed-issue query will want
it.

* fix(analytics): wire $pageview and harden signup_source cookie payload

Two frontend fixes from the PR review:

- PageviewTracker, mounted under WebProviders, fires capturePageview on
  every Next.js App Router path / query-string change. Without this the
  capturePageview helper in @multica/core/analytics was never called and
  the acquisition funnel's / → signup step was empty.
- captureSignupSource now caps each UTM / referrer value at 96 chars
  *before* JSON.stringify, and drops the whole cookie when the serialised
  payload still exceeds 512 chars. Previously the overall slice(0, 256)
  could leave a half-JSON string on the wire that neither the backend nor
  PostHog could parse.

Both capturePageview and identify now buffer a single pending call when
fired before initAnalytics resolves — otherwise the initial "/" pageview
and same-turn login identify race the /api/config fetch and get dropped.
resetAnalytics clears both buffers so a logout→login cycle stays clean.

* fix(analytics): URL-decode signup_source cookie on read

Go does not URL-decode Cookie.Value automatically, so the frontend's
JSON-then-encodeURIComponent payload was landing in PostHog as
percent-encoded garbage (%7B%22utm_source...). Unescape on read so the
backend receives the original JSON string the frontend intended, and
drop values that fail to decode or exceed the server-side cap — sending
truncated garbage is worse than sending nothing. Oversized-cookie guard
matches the frontend's SIGNUP_SOURCE_MAX_LEN.

* docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding

Pulls the schema doc back in line with the code: issue_executed no longer
advertises nth_issue_for_workspace (with a note about why PostHog derives
it at query time instead), the frontend $pageview section names the
actual PageviewTracker component that fires it, and the signup_source
section documents the per-value cap / overall drop rule and the
encode-on-write / decode-on-read contract.

---------

Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
2026-04-21 14:42:52 +08:00