Private beta — the @z0devs packages are available to design partners while we harden for general availability.Try the live demoRequest access

Observability & resilience

Lifecycle records, load-timing metrics, the typed error union, retry policy, integrity checks, prefetch and the debug panel.

Runtime plugin loading moves failure from build time to your users' browsers. This page is everything the library gives you so those failures are visible, bounded and mostly self-healing.

The lifecycle registry#

Every plugin the host touches gets a record, driven by PluginRouteLoader:

discovered → loading → ready
                    ↘ failed
manifest-invalid ↗   disabled   blocked (version / dependency)
import { usePluginRuntime, getAllPluginRecords, subscribeToPluginRuntime } from "@z0devs/mf-core";
 
function PluginStatus() {
  const records = usePluginRuntime();          // re-renders on transitions
  return (
    <ul>
      {records.map((r) => (
        <li key={r.id}>
          {r.name}: {r.status} {r.loadDurationMs ? `(${Math.round(r.loadDurationMs)}ms)` : ""}
        </li>
      ))}
    </ul>
  );
}

Outside React, getAllPluginRecords() reads the same data and subscribeToPluginRuntime(fn) notifies on change.

Metrics#

getPluginMetrics() flattens the registry into { id, name, status, loadDurationMs, lastError } — deliberately SDK-agnostic, so it drops into whatever you already run:

src/telemetry/plugins.ts
import { subscribeToPluginRuntime, getPluginMetrics } from "@z0devs/mf-core";
 
export function instrumentPlugins(histogram: Histogram, counter: Counter) {
  return subscribeToPluginRuntime(() => {
    for (const m of getPluginMetrics()) {
      if (m.status === "ready" && m.loadDurationMs != null) {
        histogram.record(m.loadDurationMs, { plugin: m.id });
      } else if (m.status === "failed") {
        counter.add(1, { plugin: m.id, error: m.lastError?.kind ?? "unknown" });
      }
    }
  });
}

Two dashboards worth having on day one: p95 plugin load duration by plugin (a partner CDN degrading shows up here first) and failure count by error.kind (tells you whether it's the network, a version gate or a bad build).

The error union#

Failures are typed, not strings, so you can route them by severity instead of grepping messages:

kindMeaningUsual cause
manifest-invalidEntry failed schema validationHand-edited manifest, missing service.remoteEntry
version-blockedshellApiVersion incompatiblePlugin built against another shell major
dependency-unmetRequired plugin dependency missing / out of rangeManifest shipped without its dependency
integrity-violatedremoteEntryHash mismatchBundle changed since it was vetted — never retried
load-failedRemote script or chunk failed after retriesNetwork, CDN, CSP, 404
init-threwThe plugin's init threw while renderingPlugin bug
render-threwA federated component threw at renderPlugin bug, caught by the error boundary
predicate-threwAn OnboardingStep predicate threw — step skippedHook or async call inside a predicate
disabledEntry was enabled: falseInformational

pluginErrorSeverity(record) maps kinds to "info" | "warn" | "error"disabled and version-blocked are informational, manifest-invalid and predicate-threw warn, the rest escalate.

<PluginRouteLoader
  shell={sharedStore}
  auth={auth}
  manifest={loadManifest}
  loader={AppLoader}
  onPluginError={(record) => {
    const severity = pluginErrorSeverity(record);   // "info" | "warn" | "error"
    Sentry.captureMessage(`plugin ${record.kind}`, { level: severity, extra: record });
  }}
/>

onPluginError mounts a PluginErrorProvider for the subtree, so plugin code can report through usePluginErrorHandler() too. With no provider anywhere, safeEmitPluginError falls back to the console — which is why an unwired sink means nobody ever sees manifest-invalid.

Wire the sink on day one

manifest-invalid and dependency-unmet are exactly the failures that look like "the plugin just didn't show up". Without telemetry they're invisible; with it they're a one-line fix.

Retry policy#

BaseRemote retries transient failures and fails fast on everything else:

<PluginRouteLoader
  config={{
    loadPolicy: {
      maxAttempts: 5,                                  // 1 disables retries
      backoffMs: (n) => Math.min(2000, 250 * 2 ** n),  // default: 500ms × n, linear
      isTransient: (e) => /network|timeout|chunk/i.test(e.message),
      scriptTimeoutMs: 30_000,                         // default 60s
    },
  }}
  /* … */
/>

Transient by default: ScriptNetworkError, script timeouts, RUNTIME-008, and ChunkLoadError / "Loading chunk N failed". CSP violations, 404s and parse errors are not retried, because retrying them just delays the error.

The ChunkLoadError case is worth calling out: an expose chunk of an already-registered remote failing mid-flight used to fail with zero retries and surface straight to the app error boundary. Webpack evicts failed chunk promises, so a retried loadRemote genuinely refetches.

Two things this does not cover, by design:

  • Chunks fetched after a remote has mounted — that's the remote build's bundler-runtime concern.
  • Stale-deploy 404s — the host app should reload once on ChunkLoadError after a deploy.

Integrity#

For vetted builds served from someone else's infrastructure:

{
  "service": {
    "id": "acme_analytics",
    "remoteEntry": "https://acme.com/plugins/analytics/remoteEntry.js"
  },
  "remoteEntryHash": "sha256-4f2b…"
}

`remoteEntryHash` is top-level, not inside `service`

It is a sibling of service, not a field on it. Nested, entry.remoteEntryHash reads undefined, the host passes no integrityHash, and verification is silently skipped — no error, no warning, and you believe you have tamper detection. validateManifestEntry() won't flag it either, since the field is optional.

BaseRemote fetches the bytes, hashes them with crypto.subtle and compares before registerRemotes. On mismatch: integrity-violated, no retry, plugin never executes.

What this is and isn't

Supply-chain tamper detection for a build you approved — not runtime isolation. See Trust model.

Prefetch#

import { usePluginPrefetch } from "@z0devs/mf-core";
 
function PluginNavLink({ entry }) {
  const { hoverProps, prefetchOnIdle } = usePluginPrefetch(entry);
  useEffect(prefetchOnIdle, [prefetchOnIdle]);
  return <NavLink {...hoverProps} to={entry.path}>{entry.name}</NavLink>;
}

Idempotent, and it swallows its own errors — a failed warm never breaks the real load. prefetchEntry is the imperative form if you'd rather trigger it from a router event.

The debug panel#

import { PluginDebugPanel } from "@z0devs/mf-shell-kit";
 
{import.meta.env.DEV && <PluginDebugPanel />}

Every record, status, duration and last error in a table. Ship it behind a dev flag or an internal route — "which plugins are loaded, and how long did they take" is the first question in every plugin-related incident.

The runtime plugin hook#

For cross-cutting instrumentation at the federation level, wrap the runtime itself:

import { createRuntimePlugin } from "@z0devs/mf-core";
import { init } from "@module-federation/runtime";
 
const instrumented = createRuntimePlugin({
  name: "instrumented",
  beforeRequest(args) {
    console.debug("[mf] request", args.id);
    return args;
  },
  onLoad(args) {
    return args;
  },
});
 
init({ name: "shell", remotes: [], plugins: [instrumented] });

All @module-federation/runtime hooks are pass-throughs by default — override only what you need. Every runtime call in the library routes through a single mfRuntime.ts seam, so a runtime-major swap is one file.

Next#