Plugins#
A plugin extends the SDK with new event types or behaviors without bloating the core. Every plugin implements the same minimal contract, regardless of how it is distributed.
The contract#
/** @type {import("@revu-ai/core").RevuPlugin} */
export function myPlugin(options = {}) {
return {
name: "my-plugin", // unique id; double-install is a no-op
install({ record, identity, context, config }) {
// Wire listeners, observers, timers. Emit through `record()` so
// events go through the standard pipeline (identity + context +
// transport).
record("$my_event", { properties: { foo: "bar" } });
},
uninstall() {
// Optional. Tear down anything install() wired up.
},
};
}
The install API is the minimum a plugin needs:
record(eventType, data?)to emit an event through the standard pipeline.dataacceptsfingerprintandproperties(the latter wins over engine context on collision).identityfor read access toanonymousId,userId,sessionId.contextfor read access to the environment context builder.configfor the resolved config (host, environment, debug, etc).
Registering a plugin#
Two equivalent paths (exceptions() and replay() here stand in for
plugins you author or install; they are not shipped exports of this
package):
revu.init({ apiKey: "revu_pk_...", plugins: [exceptions(), replay()] });
revu.use(exceptions());
revu.use(replay());
revu.init({ apiKey: "revu_pk_..." });
Both paths queue pre-init use() calls and drain them when init()
runs. The same plugin name registered twice is a no-op so a redundant
wiring path does not cause double listeners.
When to ship a feature as a plugin vs put it in core#
Core stays universal. Anything every customer uses regardless of segment or use case (autocapture, identity, transport, attention, web vitals) lives in core. Anything segment-specific (B2B-only signals, framework adapters, industry compliance, paid-plan capabilities) ships as a plugin so customers who do not use it carry zero bytes after tree-shaking.
The rule of thumb on distribution:
- Subpath plugin (
@revu-ai/core/<name>) when the feature is small, uses no separate ingest, and shares the privacy posture of core. It ships in the same package and tree-shakes out when not imported. - Separate npm package (a future
@revu-ai/replayand so on) when the feature exceeds ~5 kB gzipped, has its own ingest endpoint, has a materially different privacy posture, or needs independent versioning.
The plugin contract is identical either way.
A worked example#
A small plugin that captures uncaught exceptions (an illustrative plugin you would author yourself, not a shipped export):
/** @type {import("@revu-ai/core").RevuPlugin} */
export function exceptions() {
return {
name: "exceptions",
install({ record }) {
if (typeof window === "undefined") return;
window.addEventListener("error", (e) => {
record("$exception", {
properties: {
message: e.message,
filename: e.filename,
line: e.lineno,
column: e.colno,
stack: e.error?.stack,
},
});
});
window.addEventListener("unhandledrejection", (e) => {
record("$unhandled_rejection", {
properties: { reason: String(e.reason) },
});
});
},
};
}
Three things worth noting:
- The plugin emits through
record(), so the event picks up identity, session, and environment context just like an autocaptured event. - It does its own DOM work (
window.addEventListener). The plugin API is deliberately minimal; everything the plugin needs from the host environment comes from platform APIs, not from a wrapper layer. - It is SSR-safe (guards on
typeof window). Plugins are responsible for their own environment guards, same as core.