Use the extracted `pkg/jsmetrics` and `runtime/metrics` packages to add reusable counters and timings to a goja runtime, including scene-oriented helpers and prefix-based module registration.
The reusable JavaScript metrics stack in this repository is now split into two layers on purpose. runtime/metrics is the generic in-process collector for counters and timing windows. pkg/jsmetrics is the goja-facing bridge that looks up a collector through runtimebridge, registers native modules, and exposes both a low-level metrics API and a higher-level scene-metrics helper API. This split matters because it keeps the instrumentation substrate portable: the current Loupedeck runtime registers these modules under the loupedeck/... prefix, but the underlying implementation is no longer conceptually owned by the Loupedeck environment and is intended to be movable into go-go-goja later.
Adding counters and timings directly to one application runtime is easy. Reusing them across multiple goja hosts is harder unless the implementation is deliberate about boundaries. The main problem is that app-specific module code tends to accidentally depend on app-specific environment objects, which makes later extraction painful.
This repository now avoids that trap by separating concerns:
runtime/metrics stores measurements in a plain Go collector.pkg/jsmetrics exposes that collector to JavaScript through runtimebridge.loupedeck/metrics, loupedeck/scene-metrics) as a registration detail, not as a baked-in implementation dependency.That architecture is what makes the package reusable in future goja runtimes, including a later move into go-go-goja.
The reusable metrics implementation is split across these files:
runtime/metrics/metrics.go — collector, snapshots, and timing aggregationpkg/jsmetrics/jsmetrics.go — generic goja/runtimebridge integration and module registrationruntime/js/runtime.go — current concrete runtime that binds a collector and registers the modules under the loupedeck prefixruntime/js/module_metrics/module.go — thin compatibility wrapper for loupedeck/metricsruntime/js/module_scene_metrics/module.go — thin compatibility wrapper for loupedeck/scene-metricsThe important design point is that the real logic lives in pkg/jsmetrics, not in the Loupedeck-specific wrappers.
The reusable stack has three conceptual layers.
runtime/metrics.Collector stores named counters and named timing windows.
It currently supports:
Inc(name, delta)ObserveDuration(name, d)ObserveMillis(name, ms)Snapshot()SnapshotAndReset()This layer does not know anything about goja, module names, or application semantics. It is just a concurrency-safe place to accumulate observations.
pkg/jsmetrics uses a runtime-scoped VM lookup to find a collector from a running goja VM.
In the current Loupedeck runtime, that lookup goes through the VM's LoupeDeckEnvironment rather than a collector-specific key in runtimebridge.Values.
This package is still reusable, but the current Loupedeck runtime now resolves the collector through the VM's environment lookup instead of storing a second copy inside runtimebridge.Values.
pkg/jsmetrics registers two native modules:
The registration is prefix-based:
jsmetrics.RegisterModules(registry, "loupedeck")
That call currently creates:
loupedeck/metricsloupedeck/scene-metricsA future runtime could choose a different prefix or none at all.
The low-level module is for generic counters and timers. It is intentionally small and reusable.
metrics.inc(name, delta = 1)Increment a named counter.
const metrics = require("loupedeck/metrics");
metrics.inc("scene.frames");
metrics.inc("scene.activations", 2);
metrics.observeMillis(name, value)Record a timing sample in milliseconds.
metrics.observeMillis("scene.renderAll", 12.5);
metrics.time(name, fn)Measure a synchronous block and record the elapsed milliseconds.
metrics.time("scene.renderAll", () => {
renderAll();
});
metrics.counted(name, fn)Increment a counter and then run a synchronous block.
metrics.counted("scene.frames", () => {
renderAll();
});
metrics.now()Return the current wall-clock time in milliseconds.
const t0 = metrics.now();
Use the low-level module when you need raw flexibility and do not want any opinionated naming helpers.
The higher-level helper exists because scene authors quickly end up repeating the same naming logic. A scene usually wants consistent prefixes, rebuild-reason counters, activation counters, loop tick counters, and per-tile timing. Repeating that naming by hand works, but it creates noisy scripts and inconsistent metric names.
The helper module is therefore still generic enough to be reusable, but opinionated enough to save work in UI/scene runtimes.
const sceneMetrics = require("loupedeck/scene-metrics").create("scene");
That helper automatically prefixes metrics with scene..
sceneMetrics.time(suffix, fn)sceneMetrics.time("renderAll", () => {
renderAll();
});
This records timing under:
scene.renderAllsceneMetrics.timeTile(name, fn)sceneMetrics.timeTile("SPIRAL", () => {
drawSpiralTile(...);
});
This records timing under:
scene.tile.SPIRALsceneMetrics.recordLoopTick()This increments:
scene.loopTickssceneMetrics.recordActivation(reason)sceneMetrics.recordActivation("T3");
sceneMetrics.recordActivation("B1");
This records:
scene.activationsscene.activations.touch or scene.activations.buttonsceneMetrics.recordRebuild(reason, fn)sceneMetrics.recordRebuild("loop", () => {
renderAll();
});
This records:
scene.renderAll.callsscene.renderAll.reason.loopscene.renderAll.reasonExact.loopfn is provided, timing under scene.renderAllsceneMetrics.reasonCategory(reason)The current helper maps reasons into these categories:
initiallooptouchbuttonotherunknownThis is useful when your event reasons are concrete values like T12 or B1 but you still want stable category counters.
The implementation works by making the current Loupedeck environment available for the VM and then letting pkg/jsmetrics.Lookup(vm) derive the collector from env.Lookup(vm) lazily.
At a high level:
collector in Go
-> LoupeDeckEnvironment.Metrics
-> env.Lookup(vm)
-> pkg/jsmetrics.Lookup(vm)
-> native module exports
-> JavaScript counters and timers
The important portability point is unchanged: the JavaScript modules do not need to know about concrete scene objects. They only need a runtime-scoped way to find the collector for the current VM.
The easiest way to reuse this package in another goja setup is to follow the same shape as the current Loupedeck runtime.
Start by creating a collector that will accumulate your per-runtime measurements.
collector := metrics.New()
Register the reusable modules with the module prefix you want your scripts to use.
registry := new(require.Registry)
jsmetrics.RegisterModules(registry, "myapp")
registry.Enable(vm)
This gives your scripts:
myapp/metricsmyapp/scene-metricsIf you want a different naming scheme, change the prefix before enabling the registry.
In the current Loupedeck runtime, the collector is stored on the VM-scoped LoupeDeckEnvironment and resolved through env.Lookup(vm).
If you reuse pkg/jsmetrics in another runtime, provide an equivalent VM-scoped lookup path so the module can find the collector for the current runtime instance.
Once the bindings and registry are in place, JavaScript can use the modules normally.
const metrics = require("myapp/metrics");
const sceneMetrics = require("myapp/scene-metrics").create("scene");
sceneMetrics.recordRebuild("loop", () => {
metrics.inc("custom.work");
});
Your host process can periodically inspect or reset the collector.
snapshot := collector.SnapshotAndReset()
for _, key := range metrics.CounterKeys(snapshot) {
fmt.Printf("%s=%d\n", key, snapshot.Counters[key])
}
That is exactly the pattern the current live runner uses when it logs periodic JS-side stats.
This stripped-down example shows the whole pattern in one place. It omits unrelated app details and focuses on the metrics integration itself.
vm := goja.New()
loop := eventloop.NewEventLoop()
go loop.Start()
registry := new(require.Registry)
jsmetrics.RegisterModules(registry, "myapp")
registry.Enable(vm)
collector := metrics.New()
owner := runtimeowner.NewRunner(vm, loop, runtimeowner.Options{Name: "myapp-runtime"})
ctx := context.Background()
// Make the collector reachable from your VM-scoped host lookup.
// In loupedeck this is env.Lookup(vm) -> LoupeDeckEnvironment.Metrics.
_ = ctx
_ = owner
_ = collector
_, err := owner.Call(ctx, "vm.run", func(_ context.Context, vm *goja.Runtime) (any, error) {
return vm.RunString(`
const sceneMetrics = require("myapp/scene-metrics").create("scene");
sceneMetrics.recordLoopTick();
sceneMetrics.recordRebuild("initial", () => {
for (let i = 0; i < 1000; i++) {}
});
`)
})
if err != nil {
panic(err)
}
snapshot := collector.SnapshotAndReset()
fmt.Println(snapshot.Counters["scene.loopTicks"])
fmt.Println(snapshot.Counters["scene.renderAll.calls"])
The important lesson is not the exact logging output. The important lesson is that the metrics module only needs a collector bound through runtimebridge, so it can travel with any owner-thread goja runtime.
The current runtime wires the reusable package through the engine registrar in:
runtime/js/registrar.goruntime/js/env/env.gopkg/jsmetrics/jsmetrics.goIt now derives the collector conceptually like this:
env.Lookup(vm).MetricsAnd it registers the JS modules with the loupedeck prefix:
loupedeck/metricsloupedeck/scene-metricsThe live runner then reads snapshots periodically and logs them when requested with:
--log-js-stats--stats-intervalThis repo-specific usage is just one concrete integration of the generic package.
The package is reusable, but it is intentionally not a full profiler.
What it is good at:
What it is not trying to be:
That narrowness is a feature. It makes the package easy to carry into other goja runtimes without dragging in a large policy surface.
| Problem | Cause | Solution |
|---|---|---|
metrics module requires collector binding | The runtime-specific VM lookup path does not expose a collector for the current VM | Ensure your runtime stores a collector in the host-side VM environment/lookup before running JS |
require("myapp/metrics") fails | The modules were not registered with the prefix your script expects | Call jsmetrics.RegisterModules(registry, "myapp") before registry.Enable(vm) |
| Counters stay at zero even though JS ran | You are reading a different collector instance than the one bound into the VM | Verify the same *metrics.Collector is both bound and later inspected |
scene-metrics names do not match your workload | The helper uses opinionated default names like renderAll and tile.<name> | Either use the low-level metrics module directly or wrap the helper with your own naming conventions |
| Moving the package into another runtime feels coupled to Loupedeck | You are still importing the wrapper modules instead of the generic package | Depend on pkg/jsmetrics and register your own prefix; treat runtime/js/module_*metrics as compatibility wrappers |
loupedeck/metrics and loupedeck/scene-metrics exportspkg/jsmetrics/jsmetrics.go — Source of truth for the reusable goja binding and module registration logicruntime/metrics/metrics.go — Source of truth for the underlying collector implementationruntime/js/runtime.go — Current concrete example of binding a collector and registering prefixed modulescmd/loupedeck/cmds/run/command.go — Example host process that periodically snapshots and logs JS-side metrics