diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 89586c92..caa717c9 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -24,6 +24,7 @@ - [Directives](/built-in/directives.md) - [For loop](/built-in/for-loop.md) - [Layout](/built-in/layout.md) + - [Stats](/built-in/stats.md) - Router - [Basics](/router/basics.md) - [Hooks](/router/hooks.md) diff --git a/docs/built-in/stats.md b/docs/built-in/stats.md new file mode 100644 index 00000000..5ec5c6e0 --- /dev/null +++ b/docs/built-in/stats.md @@ -0,0 +1,94 @@ +# Blits Stats & Performance Logging + +Blits provides a highly-performant stats and system logging mechanism. This feature is **entirely opt-in** and is controlled at build time using a Vite flag. When disabled, all stats code is removed from the final bundle for maximum performance. + +Ideally this is only enabled in development or test builds for performance validation. + +## Enabling Stats Logging + +To enable stats logging, set the `__BLITS_STATS__` flag in your `vite.config.js`: + +```js +import { defineConfig } from 'vite' + +export default defineConfig({ + define: { + __BLITS_STATS__: true // Set to false to disable stats (default) + }, + // ...other Vite config options +}) +``` + +- When `__BLITS_STATS__` is `true`, Blits will track and periodically log system statistics (components, elements, event listeners, timeouts, intervals, etc.). +- When `__BLITS_STATS__` is `false` (or not set), **all stats logic and function calls are optimized out** at build time. There is zero runtime cost in production. + +## How It Works + +- All stats-related calls (e.g., `increment`, `decrement`) are guarded with the `BLITS_STATS_ENABLED` flag, which is exported from `src/lib/stats.js`. +- Example usage in the codebase: + + ```js + import { increment, BLITS_STATS_ENABLED } from './lib/stats.js' + // ... + BLITS_STATS_ENABLED && increment('components', 'created') + ``` +- When the flag is `false`, these calls are removed by dead code elimination during the build. + +## No Runtime Settings + +There is **no runtime setting** for enabling/disabling stats. The only way to enable stats is at build time using the Vite flag. + +## What Gets Logged? + +When enabled, Blits will periodically log: + +- Number of active, created, and deleted components +- Number of elements, event listeners, timeouts, and intervals +- Rolling load averages for each category +- Renderer memory usage information (when available) + +Example log output: + +``` +--- System Statistics --- +Components: Active: 5, Created: 10, Deleted: 5, Load: 0.10, 0.05, 0.01 +Elements: Active: 20, Created: 40, Deleted: 20, Load: 0.20, 0.10, 0.02 +Listeners: Active: 3, Created: 6, Deleted: 3, Load: 0.03, 0.01, 0.00 +Timeouts: Active: 1, Created: 2, Deleted: 1, Load: 0.01, 0.00, 0.00 +Intervals: Active: 2, Created: 4, Deleted: 2, Load: 0.02, 0.01, 0.00 +--- Renderer Memory Info --- +Memory used: 27.27 Mb, Renderable: 25.62 Mb, Target: 160.00 Mb, Critical: 200.00 Mb +Textures loaded 6, renderable textures: 3 +------------------------- +``` + +## Visual Stats Overlay + +Blits includes a built-in component that displays real-time stats in the top left corner of the screen: + +```js +// Import the component +import { StatsOverlay } from 'blits' + +// Add it to your root component +export default Component('App', { + template: ` + + + + + ` +}) +``` + +The `StatsOverlay` component: +- Only updates when `__BLITS_STATS__` is enabled +- Shows real-time stats for components, elements, listeners, timeouts and intervals as well as texture and memory usage statistics +- Displays renderer memory information when available +- Automatically updates every 500ms +- Has zero impact when stats are disabled + +## Best Practices + +- **Enable stats only for development or diagnostics builds.** +- For production, keep `__BLITS_STATS__` set to `false` for optimal performance. diff --git a/src/application.js b/src/application.js index b6e38ac4..fdd11c96 100644 --- a/src/application.js +++ b/src/application.js @@ -22,6 +22,8 @@ import Settings from './settings.js' import symbols from './lib/symbols.js' import { DEFAULT_HOLD_TIMEOUT_MS } from './constants.js' +import { BLITS_STATS_ENABLED, resetStats, printStats } from './lib/stats.js' + const Application = (config) => { const defaultKeyMap = { ArrowLeft: 'left', @@ -60,6 +62,15 @@ const Application = (config) => { keyDownHandler = async (e) => { const key = keyMap[e.key] || keyMap[e.keyCode] || e.key || e.keyCode + + if (BLITS_STATS_ENABLED) { + if (key === 'p') { + printStats() + } else if (key === 'r') { + resetStats() + } + } + // intercept key press if specified in main Application component if ( this[symbols.inputEvents] !== undefined && diff --git a/src/component.js b/src/component.js index 4e468953..d73a96fe 100644 --- a/src/component.js +++ b/src/component.js @@ -24,6 +24,7 @@ import { reactive, getRaw } from './lib/reactivity/reactive.js' import { effect } from './lib/reactivity/effect.js' import Lifecycle from './lib/lifecycle.js' import symbols from './lib/symbols.js' +import { increment, BLITS_STATS_ENABLED } from './lib/stats.js' import { stage, renderer } from './launch.js' @@ -212,6 +213,8 @@ const Component = (name = required('name'), config = required('config')) => { // finaly set the lifecycle state to ready (in the next tick) setTimeout(() => (this.lifecycle.state = 'ready')) + BLITS_STATS_ENABLED && increment('components', 'created') + // and return this return this } diff --git a/src/component/base/events.js b/src/component/base/events.js index fff927f5..62007c21 100644 --- a/src/component/base/events.js +++ b/src/component/base/events.js @@ -29,6 +29,8 @@ export default { }, $listen: { value: function (event, callback, priority = 0) { + // early exit when component is marked as end of life + if (this.eol === true) return eventListeners.registerListener(this, event, callback, priority) }, writable: false, diff --git a/src/component/base/methods.js b/src/component/base/methods.js index 37593fa4..a88f3127 100644 --- a/src/component/base/methods.js +++ b/src/component/base/methods.js @@ -21,6 +21,7 @@ import eventListeners from '../../lib/eventListeners.js' import { trigger } from '../../lib/reactivity/effect.js' import { Log } from '../../lib/log.js' import { removeGlobalEffects } from '../../lib/reactivity/effect.js' +import { BLITS_STATS_ENABLED, decrement } from '../../lib/stats.js' export default { focus: { @@ -52,6 +53,7 @@ export default { }, destroy: { value: function () { + this.eol = true this.lifecycle.state = 'destroy' this.$clearTimeouts() this.$clearIntervals() @@ -87,6 +89,7 @@ export default { delete this[symbols.holder] Log.debug(`Destroyed component ${this.componentId}`) + BLITS_STATS_ENABLED && decrement('components', 'deleted') }, writable: false, enumerable: true, diff --git a/src/component/base/timeouts_intervals.js b/src/component/base/timeouts_intervals.js index 5c667096..05921ef7 100644 --- a/src/component/base/timeouts_intervals.js +++ b/src/component/base/timeouts_intervals.js @@ -16,19 +16,24 @@ */ import symbols from '../../lib/symbols.js' +import { increment, decrement, BLITS_STATS_ENABLED } from '../../lib/stats.js' export default { $setTimeout: { value: function (fn, ms, ...params) { + // early exit when component is marked as end of life + if (this.eol === true) return const timeoutId = setTimeout( () => { this[symbols.timeouts] = this[symbols.timeouts].filter((id) => id !== timeoutId) + BLITS_STATS_ENABLED && decrement('timeouts', 'deleted') fn.apply(null, params) }, ms, params ) this[symbols.timeouts].push(timeoutId) + BLITS_STATS_ENABLED && increment('timeouts', 'created') return timeoutId }, writable: false, @@ -40,6 +45,7 @@ export default { if (this[symbols.timeouts].indexOf(timeoutId) > -1) { this[symbols.timeouts] = this[symbols.timeouts].filter((id) => id !== timeoutId) clearTimeout(timeoutId) + BLITS_STATS_ENABLED && decrement('timeouts', 'deleted') } }, writable: false, @@ -50,6 +56,7 @@ export default { value: function () { for (let i = 0; i < this[symbols.timeouts].length; i++) { clearTimeout(this[symbols.timeouts][i]) + BLITS_STATS_ENABLED && decrement('timeouts', 'deleted') } this[symbols.timeouts] = [] }, @@ -59,8 +66,11 @@ export default { }, $setInterval: { value: function (fn, ms, ...params) { + // early exit when component is marked as end of life + if (this.eol === true) return const intervalId = setInterval(() => fn.apply(null, params), ms, params) this[symbols.intervals].push(intervalId) + BLITS_STATS_ENABLED && increment('intervals', 'created') return intervalId }, writable: false, @@ -72,6 +82,7 @@ export default { if (this[symbols.intervals].indexOf(intervalId) > -1) { this[symbols.intervals] = this[symbols.intervals].filter((id) => id !== intervalId) clearInterval(intervalId) + BLITS_STATS_ENABLED && decrement('intervals', 'deleted') } }, writable: false, @@ -82,6 +93,7 @@ export default { value: function () { for (let i = 0; i < this[symbols.intervals].length; i++) { clearInterval(this[symbols.intervals][i]) + BLITS_STATS_ENABLED && decrement('intervals', 'deleted') } this[symbols.intervals] = [] }, diff --git a/src/components/StatsOverlay.js b/src/components/StatsOverlay.js new file mode 100644 index 00000000..d6911b13 --- /dev/null +++ b/src/components/StatsOverlay.js @@ -0,0 +1,142 @@ +/* + * Copyright 2023 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import Component from '../component.js' +import { BLITS_STATS_ENABLED, getStats, bytesToMb } from '../lib/stats.js' +import { renderer } from '../launch.js' +import { Log } from '../lib/log.js' + +/** + * BlitsStatsOverlay + * Renders live system and renderer stats on screen (top left corner) + * Only displayed when BLITS_STATS_ENABLED is true + */ +export default () => + Component('StatsOverlay', { + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + state() { + return { + statsEnabled: BLITS_STATS_ENABLED, + statsTitle: 'System Statistics', + componentStats: 'Components: Loading...', + elementStats: 'Elements: Loading...', + listenerStats: 'Listeners: Loading...', + timeoutStats: 'Timeouts: Loading...', + intervalStats: 'Intervals: Loading...', + + hasMemoryInfo: false, + memoryTitle: 'Renderer Memory', + memoryStats1: '', + memoryStats2: '', + } + }, + hooks: { + ready() { + // Only start the interval if stats are enabled + if (BLITS_STATS_ENABLED) { + Log.info('BlitsStatsOverlay: Starting stats update interval') + this._interval = this.$setInterval(() => { + this.updateStats() + }, 500) + } + }, + destroy() { + if (this._interval) { + this.$clearInterval(this._interval) + } + }, + }, + methods: { + updateStats() { + // Skip if stats are disabled + if (!BLITS_STATS_ENABLED) return + + // Update system stats + const components = getStats('components') + const elements = getStats('elements') + const listeners = getStats('eventListeners') + const timeouts = getStats('timeouts') + const intervals = getStats('intervals') + + if (components) { + this.componentStats = `Components: Active ${components.active} | Created ${components.created} | Deleted ${components.deleted}` + } + + if (elements) { + this.elementStats = `Elements: Active ${elements.active} | Created ${elements.created} | Deleted ${elements.deleted}` + } + + if (listeners) { + this.listenerStats = `Listeners: Active ${listeners.active} | Created ${listeners.created} | Deleted ${listeners.deleted}` + } + + if (timeouts) { + this.timeoutStats = `Timeouts: Active ${timeouts.active} | Created ${timeouts.created} | Deleted ${timeouts.deleted}` + } + + if (intervals) { + this.intervalStats = `Intervals: Active ${intervals.active} | Created ${intervals.created} | Deleted ${intervals.deleted}` + } + + // Update memory info if available + const memInfo = renderer?.stage?.txMemManager.getMemoryInfo() || null + if (memInfo) { + this.hasMemoryInfo = true + this.memoryStats1 = `Memory: ${bytesToMb(memInfo.memUsed)}MB | Renderable: ${bytesToMb( + memInfo.renderableMemUsed + )}MB` + this.memoryStats2 = `Target: ${bytesToMb( + memInfo.targetThreshold + )}MB | Critical: ${bytesToMb(memInfo.criticalThreshold)}MB` + + if (memInfo.loadedTextures !== undefined) { + this.memoryStats2 += ` | Textures: ${memInfo.loadedTextures}` + } + } + }, + }, + }) diff --git a/src/components/index.js b/src/components/index.js index 3533f10b..57d89706 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -19,10 +19,12 @@ import Circle from './Circle.js' import RouterView from './RouterView.js' import Sprite from './Sprite.js' import FPScounter from './FPScounter.js' +import StatsOverlay from './StatsOverlay.js' export default () => ({ Circle: Circle(), RouterView: RouterView(), Sprite: Sprite(), FPScounter: FPScounter(), + StatsOverlay: StatsOverlay(), }) diff --git a/src/engines/L3/element.js b/src/engines/L3/element.js index d254d657..ffbfd6b6 100644 --- a/src/engines/L3/element.js +++ b/src/engines/L3/element.js @@ -17,6 +17,7 @@ import { renderer } from './launch.js' import colors from '../../lib/colors/colors.js' +import { increment, BLITS_STATS_ENABLED, decrement } from '../../lib/stats.js' import { Log } from '../../lib/log.js' import symbols from '../../lib/symbols.js' @@ -505,6 +506,9 @@ const Element = { this.config.parent.triggerLayout(this.config.parent.props) }) } + + // Increment element creation + BLITS_STATS_ENABLED && increment('elements', 'created') }, set(prop, value) { if (value === undefined) return @@ -637,6 +641,9 @@ const Element = { // remove node reference this.node = null + + // Decrement element deletion + BLITS_STATS_ENABLED && decrement('elements', 'deleted') }, get nodeId() { return this.node && this.node.id diff --git a/src/launch.js b/src/launch.js index 26936baf..30436cdd 100644 --- a/src/launch.js +++ b/src/launch.js @@ -18,6 +18,7 @@ import Settings from './settings.js' import { initLog, Log } from './lib/log.js' import engine from './engine.js' +import { enableLogging, BLITS_STATS_ENABLED } from './lib/stats.js' import blitsPackageInfo from '../package.json' assert { type: 'json' } export let renderer = {} @@ -42,6 +43,10 @@ export default (App, target, settings) => { initLog() + // Only start logging if BLITS_STATS_ENABLED is true + // This code will be optimized out if BLITS_STATS_ENABLED is false + BLITS_STATS_ENABLED && enableLogging() + rendererVersion().then((v) => { Log.info('Blits Version ', blitsPackageInfo.version) Log.info('Renderer Version ', v) diff --git a/src/lib/eventListeners.js b/src/lib/eventListeners.js index 6b96f6ff..d739fee1 100644 --- a/src/lib/eventListeners.js +++ b/src/lib/eventListeners.js @@ -15,6 +15,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { increment, decrement, BLITS_STATS_ENABLED } from './stats.js' + const eventsMap = new Map() const callbackCache = new Map() @@ -34,6 +36,9 @@ export default { components.add({ cb, priority }) callbackCache.delete(event) // Invalidate the callbackCache when a new callback is added + + // Log the event listener creation + BLITS_STATS_ENABLED && increment('eventListeners', 'created') }, deregisterListener(component, event) { @@ -46,6 +51,9 @@ export default { componentsMap.delete(component) eventsMap.set(event, componentsMap) callbackCache.delete(event) + + // Log the event listener deletion + BLITS_STATS_ENABLED && decrement('eventListeners', 'deleted') } }, @@ -90,5 +98,7 @@ export default { } } } + + BLITS_STATS_ENABLED && decrement('eventListeners', 'deleted') }, } diff --git a/src/lib/stats.js b/src/lib/stats.js new file mode 100644 index 00000000..bcaeb7c5 --- /dev/null +++ b/src/lib/stats.js @@ -0,0 +1,135 @@ +import { Log } from './log.js' +import { renderer } from '../launch.js' + +// Vite flag for enabling stats (should be replaced at build time) +// Use globalThis to avoid TDZ and reference errors +// prettier-ignore +const __BLITS_STATS__ = typeof globalThis.__BLITS_STATS__ !== 'undefined' ? globalThis.__BLITS_STATS__ : false + +/** + * Blits Stats module to track and report system statistics. + * Only enabled if __BLITS_STATS__ is true at build time. + */ +const stats = __BLITS_STATS__ + ? { + components: { created: 0, deleted: 0, active: 0 }, + elements: { created: 0, deleted: 0, active: 0 }, + eventListeners: { created: 0, deleted: 0, active: 0 }, + timeouts: { created: 0, deleted: 0, active: 0 }, + intervals: { created: 0, deleted: 0, active: 0 }, + } + : null + +let isLoggingEnabled = false + +/** + * Increment a specific statistic in the given category. + * No-op if stats are not enabled. + * @param {string} category - The category to update (e.g., 'components', 'elements', 'eventListeners'). + * @param {string} type - The type of statistic to update (e.g., 'created', 'deleted'). + */ +export function increment(category, type) { + if (!__BLITS_STATS__ || !isLoggingEnabled) return + if (stats[category] && stats[category][type] !== undefined) { + stats[category][type]++ + if (type === 'created') stats[category].active++ + } +} + +/** + * Decrement a specific statistic in the given category. + * No-op if stats are not enabled. + * @param {string} category - The category to update (e.g., 'components', 'elements', 'eventListeners'). + * @param {string} type - The type of statistic to update (e.g., 'created', 'deleted'). + */ +export function decrement(category, type) { + if (!__BLITS_STATS__ || !isLoggingEnabled) return + if (stats[category] && stats[category][type] !== undefined) { + stats[category][type]++ + if (type === 'deleted') stats[category].active-- + } +} + +function logStats() { + if (!__BLITS_STATS__) return + printStats() +} + +const formatStats = (category) => { + const { created, deleted, active } = stats[category] + return `Active: ${active}, Created: ${created}, Deleted: ${deleted}` +} + +export function printStats() { + if (!__BLITS_STATS__) return + + Log.info('------------------------------') + Log.info('--- System Statistics ---') + Log.info('URL: ', window.location.href) + Log.info('Components:', formatStats('components')) + Log.info('Elements:', formatStats('elements')) + Log.info('Listeners:', formatStats('eventListeners')) + Log.info('Timeouts:', formatStats('timeouts')) + Log.info('Intervals:', formatStats('intervals')) + + const memInfo = renderer?.stage?.txMemManager.getMemoryInfo() || null + if (memInfo) { + Log.info('--- Renderer Memory Info ---') + Log.info( + `Memory used: ${bytesToMb(memInfo.memUsed)} Mb, Renderable: ${bytesToMb( + memInfo.renderableMemUsed + )} Mb, Target: ${bytesToMb(memInfo.targetThreshold)} Mb, Critical: ${bytesToMb( + memInfo.criticalThreshold + )} Mb` + ) + Log.info( + `Textures loaded ${memInfo.loadedTextures}, renderable textures: ${memInfo.renderableTexturesLoaded}` + ) + } +} + +export function resetStats() { + if (!__BLITS_STATS__) return + for (const category in stats) { + if (Object.prototype.hasOwnProperty.call(stats, category)) { + stats[category].created = 0 + stats[category].deleted = 0 + stats[category].active = 0 + } + } +} + +/** + * Enables logging functionality. + */ +export function enableLogging() { + if (!__BLITS_STATS__) return + if (isLoggingEnabled) return + isLoggingEnabled = true + logStats() +} + +/** + * Get stats for a given category (for UI overlay). + * @param {string} category + * @returns {object|null} + */ +export function getStats(category) { + if (!__BLITS_STATS__ || !stats) return null + return stats[category] || null +} + +/** + * Format bytes to MB (for UI overlay). + * @param {number} bytes + * @returns {string} + */ +export function bytesToMb(bytes) { + return (bytes / 1024 / 1024).toFixed(2) +} + +/** + * Expose the build-time stats flag for use in other modules. + * @type {boolean} + */ +export const BLITS_STATS_ENABLED = __BLITS_STATS__