Reusable Goja JavaScript metrics subpackage

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.

Sections

Terminology & Glossary
📖 Documentation
Navigation
4 sectionsv0.1
📄 Reusable Goja JavaScript metrics subpackage — glaze help reusable-goja-js-metrics-subpackage
reusable-goja-js-metrics-subpackage

Reusable Goja JavaScript metrics subpackage

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.

Topicgojajavascriptruntimeapimetricsperformanceinstrumentationloupedecklog-js-statslog-render-statslog-writer-statsstats-interval

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.

Why this package exists

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.
  • the current Loupedeck runtime chooses the module names (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.

Package layout

The reusable metrics implementation is split across these files:

  • runtime/metrics/metrics.go — collector, snapshots, and timing aggregation
  • pkg/jsmetrics/jsmetrics.go — generic goja/runtimebridge integration and module registration
  • runtime/js/runtime.go — current concrete runtime that binds a collector and registers the modules under the loupedeck prefix
  • runtime/js/module_metrics/module.go — thin compatibility wrapper for loupedeck/metrics
  • runtime/js/module_scene_metrics/module.go — thin compatibility wrapper for loupedeck/scene-metrics

The important design point is that the real logic lives in pkg/jsmetrics, not in the Loupedeck-specific wrappers.

Core concepts

The reusable stack has three conceptual layers.

1. Collector layer

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.

2. Binding layer

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.

3. Module layer

pkg/jsmetrics registers two native modules:

  • a low-level metrics module
  • a higher-level scene-oriented helper module

The registration is prefix-based:

jsmetrics.RegisterModules(registry, "loupedeck")

That call currently creates:

  • loupedeck/metrics
  • loupedeck/scene-metrics

A future runtime could choose a different prefix or none at all.

Low-level JavaScript API

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.

Higher-level scene helper API

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.

Create a helper

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.renderAll

sceneMetrics.timeTile(name, fn)

sceneMetrics.timeTile("SPIRAL", () => {
  drawSpiralTile(...);
});

This records timing under:

  • scene.tile.SPIRAL

sceneMetrics.recordLoopTick()

This increments:

  • scene.loopTicks

sceneMetrics.recordActivation(reason)

sceneMetrics.recordActivation("T3");
sceneMetrics.recordActivation("B1");

This records:

  • scene.activations
  • scene.activations.touch or scene.activations.button

sceneMetrics.recordRebuild(reason, fn)

sceneMetrics.recordRebuild("loop", () => {
  renderAll();
});

This records:

  • scene.renderAll.calls
  • scene.renderAll.reason.loop
  • scene.renderAll.reasonExact.loop
  • and, when fn is provided, timing under scene.renderAll

sceneMetrics.reasonCategory(reason)

The current helper maps reasons into these categories:

  • initial
  • loop
  • touch
  • button
  • other
  • unknown

This is useful when your event reasons are concrete values like T12 or B1 but you still want stable category counters.

How it is implemented

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.

Integrating it into your own goja runtime

The easiest way to reuse this package in another goja setup is to follow the same shape as the current Loupedeck runtime.

Step 1 — create a collector

Start by creating a collector that will accumulate your per-runtime measurements.

collector := metrics.New()

Step 2 — register the modules

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/metrics
  • myapp/scene-metrics

If you want a different naming scheme, change the prefix before enabling the registry.

Step 3 — bind or derive the collector for the runtime

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.

Step 4 — execute your script

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");
});

Step 5 — read snapshots on the Go side

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.

Complete integration example

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.

How the current Loupedeck runtime uses it

The current runtime wires the reusable package through the engine registrar in:

  • runtime/js/registrar.go
  • runtime/js/env/env.go
  • pkg/jsmetrics/jsmetrics.go

It now derives the collector conceptually like this:

  • env.Lookup(vm).Metrics

And it registers the JS modules with the loupedeck prefix:

  • loupedeck/metrics
  • loupedeck/scene-metrics

The live runner then reads snapshots periodically and logs them when requested with:

  • --log-js-stats
  • --stats-interval

This repo-specific usage is just one concrete integration of the generic package.

Design constraints and non-goals

The package is reusable, but it is intentionally not a full profiler.

What it is good at:

  • counters
  • timing windows
  • per-runtime snapshots
  • lightweight scene/workload instrumentation
  • narrow goja-native module exposure

What it is not trying to be:

  • a sampling profiler
  • a cross-process metrics system
  • a transport for arbitrary structured logs from JS
  • a substitute for host-side tracing systems

That narrowness is a feature. It makes the package easy to carry into other goja runtimes without dragging in a large policy surface.

Troubleshooting

ProblemCauseSolution
metrics module requires collector bindingThe runtime-specific VM lookup path does not expose a collector for the current VMEnsure your runtime stores a collector in the host-side VM environment/lookup before running JS
require("myapp/metrics") failsThe modules were not registered with the prefix your script expectsCall jsmetrics.RegisterModules(registry, "myapp") before registry.Enable(vm)
Counters stay at zero even though JS ranYou are reading a different collector instance than the one bound into the VMVerify the same *metrics.Collector is both bound and later inspected
scene-metrics names do not match your workloadThe 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 LoupedeckYou are still importing the wrapper modules instead of the generic packageDepend on pkg/jsmetrics and register your own prefix; treat runtime/js/module_*metrics as compatibility wrappers

See Also

  • Loupedeck JavaScript runtime API reference — Current runtime module surface, including the concrete loupedeck/metrics and loupedeck/scene-metrics exports
  • Build your first live Loupedeck JavaScript script — Step-by-step live-runner tutorial for the current repo runtime
  • pkg/jsmetrics/jsmetrics.go — Source of truth for the reusable goja binding and module registration logic
  • runtime/metrics/metrics.go — Source of truth for the underlying collector implementation
  • runtime/js/runtime.go — Current concrete example of binding a collector and registering prefixed modules
  • cmd/loupedeck/cmds/run/command.go — Example host process that periodically snapshots and logs JS-side metrics