Using Observability
Error capture, sinks & memory — the essentials.
Declarative setup
Declare it in signalx.config.ts and it auto-wires in release builds — no code in your app entry. @sigx/lynx-plugin prepends the init for you; installing the package is all that is needed. (Dev uses the console streamer; observability auto-wiring is release-only.)
// signalx.config.ts
export default defineLynxConfig({
name: 'my-app',
logging: {
level: 'warn', // logger level (dev defaults to 'debug', release to 'warn')
namespaces: { disabled: ['http'] }, // silence namespaces at startup
production: {
sink: { url: 'https://logs.example.com/ingest', headers: { 'x-api-key': KEY }, sampleRate: 0.25 },
captureErrors: true, // default
memory: { intervalMs: 60_000 }, // periodic engine memory readings; omit to leave off
},
},
});
Imperative setup
Or wire it yourself, once in your app entry:
import { initObservability } from '@sigx/lynx-observability';
initObservability({
level: 'warn',
captureErrors: true, // default — catch uncaught errors / rejections
sink: {
url: 'https://logs.example.com/ingest',
headers: { 'x-api-key': API_KEY },
sampleRate: 0.25, // keep 25% of non-error records; errors always kept
},
});
The pieces compose individually too — installErrorCapture() and createHttpSink() are exported for custom setups:
import { addTransport } from '@sigx/lynx';
import { createHttpSink, installErrorCapture } from '@sigx/lynx-observability';
addTransport(createHttpSink({ url, minLevel: 'info' }));
const uninstall = installErrorCapture({ onError: (e) => myAnalytics.track('crash', e.message) });
Adapting a provider
There is no vendor coupling — any provider is a LogTransport. Errors arrive as error-level records with the Error in fields, so an adapter can split exceptions from breadcrumbs:
import * as Sentry from '@sentry/browser'; // your app's dep, not ours
import { addTransport, type LogRecord } from '@sigx/lynx';
import { installErrorCapture } from '@sigx/lynx-observability';
Sentry.init({ dsn: SENTRY_DSN });
addTransport((r: LogRecord) => {
const err = r.fields.find((f) => f instanceof Error) as Error | undefined;
if (r.level.name === 'error' && err) Sentry.captureException(err);
else Sentry.addBreadcrumb({ category: r.namespace, message: r.msg, level: r.level.name });
});
installErrorCapture();
The HTTP sink's wire format is POST { records: [{ level, namespace, msg, fields, ts }] } as JSON.
Engine memory
Ask what the Lynx engine is holding right now:
import { Memory } from '@sigx/lynx-observability';
const m = await Memory.query();
console.log(`${(m.totalBytes / 1e6).toFixed(1)} MB of ${(m.appBytes / 1e6).toFixed(1)} MB app`);
for (const i of m.instances) {
console.log(` ${i.url}: ${i.elementNodeCount} nodes, ${(i.elementBytes / 1e6).toFixed(1)} MB`);
}
A reading is process-global: one query covers every LynxView in the app, and the per-instance rows (sorted by totalBytes descending) tell you which one is holding what. elementBytes against elementNodeCount is the pairing worth watching — bytes alone do not tell you whether you have a leak or just a big screen; the node count does. It is what turns "the screen went blank" into "we're mounting 12,000 nodes".
Sampling on a timer
const stop = Memory.startReporting({ intervalMs: 60_000 });
Each reading is logged under the memory namespace, so it shows up in the sigx dev terminal in development and reaches your sink in production with no extra wiring. intervalMs is the gap between the end of one reading and the start of the next, not a fixed period — a query can take the full engine timeout, and overlapping process-global collections would be worse than a late reading. Unlike Memory.query(), startReporting() no-ops rather than throwing when the native module is not linked (it logs one debug line): it is ambient telemetry that often starts before app code runs, so a throw would take down an app that merely forgot sigx prebuild.
In a release build the global log level defaults to warn, so default info readings are dropped — and then not even collected. If you want memory in production, either raise logging.level to 'info' or set the reading's own level: 'warn'. An onReading hook bypasses the gate.
Reading a snapshot correctly
-
A
'timeout'status is a partial result, not an error.query()resolves. Every aggregate is then a partial sum overcompletedInstanceCountofexpectedInstanceCount, so compare the two before trusting a delta — a smaller total may just mean one instance did not report. -
Per-instance bytes can sum to more than
totalBytes, by design. Instances sharing abtsRuntimeGroupIdshare one background runtime, and the global figure counts it once. -
Field coverage differs by platform. Measured on release builds (Lynx 4.0.1):
Field Android iOS totalBytes,appBytes,ratioToAppyes yes elementBytes,elementNodeCountyes yes mainThreadRuntimeBytesyes yes backgroundThreadRuntimeBytesyes reported 0viewBytesreported 0yes urlempty string full bundle path Compare a platform against itself over time, not against the other one.
totalBytes,elementBytesandelementNodeCount— the three that matter most for diagnosing a runaway element tree — are solid on both. -
Web is unsupported.
Memory.query()rejects with aSigxError(code: 'unsupported'),startReporting()is a no-op andisAvailable()returnsfalse. It deliberately does not fall back to Chromium'sperformance.memory, which is JS-heap-only and would put a confidently wrong number in a dashboard.
What the Lynx performance API delivers
Lynx's own performance and timing APIs report the page load in a sigx app. What you can rely on today:
- Works:
lynx.performance.createObserver(cb).observe(['pipeline'])receives theloadBundlepipeline entry for the page load —pipelineStart/pipelineEnd/paintEnd, a nestedlynxFcpfirst-paint metric and a populatedframeworkRenderingTiming. The legacylynx.performance.addTimingListenerreceives its setup callback with thesetup_timingdictionary (metrics.lynx_fcp/lynx_tti). The platform perf/timing callbacks and Android'sforceGetPerf()report too. - Not yet: pipelines for post-load updates (
updateTriggeredByBts) andmetric.actualFmp. Measure the load, not later re-renders. - Never, from JS: the
memoryandjsBlockingentries. The engine sends them to platform observers only.MemoryUsageEntryis declared in@lynx-js/types, soobserve(['memory'])type-checks — and then delivers silence. UseMemory.query()/Memory.startReporting()for memory.
Two details worth knowing before you parse entries (measured on Lynx 4.0.1, release builds, a Pixel 9 Pro XL and the iPhone 16 Pro simulator):
- There is no separate
metric.fcpentry. First paint is thelynxFcpmetric nested in theloadBundlepipeline entry — 241.7 ms on the Android device, 190.8 ms on the iOS simulator in that measurement. hostPlatformTimingdiffers by platform: Android fillsmeasureStart/End,layoutStart/EndanddrawStart/End; iOS reports onlyhostPlatformType.
Notes
lynx.onErroris background-thread only upstream; main-thread error capture may need a separate path in the future.- For readable stack traces in release builds, upload your source maps to your provider.
- The engine's per-view-class memory records (
viewDetail) are not surfaced — the aggregate pluselementNodeCountis what the diagnosis needs.
See also
- API reference — every export with signatures.
- Dev Client — the on-device perf HUD shows the same engine memory figures.
- OTA Updates — why installing this package needs a store release.
