diff --git a/docs/API-Reference/command/Commands.md b/docs/API-Reference/command/Commands.md index b5ae5e4f04..b975177529 100644 --- a/docs/API-Reference/command/Commands.md +++ b/docs/API-Reference/command/Commands.md @@ -542,6 +542,12 @@ Toggles problems panel visibility ## VIEW\_TERMINAL Opens the terminal panel +**Kind**: global variable + + +## VIEW\_ENABLE\_AI +Toggles whether AI features are enabled + **Kind**: global variable diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 6135b31a23..a3b4644e07 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -252,7 +252,8 @@ define(function (require, exports, module) { Strings = require("strings"), KeyEvent = require("utils/KeyEvent"), CodeHintListModule = require("editor/CodeHintList"), - PreferencesManager = require("preferences/PreferencesManager"); + PreferencesManager = require("preferences/PreferencesManager"), + Metrics = require("utils/Metrics"); const CodeHintList = CodeHintListModule.CodeHintList; @@ -264,7 +265,9 @@ define(function (require, exports, module) { deferredHints = null, keyDownEditor = null, codeHintsEnabled = true, - codeHintOpened = false; + codeHintOpened = false, + sessionLanguageId = null, // language at cursor when the session began - metric label + sessionShownMetricSent = false; // one metric per session, not one per keystroke update // API for extensions to show hints at the top let hintsAtTopHandler = null; @@ -284,8 +287,46 @@ define(function (require, exports, module) { PreferencesManager.on("change", "showCodeHints", function () { codeHintsEnabled = PreferencesManager.get("showCodeHints"); + if (!codeHintsEnabled) { + // The opt-out signal: code hints in the non-default off state. Also fires on boot + // pref-load for users who keep it off - fine, it's a rough signal. + Metrics.countEvent(Metrics.EVENT_TYPE.CODE_HINTS, "pref", "off"); + } }); + /** + * Counts the hint list becoming visible with results, once per session. Also attributes + * the session to its language server when the provider is LSP-backed, so per-server usage + * (and acceptance rate vs the accept metric) is computable. Batched - hint sessions are + * per-keystroke-frequency events. + * @private + * @param {{hints: Array}} response the response about to be shown + */ + function _recordHintsShownMetric(response) { + if (sessionShownMetricSent || !response || !response.hints || !response.hints.length) { + return; + } + sessionShownMetricSent = true; + Metrics.countEventBatched(Metrics.EVENT_TYPE.CODE_HINTS, "show", sessionLanguageId); + if (sessionProvider && sessionProvider.client && sessionProvider.client._metricLabel) { + Metrics.countEventBatched(Metrics.EVENT_TYPE.LSP, "hint", + "Show." + sessionProvider.client._metricLabel); + } + } + + /** + * A provider threw synchronously (hasHints/getHints) - previously this propagated up + * through the typing handler and could wedge the session. Count it and report it once per + * language per app run. + * @private + */ + function _recordProviderError(err, languageId) { + console.error("[CodeHints] provider threw in hint session (" + languageId + ")", err); + Metrics.countEvent(Metrics.EVENT_TYPE.CODE_HINTS, "provErr", languageId); + window.logger.reportErrorOnce("hintProv." + languageId, err, + "[CodeHints] provider threw in hint session (" + languageId + ")"); + } + /** * Comparator to sort providers from high to low priority * @private @@ -406,6 +447,9 @@ define(function (require, exports, module) { sessionProvider = null; sessionEditor = null; if (deferredHints) { + // Mark this as a manager-side cancellation so the fail handler attached in + // _updateHintList doesn't count it as a provider failure. + deferredHints._canceledByHintManager = true; deferredHints.reject(); deferredHints = null; } @@ -445,6 +489,7 @@ define(function (require, exports, module) { callMoveUpEvent = typeof callMoveUpEvent === "undefined" ? false : callMoveUpEvent; if (deferredHints) { + deferredHints._canceledByHintManager = true; // superseded by a newer query, not a failure deferredHints.reject(); deferredHints = null; } @@ -463,7 +508,14 @@ define(function (require, exports, module) { // Get hints from regular provider if available if (sessionProvider) { - response = sessionProvider.getHints(lastChar); + try { + response = sessionProvider.getHints(lastChar); + } catch (e) { + // A throwing provider must not wedge the session - fall through to the + // no-response path below, which closes it cleanly. + _recordProviderError(e, sessionLanguageId); + response = null; + } } lastChar = null; @@ -501,6 +553,7 @@ define(function (require, exports, module) { } } + _recordHintsShownMetric(response); if (hintList.isOpen()) { // the session is open hintList.update(response); @@ -509,6 +562,16 @@ define(function (require, exports, module) { } } else { // response is a deferred deferredHints = response; + response.fail(function () { + // Provider-side failure (e.g. an LSP completion request errored) - the + // manager's own cancellations (superseded query / session end) are marked + // and excluded. Metrics only: per-server failures are counted at the LSP + // layer, this tracks how often users see hints silently not appear. + if (!response._canceledByHintManager) { + Metrics.countEvent(Metrics.EVENT_TYPE.CODE_HINTS, "fail", + sessionLanguageId || "ukn"); + } + }); response.done(function (hints) { // Guard against timing issues where the session ends before the // response gets a chance to execute the callback. If the session @@ -527,6 +590,7 @@ define(function (require, exports, module) { } } + _recordHintsShownMetric(hints); if (hintList.isOpen()) { // the session is open hintList.update(hints); @@ -565,18 +629,30 @@ define(function (require, exports, module) { var language = editor.getLanguageForSelection(), enabledProviders = _getProvidersForLanguageId(language.getId()); + sessionLanguageId = language.getId(); + sessionShownMetricSent = false; + // Check if hints-at-top handler has hints first to avoid duplication var hasTopHints = false; if (hintsAtTopHandler && hintsAtTopHandler.hasHints) { - hasTopHints = hintsAtTopHandler.hasHints(editor, lastChar); + try { + hasTopHints = hintsAtTopHandler.hasHints(editor, lastChar); + } catch (e) { + _recordProviderError(e, sessionLanguageId); + } } // Find a suitable provider only if hints-at-top handler doesn't have hints if (!hasTopHints) { enabledProviders.some(function (item, index) { - if (item.provider.hasHints(editor, lastChar)) { - sessionProvider = item.provider; - return true; + try { + if (item.provider.hasHints(editor, lastChar)) { + sessionProvider = item.provider; + return true; + } + } catch (e) { + // Skip the throwing provider; lower-priority providers still get a shot. + _recordProviderError(e, sessionLanguageId); } }); } @@ -610,25 +686,37 @@ define(function (require, exports, module) { } }); hintList.onSelect(function (hint) { - // allow extensions to handle special hint selections - var handled = false; - if (hintsAtTopHandler && hintsAtTopHandler.insertHint) { - handled = hintsAtTopHandler.insertHint(hint); - } + var acceptedLanguageId = sessionLanguageId; + try { + // allow extensions to handle special hint selections + var handled = false; + if (hintsAtTopHandler && hintsAtTopHandler.insertHint) { + handled = hintsAtTopHandler.insertHint(hint); + } - if (handled) { - // If hints-at-top handler handled it, end the session - _endSession(); - } else if (sessionProvider) { - // Regular hint provider handling - var restart = sessionProvider.insertHint(hint), - previousEditor = sessionEditor; - _endSession(); - if (restart) { - _beginSession(previousEditor); + if (handled) { + // If hints-at-top handler handled it, end the session + Metrics.countEvent(Metrics.EVENT_TYPE.CODE_HINTS, "accept", acceptedLanguageId); + _endSession(); + } else if (sessionProvider) { + // Regular hint provider handling + var restart = sessionProvider.insertHint(hint), + previousEditor = sessionEditor; + Metrics.countEvent(Metrics.EVENT_TYPE.CODE_HINTS, "accept", acceptedLanguageId); + _endSession(); + if (restart) { + _beginSession(previousEditor); + } + } else { + // if none of the provider handled it, we just end the session + _endSession(); } - } else { - // if none of the provider handled it, we just end the session + } catch (e) { + // An insertHint throw used to skip _endSession and leave a stuck popup. + console.error("[CodeHints] insertHint failed (" + acceptedLanguageId + ")", e); + Metrics.countEvent(Metrics.EVENT_TYPE.CODE_HINTS, "insErr", acceptedLanguageId); + window.logger.reportErrorOnce("hintInsert." + acceptedLanguageId, e, + "[CodeHints] insertHint failed (" + acceptedLanguageId + ")"); _endSession(); } }); diff --git a/src/extensibility/ExtensionManagerDialog.js b/src/extensibility/ExtensionManagerDialog.js index 6f182fe437..c74f5161ff 100644 --- a/src/extensibility/ExtensionManagerDialog.js +++ b/src/extensibility/ExtensionManagerDialog.js @@ -432,7 +432,10 @@ define(function (require, exports, module) { CommandManager.register(Strings.CMD_EXTENSION_MANAGER, Commands.FILE_EXTENSION_MANAGER, _showDialog, { supportsDesignMode: true }); AppInit.appReady(function () { - $("#toolbar-extension-manager").click(_showDialog); + $("#toolbar-extension-manager").click(function () { + Metrics.countEvent(Metrics.EVENT_TYPE.UI, "toolbarBtn", "extnMgr"); + _showDialog(); + }); }); // Unit tests diff --git a/src/extensions/default/HealthData/HealthDataManager.js b/src/extensions/default/HealthData/HealthDataManager.js index bd43e0ab6f..8d798c3ffa 100644 --- a/src/extensions/default/HealthData/HealthDataManager.js +++ b/src/extensions/default/HealthData/HealthDataManager.js @@ -120,7 +120,7 @@ define(function (require, exports, module) { // // To track this discrepancy, we emit a one-time metric just before disabling tracking, // so we’re aware of this inconsistency and can address it if needed. - Metrics.countEvent(Metrics.PLATFORM, "metricBoot", "disableErr"); + Metrics.countEvent(Metrics.EVENT_TYPE.PLATFORM, "metricBoot", "disableErr"); } Metrics.setDisabled(healthDataDisabled); SendToAnalytics.sendPlatformMetrics(); diff --git a/src/extensions/default/TypeScriptSupport/unittests.js b/src/extensions/default/TypeScriptSupport/unittests.js index 21b08a2aae..bc8aa1c349 100644 --- a/src/extensions/default/TypeScriptSupport/unittests.js +++ b/src/extensions/default/TypeScriptSupport/unittests.js @@ -18,7 +18,7 @@ * */ -/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, awaitsForDone, path, jsPromise */ +/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, awaitsForDone, path, jsPromise, spyOn */ define(function (require, exports, module) { @@ -946,5 +946,141 @@ define(function (require, exports, module) { expect(panelText().includes("CommonJS")).toBe(false); }, 90000); }); + + describe("usage metrics", function () { + // Metrics go through the audit map even in the test environment (senders are + // disabled, the audit log is not), so assertions read getLoggedDataForAudit(). + let Metrics; + + beforeAll(function () { + Metrics = testWindow.brackets.getModule("utils/Metrics"); + }); + + beforeEach(async function () { + // Earlier suites leave dirty unsaved documents behind; a project switch with + // those open would block on the save prompt and time the spec out. + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), + "close all files"); + }); + + it("should count server starts and time completion round trips per server", async function () { + const audit = Metrics.getLoggedDataForAudit(); + // the warm-up already started the typescript server at least once + const startEntry = audit.get("lsp.srv.Start.typescript"); + expect(startEntry && startEntry.sum >= 1).toBe(true); + + await _openInProject("ts/", "type-error.ts"); + const editor = EditorManager.getActiveEditor(); + editor.document.setText("const arr = [1, 2, 3];\narr."); + editor.setCursorPos(1, 4); // after `arr.` + + const TsMain = await new Promise(function (resolve, reject) { + testWindow.brackets.getModule("utils/ExtensionLoader") + .getRequireContextForExtension("TypeScriptSupport")(["main"], resolve, reject); + }); + const client = TsMain._getClient(); + expect(client).toBeTruthy(); + + // Drive the LSP hint provider directly (the popup UI wants OS focus). A + // successful completion round trip must record batched latency under + // lsp.time.typescriptComp - visible in the audit only after a flush. + let gotHints = false; + await awaitsFor(function () { + if (gotHints) { + return true; + } + client.codeHints.getHints(null).done(function (result) { + if (result && result.hints && result.hints.length) { + gotHints = true; + } + }); + return false; + }, "completions at arr. from the typescript server", 30000, 500); + + await Metrics.flushMetrics(); + const timeEntry = audit.get("lsp.time.Comp.typescript"); + expect(timeEntry && timeEntry.count >= 1).toBe(true); + expect(timeEntry.eventType).toBe("val"); + + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), + "close type-error.ts"); + }, 60000); + + it("should count an LSP hint acceptance (and auto-imports) on insertHint", async function () { + await _openInProject("ts/", "type-error.ts"); + const editor = EditorManager.getActiveEditor(); + editor.document.setText("const arr = [1, 2, 3];\narr.pu"); + editor.setCursorPos(1, 6); // after `arr.pu` + + const TsMain = await new Promise(function (resolve, reject) { + testWindow.brackets.getModule("utils/ExtensionLoader") + .getRequireContextForExtension("TypeScriptSupport")(["main"], resolve, reject); + }); + const client = TsMain._getClient(); + let $pushHint = null; + await awaitsFor(function () { + if ($pushHint) { + return true; + } + client._completionCache = null; // always request fresh + client.codeHints.getHints(null).done(function (result) { + const hints = (result && result.hints) || []; + $pushHint = hints.find(function ($hint) { + const token = $hint.data("token"); + return token && token.label === "push"; + }) || null; + }); + return false; + }, "a push completion at arr.pu", 30000, 500); + + const audit = Metrics.getLoggedDataForAudit(); + const before = audit.get("lsp.hint.Acc.typescript"); + const beforeSum = (before && before.sum) || 0; + client.codeHints.insertHint($pushHint); // what selecting the hint with Enter does + const after = audit.get("lsp.hint.Acc.typescript"); + expect(after && after.sum).toBeGreaterThan(beforeSum); + expect(editor.document.getLine(1)).toContain("push"); + + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), + "close type-error.ts"); + }, 60000); + + it("should report an error only once per key via logger.reportErrorOnce", function () { + const logger = testWindow.logger; + // In the test environment Bugsnag is disabled, so reportError degrades to + // console.error - spy on that to observe the dedup. + const spy = spyOn(testWindow.console, "error"); + const key = "unitTest.reportOnce." + Date.now(); + logger.reportErrorOnce(key, new Error("boom"), "first"); + logger.reportErrorOnce(key, new Error("boom"), "second"); + expect(spy.calls.count()).toBe(1); + logger.reportErrorOnce(key + ".other", new Error("boom"), "third"); + expect(spy.calls.count()).toBe(2); + }); + + it("should scrub user paths from report messages but keep internal/system paths", function () { + const scrub = testWindow.logger._scrubPathsInText; + // home prefix cut + user project path redacted + expect(scrub("failed on /home/alice/projects/clientX/app.js")) + .toBe("failed on "); + expect(scrub("failed on C:\\Users\\alice\\Documents\\secret\\app.js")) + .toBe("failed on "); + // app-managed dirs under home stay after the username is cut + expect(scrub("server at /home/alice/.local/share/phcode/lsp/intelephense.js")) + .toBe("server at ~/.local/share/phcode/lsp/intelephense.js"); + // machine-generic system paths and app origins stay verbatim + expect(scrub("spawn /usr/bin/node failed")).toBe("spawn /usr/bin/node failed"); + expect(scrub("at phtauri://localhost/assets/main.js:1:1")) + .toBe("at phtauri://localhost/assets/main.js:1:1"); + // non-path text untouched + expect(scrub("code=1 signal=none")).toBe("code=1 signal=none"); + // must never throw, whatever it is fed - fails closed to a redacted placeholder + expect(scrub(null)).toBe("null"); + expect(scrub(undefined)).toBe("undefined"); + expect(scrub(42)).toBe("42"); + expect(scrub({ toString: function () { throw new Error("boom"); } })) + .toBe(""); + }); + }); }); }); diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 29e1649733..a9e10ae4e1 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -889,6 +889,7 @@ define(function (require, exports, module) { .insertBefore("#app-drawer-button"); $btn.on("click", function () { + Metrics.countEvent(Metrics.EVENT_TYPE.UI, "toolbarBtn", "terminal"); if (WorkspaceManager.isInDesignMode()) { CommandManager.execute(Commands.VIEW_TOGGLE_DESIGN_MODE); CommandManager.execute(CMD_VIEW_TERMINAL); diff --git a/src/features/FindReferencesManager.js b/src/features/FindReferencesManager.js index 872f0c28a7..bdc5cd0300 100644 --- a/src/features/FindReferencesManager.js +++ b/src/features/FindReferencesManager.js @@ -33,7 +33,8 @@ define(function (require, exports, module) { ProviderRegistrationHandler = require("features/PriorityBasedRegistration").RegistrationHandler, SearchResultsView = require("search/SearchResultsView").SearchResultsView, SearchModel = require("search/SearchModel").SearchModel, - Strings = require("strings"); + Strings = require("strings"), + Metrics = require("utils/Metrics"); var _providerRegistrationHandler = new ProviderRegistrationHandler(), removeFindReferencesProvider = _providerRegistrationHandler.removeProvider.bind(_providerRegistrationHandler); @@ -101,10 +102,13 @@ define(function (require, exports, module) { // If one of them will provide a widget, show it inline once ready if (referencesPromise) { referencesPromise.done(function () { + // Explicit user action - Ok vs None gives the per-language hit rate. + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "ref", "Ok." + language.getId()); if(_resultsView) { _resultsView.open(); } }).fail(function () { + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "ref", "None." + language.getId()); if(_resultsView) { _resultsView.close(); } @@ -115,6 +119,7 @@ define(function (require, exports, module) { if(_resultsView) { _resultsView.close(); } + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "ref", "None." + language.getId()); editor.displayErrorMessageAtCursor(errorMsg); result.reject(); } diff --git a/src/features/JumpToDefManager.js b/src/features/JumpToDefManager.js index bfa8307b75..6f476fafad 100644 --- a/src/features/JumpToDefManager.js +++ b/src/features/JumpToDefManager.js @@ -30,6 +30,7 @@ define(function (require, exports, module) { CommandManager = require("command/CommandManager"), EditorManager = require("editor/EditorManager"), Editor = require("editor/Editor").Editor, + Metrics = require("utils/Metrics"), ProviderRegistrationHandler = require("features/PriorityBasedRegistration").RegistrationHandler; const JUMP_TO_DEF_MARKER = "jumpMarker"; @@ -70,21 +71,28 @@ define(function (require, exports, module) { return result.promise(); } + // Explicit user action (Ctrl-J / menu) - Ok vs Fail gives the per-language success rate. + const languageId = editor.getLanguageForSelection().getId(); + let jumpToDefProvider = _getJumpToDefProvider(editor); if (!jumpToDefProvider) { + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "jump", "Fail." + languageId); result.reject(); return result.promise(); } request = jumpToDefProvider.doJumpToDef(editor); if (!request) { + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "jump", "Fail." + languageId); result.reject(); return result.promise(); } request.done(function () { + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "jump", "Ok." + languageId); result.resolve(); }).fail(function () { + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "jump", "Fail." + languageId); result.reject(); }); diff --git a/src/features/ParameterHintsManager.js b/src/features/ParameterHintsManager.js index f1e28d1dd0..ce1d903b97 100644 --- a/src/features/ParameterHintsManager.js +++ b/src/features/ParameterHintsManager.js @@ -35,6 +35,7 @@ define(function (require, exports, module) { Strings = require("strings"), PreferencesManager = require("preferences/PreferencesManager"), WorkspaceManager = require("view/WorkspaceManager"), + Metrics = require("utils/Metrics"), ProviderRegistrationHandler = require("features/PriorityBasedRegistration").RegistrationHandler; @@ -99,6 +100,9 @@ define(function (require, exports, module) { PreferencesManager.on("change", "showParameterHints", function () { paramHintsEnabled = PreferencesManager.get("showParameterHints"); if (!paramHintsEnabled) { + // The opt-out signal: parameter hints in the non-default off state. Also fires on + // boot pref-load for users who keep it off - fine, it's a rough signal. + Metrics.countEvent(Metrics.EVENT_TYPE.CODE_HINTS, "pref", "sigOff"); dismissHint(); } }); @@ -384,6 +388,12 @@ define(function (require, exports, module) { return; } + // Count the popup APPEARING, not every re-render while the caret moves within the + // call. Batched - typing through calls triggers this constantly. + if (!hintState.visible) { + Metrics.countEventBatched(Metrics.EVENT_TYPE.CODE_HINTS, "sig", language.getId()); + } + _formatHint(editor, parameterHint); $hintContainer.show(); // no-op when already visible -> content updates in place, no blink @@ -507,6 +517,9 @@ define(function (require, exports, module) { */ function handleShowParameterHint() { let editor = EditorManager.getActiveEditor(); + // No metric here: CodeHintManager's explicit Ctrl-Space session also executes this + // command internally, so an "explicit param hint command" count would mostly measure + // code-hint invocations. The batched popup-shown metric is the usage signal. // Pop up function hint popUpHint(editor, true, false); } diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js index 18e0601b1e..2049d92523 100644 --- a/src/languageTools/DefaultProviders.js +++ b/src/languageTools/DefaultProviders.js @@ -40,6 +40,7 @@ define(function (require, exports, module) { TabstopManager = require("editor/TabstopManager"), Strings = require("strings"), StringUtils = require("utils/StringUtils"), + Metrics = require("utils/Metrics"), marked = require("thirdparty/marked.min"), matcher = new StringMatch.StringMatcher({ preferPrefixMatches: true @@ -541,6 +542,8 @@ define(function (require, exports, module) { "selectInitial": true }); }).fail(function () { + // requestHints rejects only on a real request failure - empty results resolve. + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "hint", "Fail." + self.client._metricLabel); self._importLabel = null; $deferredHints.reject(); }); @@ -693,6 +696,12 @@ define(function (require, exports, module) { { line: te.range.end.line, ch: te.range.end.character }); }); + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "hint", "Acc." + this.client._metricLabel); + if (token.additionalTextEdits && token.additionalTextEdits.length) { + // An auto-import was applied with the completion - a high-value signal on its own. + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "hint", "Import." + this.client._metricLabel); + } + // Return false to indicate that another hinting session is not needed return false; }; @@ -725,6 +734,7 @@ define(function (require, exports, module) { var editor = EditorManager.getActiveEditor(), pos = editor.getCursorPos(), docPath = editor.document.file._path, + metricLabel = this.client._metricLabel, $deferredHints = $.Deferred(); // A language server may VETO signature help at specific cursor positions by supplying a @@ -781,7 +791,12 @@ define(function (require, exports, module) { } else { $deferredHints.reject(); } - }).fail(function () { + }).fail(function (err) { + // requestParameterHints rejects bare for "no signatures here" (normal) and with an + // error for a real request failure - only the latter is a health signal. + if (err) { + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "sig", "Fail." + metricLabel); + } $deferredHints.reject(); }); @@ -832,6 +847,7 @@ define(function (require, exports, module) { const pos = editor.getCursorPos(), docPath = editor.document.file._path, docPathUri = PathConverters.pathToUri(docPath), + metricLabel = this.client._metricLabel, $deferredHints = $.Deferred(); this.client.gotoDefinition({ @@ -871,7 +887,11 @@ define(function (require, exports, module) { // the NAVIGATE_JUMPTO_DEFINITION command promise pending forever. $deferredHints.reject(); } - }).fail(function () { + }).fail(function (err) { + // gotoDefinition rejects bare for "no definition here" - only real failures count. + if (err) { + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "def", "Fail." + metricLabel); + } $deferredHints.reject(); }); @@ -1178,6 +1198,10 @@ define(function (require, exports, module) { } } catch (err) { console.warn("[LSP] quickfix fetch failed:", err && (err.message || err)); + if (self._quickFixClient) { + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "fix", + "Fail." + self._quickFixClient._metricLabel); + } } }; @@ -1282,6 +1306,7 @@ define(function (require, exports, module) { result = $.Deferred(); if (this.client) { + const metricLabel = this.client._metricLabel; this.client.findReferences({ filePath: docPath, cursorPos: pos @@ -1294,7 +1319,10 @@ define(function (require, exports, module) { } else { result.reject(); } - }).fail(function(){ + }).fail(function(err){ + if (err) { + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "ref", "Fail." + metricLabel); + } result.reject(); }); return result.promise(); diff --git a/src/languageTools/HoverProvider.js b/src/languageTools/HoverProvider.js index e7383abc52..77b61195e0 100644 --- a/src/languageTools/HoverProvider.js +++ b/src/languageTools/HoverProvider.js @@ -34,7 +34,8 @@ define(function (require, exports, module) { Commands = require("command/Commands"), KeyBindingManager = require("command/KeyBindingManager"), QuickViewManager = require("features/QuickViewManager"), - Strings = require("strings"); + Strings = require("strings"), + Metrics = require("utils/Metrics"); /** * Convert LSP hover `contents` (MarkupContent | MarkedString | string | array of those) @@ -193,13 +194,22 @@ define(function (require, exports, module) { if ($actions) { $content.append($actions); } + // Batched: fires on every dwell that produces a hover popup. + Metrics.countEventBatched(Metrics.EVENT_TYPE.LSP, "hover", + "Ok." + self.client._metricLabel); resolve({ start: start, end: end, content: $content }); }) - .fail(function () { + .fail(function (err) { + // requestHover rejects bare when there's simply nothing to show - only a + // real request failure is a health signal. + if (err) { + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "hover", + "Fail." + self.client._metricLabel); + } reject(); }); }); diff --git a/src/languageTools/LSPClient.js b/src/languageTools/LSPClient.js index 81d07b8ee9..f9646cee99 100644 --- a/src/languageTools/LSPClient.js +++ b/src/languageTools/LSPClient.js @@ -68,7 +68,8 @@ define(function (require, exports, module) { EventDispatcher = require("utils/EventDispatcher"), PreferencesManager = require("preferences/PreferencesManager"), Strings = require("strings"), - StringUtils = require("utils/StringUtils"); + StringUtils = require("utils/StringUtils"), + Metrics = require("utils/Metrics"); EventDispatcher.makeEventDispatcher(exports); @@ -251,9 +252,16 @@ define(function (require, exports, module) { console.error("[LSP] server '" + data.serverId + "' exited unexpectedly (code=" + data.code + (data.signal ? ", signal=" + data.signal : "") + ")." + (data.stderr ? "\n--- server stderr ---\n" + data.stderr : "")); + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "Crash." + client._metricLabel); + window.logger.leaveTrail("[LSP] " + client.serverId + " crashed, code=" + data.code); client._crashCount = (client._crashCount || 0) + 1; if (client._crashCount > MAX_AUTO_RESTARTS) { console.error("[LSP]", client.serverId, "exited repeatedly; not restarting"); + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "Gaveup." + client._metricLabel); + window.logger.reportErrorOnce("lspGaveup." + client.serverId, + new Error("code=" + data.code + " signal=" + (data.signal || "none") + + _stderrSnippet(data.stderr)), + "[LSP] server crashed repeatedly, gave up: " + client.serverId); return; } setTimeout(function () { @@ -267,13 +275,32 @@ define(function (require, exports, module) { DocumentSync.openSupportedDocuments(client); }).catch(function (err) { console.error("[LSP] auto-restart failed", client.serverId, err && (err.message || err)); + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "RstErr." + client._metricLabel); }); }, 1000 * client._crashCount); } + /** + * First non-empty stderr line, truncated, for the crash give-up report. Any user path in + * it is redacted by the loggerSetup message scrubber before the report leaves the app. + * @param {string} stderr + * @return {string} " stderr: " or "" + */ + function _stderrSnippet(stderr) { + const lines = String(stderr || "").split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line) { + return " stderr: " + line.substring(0, 200); + } + } + return ""; + } + function _onServerError(_event, data) { if (data) { console.error("[LSP] server error", data.serverId, data.error); + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "Err." + _srvLabel(data.serverId)); } } @@ -281,11 +308,37 @@ define(function (require, exports, module) { // LanguageClient - one per server, exposes the provider-facing method surface // ------------------------------------------------------------------------------------------ + /** + * ServerId as a metric name part. Metric names must be short js-var-safe strings (the GA + * event name has a ~30 char budget), and plugin serverIds are unbounded - so sanitize and + * truncate once here; every metric in the LSP stack labels with this. + * @param {string} serverId + * @return {string} + */ + function _srvLabel(serverId) { + const cleaned = String(serverId || "").replace(/[^a-zA-Z0-9]/g, ""); + return cleaned.substring(0, 10) || "ukn"; + } + + // LSP request method -> short metric suffix for per-method latency. Anything unlisted is + // lumped into "Oth" so plugin/custom methods can't explode metric cardinality. + const METRIC_METHOD_GROUPS = { + "textDocument/completion": "Comp", + "textDocument/signatureHelp": "Sig", + "textDocument/hover": "Hover", + "textDocument/definition": "Def", + "textDocument/references": "Ref", + "textDocument/codeAction": "Fix", + "completionItem/resolve": "Res", + "textDocument/documentHighlight": "Hlt" + }; + function LanguageClient(serverId, languages, config) { this.serverId = serverId; this.languages = languages; this.config = config; this.capabilities = null; + this._metricLabel = _srvLabel(serverId); } LanguageClient.prototype.getServerCapabilities = function () { @@ -320,8 +373,16 @@ define(function (require, exports, module) { LanguageClient.prototype._request = function (method, params) { const serverId = this.serverId; + const metricLabel = this._metricLabel; + const startTime = Date.now(); return getConnector().then(function (conn) { return conn.execPeer("sendRequest", { serverId: serverId, method: method, params: params }); + }).then(function (result) { + // Successful requests only - failures include the 120s node-side timeout, which + // would wreck the averages. Failures are counted per feature instead. + Metrics.valueEventBatched(Metrics.EVENT_TYPE.LSP, "time", + (METRIC_METHOD_GROUPS[method] || "Oth") + "." + metricLabel, Date.now() - startTime); + return result; }); }; @@ -726,6 +787,7 @@ define(function (require, exports, module) { } async function _startAndInit(client) { + const startTime = Date.now(); const config = client.config; const conn = await getConnector(); const rootVfsPath = (config.rootUriProvider && config.rootUriProvider()) || _projectRootPath(); @@ -768,6 +830,9 @@ define(function (require, exports, module) { method: "initialized", params: {} }); + // Spawn + initialize handshake time - rare enough to send unbatched. + Metrics.valueEvent(Metrics.EVENT_TYPE.LSP, "time", "Init." + client._metricLabel, + Date.now() - startTime); } function _registerProviders(client) { @@ -816,6 +881,7 @@ define(function (require, exports, module) { * every path that brings a server up: initial registration, restart, and crash auto-restart. */ function _announceServerStarted(client) { + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "Start." + client._metricLabel); exports.trigger(EVENT_LANGUAGE_SERVER_STARTED, { serverId: client.serverId, languages: client.languages @@ -852,12 +918,25 @@ define(function (require, exports, module) { return; } _prefWatchedServers.add(serverId); + // Change events also fire without the value flipping (e.g. pref scopes reloading on a + // project switch). Only a real flip may act - reacting to a same-value event would + // needlessly restart a running server (and miscount the opt-out metric). + let lastDisabled = _isDisabledByPref(serverId); PreferencesManager.on("change", _codeIntelPrefKey(serverId), function () { + const disabledNow = _isDisabledByPref(serverId); + if (disabledNow === lastDisabled) { + return; + } + lastDisabled = disabledNow; const client = clients.get(serverId); if (!client) { return; } - if (_isDisabledByPref(serverId)) { + if (disabledNow) { + // The opt-out signal: users actively turning code intelligence off. Only the + // non-default (off) state is logged - flips back to the default would just be + // noise. + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "PrefOff." + client._metricLabel); if (client.capabilities) { stopServerProcess(client).then(function () { CodeInspection.requestRun(); @@ -922,6 +1001,9 @@ define(function (require, exports, module) { } _watchServerPref(config.serverId); if (_isDisabledByPref(config.serverId)) { + // Users who keep code intelligence durably off. Registration retries can bump this + // more than once per run - fine, it's a rough opt-out signal, not an exact count. + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "OffBoot." + _srvLabel(config.serverId)); return null; } const client = new LanguageClient(config.serverId, config.languages, config); @@ -939,6 +1021,12 @@ define(function (require, exports, module) { return client; } catch (err) { console.error("[LSP] failed to start server", config.serverId, err && (err.message || err)); + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "StartErr." + client._metricLabel); + // err.message distinguishes the classes (spawn error / spawn timeout / init + // failure - built node-side). Key shared with the restart-failed report so a + // broken server reports exactly once per app run whichever path hits first. + window.logger.reportErrorOnce("lspStart." + config.serverId, err, + "[LSP] start failed: " + config.serverId); clients.delete(config.serverId); return null; } @@ -1043,6 +1131,9 @@ define(function (require, exports, module) { FindReferencesManager.setMenuItemStateForLanguage(); } catch (err) { console.error("[LSP] failed to restart server", serverId, err && (err.message || err)); + Metrics.countEvent(Metrics.EVENT_TYPE.LSP, "srv", "RstErr." + client._metricLabel); + window.logger.reportErrorOnce("lspStart." + serverId, err, + "[LSP] restart failed: " + serverId); } } diff --git a/src/loggerSetup.js b/src/loggerSetup.js index 7f39c02bbd..b768e48f2b 100644 --- a/src/loggerSetup.js +++ b/src/loggerSetup.js @@ -46,6 +46,7 @@ MAX_ERR_SENT_FIRST_MINUTE = 10, MAX_ERR_ALLOWED_IN_MINUTE = 2; let firstMinuteElapsed = false, errorsSentThisMinute = 0; + const reportedOnceKeys = new Set(); class CustomBugSnagError extends Error { constructor(message, err){ @@ -73,6 +74,21 @@ console.error(message, error, error.nodeStack); } }, + /** + * Same as reportError, but reports at most once per `key` per app run. Use for + * failures that can repeat (a broken language server fails on every retry) so a + * single user cannot exhaust the app-wide error reporting budget with one issue. + * @param {string} key dedup key, e.g. "lspStart.typescript" + * @param {Error} error + * @param {string} [message] optional message + */ + reportErrorOnce: function (key, error, message) { + if(reportedOnceKeys.has(key)){ + return; + } + reportedOnceKeys.add(key); + logger.reportError(error, message); + }, /** * By default all uncaught exceptions and promise rejections are sent to logger utility. But in some cases * you may want to log error without having an error object with you. @@ -214,9 +230,64 @@ return false; } + // Paths that are safe to keep in reported error messages: they point at the app's own + // code, origins or machine-generic system locations - never at user content - and are + // valuable for diagnosis (e.g. language server stderr referencing its own install dir). + function _isSafePathForLogging(pathText) { + const lower = pathText.toLowerCase(); + const safeStarts = ["phtauri://", "https://phtauri.localhost", "http://localhost", + "https://localhost", "https://phcode.dev", "https://create.phcode.dev", + "https://dev.phcode.dev", "https://staging.phcode.dev", + "/usr/", "/lib/", "/lib64/", "/opt/", "/snap/", "/bin/", "/sbin/", "/etc/", + "/system/", "/applications/", "c:\\program files", "c:\\windows"]; + for(let prefix of safeStarts){ + if(lower.startsWith(prefix)){ + return true; + } + } + // App-managed dirs (installed language servers, caches) live under a phoenix/phcode + // named folder; after home-dir normalization they carry no user identity, so keep them. + if(lower.startsWith("~") && (lower.includes("phcode") || lower.includes("phoenix"))){ + return true; + } + return false; + } + + const HOME_DIR_PREFIX_REGEX = /(?:\/(?:home|Users)\/[^/\s'"`]+|[A-Za-z]:[\\/]Users[\\/][^\\/\s'"`]+)/g; + // URLs; ~-anchored paths (1+ segments - a bare ~/file.txt is still a user file); absolute + // paths need 2+ segments so prose like "and/or" is not treated as a path. + const PATH_LIKE_REGEX = /(?:[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s'"`)\]]+|~(?:[\\/][^\\/\s'"`)\]]+)+|(?:[\\/][^\\/\s'"`)\]]+){2,})/g; + + // Redacts user paths from an error message while keeping internal app/system paths. + // The home-dir prefix is cut first (drops the username), then any remaining path-like + // substring not under a known-internal prefix is replaced with - a trimmed user + // path still leaks project/folder names, and the privacy bar here matches + // _shouldDiscardError which drops user-fs stacks entirely. + // Must NEVER throw: it runs inside onError, whose catch would swallow the throw and let + // the report leave UNSCRUBBED. On any internal failure it fails closed (fully redacted + // placeholder) rather than returning the original text. + function _scrubPathsInText(text) { + try { + const homeTrimmed = String(text).replace(HOME_DIR_PREFIX_REGEX, "~"); + return homeTrimmed.replace(PATH_LIKE_REGEX, function (match) { + return _isSafePathForLogging(match) ? match : ""; + }); + } catch (e) { + return ""; + } + } + logger._scrubPathsInText = _scrubPathsInText; // exposed for unit tests + function onError(event) { // for more info https://docs.bugsnag.com/platforms/javascript/customizing-error-reports try{ + if(Array.isArray(event.errors)){ + for(let error of event.errors){ + if(error && error.errorMessage){ + error.errorMessage = _scrubPathsInText(error.errorMessage); + } + } + } let reportedStatus = "Reported"; let shouldReport = true; if(logger.loggingOptions.healthDataDisabled diff --git a/src/utils/Metrics.js b/src/utils/Metrics.js index 5b05b447ea..ae47fd80f7 100644 --- a/src/utils/Metrics.js +++ b/src/utils/Metrics.js @@ -114,7 +114,7 @@ define(function (require, exports, module) { LIVE_PREVIEW: "live-preview", LP_EDIT: "lp-edit", // live preview edit related KEYBOARD: "keyboard", - CODE_HINTS: "code-hints", + CODE_HINTS: "hints", EDITOR: "editor", QUICK_VIEW: "quickView", SEARCH: "search", @@ -132,7 +132,8 @@ define(function (require, exports, module) { AI: "ai", TERMINAL: "term", MD: "md", - GUIDE: "guide" + GUIDE: "guide", + LSP: "lsp" }; /** @@ -449,6 +450,73 @@ define(function (require, exports, module) { _valueEvent(eventType, eventCategory, eventSubCategory, value); } + // High-frequency events (per-keystroke hint popups, per-request latencies) would overwhelm + // the GA free-tier event budget if sent per occurrence, so they are accumulated here and + // flushed as aggregates every BATCH_FLUSH_MS through the normal countEvent/valueEvent + // pipeline (which keeps power-user prefixing, the audit map and both sinks working). + const BATCH_FLUSH_MS = 120 * 1000; + const BATCH_KEY_SEPARATOR = "|"; + const _batchedCounts = new Map(); // "type|category|label" -> count + const _batchedValues = new Map(); // "type|category|label" -> {sum, count} + + /** + * Same as countEvent, but aggregated client side: occurrences are summed and emitted as a + * single count every 2 minutes (and on flushMetrics before app quit). Use for events that + * can fire many times a minute; low-frequency user actions should use countEvent directly + * so they are not delayed. + * + * @param {EVENT_TYPE|string} eventType The kind of Event Type that needs to be logged- should be + * a js var compatible string. + * @param {string} eventCategory The kind of Event Category that + * needs to be logged- should be a js var compatible string + * @param {string} eventSubCategory The kind of Event Sub Category that + * needs to be logged- should be a js var compatible string + * @type {function} + */ + function countEventBatched(eventType, eventCategory, eventSubCategory) { + const key = eventType + BATCH_KEY_SEPARATOR + eventCategory + BATCH_KEY_SEPARATOR + eventSubCategory; + _batchedCounts.set(key, (_batchedCounts.get(key) || 0) + 1); + } + + /** + * Same as valueEvent, but aggregated client side: values are averaged and emitted as a + * single valueEvent per key every 2 minutes (and on flushMetrics before app quit). Note + * that only the window average survives - individual samples and sample counts do not. + * + * @param {EVENT_TYPE|string} eventType The kind of Event Type that needs to be logged- should be + * a js var compatible string. + * @param {string} eventCategory The kind of Event Category that + * needs to be logged- should be a js var compatible string + * @param {string} eventSubCategory The kind of Event Sub Category that + * needs to be logged- should be a js var compatible string + * @param {number} value + * @type {function} + */ + function valueEventBatched(eventType, eventCategory, eventSubCategory, value) { + const key = eventType + BATCH_KEY_SEPARATOR + eventCategory + BATCH_KEY_SEPARATOR + eventSubCategory; + const entry = _batchedValues.get(key); + if(entry){ + entry.sum += Number(value); + entry.count++; + } else { + _batchedValues.set(key, {sum: Number(value), count: 1}); + } + } + + function _flushBatchedEvents() { + for(const [key, count] of _batchedCounts){ + const parts = key.split(BATCH_KEY_SEPARATOR); + countEvent(parts[0], parts[1], parts[2], count); + } + _batchedCounts.clear(); + for(const [key, entry] of _batchedValues){ + const parts = key.split(BATCH_KEY_SEPARATOR); + valueEvent(parts[0], parts[1], parts[2], Math.round(entry.sum / entry.count)); + } + _batchedValues.clear(); + } + setInterval(_flushBatchedEvents, BATCH_FLUSH_MS); + function setDisabled(shouldDisable) { Phoenix._setHealthTrackingDisabled(shouldDisable); disabled = shouldDisable; @@ -472,6 +540,7 @@ define(function (require, exports, module) { */ async function flushMetrics() { try{ + _flushBatchedEvents(); if(Phoenix.isNativeApp) { _sendQueuedTauriGAEvents(); } @@ -535,6 +604,8 @@ define(function (require, exports, module) { exports.clearAuditData = clearAuditData; exports.countEvent = countEvent; exports.valueEvent = valueEvent; + exports.countEventBatched = countEventBatched; + exports.valueEventBatched = valueEventBatched; exports.logPerformanceTime = logPerformanceTime; exports.flushMetrics = flushMetrics; exports.getRangeName = getRangeName; diff --git a/src/view/DefaultPanelView.js b/src/view/DefaultPanelView.js index 8ea39f8053..ea06e3326b 100644 --- a/src/view/DefaultPanelView.js +++ b/src/view/DefaultPanelView.js @@ -31,7 +31,8 @@ define(function (require, exports, module) { CommandManager = require("command/CommandManager"), Strings = require("strings"), WorkspaceManager = require("view/WorkspaceManager"), - PanelView = require("view/PanelView"); + PanelView = require("view/PanelView"), + Metrics = require("utils/Metrics"); /** * Descriptors for each launcher button. @@ -182,6 +183,7 @@ define(function (require, exports, module) { .attr("title", Strings.BOTTOM_PANEL_DEFAULT_TITLE); $drawerBtn.on("click", function () { + Metrics.countEvent(Metrics.EVENT_TYPE.UI, "toolbarBtn", "appDrawer"); // Design mode collapses the editor and stretches live preview, which // leaves no room for the bottom tools panel. Exit design mode first // so the panel has somewhere to go. diff --git a/test/spec/Extn-JSONSupport-integ-test.js b/test/spec/Extn-JSONSupport-integ-test.js index 3a82eb48cb..3562fa4095 100644 --- a/test/spec/Extn-JSONSupport-integ-test.js +++ b/test/spec/Extn-JSONSupport-integ-test.js @@ -182,16 +182,27 @@ define(function (require, exports, module) { async function () { const PreferencesManager = testWindow.brackets.test.PreferencesManager; const LSPClient = testWindow.require("languageTools/LSPClient"); + const Metrics = testWindow.brackets.getModule("utils/Metrics"); + const audit = Metrics.getLoggedDataForAudit(); + function auditSum(key) { + const entry = audit.get(key); + return (entry && entry.sum) || 0; + } await _openFile("broken.json"); await awaitsFor(function () { return LSPClient.isLintingProviderActive("json"); }, "json server to be active", 30000); + const startsBefore = auditSum("lsp.srv.Start.json"), + prefOffBefore = auditSum("lsp.srv.PrefOff.json"); PreferencesManager.set("codeIntelligence.json", false); try { await awaitsFor(function () { return !LSPClient.isLintingProviderActive("json"); }, "json server to stop on pref off", 15000); + // greater-than, not exactly +1: incidental server events (a crash + // auto-restart, a scope reload) must not flake the spec + expect(auditSum("lsp.srv.PrefOff.json")).toBeGreaterThan(prefOffBefore); } finally { // restore even on failure so later suites see a healthy default PreferencesManager.set("codeIntelligence.json", true); @@ -199,6 +210,8 @@ define(function (require, exports, module) { await awaitsFor(function () { return LSPClient.isLintingProviderActive("json"); }, "json server to restart on pref on", 45000); + // re-enable is a return to the default - no pref metric, but the restart counts + expect(auditSum("lsp.srv.Start.json")).toBeGreaterThan(startsBefore); }, 90000); }); }); diff --git a/test/spec/Metrics-test.js b/test/spec/Metrics-test.js index f77f4b71e6..6dccf1aa63 100644 --- a/test/spec/Metrics-test.js +++ b/test/spec/Metrics-test.js @@ -65,5 +65,33 @@ define(function (require, exports, module) { data = Metrics.getLoggedDataForAudit(); expect(data.size < 2050).toBeTrue(); }); + + it("should aggregate batched count events and emit the sum on flushMetrics", async function () { + Metrics.init(); + let data = Metrics.getLoggedDataForAudit(); + data.clear(); + Metrics.countEventBatched("typeb", "cat", "sub"); + Metrics.countEventBatched("typeb", "cat", "sub"); + Metrics.countEventBatched("typeb", "cat", "sub"); + // nothing reaches the pipeline until a flush + expect(data.get("typeb.cat.sub")).toBeUndefined(); + await Metrics.flushMetrics(); + expect(data.get("typeb.cat.sub")).toEqual({ eventType: 'count', sum: 3, count: 1 }); + // the window was consumed - a second flush must not double count + await Metrics.flushMetrics(); + expect(data.get("typeb.cat.sub")).toEqual({ eventType: 'count', sum: 3, count: 1 }); + }); + + it("should aggregate batched value events and emit the window average on flushMetrics", async function () { + Metrics.init(); + let data = Metrics.getLoggedDataForAudit(); + data.clear(); + Metrics.valueEventBatched("typebv", "cat", "sub", 10); + Metrics.valueEventBatched("typebv", "cat", "sub", 30); + expect(data.get("typebv.cat.sub")).toBeUndefined(); + await Metrics.flushMetrics(); + // one value event carrying the average of the window (20), not two events + expect(data.get("typebv.cat.sub")).toEqual({ eventType: 'val', sum: 20, count: 1 }); + }); }); }); diff --git a/tracking-repos.json b/tracking-repos.json index cb87e9987d..8b3d419324 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "f0774ecdb6a1ad14330dae7c4482a7c77c385f60" + "commitID": "5b52e051868bfb97e167e3c0233733443cd68701" } }