lib/analytics-events.ts
Rama main · réplica de lectura del repositorio original.
/**
* Analytics Event Catalog — THE source of truth for PostHog events
* emitted client-side via `useAnalytics().track()` (see `lib/use-analytics.ts`).
*
* To add an event:
* 1. add a line to `AnalyticsEventProps` (name → prop form);
* 2. add the name to the table `EVENT_NAMES`.
* The `satisfies` refuses a name that does not exist in the catalog, and keeps it
* `_assertNoMissingEvents` below refuses to compile if a cataloged
* event is missing from the table. As `track()` is generic to
* `AnalyticsEventName`, a typo at the call site is also a
* compilation error — no more silently lost events.
*
* NAMING: snake_case, `^[a-z0-9_:-]+$` (see `sanitizeAnalyticsEventName`).
*
* DATA: never PII or free text in props — only
* metadata (counters, booleans, ids, enums, slices via `lengthBucket` /
* `durationBucket` / `sizeBucket`). The props are sanitized on sending
* (primitives, ≤24 keys, strings ≤512 characters).
*
* SERVER events have their own closed catalog below. They pass
* through the same sanitization when sending, even if their properties remain more flexible because they come from webhooks, crons and integrations.
*/
/**
* Exhaustive catalog of events emitted by `lib/server/posthog.ts`.
*
* Keeping this list distinct from the client catalog avoids confusing a browser gesture
* (subject to the choice of cookies) with the server business fact which makes
* authority. The type closes call sites at compilation; Set protects
* values that would still cross an untyped JavaScript boundary.
*/
export const SERVER_ANALYTICS_EVENT_NAMES = [
"account_data_exported",
"account_deleted",
"agent_run_completed",
"agent_run_failed",
"agent_run_started",
"automation_chain_finished",
"automation_chain_started",
"desktop_download_started",
"issue_created_server",
"issue_updated_server",
"mcp_tool_called",
"mfa_disabled",
"mfa_enabled",
"oauth_grant_created",
"public_feedback_created",
"public_feedback_voted",
"signup_email_confirmed",
"subscription_activated",
"subscription_cancelled",
"subscription_paused",
"subscription_payment_failed",
"subscription_resumed",
"subscription_updated",
"user_signed_in",
"user_signed_up",
] as const;
export type ServerAnalyticsEventName = typeof SERVER_ANALYTICS_EVENT_NAMES[number];
export const ALLOWED_SERVER_ANALYTICS_EVENTS: ReadonlySet<ServerAnalyticsEventName> =
new Set(SERVER_ANALYTICS_EVENT_NAMES);
/** Primitive values accepted after sanitization (optional for convenience). */
type PropValue = string | number | boolean | null | undefined;
/** Events that carry no structured prop. */
type NoProps = Record<string, never>;
/** Ticket statuses — taken from `lib/issue-constants.ts` (string to remain flexible). */
type IssueStatus = string;
export interface AnalyticsEventProps {
// ── Consentement ──
cookie_consent_choice: { choice: "accepted" | "declined" };
// ── Auth ──
login_submitted: { method: "password" };
login_failed: { method: "password" | "google" | "github"; reason: string };
login_succeeded: { method: "password" };
oauth_initiated: { provider: "google" | "github"; context: "login" | "signup" };
signup_submitted: NoProps;
signup_succeeded: { requires_email_confirmation: boolean };
signup_failed: { reason: string };
/** “Forgotten password” route (MIN-297): requested, failed, completed. */
password_reset_requested: NoProps;
password_reset_failed: { reason: string };
password_reset_completed: NoProps;
user_signed_out: NoProps;
// ── Onboarding (MIN-74) ──
onboarding_viewed: { current_step: string; completed_count: number };
onboarding_step_viewed: { step: string; step_number: number };
onboarding_step_acknowledged: { step: string };
/** “Import my tickets” step (MIN-98): which tool does the account come from.
* `import_started` / `import_completed` already cover the import itself. */
onboarding_import_provider_selected: { provider: string };
/** “Join a project” in step 1: the account does not create one, it waits for an invitation. This is the only place in the product that says so. */
onboarding_join_opened: NoProps;
/** “Connect an agent” step: which one. Tells who to write the MCP
* doc for first. */
onboarding_mcp_agent_selected: { agent: string };
/** "Your API key" step (MIN-149): The account has arrived with its key.
* This is the measure of the BYOK argument — how many new accounts are already paying for their tokens elsewhere and don't have to be counted here. */
onboarding_ai_key_added: NoProps;
onboarding_dismissed: { last_step: string; completed_count: number };
onboarding_completed: { steps_acknowledged: number };
// ── Projects ──
/** `draft` = a draft taken from the sidebar; `resume` = the
* return of a round trip to the git provider, which is not a voluntary recovery
* but the middle of a gesture. */
project_wizard_opened: {
source: "sidebar" | "home" | "palette" | "resume" | "draft";
};
project_wizard_step_viewed: { step: string };
/** Closed WITHOUT keeping the entry. Since the project drafts, abandoning
* is an explicit choice ("Abort") and no longer the default outcome of
* any closure: it is `project_wizard_draft_saved` that must be compared to read the step which causes the disconnection. */
project_wizard_abandoned: { last_step: string };
/** Closed WHILE KEEPing input — the draft takes its line in the
* sidebar. The stage says where we stop when we don't give up. */
project_wizard_draft_saved: { step: string };
/** The wizard's first question (MIN-171): where do we start from. This is the measure
* of which of the two entries is really used — a new project to frame, or
* a backlog that already exists elsewhere. */
project_wizard_origin_chosen: { origin: "new" | "existing" };
/** “I want to join a project”, from the “name” step: the account
* did not have a project to create, it needed an invitation. This is a legitimate
* ABANDONMENT of the wizard — to be removed from those who drop out. */
project_wizard_join_opened: NoProps;
/** What the seed step collected, including “nothing”: the gap between
* the chosen origin and the retained seed says whether the step keeps its promise. */
project_wizard_seed_chosen: { seed: "brief" | "numo" | "import" | "none" };
project_wizard_completed: {
has_git_link: boolean;
smart_assign_enabled: boolean;
};
project_created: { has_icon: boolean; has_git_link: boolean };
project_opened: { project_id: string };
project_deleted: { project_id: string };
project_updated: { field: string };
project_icon_changed: { kind: "favicon" | "upload" | "orb" };
/** Restart drawing of the generated orb (no property: it's a gesture). */
project_orb_rerolled: NoProps;
project_setup_resumed: { step: string };
// ── Tickets: creation ──
issue_created: {
source:
| "dialog"
| "palette"
| "kanban"
| "numo"
| "board"
| "objective"
| "sub_issue"
/** Promotion of a return: the form opens already completed by him. */
| "feedback";
has_description: boolean;
has_categories: boolean;
has_assignee: boolean;
priority: string;
status: IssueStatus;
effort?: string | null;
/** Created from the “in another project” selector. */
cross_project?: boolean;
resource_count?: number;
description_length_bucket?: string;
/** The ticket comes from a retrieved local draft (MIN-41). */
created_from_draft?: boolean;
/** Smart-fill was armed on this ticket (MIN-260): its properties come
from the model, not from the person. The other properties of this event
(priority, effort, has_categories) then describe what the FORM
carried at the time of sending, that is to say the defaults — the filling
is done on the server side, afterwards. To read with this flag, never without. */
smart_fill?: boolean;
};
issue_create_dialog_opened: { source: string };
issue_dictation_used: { surface: "create_dialog" | "side_panel" };
issue_draft_recovered: NoProps;
issue_draft_discarded: NoProps;
// ── Tickets: edition ──
issue_opened: { surface: string };
/** `from` is only set if the caller knew the previous state. */
issue_status_changed: { from?: IssueStatus | null; to: IssueStatus; surface: string };
issue_priority_changed: { to: string; surface: string };
issue_assignee_changed: { assigned: boolean; surface: string; self?: boolean };
issue_effort_changed: { to: string };
issue_due_date_changed: { cleared: boolean };
issue_category_changed: { count: number };
issue_objective_changed: { assigned: boolean };
issue_title_edited: NoProps;
issue_description_edited: { length_bucket: string };
issue_deleted: { surface: string };
issue_moved_project: NoProps;
// ── Tickets : contenu ──
issue_plan_edited: { task_count: number };
plan_task_toggled: { to_state: "pending" | "in_progress" | "completed" | "cancelled" };
/** The thread of a PAGE is the fourth target (MIN-282), and the only one that
can be ANCHORED to a block — hence `anchored`, absent from the other three. */
comment_added: {
target: "issue" | "objective" | "feedback" | "page";
length_bucket: string;
};
comment_deleted: { target: "issue" | "objective" | "feedback" | "page" };
/** An added resource — file OR link (MIN-184). No data from
content: neither the URL, nor the title, nor the file name. `size_bucket` and
`mime_kind` (the MIME FAMILY: image, application…) only exist for one
file. */
resource_added: {
target: "issue" | "objective" | "comment";
kind: "file" | "link" | "page";
size_bucket?: string;
mime_kind?: string;
compressed?: boolean;
};
resource_removed: { target: "issue" | "objective" | "comment" };
resource_renamed: { kind: "file" | "link" };
sub_issue_created: NoProps;
issue_relation_added: { relation: string };
issue_relation_removed: { relation: string };
// ── Board, vues, filtres ──
board_viewed: { scope: "global" | "project" | "triage"; layout: string; issue_count: number };
board_layout_changed: { to: "list" | "kanban" };
board_grouped_by: { field: string };
board_filter_applied: { filter: string; active_filter_count: number };
board_filters_cleared: NoProps;
board_sorted: { field: string };
issue_dragged: { from: IssueStatus; to: IssueStatus; scope: "global" | "project" };
view_created: { has_filters: boolean };
view_switched: { view_kind: "system" | "custom" };
view_updated: NoProps;
view_deleted: NoProps;
view_shared: { has_password: boolean };
tab_reordered: NoProps;
// ── Group selection (MIN-75) ──
bulk_selection_started: { surface: string };
bulk_action_executed: { action: string; count: number };
bulk_selection_cleared: { count: number };
// ── Palette de commandes ──
command_palette_opened: { source: "shortcut" | "click" | "mobile" | "new_tab_shortcut" };
command_executed: { command_id: string; category: string };
command_palette_no_results: { query_length: number };
palette_favorite_toggled: { command_id: string; favorited: boolean };
palette_view_changed: { view: string };
// ── Numo (assistant) ──
assistant_opened: { source: "fab" | "palette" | "shortcut" | "home" | "issue" };
assistant_closed: NoProps;
assistant_message_sent: { has_page_context: boolean; length_bucket: string; is_first_of_conversation: boolean };
assistant_response_received: { had_tool_calls: boolean; tool_count: number; duration_bucket: string };
assistant_response_failed: { reason: string };
assistant_stopped: NoProps;
assistant_conversation_new: NoProps;
assistant_conversation_loaded: NoProps;
assistant_conversation_deleted: NoProps;
assistant_dictation_used: NoProps;
assistant_suggestion_clicked: { suggestion_index: number };
assistant_ask_user_answered: { answered: boolean };
// ── Agent de code (MIN-46) ──
agent_launch_opened: { surface: "issue" | "agents_page" | "palette" };
agent_launched: { has_branch: boolean; provider: string };
agent_run_opened: { status: string };
agent_steered: { length_bucket: string };
agent_stopped: NoProps;
agent_diff_viewed: { file_count: number };
agent_note_sent: NoProps;
agent_question_answered: NoProps;
agent_model_changed: { model: string };
agent_preferences_updated: { field: string };
// ── Routines (MIN-185) ──
// Measure the shape of the gesture, never the instruction itself.
routine_created: {
frequency: string;
trigger_type?: string;
spend_cap_percent: number;
prompt_length_bucket: string;
};
/** “Launch now”: an off-schedule passage, triggered by hand. */
routine_run_now: NoProps;
// ── Pull requests ──
pr_list_viewed: { count: number };
pr_opened: { state: string; provider: string };
pr_diff_file_opened: NoProps;
pr_review_comment_added: { length_bucket: string };
pr_review_submitted: { verdict: string };
/** “Have it checked by Numo” (MIN-141) — the review triggered by hand. */
pr_ai_review_requested: NoProps;
/**
* Ticket attached BY HAND to a PR which did not have one (MIN-163). `pr_state`
* says at what point in the life of the PR we catch the missing link — this is what
* we want to know to judge whether the naming convention is sufficient.
*/
pr_issue_linked: { pr_state: string };
pr_external_link_clicked: { provider: string };
// ── Cycles ──
cycle_viewed: { issue_count: number; is_empty: boolean };
cycle_filled: { added_count: number };
cycle_issue_added: { surface: string };
cycle_issue_removed: { surface: string };
cycle_prefs_changed: { field: string };
cycles_enabled: { enabled: boolean };
// ── Notes rapides (scratchpad) ──
scratchpad_opened: { source: "shortcut" | "sidebar" | "palette" | "home" | "click" | "mention" };
scratchpad_task_added: NoProps;
scratchpad_task_completed: NoProps;
scratchpad_task_promoted: NoProps;
scratchpad_edited: { length_bucket: string };
// ── Team-side feedback (MIN-37) ──
/** Team action on a return (promote, merge, undo…) — the verb comes
* from the last segment of the called route. */
feedback_action: { action: string };
feedback_board_viewed: { post_count: number };
feedback_post_opened: { status: string };
feedback_post_promoted: NoProps;
feedback_post_merged: { count: number };
feedback_merge_undone: NoProps;
feedback_responded: { length_bucket: string };
feedback_status_changed: { to: string };
feedback_linked_to_issue: NoProps;
feedback_suggestion_used: { accepted: boolean };
feedback_board_enabled: { enabled: boolean };
feedback_integration_wizard_step: { step: string };
feedback_integration_prompt_copied: NoProps;
// ── Board public (visiteurs anonymes) ──
public_board_viewed: { board_token_present: boolean };
public_feedback_submitted: { length_bucket: string; has_title: boolean };
public_feedback_voted: { voted: boolean };
public_feedback_commented: { length_bucket: string };
public_feedback_opened: NoProps;
public_board_signin_started: { method: "otp" | "sso" };
public_board_signin_completed: { method: "otp" | "sso" };
// ── Shared views (MIN-26) ──
shared_view_opened: { has_password: boolean };
shared_view_password_submitted: { success: boolean };
share_link_created: { has_password: boolean };
share_link_revoked: NoProps;
share_link_copied: NoProps;
// ── Published and exported pages (MIN-283) ──
page_published: { has_password: boolean; with_children: boolean };
page_unpublished: NoProps;
page_exported: { format: "md" | "zip" | "pdf" };
/** A page's link or Markdown content copied from its Copy menu: `source`
says whether the link came from the menu or from ⌘⇧L, which tells if the
shortcut found its audience. */
page_copied: { what: "content" | "link"; source: "menu" | "shortcut" };
// ── Notifications ──
notifications_opened: { unread_count: number; surface: "inbox" | "popover" };
notification_clicked: { type: string; was_unread: boolean };
notifications_marked_read: { count: number };
notification_prefs_changed: { key: string; enabled: boolean };
// ── Settings ──
settings_opened: { scope: "account" | "project"; source: string };
settings_tab_switched: { scope: "account" | "project"; tab: string };
profile_updated: { field: "name" | "email" | "password" | "avatar" };
language_changed: { locale: string };
theme_changed: { theme: string };
theme_preset_changed: { mode: "light" | "dark"; preset: string };
account_preference_toggled: { key: string; enabled: boolean };
settings_assistant_prompt_sent: { scope: "account" | "project" };
// Connections / integrations
mcp_connect_opened: { source: string };
mcp_install_copied: { client: string; method: string };
mcp_client_link_opened: { client: string };
ai_key_added: { provider: string };
ai_key_removed: NoProps;
git_connection_started: { provider: "github" | "gitlab" };
git_connection_completed: { provider: "github" | "gitlab" };
/** `provider` can be "unknown": disconnection is done by id of
* connection, without the caller knowing which provider it is. */
git_connection_removed: { provider: string };
/** PERSONAL git account, under which PR gestures are sent (MIN-144) —
* distinct from the installation of the App above. */
git_identity_connect_started: { provider: "github" | "gitlab" };
git_identity_removed: { provider: string };
project_git_linked: { provider: string };
project_git_unlinked: { provider: string };
/** Unidirectional synchronization of linked repository outputs (MIN-97). */
project_git_issue_sync_toggled: { provider: string; enabled: boolean };
/** Cleaning up closed PR agent branches (MIN-102). */
project_git_branches_cleaned: { provider: string; deleted: number; failed: number };
oauth_grant_revoked: NoProps;
connected_app_viewed: NoProps;
integration_added: { kind: string };
integration_removed: { kind: string };
smart_assign_toggled: { enabled: boolean };
// ── Notifications push / web (MIN-183) ──
//
// NO PII: neither endpoint (it is a delivery address, therefore a
// stable device identifier), neither raw user-agent nor device label.
// `platform` reste au niveau de `navigator.platform` (« MacIntel »,
// “iPhone”) — enough to know where the activations come from, never enough
// to recognize someone.
push_device_enabled: { platform: string };
push_device_disabled: NoProps;
push_device_removed: NoProps;
/** The browser dialog has been refused (or already was): this is
* THE lost point of the path, and it is not catching up from the page. */
push_permission_denied: NoProps;
push_test_sent: NoProps;
// Members, categories, import
project_member_invited: NoProps;
project_member_removed: NoProps;
project_invitation_responded: { response: "accepted" | "declined" };
category_created: NoProps;
category_updated: NoProps;
category_reordered: NoProps;
category_deleted: NoProps;
import_started: { source: string };
import_completed: { source: string; issue_count: number };
import_failed: { source: string; reason: string };
/** The other direction: CSV export from ⌘K. `scope` says if the account leaves UN
* project or all, `status_count` how many statuses remained checked — the
* two answers that cannot be deduced from the rest. A repeated `truncated: true`
* would say the route ceiling is too low. */
issues_exported: {
scope: "project" | "all";
status_count: number;
issue_count: number;
truncated: boolean;
};
/** Start of a project with a brief (MIN-172, MIN-173). Only WRITING se
* still counts on the browser side: the request and the proposal are passed
* in the conversation with Numo, where they are a tool call like a
* other. `issue_count` is what REMAINS after unchecks. */
brief_split_applied: { issue_count: number; objective_count: number };
// ── Billing (MIN-72) ──
pricing_viewed: { surface: "marketing" | "app" };
plan_cta_clicked: { plan_id: string; interval: string; current_plan_id: string };
checkout_started: { plan_id: string; interval: string };
billing_portal_opened: { current_plan_id: string };
// Termination and recovery FROM the app (MIN-296): the gesture no longer goes through
// the Stripe portal, so no more by `billing_portal_opened`.
subscription_canceled: NoProps;
subscription_resumed: NoProps;
usage_viewed: NoProps;
usage_history_filtered: { filter: string };
plan_limit_hit: { limit_type: string; plan_id: string };
upgrade_prompt_clicked: { source: string };
// ── Site public (MIN-73) ──
landing_viewed: { locale: string };
// `mcp_page`, `comparison` and `changelog`: the content pages added by
//MIN-93. They exist only to be found — knowing which one leads
// really at registration is half the lot measurement.
landing_cta_clicked: {
location:
| "hero"
| "nav"
| "pricing_teaser"
| "cta_section"
| "footer"
| "faq"
| "mcp_page"
| "comparison"
| "changelog";
};
// `landing_section_viewed` has been removed (MIN-150): cataloged at its creation,
// he NEVER had a transmitter, and PostHog therefore never received one.
// A name lying around here reads like an existing measurement — we think we can
// query "which sections are seen", and the empty response passes as
// an absence of traffic. Revert it = a line here + a client component
// who emits it, both in the same gesture.
landing_faq_opened: { question_index: number };
// ── App de bureau (MIN-292) ──
//
// These are the INTENTIONS. The download itself is counted by the
// server (`desktop_download_started`, in app/api/desktop/download): him
// only knows that a file is gone, and it also counts shared links
// outside the app. The two together give the success rate; mon
// sans l'autre ne donne rien.
/** The proposal to install the app, on the web home page — seen by someone
* who is eligible (a Mac, outside the app, never rejected). This is the
* denominator of the next two events. */
desktop_install_prompt_shown: NoProps;
desktop_install_prompt_clicked: { surface: "home_banner" | "settings" };
/** "No thanks", and it's forever (see lib/desktop/install-prompt.ts).
* The report to `shown` says if the proposal bothers more than it helps. */
desktop_install_prompt_dismissed: NoProps;
/** Clicking on a downloadable desktop package, before the server confirms it left. */
desktop_download_clicked: {
platform: "macos" | "linux";
format: "dmg" | "AppImage" | "deb" | "rpm";
arch: "arm64" | "x64";
};
/** Playable dictation demo (MIN-150). `input` distinguishes taking the microphone from
* the example sentence: knowing which of the two makes the “aha” decides
* which one to highlight. No dictated text comes back, ever. */
landing_voice_demo_started: { input: "mic" | "sample" };
/** `duration_bucket` = the wait between click and ticket filled. A demo
* that takes ten seconds to respond is no longer a demo: it's the measure that
* will tell you before anyone complains. */
landing_voice_demo_completed: { input: "mic" | "sample"; duration_bucket: string };
landing_voice_demo_failed: { input: "mic" | "sample"; reason: string };
// ── Recherche, raccourcis, divers ──
search_opened: { source: string };
search_result_selected: { kind: string; query_length: number };
search_no_results: { query_length: number };
keyboard_shortcut_used: { shortcut: string };
cheatsheet_opened: NoProps;
undo_triggered: { action: string };
statistics_viewed: { range: string };
// Corbeille (MIN-133) — `item_type` ∈ issue | project | objective | feedback.
trash_viewed: { items: number };
trash_item_restored: { item_type: string };
trash_item_purged: { item_type: string };
trash_emptied: NoProps;
home_viewed: { has_projects: boolean; onboarding_visible: boolean };
home_quick_action_clicked: { action: string };
objective_created: NoProps;
objective_opened: NoProps;
objective_updated: { field: string };
objective_deleted: NoProps;
// “page” since MIN-226: the lens side panel has disappeared in favor
// of the Objectives page, and it is she who now carries the editing dictation.
objective_dictation_used: { surface: "create_dialog" | "page" };
admin_dashboard_viewed: { tab: string };
external_link_clicked: { destination: string };
}
/** Union of all cataloged names. */
export type AnalyticsEventName = keyof AnalyticsEventProps;
/** Props accepted by `track()` for a given name. */
export type AnalyticsPropsFor<E extends AnalyticsEventName> =
AnalyticsEventProps[E] & Record<string, PropValue>;
/**
* Runtime allowlist source. `satisfies` refuses a name absent from the catalog;
* the lower guard refuses a cataloged name absent from this table.
*/
const EVENT_NAMES = [
// Consentement
"cookie_consent_choice",
// Auth
"login_submitted",
"login_failed",
"login_succeeded",
"oauth_initiated",
"signup_submitted",
"signup_succeeded",
"signup_failed",
"password_reset_requested",
"password_reset_failed",
"password_reset_completed",
"user_signed_out",
// Onboarding
"onboarding_viewed",
"onboarding_step_viewed",
"onboarding_step_acknowledged",
"onboarding_import_provider_selected",
"onboarding_join_opened",
"onboarding_mcp_agent_selected",
"onboarding_ai_key_added",
"onboarding_dismissed",
"onboarding_completed",
// Projects
"project_wizard_opened",
"project_wizard_step_viewed",
"project_wizard_abandoned",
"project_wizard_draft_saved",
"project_wizard_origin_chosen",
"project_wizard_join_opened",
"project_wizard_seed_chosen",
"project_wizard_completed",
"project_created",
"project_opened",
"project_deleted",
"project_updated",
"project_icon_changed",
"project_orb_rerolled",
"project_setup_resumed",
// Tickets: creation
"issue_created",
"issue_create_dialog_opened",
"issue_dictation_used",
"issue_draft_recovered",
"issue_draft_discarded",
// Tickets: edition
"issue_opened",
"issue_status_changed",
"issue_priority_changed",
"issue_assignee_changed",
"issue_effort_changed",
"issue_due_date_changed",
"issue_category_changed",
"issue_objective_changed",
"issue_title_edited",
"issue_description_edited",
"issue_deleted",
"issue_moved_project",
// Tickets : contenu
"issue_plan_edited",
"plan_task_toggled",
"comment_added",
"comment_deleted",
"resource_added",
"resource_removed",
"resource_renamed",
"sub_issue_created",
"issue_relation_added",
"issue_relation_removed",
// Board / vues
"board_viewed",
"board_layout_changed",
"board_grouped_by",
"board_filter_applied",
"board_filters_cleared",
"board_sorted",
"issue_dragged",
"view_created",
"view_switched",
"view_updated",
"view_deleted",
"view_shared",
"tab_reordered",
// Bulk selection
"bulk_selection_started",
"bulk_action_executed",
"bulk_selection_cleared",
// Palette
"command_palette_opened",
"command_executed",
"command_palette_no_results",
"palette_favorite_toggled",
"palette_view_changed",
// Numo
"assistant_opened",
"assistant_closed",
"assistant_message_sent",
"assistant_response_received",
"assistant_response_failed",
"assistant_stopped",
"assistant_conversation_new",
"assistant_conversation_loaded",
"assistant_conversation_deleted",
"assistant_dictation_used",
"assistant_suggestion_clicked",
"assistant_ask_user_answered",
// Agent de code
"agent_launch_opened",
"agent_launched",
"agent_run_opened",
"agent_steered",
"agent_stopped",
"agent_diff_viewed",
"agent_note_sent",
"agent_question_answered",
"agent_model_changed",
"agent_preferences_updated",
// Routines
"routine_created",
"routine_run_now",
// Pull requests
"pr_list_viewed",
"pr_opened",
"pr_diff_file_opened",
"pr_review_comment_added",
"pr_review_submitted",
"pr_ai_review_requested",
"pr_issue_linked",
"pr_external_link_clicked",
// Cycles
"cycle_viewed",
"cycle_filled",
"cycle_issue_added",
"cycle_issue_removed",
"cycle_prefs_changed",
"cycles_enabled",
// Scratchpad
"scratchpad_opened",
"scratchpad_task_added",
"scratchpad_task_completed",
"scratchpad_task_promoted",
"scratchpad_edited",
// Team feedback
"feedback_action",
"feedback_board_viewed",
"feedback_post_opened",
"feedback_post_promoted",
"feedback_post_merged",
"feedback_merge_undone",
"feedback_responded",
"feedback_status_changed",
"feedback_linked_to_issue",
"feedback_suggestion_used",
"feedback_board_enabled",
"feedback_integration_wizard_step",
"feedback_integration_prompt_copied",
// Board public
"public_board_viewed",
"public_feedback_submitted",
"public_feedback_voted",
"public_feedback_commented",
"public_feedback_opened",
"public_board_signin_started",
"public_board_signin_completed",
// Shared views
"shared_view_opened",
"shared_view_password_submitted",
"share_link_created",
"share_link_revoked",
"share_link_copied",
// Published pages (MIN-283)
"page_published",
"page_unpublished",
"page_exported",
"page_copied",
// Notifications
"notifications_opened",
"notification_clicked",
"notifications_marked_read",
"notification_prefs_changed",
// Settings
"settings_opened",
"settings_tab_switched",
"profile_updated",
"language_changed",
"theme_changed",
"theme_preset_changed",
"account_preference_toggled",
"settings_assistant_prompt_sent",
"mcp_connect_opened",
"mcp_install_copied",
"mcp_client_link_opened",
"ai_key_added",
"ai_key_removed",
"git_connection_started",
"git_connection_completed",
"git_connection_removed",
"git_identity_connect_started",
"git_identity_removed",
"project_git_linked",
"project_git_unlinked",
"project_git_issue_sync_toggled",
"project_git_branches_cleaned",
"oauth_grant_revoked",
"connected_app_viewed",
"integration_added",
"integration_removed",
"smart_assign_toggled",
"push_device_enabled",
"push_device_disabled",
"push_device_removed",
"push_permission_denied",
"push_test_sent",
"project_member_invited",
"project_member_removed",
"project_invitation_responded",
"category_created",
"category_updated",
"category_reordered",
"category_deleted",
"import_started",
"import_completed",
"import_failed",
"issues_exported",
"brief_split_applied",
// Billing
"pricing_viewed",
"plan_cta_clicked",
"checkout_started",
"billing_portal_opened",
"subscription_canceled",
"subscription_resumed",
"usage_viewed",
"usage_history_filtered",
"plan_limit_hit",
"upgrade_prompt_clicked",
// Site public
"landing_viewed",
"landing_cta_clicked",
"landing_faq_opened",
// App de bureau
"desktop_install_prompt_shown",
"desktop_install_prompt_clicked",
"desktop_install_prompt_dismissed",
"desktop_download_clicked",
"landing_voice_demo_started",
"landing_voice_demo_completed",
"landing_voice_demo_failed",
// Divers
"search_opened",
"search_result_selected",
"search_no_results",
"keyboard_shortcut_used",
"cheatsheet_opened",
"undo_triggered",
"statistics_viewed",
"trash_viewed",
"trash_item_restored",
"trash_item_purged",
"trash_emptied",
"home_viewed",
"home_quick_action_clicked",
"objective_created",
"objective_opened",
"objective_updated",
"objective_deleted",
"objective_dictation_used",
"admin_dashboard_viewed",
"external_link_clicked",
] as const satisfies readonly AnalyticsEventName[];
// Compilation guard: if a cataloged event is missing from EVENT_NAMES, this type
// is that name and the `= true` assignment fails. Fix: Add it above.
type _MissingEvent = Exclude<AnalyticsEventName, (typeof EVENT_NAMES)[number]>;
const _assertNoMissingEvents: _MissingEvent extends never ? true : _MissingEvent = true;
void _assertNoMissingEvents;
/** Allowlist runtime consumed by `track()`. */
export const ALLOWED_ANALYTICS_EVENTS: ReadonlySet<AnalyticsEventName> = new Set(EVENT_NAMES);