catalyst-core 0.2.0-beta.2 → 0.2.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/native/androidProject/app/build.gradle.kts +1 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
- package/dist/native/bridge/WebBridge.js +2 -2
- package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
- package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
- package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
- package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
- package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
- package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
- package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
- package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
- package/dist/native/bridge/perf/core/constants.js +43 -0
- package/dist/native/bridge/perf/core/measure.js +40 -0
- package/dist/native/bridge/perf/core/session.js +34 -0
- package/dist/native/bridge/perf/core/store.js +1 -0
- package/dist/native/bridge/perf/core/summary.js +1 -0
- package/dist/native/bridge/perf/core/traceExport.js +1 -0
- package/dist/native/bridge/perf/core/waterfalls.js +1 -0
- package/dist/native/bridge/perf/index.js +81 -0
- package/dist/native/buildIos/config.js +1 -1
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
- package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +10 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +126 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _NativeInterfaces=require("./constants/NativeInterfaces.js");var _NativeBridge=_interopRequireDefault(require("./utils/NativeBridge.js"));var _CameraUtils=_interopRequireDefault(require("./utils/CameraUtils.js"));var _PluginBridge=_interopRequireDefault(require("../plugin-bridge/PluginBridge.js"));function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e};}const DEVICE_INFO_PLUGIN={pluginId:"io.catalyst.device_info",command:"getDeviceInfo",successEvent:"onSuccess",errorEvent:"onError"};const DEVICE_INFO_REQUEST_TIMEOUT_MS=5000;const DEVICE_INFO_PLUGIN_FALLBACK_CODES=new Set(["PLUGIN_NOT_FOUND","COMMAND_NOT_SUPPORTED","PLUGIN_NOT_REGISTERED"]);const DEVICE_INFO_PLUGIN_FALLBACK_MESSAGES=["pluginbridge is not available in this environment","pluginbridge is not available","device info plugin request timed out"];const normalizeDeviceInfoPayload=payload=>{if(typeof payload!=="string"){return payload;}try{return JSON.parse(payload);}catch{return{message:payload};}};const createDeviceInfoError=(payload,fallbackMessage)=>{const parsed=normalizeDeviceInfoPayload(payload);const message=parsed?.message||parsed?.error||fallbackMessage;const error=new Error(message);if(parsed?.code){error.code=parsed.code;}if(parsed?.pluginId){error.pluginId=parsed.pluginId;}if(parsed?.command){error.command=parsed.command;}return error;};class WebBridge{constructor(){this.handlers=new Map();this.initialized=false;this._logInitialization();}/**
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _NativeInterfaces=require("./constants/NativeInterfaces.js");var _NativeBridge=_interopRequireDefault(require("./utils/NativeBridge.js"));var _CameraUtils=_interopRequireDefault(require("./utils/CameraUtils.js"));var _PluginBridge=_interopRequireDefault(require("../plugin-bridge/PluginBridge.js"));function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e};}function _interopRequireWildcard(e,t){if("function"==typeof WeakMap)var r=new WeakMap(),n=new WeakMap();return(_interopRequireWildcard=function(e,t){if(!t&&e&&e.__esModule)return e;var o,i,f={__proto__:null,default:e};if(null===e||"object"!=typeof e&&"function"!=typeof e)return f;if(o=t?n:r){if(o.has(e))return o.get(e);o.set(e,f);}for(const t in e)"default"!==t&&{}.hasOwnProperty.call(e,t)&&((i=(o=Object.defineProperty)&&Object.getOwnPropertyDescriptor(e,t))&&(i.get||i.set)?o(f,t,i):f[t]=e[t]);return f;})(e,t);}const DEVICE_INFO_PLUGIN={pluginId:"io.catalyst.device_info",command:"getDeviceInfo",successEvent:"onSuccess",errorEvent:"onError"};const DEVICE_INFO_REQUEST_TIMEOUT_MS=5000;const DEVICE_INFO_PLUGIN_FALLBACK_CODES=new Set(["PLUGIN_NOT_FOUND","COMMAND_NOT_SUPPORTED","PLUGIN_NOT_REGISTERED"]);const DEVICE_INFO_PLUGIN_FALLBACK_MESSAGES=["pluginbridge is not available in this environment","pluginbridge is not available","device info plugin request timed out"];const normalizeDeviceInfoPayload=payload=>{if(typeof payload!=="string"){return payload;}try{return JSON.parse(payload);}catch{return{message:payload};}};const createDeviceInfoError=(payload,fallbackMessage)=>{const parsed=normalizeDeviceInfoPayload(payload);const message=parsed?.message||parsed?.error||fallbackMessage;const error=new Error(message);if(parsed?.code){error.code=parsed.code;}if(parsed?.pluginId){error.pluginId=parsed.pluginId;}if(parsed?.command){error.command=parsed.command;}return error;};class WebBridge{constructor(){this.handlers=new Map();this.initialized=false;this._logInitialization();}/**
|
|
2
2
|
* Log initialization info
|
|
3
3
|
*/_logInitialization(){console.log("🌉 WebBridge initialized");console.log("🌉 Available callback interfaces:",_NativeInterfaces.ALL_CALLBACKS.length);if(process.env.NODE_ENV==="development"){(0,_NativeInterfaces.debugInterfaces)();}}/**
|
|
4
4
|
* Static method to initialize WebBridge on window object
|
|
5
|
-
*/static init=()=>{if(typeof window==="undefined"){console.error("🌉 WebBridge cannot be initialized outside the browser!");return null;}if(window.WebBridge){console.warn("🌉 WebBridge already initialized!");return window.WebBridge;}const bridge=new WebBridge();window.WebBridge=bridge;bridge.initialized=true;const{platform}=_NativeBridge.default.getEnvironmentInfo();console.log("🌉 WebBridge created and attached to window");return{bridge,platform,getDeviceInfo:bridge.getDeviceInfo};};/**
|
|
5
|
+
*/static init=()=>{if(typeof window==="undefined"){console.error("🌉 WebBridge cannot be initialized outside the browser!");return null;}if(window.WebBridge){console.warn("🌉 WebBridge already initialized!");return window.WebBridge;}const bridge=new WebBridge();window.WebBridge=bridge;bridge.initialized=true;const{platform}=_NativeBridge.default.getEnvironmentInfo();if(window.__CATALYST_PROFILER_ENABLED===true){Promise.resolve().then(()=>_interopRequireWildcard(require("./perf/index.js"))).then(({default:WebPerfCollector})=>{WebPerfCollector.init(bridge);});}console.log("🌉 WebBridge created and attached to window");return{bridge,platform,getDeviceInfo:bridge.getDeviceInfo};};/**
|
|
6
6
|
* Main callback handler called by native platforms
|
|
7
7
|
* @param {string} interfaceName - The callback interface name
|
|
8
8
|
* @param {any} data - Data from native platform
|
|
@@ -25,7 +25,8 @@ ON_GOOGLE_SIGN_IN_SUCCESS:"ON_GOOGLE_SIGN_IN_SUCCESS",ON_GOOGLE_SIGN_IN_ERROR:"O
|
|
|
25
25
|
NETWORK_STATUS_CHANGED:"NETWORK_STATUS_CHANGED",// Safe area callbacks
|
|
26
26
|
ON_SAFE_AREA_INSETS_UPDATED:"ON_SAFE_AREA_INSETS_UPDATED",// Transition callbacks
|
|
27
27
|
ON_TRANSITION_COMMITTED:"ON_TRANSITION_COMMITTED",ON_TRANSITION_CANCELLED:"ON_TRANSITION_CANCELLED",ON_TRANSITION_TIMEOUT:"ON_TRANSITION_TIMEOUT",// Security callbacks
|
|
28
|
-
ON_SCREEN_SECURE_SET:"ON_SCREEN_SECURE_SET",ON_SCREEN_SECURE_STATUS:"ON_SCREEN_SECURE_STATUS",ON_SCREEN_SECURE_ERROR:"ON_SCREEN_SECURE_ERROR",ON_WEB_DATA_CLEARED:"ON_WEB_DATA_CLEARED",ON_WEB_DATA_CLEAR_ERROR:"ON_WEB_DATA_CLEAR_ERROR"
|
|
28
|
+
ON_SCREEN_SECURE_SET:"ON_SCREEN_SECURE_SET",ON_SCREEN_SECURE_STATUS:"ON_SCREEN_SECURE_STATUS",ON_SCREEN_SECURE_ERROR:"ON_SCREEN_SECURE_ERROR",ON_WEB_DATA_CLEARED:"ON_WEB_DATA_CLEARED",ON_WEB_DATA_CLEAR_ERROR:"ON_WEB_DATA_CLEAR_ERROR",// Perf telemetry callbacks (native → web)
|
|
29
|
+
CATALYST_PERF_EVENT:"CATALYST_PERF_EVENT"};// Interface Categories for easier management
|
|
29
30
|
const INTERFACE_CATEGORIES=exports.INTERFACE_CATEGORIES={CAMERA:{commands:[NATIVE_COMMANDS.OPEN_CAMERA,NATIVE_COMMANDS.REQUEST_CAMERA_PERMISSION],callbacks:[NATIVE_CALLBACKS.CAMERA_PERMISSION_STATUS,NATIVE_CALLBACKS.ON_CAMERA_CAPTURE,NATIVE_CALLBACKS.ON_CAMERA_ERROR]},FILE:{commands:[NATIVE_COMMANDS.PICK_FILE,NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_FILE_PICKED,NATIVE_CALLBACKS.ON_FILE_PICK_ERROR,NATIVE_CALLBACKS.ON_FILE_PICK_CANCELLED,NATIVE_CALLBACKS.ON_FILE_PICK_STATE_UPDATE]},INTENT:{commands:[NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_INTENT_SUCCESS,NATIVE_CALLBACKS.ON_INTENT_ERROR,NATIVE_CALLBACKS.ON_INTENT_CANCELLED]},HAPTIC:{commands:[NATIVE_COMMANDS.REQUEST_HAPTIC_FEEDBACK],callbacks:[NATIVE_CALLBACKS.HAPTIC_FEEDBACK]},NOTIFICATION:{commands:[NATIVE_COMMANDS.SCHEDULE_LOCAL_NOTIFICATION,NATIVE_COMMANDS.CANCEL_LOCAL_NOTIFICATION,NATIVE_COMMANDS.REQUEST_NOTIFICATION_PERMISSION,NATIVE_COMMANDS.REGISTER_FOR_PUSH_NOTIFICATIONS,NATIVE_COMMANDS.UPDATE_BADGE_COUNT,NATIVE_COMMANDS.SUBSCRIBE_TO_TOPIC,NATIVE_COMMANDS.UNSUBSCRIBE_FROM_TOPIC,NATIVE_COMMANDS.GET_SUBSCRIBED_TOPICS],callbacks:[NATIVE_CALLBACKS.NOTIFICATION_PERMISSION_STATUS,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_SCHEDULED,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_CANCELLED,NATIVE_CALLBACKS.PUSH_NOTIFICATION_TOKEN,NATIVE_CALLBACKS.NOTIFICATION_RECEIVED,NATIVE_CALLBACKS.NOTIFICATION_ACTION_PERFORMED,NATIVE_CALLBACKS.NOTIFICATION_TAPPED,NATIVE_CALLBACKS.TOPIC_SUBSCRIPTION_RESULT,NATIVE_CALLBACKS.SUBSCRIBED_TOPICS_RESULT]},DEVICE_INFO:{commands:[NATIVE_COMMANDS.GET_DEVICE_INFO],callbacks:[NATIVE_CALLBACKS.ON_DEVICE_INFO_SUCCESS,NATIVE_CALLBACKS.ON_DEVICE_INFO_ERROR]},AUTH:{commands:[NATIVE_COMMANDS.GOOGLE_SIGN_IN],callbacks:[NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_SUCCESS,NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_ERROR,NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_CANCELLED]},NETWORK:{commands:[NATIVE_COMMANDS.GET_NETWORK_STATUS],callbacks:[NATIVE_CALLBACKS.NETWORK_STATUS_CHANGED]},SECURITY:{commands:[NATIVE_COMMANDS.SET_SCREEN_SECURE,NATIVE_COMMANDS.GET_SCREEN_SECURE,NATIVE_COMMANDS.CLEAR_WEB_DATA],callbacks:[NATIVE_CALLBACKS.ON_SCREEN_SECURE_SET,NATIVE_CALLBACKS.ON_SCREEN_SECURE_ERROR,NATIVE_CALLBACKS.ON_WEB_DATA_CLEARED,NATIVE_CALLBACKS.ON_WEB_DATA_CLEAR_ERROR]},TRANSITION:{commands:[NATIVE_COMMANDS.START_TRANSITION,NATIVE_COMMANDS.COMMIT_TRANSITION,NATIVE_COMMANDS.CANCEL_TRANSITION],callbacks:[NATIVE_CALLBACKS.ON_TRANSITION_COMMITTED,NATIVE_CALLBACKS.ON_TRANSITION_CANCELLED,NATIVE_CALLBACKS.ON_TRANSITION_TIMEOUT]}};// All available interfaces (for validation)
|
|
30
31
|
const ALL_COMMANDS=exports.ALL_COMMANDS=Object.values(NATIVE_COMMANDS);const ALL_CALLBACKS=exports.ALL_CALLBACKS=Object.values(NATIVE_CALLBACKS);const ALL_INTERFACES=exports.ALL_INTERFACES=[...ALL_COMMANDS,...ALL_CALLBACKS];// Permission status constants
|
|
31
32
|
const PERMISSION_STATUS=exports.PERMISSION_STATUS={GRANTED:"GRANTED",DENIED:"DENIED",NOT_DETERMINED:"NOT_DETERMINED",RESTRICTED:"RESTRICTED"};// Haptic feedback types (matching Android implementation)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.ApiCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* ApiCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments native API call timing — how long each @JavascriptInterface
|
|
5
|
+
* method takes from native receipt until its immediate native handler work is
|
|
6
|
+
* dispatched. It does not measure JS-to-native transport, JS callback delivery,
|
|
7
|
+
* or UI completion.
|
|
8
|
+
*
|
|
9
|
+
* Native events received (via __catalystPerfBatch drain):
|
|
10
|
+
* api-call { callId, method, nativeStartMs, nativeEndMs, durationMs, thread }
|
|
11
|
+
*
|
|
12
|
+
* Spans emitted (one per call, on Catalyst > Native API track):
|
|
13
|
+
* catalyst:api/<method> — nativeStart → nativeEnd
|
|
14
|
+
* color: primary-dark (<50ms), secondary (<200ms), error (>200ms)
|
|
15
|
+
* properties: method, durationMs, callId, thread, simulatorValid: false
|
|
16
|
+
*
|
|
17
|
+
* Reading the track:
|
|
18
|
+
* Blue = fast native call (<50ms) — expected for sync APIs (getDeviceInfo, haptic)
|
|
19
|
+
* Grey = medium native call (<200ms) — native work worth inspecting
|
|
20
|
+
* Red = slow native call (>200ms) — native work worth inspecting
|
|
21
|
+
*/class ApiCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;}init(){// No web-side observers — all events come from native buffer drain
|
|
22
|
+
}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}setInteractionSource(fn){this._getInteraction=fn;}onBatchEvent(event){if(event.type!=="api-call")return;const{method,callId,nativeStartMs,nativeEndMs,durationMs,thread}=event;const startTime=this._nativeToWeb(nativeStartMs);const endTime=this._nativeToWeb(nativeEndMs);const interactionId=this._getInteraction?.()?.interactionIdForRange(startTime,endTime)??null;const color=durationMs>200?_constants.COLOR.ERROR:durationMs>50?_constants.COLOR.SECONDARY:_constants.COLOR.PRIMARY_DARK;const startMark=`${_constants.PREFIX.NATIVE_API}:${method}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._notifyWaterfall?.({method,durationMs,callId,thread:thread??null,startTime,endTime,sessionId:interactionId,parentOperation:`Native execution: ${method}`});this._measure.emit(`${interactionId?`[${interactionId}] `:""}Native API: ${method} - ${durationMs}ms`,startMark,endTime,{interactionId,parentOperation:`Native execution: ${method}`,method,durationMs,callId,thread:thread??null,simulatorValid:false},_constants.TRACK.NATIVE_API,color);}}exports.ApiCollector=ApiCollector;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.CacheCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* CacheCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #1, #2, #10
|
|
5
|
+
* #1 page slowness OTC pages → cache miss → slow fetch → resource blocks render
|
|
6
|
+
* #2 back-forward reloads screen → back navigation triggers a burst of cache misses
|
|
7
|
+
* #10 footer flash on Drug/OTC → cache miss for footer chunk → layout shift after load
|
|
8
|
+
*
|
|
9
|
+
* Native events received in two modes:
|
|
10
|
+
*
|
|
11
|
+
* 1. Live (legacy path — only used if buffer disabled):
|
|
12
|
+
* cache-hit-memory / cache-hit-disk / cache-miss-fetch / network-fetch-complete
|
|
13
|
+
* → Each emits immediately to Catalyst > Cache track via onNativeEvent()
|
|
14
|
+
*
|
|
15
|
+
* 2. Batched (primary path via __catalystPerfBatch drain in index.js):
|
|
16
|
+
* Same event types arrive as part of the native buffer flush after onPageFinished.
|
|
17
|
+
* JS drains via requestIdleCallback → onBatchEvent() → emits spans on Catalyst > Cache > Detail
|
|
18
|
+
*
|
|
19
|
+
* cache-summary { hits, misses, fetches, total, hitRatePct, avgFetchMs, topSlowest[] }
|
|
20
|
+
* → One summary span emitted on main Catalyst > Cache track for InsightsCollector
|
|
21
|
+
*
|
|
22
|
+
* Spans emitted:
|
|
23
|
+
* Catalyst > Cache > Detail individual spans (one per file) during idle drain
|
|
24
|
+
* Catalyst > Cache summary span (hit rate, avg fetch, top slowest)
|
|
25
|
+
*
|
|
26
|
+
* Reading the track:
|
|
27
|
+
* Blue = cache HIT memory — fastest path
|
|
28
|
+
* Blue-light = cache HIT disk — good, slightly slower
|
|
29
|
+
* Red = cache MISS — matched pattern but not cached
|
|
30
|
+
* Yellow/Orange = network fetch — orange if slow >300ms
|
|
31
|
+
*/const TRACK_CACHE_DETAIL="Cache > Detail";class CacheCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;this._notifyInsights=null;// set by index.js after construction
|
|
32
|
+
}/** Called by index.js to wire up insights notifications for cache-summary. */setInsightsSource(fn){this._notifyInsights=fn;}init(){// No web-side observers — all events come from native
|
|
33
|
+
}// ── Live path (individual events routed from index.js) ──────────────────
|
|
34
|
+
onNativeEvent(event){const{type}=event;if(!type?.startsWith("cache-")&&type!=="network-fetch-complete")return;this._emitCacheSpan(event,_constants.TRACK.CACHE);}// ── Batch path (native buffer flush) ────────────────────────────────────
|
|
35
|
+
onBatchEvent(event){const{type}=event;if(type==="cache-summary"){this._emitSummarySpan(event);return;}if(!type?.startsWith("cache-")&&type!=="network-fetch-complete")return;// Individual cache spans go on the Detail sub-track
|
|
36
|
+
this._emitCacheSpan(event,TRACK_CACHE_DETAIL);}// ── Internal ─────────────────────────────────────────────────────────────
|
|
37
|
+
_emitCacheSpan(event,track){const{type,url,durationMs,nativeStartMs,resourceType,thread,statusCode}=event;const resType=resourceType??"other";const filename=this._filename(url);const endTime=nativeStartMs!=null?this._nativeToWeb(nativeStartMs)+(durationMs??0):performance.now();const startTime=endTime-(durationMs??0);let measureName,color,detail;const interceptThread=event.interceptThread??null;const emitThread=event.emitThread??null;if(type==="cache-hit-memory"){measureName=`${_constants.PREFIX.CACHE}/hit|memory|${resType}|${filename}`;color=_constants.COLOR.PRIMARY;detail={url,source:"memory",resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="cache-hit-disk"){measureName=`${_constants.PREFIX.CACHE}/hit|disk|${resType}|${filename}`;color=_constants.COLOR.PRIMARY_LIGHT;detail={url,source:"disk",resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="cache-miss-fetch"){measureName=`${_constants.PREFIX.CACHE}/miss|${resType}|${filename}`;color=_constants.COLOR.ERROR;detail={url,resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="network-fetch-complete"){const slow=(durationMs??0)>_constants.THRESHOLD.FETCH_SLOW_MS;measureName=`${_constants.PREFIX.CACHE}/fetch|${resType}|${filename}`;color=slow?_constants.COLOR.TERTIARY_DARK:_constants.COLOR.TERTIARY;detail={url,resourceType:resType,durationMs,statusCode:statusCode??null,thread:thread??null,emitThread,slow,simulatorValid:false};}else{return;}const startMark=`${measureName}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._measure.emit(measureName,startMark,endTime,detail,track,color);}_emitSummarySpan(event){const{hits,misses,fetches,total,hitRatePct,avgFetchMs,topSlowest,nativeTime}=event;if(total===0)return;// Place summary span at current time (post-load, idle phase)
|
|
38
|
+
const startTime=this._nativeToWeb(nativeTime??0)||performance.now();const endTime=startTime+1;// point span
|
|
39
|
+
const color=hitRatePct>=80?_constants.COLOR.PRIMARY_DARK:hitRatePct>=50?_constants.COLOR.SECONDARY:_constants.COLOR.ERROR;const topNames=Array.isArray(topSlowest)?topSlowest.map(e=>`${e.filename}(${e.durationMs}ms)`).join(", "):"";const measureName=`${_constants.PREFIX.CACHE}/summary`;const startMark=`${measureName}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._measure.emit(measureName,startMark,endTime,{hits,misses,fetches,total,hitRatePct,avgFetchMs,topSlowest:topNames,simulatorValid:false},_constants.TRACK.CACHE,color);// Notify InsightsCollector so it can flag low hit rates
|
|
40
|
+
this._notifyInsights?.({hitRatePct,total,avgFetchMs,startTime,endTime});}_filename(url){try{const path=new URL(url).pathname;const name=path.split("/").filter(Boolean).pop()??path;return name.slice(0,40);}catch{return String(url).slice(0,40);}}}exports.CacheCollector=CacheCollector;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.HookCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* HookCollector.js — Option A (symptom detection only)
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #13, #15, #16
|
|
5
|
+
* #13 cart computation loop jank → LoAF bursts sustained over time, not tied to a single interaction
|
|
6
|
+
* detected as: LoAF count > threshold within a rolling window
|
|
7
|
+
* #15 quantity selector slow first → interaction → first LoAF burst → long gap → paint
|
|
8
|
+
* detected as: LoAF fires within LAZY_LOAF_WINDOW_MS after tap
|
|
9
|
+
* #16 prescription screen touch lag → same pattern as #15
|
|
10
|
+
*
|
|
11
|
+
* Option A strategy (no React hooks needed):
|
|
12
|
+
* - Watch for LoAF bursts within 2s of a pointerdown (taps that might trigger lazy load)
|
|
13
|
+
* - If LoAF count > 0 in that window → emit hook/lazy span showing the blocked period
|
|
14
|
+
* - Sustained LoAF (3+ frames in 5s with no user interaction) → emit hook/mount span
|
|
15
|
+
* flagging a background computation loop
|
|
16
|
+
*
|
|
17
|
+
* Spans emitted:
|
|
18
|
+
* catalyst:hook/lazy|<label> — LoAF burst after interaction (Track: Hook)
|
|
19
|
+
* detail: loafCount, totalBlockingMs, triggerTarget
|
|
20
|
+
* catalyst:hook/mount|loop — sustained background LoAF (Track: Hook)
|
|
21
|
+
* detail: loafCount, windowMs, likelyCause='computation-loop'
|
|
22
|
+
*
|
|
23
|
+
* TODO (Option B — future):
|
|
24
|
+
* Expose window.catalyst.markComponentMount(name) / markComponentReady(name)
|
|
25
|
+
* for precise hook-lifecycle spans without symptom inference.
|
|
26
|
+
*/const LOOP_LOAF_COUNT=3;// ≥3 LoAFs in the loop window = computation loop
|
|
27
|
+
const LOOP_WINDOW_MS=5000;// rolling window to count sustained LoAFs
|
|
28
|
+
class HookCollector{constructor(measure){this._measure=measure;// Lazy-load detection state
|
|
29
|
+
this._pendingLazy=null;// { startMark, startTime, target, loafCount, totalBlocking, timer }
|
|
30
|
+
// Computation loop detection state
|
|
31
|
+
this._loopLoafs=[];// [{ start, end }] in rolling LOOP_WINDOW_MS window
|
|
32
|
+
}init(){// Watch for taps that might trigger lazy component loads
|
|
33
|
+
document.addEventListener("pointerdown",this._onPointerDown.bind(this),{passive:true});}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}// Called by RenderCollector via addLoafListener() on every LoAF
|
|
34
|
+
onLoaf(start,end){// ── Lazy-load window ────────────────────────────────────────────────
|
|
35
|
+
if(this._pendingLazy){const p=this._pendingLazy;p.loafCount++;p.totalBlocking+=end-start;// Reset the close timer — extend window on each LoAF
|
|
36
|
+
clearTimeout(p.timer);p.timer=setTimeout(()=>this._closeLazySpan(),_constants.THRESHOLD.LAZY_LOAF_WINDOW_MS);}// ── Computation loop detection ───────────────────────────────────────
|
|
37
|
+
const now=performance.now();this._loopLoafs.push({start,end});// Evict entries outside the rolling window
|
|
38
|
+
this._loopLoafs=this._loopLoafs.filter(e=>now-e.start<LOOP_WINDOW_MS);if(this._loopLoafs.length>=LOOP_LOAF_COUNT){this._emitLoopSpan();this._loopLoafs=[];// reset after emitting to avoid duplicate spans
|
|
39
|
+
}}// ─── Lazy-load span ───────────────────────────────────────────────────────
|
|
40
|
+
_onPointerDown(e){// If a lazy span is already open, let it close naturally
|
|
41
|
+
if(this._pendingLazy)return;const target=e.target?.tagName?.toLowerCase()??"unknown";const startTime=performance.now();const startMark=`${_constants.PREFIX.HOOK_LAZY}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});const timer=setTimeout(()=>this._closeLazySpan(),_constants.THRESHOLD.LAZY_LOAF_WINDOW_MS);this._pendingLazy={startMark,startTime,target,loafCount:0,totalBlocking:0,timer};}_closeLazySpan(){const p=this._pendingLazy;if(!p)return;this._pendingLazy=null;clearTimeout(p.timer);// Only emit if LoAF actually fired — otherwise it was a clean interaction
|
|
42
|
+
if(p.loafCount===0)return;const endTime=performance.now();this._measure.emit(`${_constants.PREFIX.HOOK_LAZY}|${p.target}`,p.startMark,endTime,{loafCount:p.loafCount,totalBlockingMs:Math.round(p.totalBlocking),triggerTarget:p.target,likelyCause:"lazy-load",simulatorValid:false},_constants.TRACK.HOOK);this._notifyWaterfall?.({hookType:"lazy-load",label:`Hook lazy-load ${p.target}`,startTime:p.startTime,endTime,durationMs:Math.round(endTime-p.startTime),loafCount:p.loafCount,totalBlockingMs:Math.round(p.totalBlocking),likelyCause:"lazy-load"});}// ─── Computation loop span ────────────────────────────────────────────────
|
|
43
|
+
_emitLoopSpan(){const first=this._loopLoafs[0];const last=this._loopLoafs[this._loopLoafs.length-1];const markName=`${_constants.PREFIX.HOOK_MOUNT}:loop:${Math.round(first.start)}`;performance.mark(markName,{startTime:first.start});const totalBlocking=this._loopLoafs.reduce((sum,e)=>sum+(e.end-e.start),0);this._measure.emit(`${_constants.PREFIX.HOOK_MOUNT}|loop`,markName,last.end,{loafCount:this._loopLoafs.length,windowMs:Math.round(last.end-first.start),totalBlockingMs:Math.round(totalBlocking),likelyCause:"computation-loop",simulatorValid:false},_constants.TRACK.HOOK);this._notifyWaterfall?.({hookType:"computation-loop",label:"Hook computation loop",startTime:first.start,endTime:last.end,durationMs:Math.round(last.end-first.start),loafCount:this._loopLoafs.length,totalBlockingMs:Math.round(totalBlocking),likelyCause:"computation-loop"});}}exports.HookCollector=HookCollector;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.InsightsCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* InsightsCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Watches accumulated perf data and emits actionable warnings to the
|
|
5
|
+
* Catalyst > Insights track in Chrome DevTools.
|
|
6
|
+
*
|
|
7
|
+
* Each insight is a point-in-time span with:
|
|
8
|
+
* - severity: 'critical' | 'warning' | 'info'
|
|
9
|
+
* - rule: short machine-readable id (e.g. 'slow-page-load')
|
|
10
|
+
* - message: one-sentence human diagnosis
|
|
11
|
+
* - fix: immediate actionable recommendation
|
|
12
|
+
* - evidence: the numbers that triggered the rule
|
|
13
|
+
*
|
|
14
|
+
* Insights are emitted in two modes:
|
|
15
|
+
* 1. Reactive — emitted immediately when a threshold breach is detected
|
|
16
|
+
* (e.g. an fps-drop episode during interaction)
|
|
17
|
+
* 2. Summary — emitted once after SESSION_SUMMARY_DELAY_MS of inactivity
|
|
18
|
+
* to give a rolled-up view of what happened in the last session
|
|
19
|
+
*
|
|
20
|
+
* How to read this in DevTools:
|
|
21
|
+
* Performance tab → Catalyst > Insights track
|
|
22
|
+
* Click any span → Tooltip shows: severity, rule, message, fix, evidence
|
|
23
|
+
* Red spans = critical (user-visible jank/failure)
|
|
24
|
+
* Yellow (tertiary) = warning
|
|
25
|
+
* Blue (secondary) = info / optimization opportunity
|
|
26
|
+
*/const SESSION_SUMMARY_DELAY_MS=3000;function round(value){return Number.isFinite(value)?Math.round(value):value;}class InsightsCollector{constructor(measure){this._measure=measure;this._summaryTimer=null;this._insightSeq=0;// Counters reset per session
|
|
27
|
+
this._fpsDrops=[];// [{ minFps, durationMs }]
|
|
28
|
+
this._slowLoads=[];// [{ url, durationMs }]
|
|
29
|
+
this._loafsDuringNav=0;this._slowInteractions=[];// [{ target, responseMs }]
|
|
30
|
+
this._highMemory=[];// [{ currentMb }]
|
|
31
|
+
this._layoutShifts=[];// [{ value }]
|
|
32
|
+
this._coldStartMs=null;// always attached to other insights once known
|
|
33
|
+
}init(){// No web observers — fed via notify*() methods from index.js
|
|
34
|
+
}setStoreSource(fn){this._notifyStore=fn;}// ─── Feed methods — called by index.js after routing native events ─────────
|
|
35
|
+
notifyFpsDrop({minFps,avgFps,durationMs,duringInteraction,deltaFps,baselineFps,startTime,endTime,interactionId}){const source={startTime,endTime,related:{interactionId}};this._fpsDrops.push({minFps,durationMs});if(duringInteraction){this._emitReactive({severity:"critical",rule:"fps-drop-during-tap",message:`FPS dropped to ${minFps.toFixed(1)} for ${Math.round(durationMs)}ms right after a tap`,fix:"Heavy computation is blocking the render thread after interaction — compare spans with the same interaction ID",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}else if(minFps<30){this._emitReactive({severity:"warning",rule:"severe-fps-drop",message:`Severe FPS drop: ${minFps.toFixed(1)}fps min for ${Math.round(durationMs)}ms`,fix:"Look for LoAF spans overlapping this window on the Render track — a long script or layout is stalling frames",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}else if(deltaFps!=null&&deltaFps>=10){// Sudden spike: FPS fell sharply even if absolute value is not critical
|
|
36
|
+
this._emitReactive({severity:"warning",rule:"fps-spike-drop",message:`Sudden FPS spike: dropped ${deltaFps.toFixed(1)}fps (${baselineFps?.toFixed(1)} → ${minFps.toFixed(1)}) in one frame window`,fix:"A sudden spike drop — often a one-off heavy layout or image decode. Check Render track for a LoAF aligned with this episode.",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}this._scheduleSummary();}notifySlowLoad({url,durationMs,startTime,endTime}){this._slowLoads.push({url,durationMs});if(durationMs>4000){this._emitReactive({severity:"critical",rule:"page-load-too-slow",message:`Page load took ${Math.round(durationMs)}ms (${url.split("/").pop()||url})`,fix:"Check Cache track for cache-miss-fetch bursts; consider preloading route chunks or warming the WebView cache",evidence:{url,durationMs},startTime,endTime});}this._scheduleSummary();}notifyColdStart({durationMs,startTime,endTime}){this._coldStartMs=durationMs;// attach to all future insights automatically
|
|
37
|
+
const severity=durationMs>3000?"critical":durationMs>1800?"warning":"info";const label=durationMs>3000?"slow":durationMs>1800?"acceptable":"fast";this._emitReactive({severity,rule:"cold-start-time",message:`Cold start: ${Math.round(durationMs)}ms (${label}) — app launched to first page finish`,fix:durationMs>3000?"Cold start >3s — check if WebView.loadUrl() is called before layout inflation completes; defer non-critical MainActivity init; consider prewarming WebView in Application.onCreate()":durationMs>1800?"Cold start >1.8s — consider moving WebView construction earlier (Application.onCreate vs MainActivity.onCreate); check if large local assets block onPageFinished":"Cold start within target (≤1.8s). No action needed.",evidence:{durationMs,targetMs:1800,acceptableMs:3000},startTime,endTime});}notifyLoafDuringNav({durationMs,startTime,endTime}){this._loafsDuringNav++;if(this._loafsDuringNav===1){this._emitReactive({severity:"warning",rule:"loaf-blocking-nav",message:`Long animation frame (${Math.round(durationMs)}ms) fired during navigation — may cause blank screen`,fix:"Defer non-critical JS execution after page-load-end; split large bundles with dynamic import()",evidence:{durationMs,loafsDuringNav:this._loafsDuringNav},startTime,endTime});}}notifySlowInteraction({target,responseMs,startTime,endTime,interactionId}){this._slowInteractions.push({target,responseMs});if(responseMs>300){this._emitReactive({severity:"critical",rule:"interaction-unresponsive",message:`Tap on '${target}' took ${responseMs}ms to paint (threshold ${_constants.THRESHOLD.INTERACTION_SLOW_MS}ms)`,fix:"A LoAF is delaying the paint — inspect work overlapping the interaction",evidence:{target,responseMs,thresholdMs:_constants.THRESHOLD.INTERACTION_SLOW_MS},startTime,endTime,related:{interactionId}});}this._scheduleSummary();}notifyHighMemory({currentMb,startTime,endTime,memoryId}){const source={startTime,endTime,related:{memoryId}};// currentMb is webviewMb (V8 + Blink + JNI PSS) — the WebView engine memory.
|
|
38
|
+
// A healthy WebView app idles at 80-120MB. Growth beyond that signals
|
|
39
|
+
// a real leak (un-released image bitmaps, accumulating JS closures, JNI objects).
|
|
40
|
+
// Thresholds are intentionally higher than total-PSS thresholds used previously
|
|
41
|
+
// because we now track the native slice, not the whole process.
|
|
42
|
+
this._highMemory.push({currentMb});if(currentMb>120){this._emitReactive({severity:"critical",rule:"memory-critical",message:`WebView memory at ${Math.round(currentMb)}MB — above 120MB critical threshold (V8/Blink/JNI leak likely)`,fix:"Check for un-released image bitmaps, accumulating JS closures, or JNI objects not returned to GC. Compare WebView memory growth across snapshots on the Memory track.",evidence:{webviewMb:currentMb},...source});}else if(currentMb>80){this._emitReactive({severity:"warning",rule:"memory-high",message:`WebView memory at ${Math.round(currentMb)}MB — above 80MB warning threshold (healthy idle is <80MB)`,fix:"Monitor WebView memory on the Memory track — sustained growth = leak. Check image cache eviction and dispose WebView listeners on route unmount.",evidence:{webviewMb:currentMb},...source});}}notifyHwAccelDuringFrame({durationMs,thread,trigger,startTime,endTime}){this._emitReactive({severity:"critical",rule:"hw-accel-during-frame",message:`Software rendering was active for ${Math.round(durationMs)}ms during a dropped frame (hw-accel disabled by ${trigger??"cache-serve"})`,fix:"Cache serving on shouldInterceptRequest disables hardware acceleration — move cache I/O off the WebView thread or batch cache warming on app start to reduce mid-session hw-accel toggles",evidence:{durationMs,thread:thread??"unknown",trigger:trigger??"unknown"},startTime,endTime});}notifyLayoutShift({value,sessionContext,startTime,endTime}){this._layoutShifts.push({value});if(value>0.25){this._emitReactive({severity:"warning",rule:"large-layout-shift",message:`Large layout shift (CLS ${value.toFixed(3)}) during '${sessionContext}' phase`,fix:"Reserve space for async-loaded content (images, ads, banners) using explicit height; avoid injecting DOM above visible content",evidence:{value,sessionContext},startTime,endTime});}}notifyScrollJank({minFps,frameDropCount,startTime,endTime}){const severity=minFps<30?"critical":"warning";const rule=minFps<30?"scroll-jank-critical":"scroll-jank-warning";this._emitReactive({severity,rule,message:`Scroll jank: ${Math.round(frameDropCount)} frame drop(s), min ${minFps.toFixed(1)}fps during scroll`,fix:minFps<30?"Severe scroll jank — check for layout thrash triggered during scroll. Look for LoAF spans on Render track overlapping the Input track scroll session.":"Scroll dropped below 55fps — common causes: sticky header recalc, cart badge updates, or image decode on scroll. Check Render track LoAF spans inside the scroll session.",evidence:{minFps,frameDropCount},startTime,endTime});this._scheduleSummary();}notifyCacheSummary({hitRatePct,total,avgFetchMs,startTime,endTime}){if(total<5)return;// not enough data
|
|
43
|
+
if(hitRatePct<50){this._emitReactive({severity:"warning",rule:"low-cache-hit-rate",message:`Cache hit rate ${hitRatePct}% (${total} resources) — majority served via network`,fix:"Add missed URLs to the allowedUrls cache list; check cache max-age and disk eviction policy",evidence:{hitRatePct,total,avgFetchMs},startTime,endTime});}}// ─── Reactive insight emission ─────────────────────────────────────────────
|
|
44
|
+
_emitReactive({severity,rule,message,fix,evidence={},startTime,endTime,related={}}){const start=Number.isFinite(startTime)?startTime:performance.now();const end=Number.isFinite(endTime)?Math.max(start+1,endTime):start+2;const markName=`${_constants.PREFIX.INSIGHT}:${rule}:${Math.round(start)}:${++this._insightSeq}`;const color=severity==="critical"?"error":severity==="warning"?"tertiary":"secondary";const title=this._titleFor(rule,evidence,message);const category=this._categoryFor(rule);const detail={severity,rule,title,category,message,fix,...related,...(this._coldStartMs!=null?{"coldStart.ms":String(Math.round(this._coldStartMs))}:{}),...Object.fromEntries(Object.entries(evidence).filter(([,value])=>value!=null).map(([key,value])=>[`evidence.${key}`,String(value)]))};performance.mark(markName,{startTime:start});this._notifyStore?.({severity,rule,title,category,message,fix,evidence,...related,startTime:start,endTime:end,durationMs:end-start});this._measure.emit(`${related.interactionId?`[${related.interactionId}] `:""}Insight: ${title}`,markName,end,detail,_constants.TRACK.INSIGHTS,color);}_titleFor(rule,evidence,message){switch(rule){case"fps-drop-during-tap":return`FPS drop after tap: ${round(evidence.minFps)}fps`;case"severe-fps-drop":return`FPS drop: ${round(evidence.minFps)}fps for ${round(evidence.durationMs)}ms`;case"fps-spike-drop":return`FPS spike drop: -${round(evidence.deltaFps)}fps`;case"page-load-too-slow":return`Page load slow: ${round(evidence.durationMs)}ms`;case"cold-start-time":return`Cold start: ${round(evidence.durationMs)}ms`;case"loaf-blocking-nav":return`Render blocked during navigation: ${round(evidence.durationMs)}ms`;case"interaction-unresponsive":return`Slow tap: ${evidence.target??"unknown"} ${round(evidence.responseMs)}ms`;case"memory-critical":case"memory-high":return`Memory high: ${round(evidence.webviewMb)}MB`;case"hw-accel-during-frame":return`Software render window: ${round(evidence.durationMs)}ms`;case"large-layout-shift":return`Large layout shift: ${Number.isFinite(evidence.value)?evidence.value.toFixed(3):"unknown"}`;case"scroll-jank-critical":case"scroll-jank-warning":return`Scroll jank: ${round(evidence.minFps)}fps`;case"low-cache-hit-rate":return`Cache hit rate low: ${evidence.hitRatePct}%`;case"session-summary":return"Session issues";default:return message??rule;}}_categoryFor(rule){if(rule.includes("fps")||rule.includes("loaf")||rule.includes("layout"))return"render";if(rule.includes("page-load")||rule.includes("cold-start"))return"navigation";if(rule.includes("interaction")||rule.includes("tap"))return"interaction";if(rule.includes("memory"))return"memory";if(rule.includes("scroll"))return"scroll";if(rule.includes("cache"))return"cache";if(rule.includes("session"))return"session";return"general";}// ─── Session summary ───────────────────────────────────────────────────────
|
|
45
|
+
// Emitted after SESSION_SUMMARY_DELAY_MS of quiet — a rolled-up health check.
|
|
46
|
+
_scheduleSummary(){if(this._summaryTimer)clearTimeout(this._summaryTimer);this._summaryTimer=setTimeout(()=>this._emitSummary(),SESSION_SUMMARY_DELAY_MS);}_emitSummary(){this._summaryTimer=null;const problems=[];if(this._fpsDrops.length>0)problems.push(`${this._fpsDrops.length} fps-drop episode(s), min ${Math.min(...this._fpsDrops.map(f=>f.minFps)).toFixed(0)}fps`);if(this._slowLoads.length>0)problems.push(`${this._slowLoads.length} slow page load(s)`);if(this._slowInteractions.length>0)problems.push(`${this._slowInteractions.length} slow tap(s)`);if(this._highMemory.length>0)problems.push(`peak ${Math.max(...this._highMemory.map(m=>m.currentMb)).toFixed(0)}MB memory`);const severity=this._fpsDrops.some(f=>f.minFps<20)?"critical":problems.length>2?"warning":"info";const message=problems.length===0?"Session clean — no threshold breaches detected":`Session issues: ${problems.join(" · ")}`;const fix=problems.length===0?"No action needed":"Expand each red/yellow span above for per-issue fixes";this._emitReactive({severity:severity==="info"?"info":severity,rule:"session-summary",message,fix,evidence:{fpsDropEpisodes:this._fpsDrops.length,slowPageLoads:this._slowLoads.length,slowInteractions:this._slowInteractions.length,highMemoryReadings:this._highMemory.length,layoutShifts:this._layoutShifts.length}});// Reset counters for next session
|
|
47
|
+
this._fpsDrops=[];this._slowLoads=[];this._loafsDuringNav=0;this._slowInteractions=[];this._highMemory=[];this._layoutShifts=[];}}exports.InsightsCollector=InsightsCollector;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.InteractionCollector=void 0;var _constants=require("../core/constants.js");var _session=require("../core/session.js");/**
|
|
2
|
+
* InteractionCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #6, #8, #13, #17
|
|
5
|
+
* #6 blank screens → tap → no paint → LoAF blocked first render
|
|
6
|
+
* detected as: interaction span with no following rAF / LoAF overlap
|
|
7
|
+
* #8 long press context menu → pointerdown held > 500ms = native context menu will appear
|
|
8
|
+
* detected as: long-press span so devs see which elements trigger it
|
|
9
|
+
* #13 cart jank haywire clicks → interaction timestamps vs render timestamps diverge
|
|
10
|
+
* detected as: tap → paint delta > INTERACTION_SLOW_MS
|
|
11
|
+
* #17 prescription modal wrong → event queue backed up: pointerdown fires long after it was queued
|
|
12
|
+
* detected as: slow interaction span overlapping LoAF
|
|
13
|
+
*
|
|
14
|
+
* Spans emitted:
|
|
15
|
+
* catalyst:session/interaction|<tag>#<id> — currently disabled; preserved below as the
|
|
16
|
+
* old 2s grouping window for future re-enable.
|
|
17
|
+
* catalyst:interaction|<tag>#<id> — pointerdown → next rAF (Track: Interaction)
|
|
18
|
+
* detail: responseMs, loafOverlap, target, sessionId
|
|
19
|
+
* catalyst:long-press|<tag> — pointerdown held > 500ms (Track: Interaction)
|
|
20
|
+
* detail: target, heldMs
|
|
21
|
+
*
|
|
22
|
+
* Correlation:
|
|
23
|
+
* A lightweight interaction ID remains active from pointerdown to the next frame.
|
|
24
|
+
* Related work is tagged without emitting a parent session track.
|
|
25
|
+
*
|
|
26
|
+
* Disabled session nesting:
|
|
27
|
+
* On pointerdown, a 2-second interaction session opens.
|
|
28
|
+
* RenderCollector queries activeSessionId to tag its spans.
|
|
29
|
+
* Session closes after SESSION_WINDOW_MS (2s) or next pointerdown, whichever comes first.
|
|
30
|
+
*/const SESSION_WINDOW_MS=2000;const MAX_RECENT_INTERACTIONS=50;class InteractionCollector{constructor(measure){this._measure=measure;this._pendingTaps=new Map();// pointerId → { startMark, startTime, target }
|
|
31
|
+
this._session=new _session.Session(_constants.PREFIX.SESSION_INTERACTION,measure,_constants.TRACK.INTERACTION);this._sessionTimer=null;this._sessionId=null;this._sessionNetworkCalls=0;this._sessionFpsDrop=false;this._activeInteraction=null;this._recentInteractions=[];this._interactionSeq=0;}// ─── Public API — queried by native API and render collectors ──────────────
|
|
32
|
+
get activeInteractionId(){return this._activeInteraction?.id??null;}interactionIdForRange(startTime,endTime=startTime){const interactions=this._activeInteraction?[...this._recentInteractions,this._activeInteraction]:this._recentInteractions;return interactions.slice().reverse().find(interaction=>startTime<=(interaction.endTime??performance.now())&&endTime>=interaction.startTime)?.id??null;}/** Called by index.js to wire up insights notifications. */setInsightsSource(fn){this._notifyInsights=fn;}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}/** Called by RenderCollector when an fps-drop-episode fires. */onFpsDrop(){if(this._session.isOpen){this._sessionFpsDrop=true;this._session.set("fpsDrop",true);}}/** Called by NetworkTimingCollector when a fetch/XHR starts during an interaction. */onNetworkCall(){if(this._session.isOpen){this._sessionNetworkCalls++;this._session.set("networkCalls",this._sessionNetworkCalls);}}init(){document.addEventListener("pointerdown",this._onPointerDown.bind(this),{passive:true});document.addEventListener("pointerup",this._onPointerUp.bind(this),{passive:true});document.addEventListener("pointercancel",this._onPointerCancel.bind(this),{passive:true});}_openSession(target,startTime){// Close any existing session immediately — new tap = new session
|
|
33
|
+
this._closeSession();this._sessionId=`${Math.round(startTime)}`;this._sessionNetworkCalls=0;this._sessionFpsDrop=false;this._session.open({target,sessionId:this._sessionId,networkCalls:0,fpsDrop:false},startTime);this._notifyWaterfall?.({phase:"start",target,sessionId:this._sessionId,startTime});// Auto-close after 2s
|
|
34
|
+
this._sessionTimer=setTimeout(()=>this._closeSession(),SESSION_WINDOW_MS);}_closeSession(){if(this._sessionTimer){clearTimeout(this._sessionTimer);this._sessionTimer=null;}if(this._session.isOpen){const target=this._session.get("target");const startTime=this._session.startTime;const endTime=performance.now();this._session.close({sessionId:this._sessionId,networkCalls:this._sessionNetworkCalls,fpsDrop:this._sessionFpsDrop},endTime,this._sessionFpsDrop?"error":"secondary");this._notifyWaterfall?.({phase:"end",target,sessionId:this._sessionId,networkCalls:this._sessionNetworkCalls,fpsDrop:this._sessionFpsDrop,startTime,endTime,durationMs:Math.round(endTime-startTime)});}this._sessionId=null;}_onPointerDown(e){const startTime=performance.now();const target=this._describeTarget(e.target);const startMark=`${_constants.PREFIX.INTERACTION}:${e.pointerId}:start`;const interactionId=`Tap #${++this._interactionSeq}`;performance.mark(startMark,{startTime});this._activeInteraction={id:interactionId,startTime,endTime:null};this._measure.marker(`${interactionId}: ${target}`,startTime,{interactionId,target},"secondary");// The old 2s parent session
|
|
35
|
+
// this._openSession(target, startTime)
|
|
36
|
+
// Long-press detection timer
|
|
37
|
+
const longPressTimer=setTimeout(()=>{this._emitLongPress(e.pointerId,target,startTime);},_constants.THRESHOLD.LONG_PRESS_MS);this._pendingTaps.set(e.pointerId,{startMark,startTime,target,interactionId,longPressTimer});// Measure tap → next paint (interaction responsiveness)
|
|
38
|
+
requestAnimationFrame(()=>{const endTime=performance.now();const tap=this._pendingTaps.get(e.pointerId);if(!tap)return;tap.frameComplete=true;if(tap.released)this._pendingTaps.delete(e.pointerId);this._rememberInteraction(interactionId,startTime,endTime);const responseMs=Math.round(endTime-startTime);if(responseMs>_constants.THRESHOLD.INTERACTION_SLOW_MS){this._notifyInsights?.({target,responseMs,startTime,endTime,interactionId});}this._measure.emit(`${interactionId}: ${target} - ${responseMs}ms`,startMark,endTime,{responseMs,loafOverlap:this._measure.overlapsLoaf(startTime,endTime),target,interactionId,simulatorValid:false},_constants.TRACK.INTERACTION);this._notifyWaterfall?.({phase:"end",target,sessionId:interactionId,networkCalls:0,fpsDrop:false,startTime,endTime,durationMs:responseMs,responseMs,shortInteraction:true});});}_onPointerUp(e){const tap=this._pendingTaps.get(e.pointerId);if(tap){clearTimeout(tap.longPressTimer);tap.released=true;if(tap.frameComplete)this._pendingTaps.delete(e.pointerId);}}_onPointerCancel(e){const tap=this._pendingTaps.get(e.pointerId);if(!tap)return;clearTimeout(tap.longPressTimer);this._pendingTaps.delete(e.pointerId);if(this._activeInteraction?.id===tap.interactionId)this._activeInteraction=null;}_rememberInteraction(id,startTime,endTime){this._recentInteractions.push({id,startTime,endTime});if(this._recentInteractions.length>MAX_RECENT_INTERACTIONS){this._recentInteractions.shift();}if(this._activeInteraction?.id===id)this._activeInteraction=null;}_emitLongPress(pointerId,target,startTime){const tap=this._pendingTaps.get(pointerId);if(!tap)return;const endTime=performance.now();const heldMs=Math.round(endTime-startTime);const markName=`${_constants.PREFIX.LONG_PRESS}:${Math.round(startTime)}`;performance.mark(markName,{startTime});this._measure.emit(`${tap.interactionId}: Long press ${target} - ${heldMs}ms`,markName,endTime,{target,heldMs,interactionId:tap.interactionId,simulatorValid:true},_constants.TRACK.INTERACTION,"tertiary");}_describeTarget(el){if(!el)return"unknown";const tag=el.tagName?.toLowerCase()??"unknown";const id=el.id?`#${el.id}`:"";const cls=el.classList?.length?`.${[...el.classList].slice(0,2).join(".")}`:"";return`${tag}${id}${cls}`.slice(0,40);}}exports.InteractionCollector=InteractionCollector;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.KeyboardCollector=void 0;var _constants=require("../core/constants.js");var _session=require("../core/session.js");/**
|
|
2
|
+
* KeyboardCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #3, #12
|
|
5
|
+
* #3 keyboard sticky scroll → keyboard-show → viewport-resize → scroll fires during session
|
|
6
|
+
* if sticky element recalcs during this window it causes thrash
|
|
7
|
+
* #12 search input no auto-focus → keyboard-show event should fire after route transition to search
|
|
8
|
+
* if it doesn't, the native WebView suppressed programmatic focus
|
|
9
|
+
*
|
|
10
|
+
* Native events received (via index.js router):
|
|
11
|
+
* keyboard-show { nativeTime, keyboardHeight }
|
|
12
|
+
* keyboard-hide { nativeTime }
|
|
13
|
+
*
|
|
14
|
+
* Spans emitted:
|
|
15
|
+
* catalyst:session/keyboard — keyboard-show → viewport settled (Track: Keyboard)
|
|
16
|
+
* detail: keyboardHeight, viewportDelta, hadScrollDuringSession
|
|
17
|
+
* catalyst:viewport-resize — visualViewport resize → next rAF (Track: Keyboard)
|
|
18
|
+
* detail: deltaHeight
|
|
19
|
+
*/class KeyboardCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;this._session=new _session.Session(_constants.PREFIX.SESSION_KEYBOARD,measure,_constants.TRACK.INPUT);}init(){this._observeVisualViewport();}setWaterfallSource(fn){this._notifyWaterfall=fn;}// ─── Native event handlers ────────────────────────────────────────────────
|
|
20
|
+
onKeyboardShow(event){const webNow=this._nativeToWeb(event.nativeTime);this._session.open({keyboardHeight:event.keyboardHeight,hadScroll:false},webNow);}onKeyboardHide(event){const webNow=this._nativeToWeb(event.nativeTime);if(this._session.isOpen){this._closeSession(webNow);}// Emit a point mark so keyboard-hide is visible on the timeline
|
|
21
|
+
const hideMark=`catalyst:keyboard-hide:${Math.round(webNow)}`;performance.mark(hideMark,{startTime:webNow});}// Called by ScrollCollector (via index.js) if scroll fires while keyboard open
|
|
22
|
+
onScrollDuringKeyboard(){if(this._session.isOpen){this._session.set("hadScroll",true);}}// ─── Viewport resize ──────────────────────────────────────────────────────
|
|
23
|
+
// Fires when keyboard pushes/pulls the viewport.
|
|
24
|
+
// Also closes the keyboard session — viewport settling = keyboard done.
|
|
25
|
+
_observeVisualViewport(){if(!window.visualViewport)return;let lastHeight=window.visualViewport.height;window.visualViewport.addEventListener("resize",()=>{const now=performance.now();const newHeight=window.visualViewport.height;const delta=Math.abs(newHeight-lastHeight);lastHeight=newHeight;if(delta<_constants.THRESHOLD.VIEWPORT_MIN_DELTA)return;const markName=`${_constants.PREFIX.VIEWPORT_RESIZE}:start:${Math.round(now)}`;performance.mark(markName,{startTime:now});requestAnimationFrame(()=>{const endTime=performance.now();const durationMs=Math.round(endTime-now);this._measure.emit(_constants.PREFIX.VIEWPORT_RESIZE,markName,endTime,{deltaHeight:Math.round(delta),simulatorValid:true},_constants.TRACK.INPUT);this._notifyWaterfall?.({metric:"viewport-resize",startTime:now,endTime,durationMs,deltaHeight:Math.round(delta)});if(this._session.isOpen){this._closeSession(endTime);}});});}_closeSession(endTime){const startTime=this._session.startTime;const detail={keyboardHeight:this._session.get("keyboardHeight"),hadScrollDuringSession:this._session.get("hadScroll"),simulatorValid:false};this._session.close(detail,endTime);this._notifyWaterfall?.({kind:"keyboard",startTime,endTime,durationMs:Math.round(endTime-startTime),...detail});}}exports.KeyboardCollector=KeyboardCollector;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.NavigationCollector=void 0;var _constants=require("../core/constants.js");var _session=require("../core/session.js");/**
|
|
2
|
+
* NavigationCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #2, #7, #9, #11, #14
|
|
5
|
+
* #2 back-forward reloads screen → new nav session opens, cache miss burst follows
|
|
6
|
+
* #7 header disappears on back → route-transition span shows animation torn down early
|
|
7
|
+
* #9 forward transition broken → route-transition fires but no visual end mark
|
|
8
|
+
* #11 banner overlaps search box → nav session ends but layout-shift fires after
|
|
9
|
+
* #14 transitions not soft → skeleton→content swap visible as layout-shift during nav
|
|
10
|
+
*
|
|
11
|
+
* Spans emitted:
|
|
12
|
+
* catalyst:session/navigation — page start → LCP finalized (Track: Navigation)
|
|
13
|
+
* catalyst:route-transition|<from→to> — popstate/hashchange → rAF settle (Track: Navigation)
|
|
14
|
+
* catalyst:lcp|<ms>ms — LCP candidate finalized (Track: Navigation)
|
|
15
|
+
* catalyst:fcp|<ms>ms — FCP (iOS fallback) (Track: Navigation)
|
|
16
|
+
* catalyst:page-load|<url> — native page-load-start → page-load-end (Track: Navigation)
|
|
17
|
+
* catalyst:cold-start|<ms>ms — native app cold start duration (Track: Navigation)
|
|
18
|
+
* catalyst:boot/activity-created — batch: MainActivity.onCreate (Track: Navigation)
|
|
19
|
+
* catalyst:boot/webview-constructed — batch: CustomWebView init{} (Track: Navigation)
|
|
20
|
+
* catalyst:boot/load-url — batch: WebView.loadUrl() call (Track: Navigation)
|
|
21
|
+
* catalyst:boot/page-started — batch: onPageStarted callback (Track: Navigation)
|
|
22
|
+
* catalyst:boot/page-finished — batch: onPageFinished callback (Track: Navigation)
|
|
23
|
+
*/class NavigationCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb??(t=>t);this._session=new _session.Session(_constants.PREFIX.SESSION_NAV,measure,_constants.TRACK.NAVIGATION);this._pendingLcp=null;// { entry, label } — buffered until finalized
|
|
24
|
+
this._lcpFinalized=false;// Track pending native page-load spans: url → { markName, startWeb }
|
|
25
|
+
this._pendingPageLoads=new Map();}init(){this._session.open();this._observeLCP();this._observeRouteTransitions();this._setupLcpFinalization();}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}// Called by other collectors to check if nav is still loading
|
|
26
|
+
get isLoading(){return this._session.isOpen;}// ─── LCP ──────────────────────────────────────────────────────────────────
|
|
27
|
+
_observeLCP(){const types=PerformanceObserver.supportedEntryTypes??[];const type=types.includes("largest-contentful-paint")?"largest-contentful-paint":types.includes("paint")?"paint":null;if(!type)return;const obs=new PerformanceObserver(list=>{for(const entry of list.getEntries()){if(type==="paint"&&entry.name!=="first-contentful-paint")continue;const label=type==="largest-contentful-paint"?"lcp":"fcp";this._pendingLcp={entry,label};// overwrite — keep latest candidate
|
|
28
|
+
}});obs.observe({type,buffered:true});}_setupLcpFinalization(){// Browser stops updating LCP candidate on first user interaction or page hide
|
|
29
|
+
const finalize=()=>{this._finalizeLcp();document.removeEventListener("pointerdown",finalize);document.removeEventListener("keydown",finalize);};document.addEventListener("pointerdown",finalize);document.addEventListener("keydown",finalize);document.addEventListener("visibilitychange",()=>{if(document.visibilityState==="hidden")this._finalizeLcp();});}_finalizeLcp(){if(this._lcpFinalized||!this._pendingLcp)return;this._lcpFinalized=true;const{entry,label}=this._pendingLcp;this._pendingLcp=null;const markName=`${_constants.PREFIX[label.toUpperCase()]}:mark`;performance.mark(markName,{startTime:0});this._measure.emit(`${_constants.PREFIX[label.toUpperCase()]}|${Math.round(entry.startTime)}ms`,markName,entry.startTime,{renderTime:entry.renderTime??null,loadTime:entry.loadTime??null,simulatorValid:false},_constants.TRACK.NAVIGATION);this._notifyWaterfall?.({label:label.toUpperCase(),startTime:0,endTime:entry.startTime,durationMs:Math.round(entry.startTime)});// LCP finalized = page is loaded — close the navigation session
|
|
30
|
+
this._session.close({simulatorValid:false},entry.startTime);}// ─── Route transitions ────────────────────────────────────────────────────
|
|
31
|
+
// Detects SPA route changes via popstate + hashchange.
|
|
32
|
+
// Emits a span from event fire → next rAF (visual settle).
|
|
33
|
+
//
|
|
34
|
+
// Covers:
|
|
35
|
+
// #7 — if rAF never fires cleanly, transition span shows the gap
|
|
36
|
+
// #9 — span fires on back but not on some forward routes → missing spans = broken routes
|
|
37
|
+
// #11 — nav session re-opens; layout-shift collector shows shift after session close
|
|
38
|
+
_observeRouteTransitions(){let _fromPath=location.pathname+location.hash;const onTransition=e=>{const from=_fromPath;const to=location.pathname+location.hash;_fromPath=to;const startTime=performance.now();const markName=`${_constants.PREFIX.ROUTE_TRANSITION}:start:${Math.round(startTime)}`;performance.mark(markName,{startTime});// Re-open nav session for the SPA route change.
|
|
39
|
+
// LCP only fires once per WebView load — it does not reset on pushState.
|
|
40
|
+
// So we can't wait for LCP to close the session. Instead, close it after
|
|
41
|
+
// the next rAF (visual settle) + a short timeout to catch deferred renders.
|
|
42
|
+
if(!this._session.isOpen){this._lcpFinalized=false;this._session.open({},startTime);this._setupLcpFinalization();// Fallback: if LCP doesn't fire (SPA), close after rAF + 1s
|
|
43
|
+
requestAnimationFrame(()=>{setTimeout(()=>{if(this._session.isOpen&&!this._lcpFinalized){this._session.close({simulatorValid:true},performance.now());}},1000);});}requestAnimationFrame(()=>{const endTime=performance.now();this._measure.emit(`${_constants.PREFIX.ROUTE_TRANSITION}|${from}→${to}`,markName,endTime,{from,to,type:e.type,// 'popstate' or 'hashchange'
|
|
44
|
+
simulatorValid:true},_constants.TRACK.NAVIGATION);this._notifyWaterfall?.({label:`Route ${to}`,startTime,endTime,durationMs:Math.round(endTime-startTime)});});};window.addEventListener("popstate",onTransition);window.addEventListener("hashchange",onTransition);}// ─── Native page-load events (from CustomWebview.kt) ─────────────────────
|
|
45
|
+
// page-load-start: fired by onPageStarted, carries nativeTime + url
|
|
46
|
+
// page-load-end: fired by onPageFinished, carries nativeTime + url + durationMs
|
|
47
|
+
// page-load-error: fired by onReceivedError, carries nativeTime + url + errorCode
|
|
48
|
+
onNativeEvent(event){switch(event.type){case"page-load-start":{const startWeb=this._nativeToWeb(event.nativeTime);const markName=`${_constants.PREFIX.PAGE_LOAD}:start:${Math.round(startWeb)}`;performance.mark(markName,{startTime:startWeb});this._pendingPageLoads.set(event.url,{markName,startWeb});break;}case"page-load-end":{const pending=this._pendingPageLoads.get(event.url);if(!pending)break;this._pendingPageLoads.delete(event.url);const endWeb=this._nativeToWeb(event.nativeTime);const durMs=event.durationMs??Math.round(endWeb-pending.startWeb);const isSlow=durMs>2500;this._measure.emit(`${_constants.PREFIX.PAGE_LOAD}|${event.url.split("/").pop()||event.url}`,pending.markName,endWeb,{url:event.url,durationMs:durMs,simulatorValid:true},_constants.TRACK.NAVIGATION,isSlow?"error":"primary");break;}case"page-load-error":{const pending=this._pendingPageLoads.get(event.url);const startWeb=pending?pending.startWeb:this._nativeToWeb(event.nativeTime);const markName=pending?pending.markName:`${_constants.PREFIX.PAGE_LOAD}:err:${Math.round(startWeb)}`;if(!pending)performance.mark(markName,{startTime:startWeb});this._pendingPageLoads.delete(event.url);const errEnd=this._nativeToWeb(event.nativeTime)+1;this._measure.emit(`${_constants.PREFIX.PAGE_LOAD}/error|${event.errorCode??"?"}`,markName,errEnd,{url:event.url,errorCode:event.errorCode,description:event.description??"",simulatorValid:true},_constants.TRACK.NAVIGATION,"error");break;}case"cold-start":{// emitted by markAppStartComplete() — point-in-time span at page init
|
|
49
|
+
const startWeb=this._nativeToWeb(event.nativeTime);const markName=`${_constants.PREFIX.COLD_START}:mark`;performance.mark(markName,{startTime:Math.max(0,startWeb-event.durationMs)});this._measure.emit(`${_constants.PREFIX.COLD_START}|${event.durationMs}ms`,markName,startWeb,{durationMs:event.durationMs,simulatorValid:true},_constants.TRACK.NAVIGATION,event.durationMs>3000?"error":"secondary");break;}}}// ─── Batch boot timing events ─────────────────────────────────────────────
|
|
50
|
+
// Emitted from PerfEventBuffer flush after onPageFinished.
|
|
51
|
+
// Each boot-* event is a point-in-time marker on Catalyst > Navigation.
|
|
52
|
+
onBatchEvent(event){if(event.type==="navigation-back"){const t=this._nativeToWeb(event.nativeTime);const markName=`catalyst:navigation/back:${Math.round(t)}`;performance.mark(markName,{startTime:Math.max(0,t)});this._measure.emit("catalyst:navigation/back",markName,Math.max(0,t)+1,{simulatorValid:false},_constants.TRACK.NAVIGATION,_constants.COLOR.SECONDARY);return;}const bootTypeMap={"boot-activity-created":"activity-created","boot-webview-constructed":"webview-constructed","boot-load-url":"load-url","boot-page-started":"page-started","boot-page-finished":"page-finished"};const label=bootTypeMap[event.type];if(!label)return;const t=this._nativeToWeb(event.nativeTime);const markName=`catalyst:boot/${label}:${Math.round(t)}`;performance.mark(markName,{startTime:Math.max(0,t)});this._measure.emit(`catalyst:boot/${label}`,markName,Math.max(0,t)+1,// point span
|
|
53
|
+
{nativeTime:event.nativeTime,thread:event.thread??null,simulatorValid:false},_constants.TRACK.NAVIGATION,_constants.COLOR.SECONDARY_LIGHT);}}exports.NavigationCollector=NavigationCollector;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.NetworkTimingCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* NetworkTimingCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Adds lightweight Catalyst timeline spans for fetch() and XMLHttpRequest.
|
|
5
|
+
* Chrome already has detailed network timing; these spans exist to attach
|
|
6
|
+
* network work to Catalyst interaction sessions via sessionId.
|
|
7
|
+
*/const FETCH_WRAPPED="__catalystNetworkTimingFetchWrapped";const XHR_WRAPPED="__catalystNetworkTimingXhrWrapped";const XHR_META="__catalystNetworkTimingMeta";const MAX_LABEL_LENGTH=72;class NetworkTimingCollector{constructor(measure){this._measure=measure;this._getInteraction=null;this._requestSeq=0;}setInteractionSource(fn){this._getInteraction=fn;}setWaterfallSource(fn){this._notifyWaterfall=fn;}init(){if(typeof window==="undefined")return;this._wrapFetch();this._wrapXhr();}_wrapFetch(){const originalFetch=window.fetch;if(typeof originalFetch!=="function"||originalFetch[FETCH_WRAPPED])return;const collector=this;function catalystFetch(input,init){const span=collector._start("fetch",init?.method??input?.method??"GET",init?.url??input?.url??input);try{return originalFetch.apply(this,arguments).then(response=>{collector._finish(span,{status:response?.status??null,ok:response?.ok??null});return response;},error=>{collector._finish(span,{outcome:"error",ok:false});throw error;});}catch(error){collector._finish(span,{outcome:"error",ok:false});throw error;}}catalystFetch[FETCH_WRAPPED]=true;window.fetch=catalystFetch;}_wrapXhr(){const proto=window.XMLHttpRequest?.prototype;if(!proto||proto[XHR_WRAPPED])return;if(typeof proto.open!=="function"||typeof proto.send!=="function")return;const originalOpen=proto.open;const originalSend=proto.send;const collector=this;proto.open=function catalystXhrOpen(method,url){this[XHR_META]={method,url};return originalOpen.apply(this,arguments);};proto.send=function catalystXhrSend(){const meta=this[XHR_META]??{};const span=collector._start("xhr",meta.method??"GET",meta.url??"unknown");let emitted=false;const finish=(outcome="complete")=>{if(emitted)return;emitted=true;const status=this.status;collector._finish(span,{status,ok:collector._statusOk(status),outcome});};this.addEventListener?.("loadend",()=>finish(),{once:true});try{return originalSend.apply(this,arguments);}catch(error){finish("error");throw error;}};proto[XHR_WRAPPED]=true;}_start(api,method,rawUrl){const startTime=performance.now();const normalizedMethod=String(method||"GET").toUpperCase();const url=this._normalizeUrl(rawUrl);const label=this._labelForUrl(url);const requestId=`${api}:${++this._requestSeq}`;const startMark=`${_constants.PREFIX.NETWORK_TIMING}:${requestId}:start`;performance.mark(startMark,{startTime});const interaction=this._getInteraction?.();const interactionId=interaction?.activeInteractionId??null;if(interactionId)interaction?.onNetworkCall?.();return{api,method:normalizedMethod,url,label,requestId,startMark,startTime,interactionId};}_finish(span,result={}){const endTime=performance.now();const durationMs=Math.round(endTime-span.startTime);const outcome=result.outcome??"complete";const ok=result.ok??this._statusOk(result.status);const color=outcome!=="complete"||ok===false||durationMs>_constants.THRESHOLD.FETCH_SLOW_MS?_constants.COLOR.ERROR:_constants.COLOR.SECONDARY;const detail={api:span.api,method:span.method,url:span.url,status:result.status??null,ok,outcome,durationMs,sessionId:span.interactionId,requestId:span.requestId,simulatorValid:true};this._notifyWaterfall?.({...detail,startTime:span.startTime,endTime});this._measure.emit(`${span.interactionId?`[${span.interactionId}] `:""}Network: ${span.method} ${span.label} - ${durationMs}ms`,span.startMark,endTime,{interactionId:span.interactionId,...detail,sessionId:undefined},_constants.TRACK.NETWORK,color);}_normalizeUrl(rawUrl){const value=rawUrl?.url??rawUrl??"unknown";try{const parsed=new URL(String(value),window.location?.href);return`${parsed.origin}${parsed.pathname}`;}catch{return String(value).split("?")[0].split("#")[0];}}_labelForUrl(url){try{const parsed=new URL(url,window.location?.href);const label=parsed.origin===window.location?.origin?parsed.pathname:`${parsed.host}${parsed.pathname}`;return(label||"/").slice(0,MAX_LABEL_LENGTH);}catch{return String(url||"unknown").slice(0,MAX_LABEL_LENGTH);}}_statusOk(status){return typeof status==="number"&&status>=200&&status<400;}}exports.NetworkTimingCollector=NetworkTimingCollector;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.RenderCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* RenderCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #1, #6, #10, #14, #15, #16
|
|
5
|
+
* #1 page slowness / cache miss → LoAF during navigation session = render blocked by loading
|
|
6
|
+
* #6 blank screens → LoAF blocks first render; LCP may never fire
|
|
7
|
+
* #10 footer flash on Drug/OTC → layout-shift fires just after navigation session closes
|
|
8
|
+
* #14 transitions not soft → layout-shift during nav = skeleton→content swap too abrupt
|
|
9
|
+
* #15 quantity selector slow → LoAF burst in the 2s after interaction (lazy chunk blocks)
|
|
10
|
+
* #16 prescription screen touch lag → same LoAF burst pattern after tap
|
|
11
|
+
*
|
|
12
|
+
* Spans emitted:
|
|
13
|
+
* catalyst:loaf|<ms>ms — long animation frame (Track: Render)
|
|
14
|
+
* detail: blockingDuration, scriptCount, sessionContext
|
|
15
|
+
* catalyst:layout-shift|<value> — unexpected layout shift (Track: Render)
|
|
16
|
+
* detail: value, sessionContext
|
|
17
|
+
* catalyst:fps-drop|<fps>fps-min|<ms>ms — native fps-drop episode (Track: Render)
|
|
18
|
+
* detail: minFps, avgFps, durationMs, sessionContext
|
|
19
|
+
* closes on recovery >55fps OR after 10s max
|
|
20
|
+
* catalyst:long-task|<ms>ms — native long task > 16ms (Track: Render)
|
|
21
|
+
* catalyst:mem/jvm|<MB>MB — JVM heap PSS — Kotlin/Java objects (Track: Render, blue)
|
|
22
|
+
* catalyst:mem/native|<MB>MB — native heap PSS — V8 + Blink + JNI, WebView proxy (Track: Render, orange)
|
|
23
|
+
* catalyst:mem/total|<MB>MB — total process PSS — what Android OOM killer sees (Track: Render, purple→red)
|
|
24
|
+
*
|
|
25
|
+
* Notifies:
|
|
26
|
+
* ScrollCollector.onLoaf() — so scroll session can increment its loafCount
|
|
27
|
+
* HookCollector.onLoaf() — so hook mount sessions can detect LoAF bursts
|
|
28
|
+
*/class RenderCollector{constructor(measure,getSessionContext,nativeToWeb){this._measure=measure;// getSessionContext() returns 'navigation'|'scroll'|'keyboard'|'none'
|
|
29
|
+
// provided by index.js so RenderCollector doesn't import other collectors
|
|
30
|
+
this._getSessionContext=getSessionContext;this._nativeToWeb=nativeToWeb??(t=>t);this._loafListeners=[];// [fn] called on every LoAF
|
|
31
|
+
this._getInteraction=null;// set by index.js after construction
|
|
32
|
+
}/** Called by index.js to wire up interaction session context. */setInteractionSource(fn){this._getInteraction=fn;}/** Called by index.js to wire up insights notifications. */setInsightsSource(fn){this._notifyInsights=fn;}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}// Allow other collectors to subscribe to LoAF events
|
|
33
|
+
addLoafListener(fn){this._loafListeners.push(fn);}init(){this._observeLoAF();this._observeLayoutShift();}// ─── Native MetricsMonitor events ────────────────────────────────────────
|
|
34
|
+
onNativeEvent(event){const t=this._nativeToWeb(event.nativeTime);switch(event.type){case"fps-drop-episode":{const start=Math.max(0,this._nativeToWeb(event.startNativeTime));const end=Math.max(start,this._nativeToWeb(event.endNativeTime));const markName=`${_constants.PREFIX.FPS_DROP}:${Math.round(start)}`;performance.mark(markName,{startTime:start});const interactionId=this._getInteraction?.()?.interactionIdForRange(start,end)??null;if(interactionId)this._getInteraction?.()?.onFpsDrop();this._measure.emit(`${interactionId?`[${interactionId}] `:""}FPS drop: ${event.minFps.toFixed(1)}fps - ${Math.round(event.durationMs)}ms`,markName,end,{interactionId,minFps:event.minFps,avgFps:event.avgFps,durationMs:event.durationMs,sessionContext:this._getSessionContext(),thread:event.thread??null,simulatorValid:false},_constants.TRACK.RENDER,event.minFps<30?"error":"tertiary");this._notifyWaterfall?.({metric:"fps-drop",startTime:start,endTime:end,durationMs:event.durationMs,minFps:event.minFps,avgFps:event.avgFps,sessionId:interactionId});return{interactionId,startTime:start,endTime:end};}case"long-task":{const markName=`${_constants.PREFIX.LONG_TASK}:${Math.round(t)}`;const interactionId=this._getInteraction?.()?.interactionIdForRange(t,t+event.durationMs)??null;performance.mark(markName,{startTime:t});this._measure.emit(`${interactionId?`[${interactionId}] `:""}Long task: ${event.durationMs}ms`,markName,t+event.durationMs,{interactionId,durationMs:event.durationMs,sessionContext:this._getSessionContext(),thread:event.thread??null,simulatorValid:true},_constants.TRACK.RENDER,event.durationMs>100?"error":"tertiary");this._notifyWaterfall?.({metric:"long-task",startTime:t,endTime:t+event.durationMs,durationMs:event.durationMs,sessionId:interactionId});break;}case"memory-snapshot":{const jvmMark=`catalyst:mem/jvm:${Math.round(t)}`;performance.mark(jvmMark,{startTime:t});this._measure.emit(`JVM memory: ${event.jvmMb.toFixed(1)}MB`,jvmMark,t+1,{memoryId:`memory-jvm-${Math.round(t)}`,jvmMb:event.jvmMb,peakMb:event.peakMb,thread:event.thread??null,simulatorValid:false,...(event.coldStartMs!=null?{coldStartMs:event.coldStartMs}:{})},_constants.TRACK.MEMORY,"primary");const webviewMemoryId=`memory-webview-${Math.round(t)}`;const webviewMark=`catalyst:mem/webview:${Math.round(t)}`;performance.mark(webviewMark,{startTime:t});this._measure.emit(`WebView memory: ${event.webviewMb.toFixed(1)}MB`,webviewMark,t+1,{memoryId:webviewMemoryId,webviewMb:event.webviewMb,otherMb:event.otherMb,peakMb:event.peakMb,thread:event.thread??null,simulatorValid:false,...(event.coldStartMs!=null?{coldStartMs:event.coldStartMs}:{})},_constants.TRACK.MEMORY,"tertiary-dark");const totalMark=`${_constants.PREFIX.MEMORY_TOTAL}:${Math.round(t)}`;performance.mark(totalMark,{startTime:t});this._measure.emit(`Total memory: ${event.totalMb.toFixed(1)}MB`,totalMark,t+1,{memoryId:`memory-total-${Math.round(t)}`,totalMb:event.totalMb,jvmMb:event.jvmMb,webviewMb:event.webviewMb,otherMb:event.otherMb,peakMb:event.peakMb,thread:event.thread??null,simulatorValid:false,...(event.coldStartMs!=null?{coldStartMs:event.coldStartMs}:{})},_constants.TRACK.MEMORY,event.totalMb>400?"error":"secondary");this._notifyInsights?.({currentMb:event.webviewMb,startTime:t,endTime:t+1,memoryId:webviewMemoryId});this._notifyWaterfall?.({metric:"memory",startTime:t,endTime:t+1,durationMs:1,totalMb:event.totalMb,webviewMb:event.webviewMb});break;}}}// ─── Long Animation Frames ────────────────────────────────────────────────
|
|
35
|
+
_observeLoAF(){if(!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame"))return;const obs=new PerformanceObserver(list=>{for(const entry of list.getEntries()){const start=entry.startTime;const end=start+entry.duration;const context=this._getSessionContext();// Register with shared Measure so all collectors get overlap detection
|
|
36
|
+
this._measure.recordLoaf(start,end);// Notify subscribers (ScrollCollector, HookCollector)
|
|
37
|
+
this._loafListeners.forEach(fn=>fn(start,end));const markName=`${_constants.PREFIX.LOAF}:${Math.round(start)}`;performance.mark(markName,{startTime:start});this._measure.emit(`${_constants.PREFIX.LOAF}|${Math.round(entry.duration)}ms`,markName,end,{blockingDuration:entry.blockingDuration,scriptCount:entry.scripts?.length??0,sessionContext:context,simulatorValid:false},_constants.TRACK.RENDER);this._notifyWaterfall?.({metric:"loaf",startTime:start,endTime:end,durationMs:entry.duration});}});obs.observe({type:"long-animation-frame",buffered:true});}// ─── Layout shift ─────────────────────────────────────────────────────────
|
|
38
|
+
_observeLayoutShift(){if(!PerformanceObserver.supportedEntryTypes?.includes("layout-shift"))return;const obs=new PerformanceObserver(list=>{for(const entry of list.getEntries()){if(entry.hadRecentInput)continue;// user-initiated shift, ignore
|
|
39
|
+
const context=this._getSessionContext();const markName=`catalyst:cls:${Math.round(entry.startTime)}`;performance.mark(markName,{startTime:entry.startTime});if(entry.value>0.1){this._notifyInsights?.({value:entry.value,sessionContext:context,startTime:entry.startTime,endTime:entry.startTime+1});}this._measure.emit(`${_constants.PREFIX.LAYOUT_SHIFT}|${entry.value.toFixed(4)}`,markName,entry.startTime+1,// point-in-time event, minimal duration
|
|
40
|
+
{value:entry.value,sessionContext:context,simulatorValid:false},_constants.TRACK.RENDER);this._notifyWaterfall?.({metric:"layout-shift",startTime:entry.startTime,endTime:entry.startTime+1,durationMs:1,value:entry.value});}});obs.observe({type:"layout-shift",buffered:true});}}exports.RenderCollector=RenderCollector;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.ScrollCollector=void 0;var _constants=require("../core/constants.js");var _session=require("../core/session.js");/**
|
|
2
|
+
* ScrollCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #3, #13
|
|
5
|
+
* #3 keyboard sticky scroll bug → scroll fires during keyboard session → sticky recalc → layout thrash
|
|
6
|
+
* #13 cart computation jank → fps-drop-episode during scroll → scroll session marked janky
|
|
7
|
+
*
|
|
8
|
+
* Spans emitted:
|
|
9
|
+
* catalyst:session/scroll — scroll-start → scroll-end (Track: Input, shared with keyboard)
|
|
10
|
+
* detail: frameDropCount, minFps, janky, scrollDuration
|
|
11
|
+
*
|
|
12
|
+
* Native events consumed (via index.js _routeNativeEvent):
|
|
13
|
+
* scroll-start { nativeTime } — GestureDetector first onScroll() in CustomWebview.kt
|
|
14
|
+
* scroll-end { nativeTime } — GestureDetector ACTION_UP / ACTION_CANCEL
|
|
15
|
+
*
|
|
16
|
+
* fps-drop-episode routed here (Option B) when scroll session is open:
|
|
17
|
+
* index.js checks this._scroll._session.isOpen before calling onFpsDrop(event)
|
|
18
|
+
*/class ScrollCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;this._session=new _session.Session(_constants.PREFIX.SESSION_SCROLL,measure,_constants.TRACK.INPUT);this._insightsCb=null;// set by index.js via setInsightsSource
|
|
19
|
+
this._frameDropCount=0;this._minFps=60;}init(){// No DOM listener — scroll events come from native GestureDetector
|
|
20
|
+
}setInsightsSource(cb){this._insightsCb=cb;}setWaterfallSource(fn){this._notifyWaterfall=fn;}// Called by RenderCollector when a LoAF fires (shared notification, kept for compat)
|
|
21
|
+
onLoaf(){if(this._session.isOpen){this._session.increment("loafCount");}}// ─── Native event handlers ────────────────────────────────────────────────
|
|
22
|
+
onScrollStart(event){if(this._session.isOpen)return;// already tracking, ignore duplicate start
|
|
23
|
+
const webNow=this._nativeToWeb(event.nativeTime);this._frameDropCount=0;this._minFps=60;this._session.open({loafCount:0},webNow);}onScrollEnd(event){if(!this._session.isOpen)return;const startTime=this._session.startTime;const webNow=this._nativeToWeb(event.nativeTime);const loafCount=this._session.get("loafCount")??0;const janky=this._frameDropCount>0||this._minFps<55;const durationMs=Math.round(webNow-startTime);this._session.close({loafCount,frameDropCount:this._frameDropCount,minFps:this._minFps,janky,simulatorValid:false},webNow);if(janky&&this._insightsCb){this._insightsCb({minFps:this._minFps,frameDropCount:this._frameDropCount,startTime,endTime:webNow});}this._notifyWaterfall?.({kind:"scroll",startTime,endTime:webNow,durationMs,loafCount,frameDropCount:this._frameDropCount,minFps:this._minFps,janky});this._frameDropCount=0;this._minFps=60;}// ─── fps-drop-episode correlation (Option B) ──────────────────────────────
|
|
24
|
+
// Called from index.js _routeNativeEvent when scroll session is open.
|
|
25
|
+
onFpsDrop(event){this._frameDropCount++;if(event.minFps!=null&&event.minFps<this._minFps){this._minFps=event.minFps;}}}exports.ScrollCollector=ScrollCollector;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.ThreadCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* ThreadCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: thread visibility gap during cache serving.
|
|
5
|
+
*
|
|
6
|
+
* Problem: when shouldInterceptRequest serves from cache it calls
|
|
7
|
+
* disableHardwareAcceleration() / enableHardwareAcceleration() on the WebView.
|
|
8
|
+
* This creates a software-render window that is invisible in the Render track
|
|
9
|
+
* because it happens on a bg thread and neither the thread name nor the
|
|
10
|
+
* hw-accel state appears in any existing span.
|
|
11
|
+
*
|
|
12
|
+
* Native events received (via index.js router):
|
|
13
|
+
* hw-accel-change { state: 'off'|'on', trigger: 'cache-serve'|'cleanup',
|
|
14
|
+
* thread, nativeTime }
|
|
15
|
+
*
|
|
16
|
+
* Spans emitted (on Render track):
|
|
17
|
+
* catalyst:hw-accel/off — software-render window open (error = bad, overlaps frames)
|
|
18
|
+
* catalyst:hw-accel/on — hardware acceleration restored (primary)
|
|
19
|
+
*
|
|
20
|
+
* Overlap detection:
|
|
21
|
+
* If a LoAF overlaps a software-render window → notifyInsights so
|
|
22
|
+
* InsightsCollector can fire an 'hw-accel-during-frame' critical insight.
|
|
23
|
+
*/class ThreadCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;// Track open software-render windows: { startMark, startTime, thread }
|
|
24
|
+
this._openWindow=null;this._notifyInsights=null;}/** Called by index.js to wire up insights notifications. */setInsightsSource(fn){this._notifyInsights=fn;}init(){// No web-side observers — all events come from native via onNativeEvent()
|
|
25
|
+
}onNativeEvent(event){if(event.type!=="hw-accel-change")return;const{state,trigger,thread,nativeTime}=event;const webTime=nativeTime!=null?this._nativeToWeb(nativeTime):performance.now();if(state==="off"){// Software-render window opens — record start, wait for 'on' to close span
|
|
26
|
+
const markName=`${_constants.PREFIX.HW_ACCEL}/off:start:${Math.round(webTime)}`;performance.mark(markName,{startTime:webTime});this._openWindow={startMark:markName,startTime:webTime,thread:thread??null,trigger:trigger??null};}else if(state==="on"){const endTime=webTime;if(this._openWindow){const{startMark,startTime,thread:offThread,trigger:offTrigger}=this._openWindow;this._openWindow=null;const loafOverlap=this._measure.overlapsLoaf(startTime,endTime);this._measure.emit(`${_constants.PREFIX.HW_ACCEL}/sw-render-window|${offTrigger??"unknown"}`,startMark,endTime,{trigger:offTrigger??null,thread:offThread??null,loafOverlap,simulatorValid:false},_constants.TRACK.RENDER,loafOverlap?_constants.COLOR.ERROR:_constants.COLOR.SECONDARY_DARK);if(loafOverlap){this._notifyInsights?.({durationMs:endTime-startTime,thread:offThread,trigger:offTrigger,startTime,endTime});}}// Emit a point-in-time marker for hw-accel restored
|
|
27
|
+
const onMark=`${_constants.PREFIX.HW_ACCEL}/on:${Math.round(endTime)}`;performance.mark(onMark,{startTime:endTime});this._measure.emit(`${_constants.PREFIX.HW_ACCEL}/restored`,onMark,endTime+1,{trigger:trigger??null,thread:thread??null,simulatorValid:false},_constants.TRACK.RENDER,_constants.COLOR.SECONDARY);}}}exports.ThreadCollector=ThreadCollector;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.TRACK_GROUP=exports.TRACK=exports.THRESHOLD=exports.PREFIX=exports.COLOR=void 0;/**
|
|
2
|
+
* constants.js
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for track names, thresholds, and color rules.
|
|
5
|
+
* All collectors import from here — no magic strings scattered across files.
|
|
6
|
+
*/// ─── Chrome DevTools track group ─────────────────────────────────────────────
|
|
7
|
+
const TRACK_GROUP=exports.TRACK_GROUP="Catalyst";const TRACK=exports.TRACK={NAVIGATION:"Navigation",SCROLL:"Scroll",INPUT:"Input",KEYBOARD:"Keyboard",// alias kept for backward compat — collectors use TRACK.INPUT
|
|
8
|
+
RENDER:"Render",MEMORY:"Memory",CACHE:"Cache",NETWORK:"Network",HOOK:"Hook",INTERACTION:"Interaction",INSIGHTS:"Insights",NATIVE_API:"Native API"};// ─── Measure name prefixes ────────────────────────────────────────────────────
|
|
9
|
+
const PREFIX=exports.PREFIX={SESSION_NAV:"catalyst:session/navigation",SESSION_SCROLL:"catalyst:session/scroll",SESSION_INPUT:"catalyst:session/input",SESSION_KEYBOARD:"catalyst:session/keyboard",// alias — KeyboardCollector still uses this
|
|
10
|
+
LCP:"catalyst:lcp",FCP:"catalyst:fcp",LOAF:"catalyst:loaf",LAYOUT_SHIFT:"catalyst:layout-shift",VIEWPORT_RESIZE:"catalyst:viewport-resize",ROUTE_TRANSITION:"catalyst:route-transition",CACHE:"catalyst:cache",CACHE_GROUP:"catalyst:cache-group",NETWORK_TIMING:"catalyst:network",NATIVE_API:"catalyst:native-api",HOOK_MOUNT:"catalyst:hook/mount",HOOK_LAZY:"catalyst:hook/lazy",INTERACTION:"catalyst:interaction",SESSION_INTERACTION:"catalyst:session/interaction",LONG_PRESS:"catalyst:long-press",INSIGHT:"catalyst:insight",HW_ACCEL:"catalyst:hw-accel",// Native MetricsMonitor events
|
|
11
|
+
PAGE_LOAD:"catalyst:page-load",COLD_START:"catalyst:cold-start",FPS_DROP:"catalyst:fps-drop",LONG_TASK:"catalyst:long-task",MEMORY_JVM:"catalyst:mem/jvm",MEMORY_NATIVE:"catalyst:mem/native",MEMORY_TOTAL:"catalyst:mem/total"};// ─── Thresholds ───────────────────────────────────────────────────────────────
|
|
12
|
+
const THRESHOLD=exports.THRESHOLD={LCP_BAD_MS:2500,// LCP > 2.5s = poor (Core Web Vitals)
|
|
13
|
+
NAV_SESSION_BAD_MS:2500,// navigation session > 2.5s = slow page
|
|
14
|
+
FETCH_SLOW_MS:300,// network fetch > 300ms = slow
|
|
15
|
+
SCROLL_IDLE_MS:150,// scroll ends after 150ms of no scroll events
|
|
16
|
+
VIEWPORT_MIN_DELTA:50,// ignore viewport resize < 50px (not keyboard)
|
|
17
|
+
CACHE_GROUP_IDLE_MS:300,// close cache group after 300ms idle
|
|
18
|
+
LOAF_WINDOW:5,// keep last N LoAF entries for overlap detection
|
|
19
|
+
LONG_PRESS_MS:500,// pointerdown held > 500ms = long press
|
|
20
|
+
INTERACTION_SLOW_MS:100,// tap → paint > 100ms = slow response
|
|
21
|
+
LAZY_LOAF_WINDOW_MS:2000,// watch for LoAF bursts up to 2s after interaction
|
|
22
|
+
CLS_BAD:0.1// CLS > 0.1 per entry = significant shift
|
|
23
|
+
};// ─── Colors ───────────────────────────────────────────────────────────────────
|
|
24
|
+
// Chrome DevTools Extensibility API color values (all 9 values):
|
|
25
|
+
//
|
|
26
|
+
// primary / primary-light / primary-dark → blue tones
|
|
27
|
+
// secondary / secondary-light / secondary-dark → purple/grey tones
|
|
28
|
+
// tertiary / tertiary-light / tertiary-dark → yellow/orange tones
|
|
29
|
+
// error → red
|
|
30
|
+
//
|
|
31
|
+
// Usage guide:
|
|
32
|
+
// error = bad outcome (LoAF, miss, slow native API, memory critical)
|
|
33
|
+
// primary = cache HIT memory (fastest path)
|
|
34
|
+
// primary-light = cache HIT disk (good, slightly slower)
|
|
35
|
+
// primary-dark = good LCP, clean scroll session
|
|
36
|
+
// secondary = informational (native API, keyboard, viewport, mem/total normal)
|
|
37
|
+
// primary = mem/jvm (JVM heap — your Kotlin/Java code)
|
|
38
|
+
// tertiary-dark = mem/native (V8 + Blink + JNI — WebView proxy; watch for growth)
|
|
39
|
+
// secondary-light = route transition, hook mount (neutral)
|
|
40
|
+
// tertiary = network fetch after miss (expected but slow)
|
|
41
|
+
// tertiary-light = small CLS, long-press (low signal)
|
|
42
|
+
// tertiary-dark = insight warning (non-critical)
|
|
43
|
+
const COLOR=exports.COLOR={ERROR:"error",PRIMARY:"primary",PRIMARY_LIGHT:"primary-light",PRIMARY_DARK:"primary-dark",SECONDARY:"secondary",SECONDARY_LIGHT:"secondary-light",SECONDARY_DARK:"secondary-dark",TERTIARY:"tertiary",TERTIARY_LIGHT:"tertiary-light",TERTIARY_DARK:"tertiary-dark"};
|