Skip to content
Merged

metrics #2883

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src-mdviewer/src/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
16 changes: 16 additions & 0 deletions src-mdviewer/src/components/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions src-mdviewer/src/components/embedded-toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
});
}
Expand All @@ -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();
});
Expand Down Expand Up @@ -347,6 +368,7 @@ function wirePrintButton() {
const printBtn = document.getElementById("emb-print-btn");
if (printBtn) {
printBtn.addEventListener("click", () => {
metricCount("print", "click");
window.print();
});
}
Expand Down
7 changes: 7 additions & 0 deletions src-mdviewer/src/components/slash-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions src-node/claude-code-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/editor/EditorCommandHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
Expand Down
84 changes: 84 additions & 0 deletions src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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,
Expand All @@ -42,6 +43,29 @@
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();

// 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/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" }
];

let _active = false;
let _doc = null;
let _$iframe = null;
Expand Down Expand Up @@ -127,6 +151,12 @@
_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) {
Expand Down Expand Up @@ -161,6 +191,30 @@
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);
Expand Down Expand Up @@ -390,6 +444,15 @@
_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)

Check warning on line 451 in src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ3kJ5LWzMkHQLnVrfM9&open=AZ3kJ5LWzMkHQLnVrfM9&pullRequest=2883
? "dark" : "light");
if (initialTheme === "dark" || initialTheme === "light") {
Metrics.countEvent(Metrics.EVENT_TYPE.MD, "theme", initialTheme);
}
if (_onIframeReadyCallback) {
_onIframeReadyCallback();
}
Expand Down Expand Up @@ -588,7 +651,7 @@
setTimeout(function () { _scrollSyncFromIframe = false; }, 200);
}

function _onIframeContentChanged(data) {

Check failure on line 654 in src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ3kKEP-B7jd3e8SMsMC&open=AZ3kKEP-B7jd3e8SMsMC&pullRequest=2883
if (!_active || !_doc) {
return;
}
Expand Down Expand Up @@ -622,6 +685,27 @@

_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;

Check warning on line 690 in src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ3kJ5LWzMkHQLnVrfM-&open=AZ3kJ5LWzMkHQLnVrfM-&pullRequest=2883
if (editedPath && !_mdEditedFiles.has(editedPath)) {
_mdEditedFiles.add(editedPath);
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.
// 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);
}
}

Check warning on line 707 in src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected a `for-of` loop instead of a `for` loop with this simple iteration.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ3kKEP-B7jd3e8SMsMD&open=AZ3kKEP-B7jd3e8SMsMD&pullRequest=2883

// 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),
Expand Down
9 changes: 9 additions & 0 deletions src/extensionsIntegrated/Phoenix-live-preview/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1620,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)
Expand Down
Loading
Loading