mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* 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>
743 lines
25 KiB
Go
743 lines
25 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"
|
|
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
|
|
|
|
// metricsOnlyEvents are operational / execution-lifecycle events that are
|
|
// recorded to Prometheus (via metrics.IncForEvent, for Grafana) but are
|
|
// deliberately NOT shipped to PostHog. They are high-volume runtime/autopilot
|
|
// telemetry whose per-event PostHog ingestion cost is not justified — Grafana
|
|
// already carries the equivalent counters. metrics.RecordEvent consults this
|
|
// set and skips the PostHog Capture for these names while still incrementing
|
|
// the counter. PostHog is reserved for user/product-behaviour events.
|
|
//
|
|
// Note: agent_task_* lifecycle events are also Prometheus-only, but their
|
|
// Prometheus side is handled by typed BusinessMetrics.RecordTask* methods, so
|
|
// they never build an analytics.Event in the first place and don't need an
|
|
// entry here.
|
|
var metricsOnlyEvents = map[string]struct{}{
|
|
EventRuntimeRegistered: {},
|
|
EventRuntimeReady: {},
|
|
EventRuntimeFailed: {},
|
|
EventRuntimeOffline: {},
|
|
EventAutopilotRunStarted: {},
|
|
EventAutopilotRunCompleted: {},
|
|
EventAutopilotRunFailed: {},
|
|
}
|
|
|
|
// IsMetricsOnly reports whether an event name is operational telemetry that
|
|
// must be counted in Prometheus but not sent to PostHog. See metricsOnlyEvents.
|
|
func IsMetricsOnly(name string) bool {
|
|
_, ok := metricsOnlyEvents[name]
|
|
return ok
|
|
}
|
|
|
|
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,
|
|
}),
|
|
}
|
|
}
|
|
|
|
// 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 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 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:])
|
|
}
|