From d5cf1afc67bfba1d5f7759af756bf5ec06f95df3 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 1 May 2026 20:07:53 +0530 Subject: [PATCH 1/6] feat(metrics): broad ui/feature usage instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds new EVENT_TYPE entries (AI = "ai", TERMINAL = "term") and wires metadata-only metrics across the editor's main feature surfaces. All labels are stable enums; no user content (filenames, prompt text, shell input/output, attached file paths) is recorded. Coverage: - src/view/CentralControlBar.js Click metrics for every CCB icon — UI/ccb/{undo,redo,save, designOn,designOff,sidebar,file}. - src/view/SidebarTabs.js Click metric for the sidebar nav tab bar — UI/navTab/ (e.g. "ai", "files"). Programmatic setActiveTab calls are NOT metric'd; only user clicks. - src/extensionsIntegrated/Phoenix-live-preview/main.js lp-edit/modeBtn/{toEdit,toPrev} on the inline edit-mode toggle button (previewModeLivePreviewButton). The companion designModeBtn already had a metric. - src/extensionsIntegrated/Terminal/main.js Terminal feature usage: term/panel/open — panel becomes visible term/new/ — new tab created, shell family bucketed (bash, zsh, fish, pwsh, cmd, ...) term/tabs/ — concurrent tab count after creation, bucketed one / LTE4 / LTE9 / GT10 term/pick/ — user picked a default shell in the dropdown term/close/user — terminal closed via UI term/exit/{ok,err} — shell process exited on its own (status bucketed) - src-node/claude-code-agent.js Per-turn token usage console-logs (each SDKAssistantMessage's message.usage) and one rolled-up usage payload per query (the terminal SDKResultMessage). The aggregate is forwarded to the browser via a new aiUsage peer event with usage / modelUsage / total_cost_usd / num_turns / duration_ms / duration_api_ms. - src/utils/Metrics.js EVENT_TYPE.AI and EVENT_TYPE.TERMINAL added so feature areas have their own dimensions instead of overloading UI / PRO. --- src-node/claude-code-agent.js | 61 +++++++++++++++++++ .../Phoenix-live-preview/main.js | 6 ++ src/extensionsIntegrated/Terminal/main.js | 46 ++++++++++++++ src/utils/Metrics.js | 2 + src/view/CentralControlBar.js | 28 ++++++++- src/view/SidebarTabs.js | 9 +++ 6 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 05475298c8..6717b3c349 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -1340,6 +1340,67 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, _log("Session:", currentSessionId); } + // Per-turn token usage: each SDKAssistantMessage carries the + // wrapped Anthropic API message whose `.usage` reflects what + // that single turn consumed. Useful for diagnosing runaway + // loops; logged but not metric'd individually (the result + // message rolls up the session totals). + if (message.type === "assistant" && + message.message && message.message.usage) { + const u = message.message.usage; + _log("Turn usage:", + "in=" + (u.input_tokens || 0), + "out=" + (u.output_tokens || 0), + "cacheRead=" + (u.cache_read_input_tokens || 0), + "cacheCreate=" + (u.cache_creation_input_tokens || 0), + message.parent_tool_use_id ? "(subagent)" : ""); + } + + // Aggregate session usage on the terminal `result` message. + // The SDK emits exactly one of these per query (success or + // error_*) with totals across all turns and the per-model + // breakdown. + if (message.type === "result") { + const u = message.usage || {}; + const mu = message.modelUsage || {}; + _log("Result:", + "turns=" + (message.num_turns || 0), + "in=" + (u.input_tokens || 0), + "out=" + (u.output_tokens || 0), + "cacheRead=" + (u.cache_read_input_tokens || 0), + "cacheCreate=" + (u.cache_creation_input_tokens || 0), + "cost=$" + (message.total_cost_usd || 0).toFixed(4), + "ms=" + (message.duration_ms || 0), + "apiMs=" + (message.duration_api_ms || 0), + "subtype=" + message.subtype); + for (const modelName of Object.keys(mu)) { + const m = mu[modelName]; + _log("Model usage[" + modelName + "]:", + "in=" + (m.inputTokens || 0), + "out=" + (m.outputTokens || 0), + "cacheRead=" + (m.cacheReadInputTokens || 0), + "cacheCreate=" + (m.cacheCreationInputTokens || 0), + "websearch=" + (m.webSearchRequests || 0), + "cost=$" + (m.costUSD || 0).toFixed(4), + "ctxWindow=" + (m.contextWindow || 0)); + } + // Forward to the browser so AIChatPanel can raise metrics. + // Stuck on its own event so the existing aiComplete handler + // doesn't have to change shape. + nodeConnector.triggerPeer("aiUsage", { + requestId: requestId, + sessionId: currentSessionId, + subtype: message.subtype, + isError: !!message.is_error, + numTurns: message.num_turns || 0, + durationMs: message.duration_ms || 0, + durationApiMs: message.duration_api_ms || 0, + totalCostUSD: message.total_cost_usd || 0, + usage: u, + modelUsage: mu + }); + } + // Handle streaming events if (message.type === "stream_event") { const event = message.event; diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index 933cb12a17..b984e9375c 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -755,6 +755,12 @@ define(function (require, exports, module) { */ function _handlePreviewBtnClick() { const currentMode = LiveDevelopment.getCurrentMode(); + // Metric: which direction the user is toggling so we can see + // how often the inline-edit affordance is actually used vs + // simply opened-then-closed. + Metrics.countEvent(Metrics.EVENT_TYPE.LP_EDIT, "modeBtn", + currentMode === LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE + ? "toPrev" : "toEdit"); if (currentMode === LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE) { LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE); return; diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 0d8e19571b..2caa47661d 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -33,6 +33,7 @@ define(function (require, exports, module) { const WorkspaceManager = require("view/WorkspaceManager"); const ProjectManager = require("project/ProjectManager"); const ExtensionUtils = require("utils/ExtensionUtils"); + const Metrics = require("utils/Metrics"); const NodeConnector = require("NodeConnector"); const Mustache = require("thirdparty/mustache/mustache"); const Dialogs = require("widgets/Dialogs"); @@ -167,6 +168,7 @@ define(function (require, exports, module) { active.focus(); } _showFocusHintToast(); + Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "panel", "open"); } }); @@ -199,6 +201,11 @@ define(function (require, exports, module) { ShellProfiles.setDefaultShell(shell.name); _populateShellDropdown(); _updateNewTerminalButtonLabel(); + // Metric: which shell the user picked from the dropdown + // (default-shell switch). _createNewTerminalWithShell below + // will also raise its own "new" metric for the spawn. + Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "pick", + _shellMetricLabel(shell.name)); _createNewTerminalWithShell(shell); }); $shellDropdown.append($item); @@ -290,11 +297,33 @@ define(function (require, exports, module) { * @param {Object} shell - Shell profile to use * @param {string} [cwdOverride] - Optional VFS path to use as cwd instead of project root */ + /** + * Map an OS shell name (e.g. "powershell.exe", "bash.exe") to a short + * family label so the metrics server's per-event length budget stays + * comfortable. Strips ".exe" and lower-cases; unknown shells fall + * through under "other" (with their lower-cased name shown only in + * the cap'd 8-char form). + */ + function _shellMetricLabel(shellName) { + if (!shellName) { return "unknown"; } + let n = String(shellName).toLowerCase(); + if (n.endsWith(".exe")) { n = n.slice(0, -4); } + // pwsh & powershell are functionally the same family for the metric. + if (n === "powershell") { n = "pwsh"; } + // Cap so an unexpected long shell name can't blow the label cell. + if (n.length > 8) { n = n.slice(0, 8); } + return n || "unknown"; + } + async function _createNewTerminalWithShell(shell, cwdOverride) { if (!shell) { console.error("Terminal: No shell available"); + Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "new", "noShell"); return; } + // Metric: a new terminal session was created, keyed by shell family. + Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "new", + _shellMetricLabel(shell.name)); // Get cwd: use override if provided, otherwise fall back to project root let cwd; @@ -320,6 +349,16 @@ define(function (require, exports, module) { // Add to list terminalInstances.push(instance); + // Tab-count bucket metric — raised only on tab creation so we + // can plot how many concurrent terminal tabs users keep open. + // Buckets: 1 → "one", 2..4 → "LTE4", 5..9 → "LTE9", 10+ → "GT10". + const count = terminalInstances.length; + const tabsBucket = count === 1 ? "one" + : count <= 4 ? "LTE4" + : count <= 9 ? "LTE9" + : "GT10"; + Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "tabs", tabsBucket); + // Activate this terminal (also updates flyout) _activateTerminal(instance.id); @@ -394,6 +433,7 @@ define(function (require, exports, module) { instance.dispose(); terminalInstances.splice(idx, 1); delete processInfo[id]; + Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "close", "user"); // If we closed the active terminal, activate another if (activeTerminalId === id) { @@ -563,6 +603,12 @@ define(function (require, exports, module) { function _onTerminalProcessExit(id, exitCode) { delete processInfo[id]; _updateFlyout(); + // Metric: terminal process exited on its own (e.g. user typed + // "exit"). Distinct from "user" close above, which records the + // X-button / panel-driven close path. Exit code is bucketed + // ok/err so cardinality stays bounded. + Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "exit", + exitCode === 0 ? "ok" : "err"); } /** diff --git a/src/utils/Metrics.js b/src/utils/Metrics.js index 9d096e5722..0c007ffd01 100644 --- a/src/utils/Metrics.js +++ b/src/utils/Metrics.js @@ -129,6 +129,8 @@ define(function (require, exports, module) { GIT: "git", AUTH: "auth", PRO: "pro", + AI: "ai", + TERMINAL: "term", GUIDE: "guide" }; diff --git a/src/view/CentralControlBar.js b/src/view/CentralControlBar.js index 69c9b0c160..23155f6ff4 100644 --- a/src/view/CentralControlBar.js +++ b/src/view/CentralControlBar.js @@ -25,6 +25,7 @@ define(function (require, exports, module) { const Commands = require("command/Commands"); const DocumentManager = require("document/DocumentManager"); const MainViewManager = require("view/MainViewManager"); + const Metrics = require("utils/Metrics"); const Strings = require("strings"); const WorkspaceManager = require("view/WorkspaceManager"); const SidebarView = require("project/SidebarView"); @@ -302,20 +303,41 @@ define(function (require, exports, module) { $btn.find("i").attr("class", isVisible ? "fa-solid fa-angles-left" : "fa-solid fa-angles-right"); } + function _ccbClickMetric(label) { + Metrics.countEvent(Metrics.EVENT_TYPE.UI, "ccb", label); + } + function _wireButtons() { - $("#ccbUndoBtn").on("click", function (e) { e.preventDefault(); _executeCmd(Commands.EDIT_UNDO); }); - $("#ccbRedoBtn").on("click", function (e) { e.preventDefault(); _executeCmd(Commands.EDIT_REDO); }); - $("#ccbSaveBtn").on("click", function (e) { e.preventDefault(); _executeCmd(Commands.FILE_SAVE); }); + $("#ccbUndoBtn").on("click", function (e) { + e.preventDefault(); + _ccbClickMetric("undo"); + _executeCmd(Commands.EDIT_UNDO); + }); + $("#ccbRedoBtn").on("click", function (e) { + e.preventDefault(); + _ccbClickMetric("redo"); + _executeCmd(Commands.EDIT_REDO); + }); + $("#ccbSaveBtn").on("click", function (e) { + e.preventDefault(); + _ccbClickMetric("save"); + _executeCmd(Commands.FILE_SAVE); + }); $("#ccbCollapseEditorBtn").on("click", function (e) { e.preventDefault(); + // editorCollapsed reflects the state *before* the toggle + // executes; record which direction the user is going. + _ccbClickMetric(editorCollapsed ? "designOff" : "designOn"); CommandManager.execute(Commands.VIEW_TOGGLE_DESIGN_MODE); }); $("#ccbSidebarToggleBtn").on("click", function (e) { e.preventDefault(); + _ccbClickMetric("sidebar"); _executeCmd(Commands.VIEW_HIDE_SIDEBAR); }); $("#ccbFileLabel").on("click", function (e) { e.preventDefault(); + _ccbClickMetric("file"); _executeCmd(Commands.NAVIGATE_SHOW_IN_FILE_TREE); }); } diff --git a/src/view/SidebarTabs.js b/src/view/SidebarTabs.js index a37da25a54..97736b9edc 100644 --- a/src/view/SidebarTabs.js +++ b/src/view/SidebarTabs.js @@ -39,6 +39,7 @@ define(function (require, exports, module) { const AppInit = require("utils/AppInit"), EventDispatcher = require("utils/EventDispatcher"), + Metrics = require("utils/Metrics"), PreferencesManager = require("preferences/PreferencesManager"); // --- Constants ----------------------------------------------------------- @@ -495,6 +496,14 @@ define(function (require, exports, module) { $navTabBar.on("click", ".sidebar-tab", function () { const tabId = $(this).attr("data-tab-id"); if (tabId) { + // Track only real user clicks here (programmatic + // setActiveTab calls — e.g. the phoenix-tour AI peek — + // shouldn't inflate the click metric). Map the built-in + // Files tab id to a short label; pass the (already short) + // id for the rest. Triple stays well within the metrics + // server's per-event length budget. + const label = (tabId === SIDEBAR_TAB_FILES) ? "files" : tabId; + Metrics.countEvent(Metrics.EVENT_TYPE.UI, "navTab", label); setActiveTab(tabId); } }); From ae92bcd2c0622dafbd945e92885168d7d9fb49d4 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 1 May 2026 20:11:21 +0530 Subject: [PATCH 2/6] chore: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index c8366fb55f..6565d10b41 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "568f1596cb1523acda5a8d882b46e3166a5eb2a2" + "commitID": "5071a6c03641ca843a3b67141e14c657228df930" } } From 946fa04f1fbc479ab9b89b5e43cad9bbfe133ab6 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 1 May 2026 20:44:42 +0530 Subject: [PATCH 3/6] feat(metrics): markdown editor usage instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds new EVENT_TYPE.MD = "md" and instruments the high-value markdown editor surfaces. All payloads are metadata-only — no markdown content, file paths, image data, or user keystrokes cross any boundary. Bridge: - New mdviewrMetric event in MarkdownSync routes { kind, subcat, label, value? } from the iframe to Metrics.countEvent / valueEvent under EVENT_TYPE.MD with type validation. - src-mdviewer/src/bridge.js exports metricCount / metricValue helpers iframe modules call directly. Host-side metrics (MarkdownSync.js + main.js + EditorCommandHandlers): - md/theme/{dark,light} initial theme on iframe-ready, plus every mdviewrThemeToggle. - md/mode/{edit,reader} every mode transition. - md/doc/edited at most once per file per session (first iframe content change). - md/upsell/shown live-preview md edit upsell opens for a free user (mirror of the existing lp-edit/mdEditUpsell/show metric). - md/image/pasteCM image pasted in the CodeMirror markdown editor. Iframe-side metrics (via mdviewrMetric): - md/nav/formatClick bold / italic / strike / underline / inline code / heading dropdown. - md/nav/itemClick every other top-toolbar button (link, lists, task, quote, hr, table, codeblock, image-url, image-upload). - md/image/insert image-insert toolbar buttons (paired with the itemClick above for cross- feature roll-up). - md/image/pasteLP image paste inside the LP iframe. - md/print/click print toolbar button. - md/slash/popup slash menu becomes visible. - md/slash/select a slash item was picked (no item id recorded; recordUsage already tracks local prioritisation). - md/table/{rowEdit,colEdit} any row-handle / column-handle menu action — single bucket per type so we keep cardinality low. --- src-mdviewer/src/bridge.js | 13 +++++ src-mdviewer/src/components/editor.js | 16 ++++++ .../src/components/embedded-toolbar.js | 22 ++++++++ src-mdviewer/src/components/slash-menu.js | 7 +++ src/editor/EditorCommandHandlers.js | 6 +++ .../Phoenix-live-preview/MarkdownSync.js | 53 +++++++++++++++++++ .../Phoenix-live-preview/main.js | 3 ++ src/utils/Metrics.js | 1 + 8 files changed, 121 insertions(+) diff --git a/src-mdviewer/src/bridge.js b/src-mdviewer/src/bridge.js index 3f87a0581f..684313c2fa 100644 --- a/src-mdviewer/src/bridge.js +++ b/src-mdviewer/src/bridge.js @@ -1430,3 +1430,16 @@ function sendToParent(eventName, payload) { ...payload }, "*"); } + +// ─── Metrics helpers ─────────────────────────────────────────────── +// Forwards metric events to the host (MarkdownSync.js) which routes them +// to Metrics.countEvent / valueEvent under EVENT_TYPE.MD. Only metadata +// (subcat, label, optional numeric value) crosses the boundary — never +// markdown content, file paths, or user keystrokes. +export function metricCount(subcat, label) { + sendToParent("mdviewrMetric", { kind: "count", subcat, label }); +} + +export function metricValue(subcat, label, value) { + sendToParent("mdviewrMetric", { kind: "value", subcat, label, value }); +} diff --git a/src-mdviewer/src/components/editor.js b/src-mdviewer/src/components/editor.js index 19009c7230..0bdbf284ed 100644 --- a/src-mdviewer/src/components/editor.js +++ b/src-mdviewer/src/components/editor.js @@ -12,6 +12,7 @@ import { initImagePopover, destroyImagePopover } from "./image-popover.js"; import { initLangPicker, destroyLangPicker, isLangPickerDropdownOpen } from "./lang-picker.js"; import { highlightCode, renderAfterHTML, normalizeCodeLanguages } from "./viewer.js"; import { initMermaidEditor, destroyMermaidEditor, insertMermaidBlock, attachOverlays } from "./mermaid-editor.js"; +import { metricCount } from "../bridge.js"; const devLog = import.meta.env.DEV ? console.log.bind(console, "[editor]") : () => {}; let turndown = null; @@ -713,6 +714,10 @@ function handleImagePaste(e, contentEl) { for (let i = 0; i < items.length; i++) { if (items[i].kind === "file" && ALLOWED_IMAGE_TYPES.includes(items[i].type)) { e.preventDefault(); + // Image paste in the live-preview iframe (paired with + // md/image/pasteCM for the CodeMirror editor path). No + // image data is recorded — just the count. + metricCount("image", "pasteLP"); const blob = items[i].getAsFile(); const fileName = blob.name || ("image." + blob.type.split("/")[1]); const uploadId = _insertUploadPlaceholder(contentEl); @@ -1005,6 +1010,17 @@ function showHandleMenu(anchor, type, ctx, contentEl, wrapper, clickX) { ]; } + // Wrap each action so picking any row/col menu item raises a single + // metric (md/table/rowEdit or colEdit). We don't record which + // specific edit — just that one happened — to keep cardinality low. + const editLabel = type === "row" ? "rowEdit" : "colEdit"; + items.forEach((it) => { + if (it.action) { + const orig = it.action; + it.action = () => { metricCount("table", editLabel); orig(); }; + } + }); + menu.innerHTML = ""; items.forEach((item) => { if (item.divider) { diff --git a/src-mdviewer/src/components/embedded-toolbar.js b/src-mdviewer/src/components/embedded-toolbar.js index 4589a98e75..8bce75653c 100644 --- a/src-mdviewer/src/components/embedded-toolbar.js +++ b/src-mdviewer/src/components/embedded-toolbar.js @@ -40,6 +40,16 @@ import { import { on, emit } from "../core/events.js"; import { getState, setState } from "../core/state.js"; import { t, tp } from "../core/i18n.js"; +import { metricCount } from "../bridge.js"; + +// Toolbar buttons whose clicks roll up under md/nav/formatClick (text- +// formatting and heading switches). Anything not in this set is treated +// as a generic md/nav/itemClick so we capture toolbar usage without +// fragmenting the metric across every button id. +const FORMAT_CLICK_IDS = new Set([ + "emb-bold", "emb-italic", "emb-strike", "emb-underline", "emb-code", + "emb-block-type" // heading dropdown +]); let toolbar = null; let resizeObserver = null; @@ -264,6 +274,14 @@ function wireFormatButtons() { if (el) { el.addEventListener("mousedown", (e) => { e.preventDefault(); + // Generic toolbar usage metric: format vs item bucket. + // Image insert specifically is also tracked under + // md/image/insert below for cross-feature roll-up. + metricCount("nav", + FORMAT_CLICK_IDS.has(binding.id) ? "formatClick" : "itemClick"); + if (binding.id === "emb-image-url" || binding.id === "emb-image-upload") { + metricCount("image", "insert"); + } emit("action:format", { command: binding.command, value: binding.value }); }); } @@ -274,6 +292,9 @@ function wireBlockTypeSelect() { const blockTypeSelect = document.getElementById("emb-block-type"); if (blockTypeSelect) { blockTypeSelect.addEventListener("change", (e) => { + // Heading dropdown rolls up under formatClick (heading + // selection is a formatting action, just delivered via select). + metricCount("nav", "formatClick"); emit("action:format", { command: "formatBlock", value: e.target.value }); e.target.blur(); }); @@ -347,6 +368,7 @@ function wirePrintButton() { const printBtn = document.getElementById("emb-print-btn"); if (printBtn) { printBtn.addEventListener("click", () => { + metricCount("print", "click"); window.print(); }); } diff --git a/src-mdviewer/src/components/slash-menu.js b/src-mdviewer/src/components/slash-menu.js index c18faba7e5..1db369b1cd 100644 --- a/src-mdviewer/src/components/slash-menu.js +++ b/src-mdviewer/src/components/slash-menu.js @@ -20,6 +20,7 @@ import { import { emit } from "../core/events.js"; import { getSelectionRect } from "./editor.js"; import { t } from "../core/i18n.js"; +import { metricCount } from "../bridge.js"; let menu = null; let contentEl = null; @@ -221,6 +222,9 @@ function show() { const rect = _savedSlashRect; const anchor = document.getElementById("slash-menu-anchor"); if (!anchor) return; + // Slash popup appearance metric — fires when the menu actually + // becomes visible, not on every keystroke that filters items. + metricCount("slash", "popup"); // Position anchor below the cursor line with gap const lineHeight = rect.bottom - rect.top; @@ -273,6 +277,9 @@ function selectItem(index) { if (index < 0 || index >= filteredItems.length) return; const item = filteredItems[index]; recordUsage(item.labelKey); + // Slash selection metric — does not record which item was picked + // (recordUsage already tracks that locally for prioritisation). + metricCount("slash", "select"); // Capture slashRange before hide() clears it const savedRange = slashRange; diff --git a/src/editor/EditorCommandHandlers.js b/src/editor/EditorCommandHandlers.js index 7a8aa3669d..4a726d303b 100644 --- a/src/editor/EditorCommandHandlers.js +++ b/src/editor/EditorCommandHandlers.js @@ -41,6 +41,7 @@ define(function (require, exports, module) { ChangeHelper = require("editor/EditorHelper/ChangeHelper"), LanguageManager = require("language/LanguageManager"), ImageUploadManager = require("features/ImageUploadManager"), + Metrics = require("utils/Metrics"), Dialogs = require("widgets/Dialogs"), AppInit = require("utils/AppInit"); @@ -1316,6 +1317,11 @@ define(function (require, exports, module) { event.preventDefault(); + // Metric: image paste in the CodeMirror markdown editor view + // (paired with md/image/pasteLP for the live-preview iframe path). + // No image data or filename is recorded — just the count. + Metrics.countEvent(Metrics.EVENT_TYPE.MD, "image", "pasteCM"); + const blob = imageItem.getAsFile(); const fileName = blob.name || ("image." + blob.type.split("/")[1]); const provider = ImageUploadManager.getImageUploadProvider(); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index 34c930b5f2..7bc041f15f 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -29,6 +29,7 @@ define(function (require, exports, module) { CommandManager = require("command/CommandManager"), Commands = require("command/Commands"), KeyBindingManager = require("command/KeyBindingManager"), + Metrics = require("utils/Metrics"), utils = require("./utils"); // Commands whose shortcuts, when forwarded from the md viewer iframe, @@ -42,6 +43,11 @@ define(function (require, exports, module) { Commands.CMD_FIND_IN_FILES ]; + // Set of file paths the user has edited in the markdown viewer this + // session. Used to fire md/doc/edited at most once per file so the + // metric tells us "users who edit at least one md doc" without + // inflating with every keystroke. + const _mdEditedFiles = new Set(); let _active = false; let _doc = null; let _$iframe = null; @@ -127,6 +133,12 @@ define(function (require, exports, module) { _handleRedo(); break; case "mdviewrEditModeChanged": + // Mode switch metric — reader/edit are the only two + // states the iframe reports. Fires on every transition + // (including init) so we see how often users go in and + // out of edit mode. + Metrics.countEvent(Metrics.EVENT_TYPE.MD, "mode", + data.editMode ? "edit" : "reader"); // When switching to reader, send CM content so the iframe // can re-render with accurate data-source-line for cursor sync. if (!data.editMode && _doc) { @@ -161,6 +173,30 @@ define(function (require, exports, module) { if (_onThemeToggle) { _onThemeToggle(data.theme); } + // Theme switch metric — fires on every user toggle. The + // initial state (light vs dark) is reported separately + // when the viewer first activates a markdown doc. + if (data.theme === "dark" || data.theme === "light") { + Metrics.countEvent(Metrics.EVENT_TYPE.MD, "theme", data.theme); + } + break; + case "mdviewrMetric": + // Generic metric forwarder for events the iframe wants + // to record. Schema: + // { kind: "count" | "value", subcat, label, [value] } + // We validate types before forwarding so a malformed + // payload from the iframe can't break the metrics + // pipeline. Category is always EVENT_TYPE.MD. + if (data && typeof data.subcat === "string" && data.subcat && + typeof data.label === "string" && data.label) { + if (data.kind === "value" && Number.isFinite(data.value)) { + Metrics.valueEvent(Metrics.EVENT_TYPE.MD, + data.subcat, data.label, data.value); + } else { + Metrics.countEvent(Metrics.EVENT_TYPE.MD, + data.subcat, data.label); + } + } break; case "mdviewrImageUploadRequest": _handleImageUploadFromIframe(data); @@ -390,6 +426,15 @@ define(function (require, exports, module) { _sendTheme(); _sendLocale(); _sendSkipRefocusShortcuts(); + // Record the effective theme the user is starting in + // (light vs dark). Mirror of mdviewrThemeToggle so the dark/ + // light counter reflects both initial state and toggles. + const initialTheme = _themeOverride + || ((ThemeManager.getCurrentTheme() && ThemeManager.getCurrentTheme().dark) + ? "dark" : "light"); + if (initialTheme === "dark" || initialTheme === "light") { + Metrics.countEvent(Metrics.EVENT_TYPE.MD, "theme", initialTheme); + } if (_onIframeReadyCallback) { _onIframeReadyCallback(); } @@ -622,6 +667,14 @@ define(function (require, exports, module) { _applyDiffToEditor(markdown); + // First-edit metric per file per session — fires at most once + // per document so "users who edit any md file" is a clean count. + const editedPath = _doc && _doc.file && _doc.file.fullPath; + if (editedPath && !_mdEditedFiles.has(editedPath)) { + _mdEditedFiles.add(editedPath); + Metrics.countEvent(Metrics.EVENT_TYPE.MD, "doc", "edited"); + } + // Send back the actual CM text so the iframe can compute accurate // data-source-line attributes. The markdown from convertToMarkdown // may differ slightly from CM's content (e.g. table formatting), diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index b984e9375c..850c374b7d 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -1626,6 +1626,9 @@ define(function (require, exports, module) { text: buttonGetProText } ]; Metrics.countEvent(Metrics.EVENT_TYPE.LP_EDIT, "mdEditUpsell", "show"); + // Mirror under the MD category so it groups with the rest + // of the markdown-editor metrics on the dashboard. + Metrics.countEvent(Metrics.EVENT_TYPE.MD, "upsell", "shown"); Dialogs.showModalDialog(Dialogs.DIALOG_ID_INFO, Strings.AVAILABLE_IN_PRO_TITLE, Strings.MD_EDIT_UPSELL_MESSAGE, buttons) diff --git a/src/utils/Metrics.js b/src/utils/Metrics.js index 0c007ffd01..5b05b447ea 100644 --- a/src/utils/Metrics.js +++ b/src/utils/Metrics.js @@ -131,6 +131,7 @@ define(function (require, exports, module) { PRO: "pro", AI: "ai", TERMINAL: "term", + MD: "md", GUIDE: "guide" }; From 64d766398ad521805acdbbb00356687810d76ce9 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 1 May 2026 20:57:44 +0530 Subject: [PATCH 4/6] chore: update pro deps --- .../Phoenix-live-preview/MarkdownSync.js | 26 +++++++++++++++++++ tracking-repos.json | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index 7bc041f15f..35ce3e35e9 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -48,6 +48,20 @@ define(function (require, exports, module) { // metric tells us "users who edit at least one md doc" without // inflating with every keystroke. const _mdEditedFiles = new Set(); + + // Session-cumulative edit-batch count for distinguishing light vs + // heavy markdown editors. Each iframe content change (already + // 50ms-debounced upstream) increments this. When the count crosses + // a bucket threshold we fire a one-shot count event so the analytics + // funnel reads as: users-with-LTE5 ⊇ LTE25 ⊇ LTE100 ⊇ GT500. + let _mdEditCount = 0; + const MD_EDIT_BUCKETS = [ + { threshold: 5, label: "LTE5" }, + { threshold: 25, label: "LTE25" }, + { threshold: 100, label: "LTE100" }, + { threshold: 500, label: "GT500" } + ]; + let _active = false; let _doc = null; let _$iframe = null; @@ -675,6 +689,18 @@ define(function (require, exports, module) { Metrics.countEvent(Metrics.EVENT_TYPE.MD, "doc", "edited"); } + // Edit-volume bucket: fire once when the cumulative session + // edit count first crosses each threshold. Each bucket fires + // at most once per session, so the metric reads as a funnel. + _mdEditCount++; + for (let i = 0; i < MD_EDIT_BUCKETS.length; i++) { + if (_mdEditCount === MD_EDIT_BUCKETS[i].threshold) { + Metrics.countEvent(Metrics.EVENT_TYPE.MD, + "edits", MD_EDIT_BUCKETS[i].label); + break; + } + } + // Send back the actual CM text so the iframe can compute accurate // data-source-line attributes. The markdown from convertToMarkdown // may differ slightly from CM's content (e.g. table formatting), diff --git a/tracking-repos.json b/tracking-repos.json index 6565d10b41..5d26a3f6a2 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "5071a6c03641ca843a3b67141e14c657228df930" + "commitID": "99559768f4fbbab86ed3589e9a51d3a915f51986" } } From 7847d97a84cb0606627fdf3d9576cc37eeae894d Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 1 May 2026 21:00:54 +0530 Subject: [PATCH 5/6] feat(metrics): bucket md viewer edit volume into LTE500 / LTE1K / GT1K MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a session-cumulative edit-batch counter in _onIframeContentChanged that fires one-shot count events as the user crosses each bucket threshold: md/edits/LTE500 first time a session reaches >=500 edit batches md/edits/LTE1K first time a session reaches >=1000 edit batches md/edits/GT1K first time a session crosses 1001+ edit batches Each bucket fires at most once per session, so the funnel reads as LTE500 >= LTE1K >= GT1K — answering "what fraction of users edit heavily in the markdown live preview?" The counter only increments on iframe content changes (md viewer edit mode), not on direct CodeMirror edits, since CM bypasses _onIframeContentChanged. A "batch" is a 50ms-debounced burst of edits (set by the iframe's existing debounce), so the buckets are representative volumes rather than per-keystroke counts. --- .../Phoenix-live-preview/MarkdownSync.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index 35ce3e35e9..c2b96acb39 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -53,13 +53,13 @@ define(function (require, exports, module) { // heavy markdown editors. Each iframe content change (already // 50ms-debounced upstream) increments this. When the count crosses // a bucket threshold we fire a one-shot count event so the analytics - // funnel reads as: users-with-LTE5 ⊇ LTE25 ⊇ LTE100 ⊇ GT500. + // funnel reads as: users-with-LTE500 ⊇ LTE1K ⊇ GT1K. A representative + // sample, not a fine-grained histogram. let _mdEditCount = 0; const MD_EDIT_BUCKETS = [ - { threshold: 5, label: "LTE5" }, - { threshold: 25, label: "LTE25" }, - { threshold: 100, label: "LTE100" }, - { threshold: 500, label: "GT500" } + { threshold: 500, label: "LTE500" }, + { threshold: 1000, label: "LTE1K" }, + { threshold: 1001, label: "GT1K" } ]; let _active = false; From 738bfe617f0e4f8b3584b7d6e55a19e508402e9d Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 1 May 2026 21:01:58 +0530 Subject: [PATCH 6/6] fix(metrics): keep original md/edits buckets, add LTE500/LTE1K/GT1K Restores the LTE5 / LTE25 / LTE100 / GT500 buckets dropped in the previous commit and adds the requested coarser buckets LTE500 / LTE1K / GT1K alongside. Drops the for-loop early-out so multiple labels at the same threshold all fire (GT500 and LTE500 both fire at 500 batches); preserves the per-session one-shot semantics. --- .../Phoenix-live-preview/MarkdownSync.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js index c2b96acb39..97902ade7d 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js @@ -53,10 +53,14 @@ define(function (require, exports, module) { // heavy markdown editors. Each iframe content change (already // 50ms-debounced upstream) increments this. When the count crosses // a bucket threshold we fire a one-shot count event so the analytics - // funnel reads as: users-with-LTE500 ⊇ LTE1K ⊇ GT1K. A representative - // sample, not a fine-grained histogram. + // funnel reads as: users-with-LTE5 ⊇ LTE25 ⊇ LTE100 ⊇ GT500/LTE500 + // ⊇ LTE1K ⊇ GT1K — a coarse-to-fine view of edit volume per session. let _mdEditCount = 0; const MD_EDIT_BUCKETS = [ + { threshold: 5, label: "LTE5" }, + { threshold: 25, label: "LTE25" }, + { threshold: 100, label: "LTE100" }, + { threshold: 500, label: "GT500" }, { threshold: 500, label: "LTE500" }, { threshold: 1000, label: "LTE1K" }, { threshold: 1001, label: "GT1K" } @@ -692,12 +696,13 @@ define(function (require, exports, module) { // Edit-volume bucket: fire once when the cumulative session // edit count first crosses each threshold. Each bucket fires // at most once per session, so the metric reads as a funnel. + // No early-out: multiple labels at the same threshold all + // fire (e.g. GT500 + LTE500 at 500 batches). _mdEditCount++; for (let i = 0; i < MD_EDIT_BUCKETS.length; i++) { if (_mdEditCount === MD_EDIT_BUCKETS[i].threshold) { Metrics.countEvent(Metrics.EVENT_TYPE.MD, "edits", MD_EDIT_BUCKETS[i].label); - break; } }