Spoar

Overview

Opt-in features, each in its own file so an app only ships what it uses.

Import plugins from @spoar/sdk/plugins and pass them in plugins, or add one later with analytics.use(plugin), which returns its remover. No plugin imports another, so each one adds only its own code to the bundle.

import { createAnalytics } from "@spoar/sdk";
import { errors, scrollDepth } from "@spoar/sdk/plugins";

export const analytics = createAnalytics({
  project: "example.com",
  key: "pk_...",
  endpoint: "/_ra",
  plugins: [scrollDepth()],
});

analytics.use(errors());

Every event a plugin sends goes through the same consent, opt-out and beforeSend checks as track. Plugins that read the page, such as scrollDepth or clicks, do nothing during server rendering.

PluginSends
pageviews()pageview on load and on client-side navigation; on by default
speedInsights(options?)web_vital for LCP, INP, CLS, FCP and TTFB
scrollDepth()scroll_depth with the deepest percentage reached on a page
engagement()engagement with the milliseconds a page was visible
clicks()click for elements with data-ra-click
outboundLinks()outbound_click for other hosts and file_download for files
forms()form_submit with the form id and action path
errors()error for uncaught errors and rejections, with breadcrumbs
ignoreSelf()Nothing; ?ra=ignore opts this browser out
botSignals()Nothing of its own; adds bot hint bits to every event
experiments(assigned)experiment_exposure per experiment, and an experiment:<id> prop on every event
notFound()not_found with the referrer on pages marked as not found
groups<Groups>()group when set joins a group, and the joined groups on every event

Bundle budgets

bun run size gzips each plugin bundled alone and fails the build above its budget: 0.6 KB per plugin, 0.7 KB for errors and 2.5 KB for speedInsights.

Writing a plugin

import { definePlugin } from "@spoar/sdk";

export function pageSeen() {
  return definePlugin({
    name: "page-seen",
    setup: (client) => {
      const remove = client.onPage(() => client.record(location.pathname, "page_seen", {}));
      return remove;
    },
  });
}

setup receives the client with every client method plus these, and returns its cleanup, which shutdown() and the remover from use() call:

MemberDoes
beforeSend(fn)Runs fn on every event before it is queued; return the event, changed or not, or null to drop it. Hooks run in registration order
onPage(fn)Runs fn after every pageview
onHidden(fn)Runs fn when the tab is hidden or the page is left, before the queue is flushed
onConsent(fn)Runs fn with granted or denied when consent changes
record(path, name, props)Sends an event credited to path instead of the current page

Each hook returns its remover. Adding the same plugin object twice runs the previous cleanup first.

On this page