mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 22:09:44 +02:00
* feat(server): funnel/community/commercial business metrics + PostHog pairing (MUL-2949) PR3 of the Grafana board metrics split (parent MUL-2328). Adds 23 new Prometheus counter/histogram families to the PR2 BusinessMetrics collector covering the activation/community/commercial funnels, and binds every PostHog event emission to a matching metric increment so the two sides cannot drift. Funnel: signup, workspace_created, team_invite_sent/accepted, onboarding_*, cloud_waitlist_joined. Content: issue_created, chat_message_sent, agent_created, squad_created, autopilot_created, issue_executed. Runtime: runtime_registered/ready/failed/offline + ready_seconds histogram, daemon_ws_message_received_total. Autopilot: autopilot_run_started/terminal/skipped. Webhook/GitHub: webhook_delivery_total, github_event_received_total, github_pr_review_total, github_pr_merge_seconds histogram. CloudRuntime: cloudruntime_request_total + duration histogram, wired through a small RequestRecorder interface so the cloudruntime package stays decoupled from metrics. Commercial: feedback_submitted, contact_sales_submitted. The pairing helper metrics.RecordEvent(client, m, ev) emits the PostHog event AND increments the matching counter via IncForEvent dispatch, reading labels from the analytics event Properties. Every existing h.Analytics.Capture(analytics.X(...)) call site has been migrated to the helper across handler/, service/, and cmd/server/runtime_sweeper.go. Lint enforcement (server/internal/metrics/business_pairing_test.go): - TestEveryAnalyticsEventHasPrometheusCounter: every Event* constant in analytics/events.go either dispatches via IncForEvent or is in the taskMetricEvents allow-list (PR2 typed RecordTask* methods). - TestNoNakedAnalyticsCaptureInHandlersOrServices: AST-walks handler/ service/cmd-server for direct Analytics.Capture(...) calls — only service/task.go's captureTaskEvent helper is allow-listed. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper: validates the third arg of every metrics.RecordEvent call is built from analytics.*. Cardinality protection: all new label values pass through fixed allow-lists in labels_pr3.go; unknown values collapse to 'other'/'unknown'/'error'. Refs: - Spec MUL-2328 / MUL-2949. - Builds on PR2 (MUL-2948) — collectors registered through the same BusinessMetrics struct, no separate Registry. - Uses PR1's taskfailure.Reason (MUL-2946) for runtime_failed's failure_reason label via NormalizeFailureReason. Out of scope: Sampler-class metrics (PR4 / MUL-2947), pr_review_total emission point (no review event handler exists yet — counter is defined, TODO to wire up when /api/webhooks/github grows pull_request_review handling). Co-authored-by: multica-agent <github@multica.ai> * fix(server): tighten PR3 review items — signup_source bucket, fill platform/kind/form_source enums, onboarding_started server emission, lint scope (MUL-2949) Addresses 张大彪's review on #3698: 1. signup_source: NormalizeSignupSource added to labels_pr3.go with a fixed allow-list bucket (direct/google/twitter/linkedin/.../other). Parses JSON cookie payload for utm_source/source/referrer fields, strips URL schemes, maps well-known hostnames to channel buckets. PostHog event still ships the raw cookie value for analytics; only the Prometheus label is bucketed. 2. Filled the unknown/other label gaps: - analytics.IssueCreated and analytics.ChatMessageSent now take a platform parameter sourced from middleware.ClientMetadataFromContext (X-Client-Platform header) at the handler. Autopilot-originated issues stamp PlatformServer. - analytics.FeedbackSubmitted now takes a kind parameter; CreateFeedback reads req.Kind (default "general") so the picker selection lights up the metric's kind label instead of long-term "other". - analytics.ContactSalesSubmitted now takes a formSource (page / onboarding / agents_page); CreateContactSales reads req.Source. The metric reads ev.Properties["form_source"] so the analytics CoreProperties.Source ("marketing_contact_sales") stays backward-compat for PostHog dashboards. 3. analytics.OnboardingStarted helper added; server-side emission lives in PatchOnboarding, fired exactly once per user on the first PATCH that carries a non-empty questionnaire payload (firstTouch logic compares prior bytes against {} / null). Frontend onboarding_started keeps firing on page open; the server emission is what guarantees the Prometheus counter exists so Grafana can be cross-checked against the PostHog funnel without depending on the SDK roundtrip. 4. business_pairing_test.go tightened: - TestNoNakedAnalyticsCaptureInHandlersOrServices now allow-lists at function granularity (just captureTaskEvent in service/task.go), not whole-file. Any future naked Capture in the same file fails CI. - TestEveryAnalyticsRecordEventTakesAnalyticsHelper now does def-use tracking inside the enclosing FuncDecl: when RecordEvent's third arg is an *ast.Ident, the test walks the function body for the assignment that defined it and confirms the RHS is an analytics.<Helper>(...) call. Bare local idents that didn't originate from analytics are now caught. 5. gofmt -w applied across the touched files; gofmt -l clean. Tests: go test ./internal/metrics/... ./internal/analytics/... pass. Pre-existing TestClaimTask_/TestWebhook_MergedPR/TestDeleteIssueByIdentifier failures on origin/main are DB-environment-dependent and not regressions from this change. Co-authored-by: multica-agent <github@multica.ai> * fix(server): normalise onboarding_started platform label + regression test (MUL-2949) Addresses 张大彪's last review nit: - IncForEvent's EventOnboardingStarted case now wraps the platform property with NormalizePlatform, matching every other platform-bearing metric. A misbehaving frontend can no longer leak a raw X-Client-Platform header value into the multica_onboarding_started_total{platform=...} series. - New labels_pr3_test.go covers every PR3 normalizer with both a happy-path value and an unknown value, asserting the unknown collapses to the documented fallback bucket. Includes a focused regression for onboarding_started: emits one event with an attacker-shaped platform string and asserts the metric only exposes web + unknown label values (no raw header bleed). - testutil.go gains a small GatherForTest helper so the regression test can pull the typed MetricFamily map without re-implementing the registry-walk dance. Co-authored-by: multica-agent <github@multica.ai> * fix(server): NormalizeTaskSource on workspace_created + document lint limitations (MUL-2949) Final review touch-ups before merge: - IncForEvent's EventWorkspaceCreated case wraps source through NormalizeTaskSource, matching the other source-bearing dispatches (issue_created, agent_created, issue_executed). Closes the last raw property leak in the dispatcher table. - business_pairing_test.go inline docstrings now spell out the two known limitations of the lint gate that 张大彪 / Eve flagged: analyticsBackedIdents matches by ident NAME (not SSA def-use, so a nested-scope shadow could pass) and isMetricsRecordEvent hard-codes the import alias set. PR description carries a Follow-ups section with the same two items so the work is visible after merge. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: 魏和尚 <agent+wei@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
777 lines
26 KiB
Go
777 lines
26 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"
|
|
EventSquadCreated = "squad_created"
|
|
EventAutopilotCreated = "autopilot_created"
|
|
)
|
|
|
|
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, platform string) Event {
|
|
props := map[string]any{}
|
|
if platform != "" {
|
|
props["platform"] = platform
|
|
}
|
|
return Event{
|
|
Name: EventIssueCreated,
|
|
DistinctID: actorID,
|
|
WorkspaceID: workspaceID,
|
|
Properties: withCoreProperties(props, 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, platform string) Event {
|
|
props := map[string]any{}
|
|
if platform != "" {
|
|
props["platform"] = platform
|
|
}
|
|
return Event{
|
|
Name: EventChatMessageSent,
|
|
DistinctID: userID,
|
|
WorkspaceID: workspaceID,
|
|
Properties: withCoreProperties(props, 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, cadence string, assignee AutopilotAssignee, triggerSource string) Event {
|
|
return autopilotRunEvent(EventAutopilotRunStarted, actorID, workspaceID, autopilotID, runID, cadence, assignee, triggerSource, nil)
|
|
}
|
|
|
|
func AutopilotRunCompleted(actorID, workspaceID, autopilotID, runID, cadence string, assignee AutopilotAssignee, triggerSource string, durationMS int64) Event {
|
|
return autopilotRunEvent(EventAutopilotRunCompleted, actorID, workspaceID, autopilotID, runID, cadence, assignee, triggerSource, map[string]any{
|
|
"duration_ms": durationMS,
|
|
})
|
|
}
|
|
|
|
func AutopilotRunFailed(actorID, workspaceID, autopilotID, runID, cadence string, assignee AutopilotAssignee, triggerSource, failureReason, errorType string, willRetry bool, durationMS int64) Event {
|
|
return autopilotRunEvent(EventAutopilotRunFailed, actorID, workspaceID, autopilotID, runID, cadence, 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).
|
|
// OnboardingStarted fires from the server side the first time a user's
|
|
// onboarding state transitions from untouched (no questionnaire payload
|
|
// recorded) to any non-empty patch. Frontends emit their own
|
|
// onboarding_started on first page open; the server emission is what
|
|
// lights up the Prometheus counter so Grafana can be cross-checked
|
|
// against the PostHog funnel without depending on the SDK roundtrip.
|
|
//
|
|
// platform is the X-Client-Platform header value at the time of the
|
|
// first onboarding interaction, fed into the
|
|
// `multica_onboarding_started_total{platform=...}` label via the fixed
|
|
// allow-list in metrics.NormalizePlatform.
|
|
func OnboardingStarted(userID, platform string) Event {
|
|
props := map[string]any{}
|
|
if platform != "" {
|
|
props["platform"] = platform
|
|
}
|
|
return Event{
|
|
Name: EventOnboardingStarted,
|
|
DistinctID: userID,
|
|
Properties: withCoreProperties(props, CoreProperties{
|
|
UserID: userID,
|
|
Source: SourceOnboarding,
|
|
}),
|
|
}
|
|
}
|
|
|
|
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, the kind picker selection,
|
|
// and the client platform / version so support can segment without leaking
|
|
// content.
|
|
func FeedbackSubmitted(userID, workspaceID, kind string, messageLen int, hasImages bool, platform, appVersion string) Event {
|
|
props := map[string]any{
|
|
"message_length_bucket": feedbackLengthBucket(messageLen),
|
|
"has_images": hasImages,
|
|
}
|
|
if kind != "" {
|
|
props["kind"] = kind
|
|
}
|
|
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, intended use case, and the form-location bucket (page /
|
|
// onboarding / agents_page) so sales / marketing can split inbound volume
|
|
// without having to query the operational DB.
|
|
//
|
|
// formSource is the page-context bucket; the CoreProperties Source stays
|
|
// "marketing_contact_sales" so PostHog dashboards keep the funnel join
|
|
// against other marketing events. The Prometheus side reads form_source
|
|
// directly via the metrics.NormalizeContactSalesSource allow-list.
|
|
func ContactSalesSubmitted(inquiryID, companySize, countryRegion, useCase, formSource string, hasGoals bool) Event {
|
|
props := map[string]any{
|
|
"inquiry_id": inquiryID,
|
|
"company_size": companySize,
|
|
"country_region": countryRegion,
|
|
"use_case": useCase,
|
|
"has_goals": hasGoals,
|
|
}
|
|
if formSource != "" {
|
|
props["form_source"] = formSource
|
|
}
|
|
return Event{
|
|
Name: EventContactSalesSubmitted,
|
|
DistinctID: inquiryID,
|
|
Properties: withCoreProperties(props, CoreProperties{
|
|
Source: "marketing_contact_sales",
|
|
}),
|
|
}
|
|
}
|
|
|
|
// SquadCreated fires when a workspace member or admin creates a new squad.
|
|
// `memberCount` is the number of members the squad was seeded with at
|
|
// creation time (frontend can pre-populate via the picker).
|
|
func SquadCreated(actorID, workspaceID, squadID string, memberCount int) Event {
|
|
return Event{
|
|
Name: EventSquadCreated,
|
|
DistinctID: actorID,
|
|
WorkspaceID: workspaceID,
|
|
Properties: withCoreProperties(map[string]any{
|
|
"squad_id": squadID,
|
|
"member_count": int64(memberCount),
|
|
}, CoreProperties{
|
|
UserID: nonAgentUserID(actorID),
|
|
WorkspaceID: workspaceID,
|
|
Source: SourceManual,
|
|
}),
|
|
}
|
|
}
|
|
|
|
// AutopilotCreated fires when a workspace member creates a new autopilot.
|
|
// `cadence` matches the autopilot.cadence enum (hourly/daily/weekly/...
|
|
// /webhook). triggerKind is the initial trigger type (schedule / webhook /
|
|
// manual) — when both schedule and webhook triggers are seeded, we report
|
|
// the dominant one (schedule wins).
|
|
func AutopilotCreated(actorID, workspaceID, autopilotID, cadence, triggerKind string) Event {
|
|
return Event{
|
|
Name: EventAutopilotCreated,
|
|
DistinctID: actorID,
|
|
WorkspaceID: workspaceID,
|
|
Properties: withCoreProperties(map[string]any{
|
|
"autopilot_id": autopilotID,
|
|
"cadence": cadence,
|
|
"trigger_kind": triggerKind,
|
|
}, CoreProperties{
|
|
UserID: nonAgentUserID(actorID),
|
|
WorkspaceID: workspaceID,
|
|
Source: SourceManual,
|
|
}),
|
|
}
|
|
}
|
|
|
|
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, cadence string, assignee AutopilotAssignee, triggerSource string, extra map[string]any) Event {
|
|
if extra == nil {
|
|
extra = map[string]any{}
|
|
}
|
|
extra["trigger_source"] = triggerSource
|
|
extra["trigger_kind"] = triggerSource
|
|
if cadence != "" {
|
|
extra["cadence"] = cadence
|
|
}
|
|
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:])
|
|
}
|