webeye-libs
v0.1.2
Published
Lightweight website tracker — active time & click tracking. Vanilla, React, Astro.
Maintainers
Readme
webeye-libs
Lightweight website tracker — active-time and click tracking for Vanilla, React, and Astro.
webeye-libs collects basic visitor device info, identifies the user + tab session, measures active time
on the tab, and counts clicks on opted-in ("scoped") elements. It reports to a small edge server that
persists to a libSQL/SQLite database.
The tracker is defensive by design: it wraps everything in try/catch, is a no-op during SSR, and
never throws into or breaks the host website.
Install
npm install webeye-libsReact and Astro are optional peer dependencies — install them only for the framework you use; the core (Vanilla) entry has no runtime dependencies.
Setting up with Claude Code
The package ships a Claude Code skill that walks through the whole setup — obtaining a key pair, wiring the right framework, and verifying that data actually reaches the database. Copy it into your project once:
mkdir -p .claude/skills
cp -r node_modules/webeye-libs/.claude/skills/webeye-setup .claude/skills/Then ask Claude to "set up webeye", or invoke /webeye-setup.
Quick start
You need three things from your deployment:
endpoint— the base URL of your webeye server, with no trailing slash (e.g.https://webeye.b-cdn.net).website— the site identifier the key pair was issued for. It must match exactly, or every request is rejected.publicKey— the site'spk_…key, generated in the admin panel. It is safe to ship in front-end code; it only ever buys a 15-minute, write-only access token for this one website.
Vanilla
Initialize once, as early as possible, then mark the elements you want to count with a
data-webeye-scope attribute.
import { init } from 'webeye-libs'
init({
endpoint: 'https://webeye.b-cdn.net',
website: 'my-site',
publicKey: 'pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
})<!-- Any click that lands inside a [data-webeye-scope] element is counted under that scope. -->
<button data-webeye-scope="signup">Sign up</button>
<a data-webeye-scope="pricing-cta" href="/pricing">See pricing</a>A single delegated click listener on document handles all scoped elements — you can add
data-webeye-scope to elements rendered at any time. Scope values are automatically slugified
(lowercased, dash-separated, alphanumeric).
For programmatic counting, call trackClick:
import { trackClick } from 'webeye-libs'
trackClick('checkout-complete')Use either
data-webeye-scopeor a manualtrackClick()on a given element — never both, or the click is counted twice.
React
Wrap your app in WebeyeProvider (it calls init once, client-side). Use <Track> for declarative
counting and useTrackClick for imperative counting.
import { WebeyeProvider, Track, useTrackClick } from 'webeye-libs/react'
function App() {
return (
<WebeyeProvider
config={{
endpoint: 'https://webeye.b-cdn.net',
website: 'my-site',
publicKey: 'pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
}}
>
<Home />
</WebeyeProvider>
)
}
function Home() {
// Declarative: <Track> renders a wrapper with data-webeye-scope; the core
// delegated listener does the counting. Defaults to a <span>; override with `as`.
// Imperative: useTrackClick returns a memoized handler.
const onCheckout = useTrackClick('checkout-complete')
return (
<>
<Track scope="signup" as="button" onClick={handleSignup}>
Sign up
</Track>
<button onClick={onCheckout}>Complete checkout</button>
</>
)
}<Track> counts via the data-webeye-scope attribute only — do not additionally call
trackClick/useTrackClick for the same element.
You can also read the instance with useWebeye() (returns Webeye | null).
Astro
Drop <Webeye> into your layout once (it injects the client init script), and wrap countable elements
with <Track>.
---
import Webeye from 'webeye-libs/astro/Webeye.astro'
import Track from 'webeye-libs/astro/Track.astro'
---
<html>
<head>
<Webeye
endpoint="https://webeye.b-cdn.net"
website="my-site"
publicKey="pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
</head>
<body>
<Track scope="signup" as="button">Sign up</Track>
</body>
</html><Track> renders the given tag (default <span>) with a slugified data-webeye-scope, wrapping its
slot. Counting is handled by the delegated listener installed by <Webeye>.
Config options
Passed to init(config) (Vanilla), <WebeyeProvider config={…}> (React), or as props on <Webeye> (Astro).
| Option | Type | Required | Default | Description |
|-------------|-----------|----------|---------|-------------|
| endpoint | string | yes | — | Server base URL, no trailing slash (e.g. https://webeye.b-cdn.net). |
| website | string | yes | — | Site identifier; must match the website the key pair was issued for. |
| publicKey | string | yes | — | The site's pk_… public key. Exchanged for a 15-minute access token. |
| idleMs | number | no | 60000 | Inactivity threshold in ms. Time only counts while the user has been active within this window. |
| flushMs | number | no | 3000 | How often (ms) the active duration is flushed to the server while active. |
| autoTrack | boolean | no | true | Install the delegated [data-webeye-scope] click listener. Set false to count only via trackClick. |
| debug | boolean | no | false | Log diagnostics to the console. |
Storage keys
webeye-libs persists a small amount of state under namespaced keys so identity and progress survive reloads.
| Key | Storage | Value |
|------------------------|------------------|-------|
| webeye:visitor_id | localStorage | The visitor id (server-generated). Identifies a distinct visitor across sessions. |
| webeye:session_id | sessionStorage | The current tab session id (server-generated). One per tab session. |
| webeye:duration | sessionStorage | Accumulated active time in whole seconds (integer). |
| webeye:clicks | sessionStorage | Per-scope click counts as JSON: { [scope: string]: number }. |
| webeye:token | sessionStorage | The current 15-minute access token. |
| webeye:token_exp | sessionStorage | That token's expiry as epoch milliseconds. |
All storage access is wrapped in try/catch, so private-mode restrictions never break the page.
How active time & clicks work
Authentication
- On the first write, the library exchanges
publicKey+websiteatPOST /tokenfor an access token valid for 15 minutes, and caches it in memory +sessionStorage. - Every tracking request carries that token as
Authorization: Bearer …. The server verifies it by signature alone — no database read — so token exchange is the only DB read the tracker causes, at most once per 15 minutes per tab. - The token is refreshed automatically once it is within 60 seconds of expiry, and concurrent sends
share a single refresh. A
401invalidates the cached token so the next send re-exchanges. - Unload flushes go through
navigator.sendBeacon, which cannot set headers, so the token is placed in the JSON body asaccess_tokeninstead — the server accepts both. - If the public key is wrong, or is used on a website it wasn't issued for, the exchange returns
401and the library simply stops sending. It never throws into the host page.
Active time
- Identity first. On init,
webeye-libsensures avisitor_id(fromlocalStorage, elsePOST /visitorswith device info) and then asession_id(fromsessionStorage, elsePOST /sessions). If the key already exists it is reused with no server call. - Activity signal. The library listens (passively) for
mousemove,pointermove,touchstart,keydown,scroll, andclick, recording the timestamp of the last interaction. This broadens the classic "recent mouse move" rule so keyboard and touch users count too. - Ticking. Once per second the library checks whether the visitor is active — the tab is
visibleand the last activity was less thanidleMsago. Each active second increments the accumulated duration (persisted towebeye:duration). - Paused when inactive. If the tab is hidden or the user has been idle for
idleMs, the counter does not advance and no duration is sent. This is the "paused when inactive" guarantee. - Flushing. While active, the absolute
active_duration(total seconds) is sent toPOST /durationeveryflushMs. Because the value is absolute, a dropped request is self-correcting — the next flush carries the true total. A final flush is also sent onvisibilitychange → hiddenand onpagehide/beforeunloadusingnavigator.sendBeacon(with afetch(..., { keepalive: true })fallback) so the last value is never lost.
Clicks
- Only scoped elements are counted — those carrying
data-webeye-scope(added directly in Vanilla, or via<Track>in React/Astro), plus any explicittrackClick(scope)calls. - Every scope is passed through
slugify(), producing a lowercase, dash-separated, alphanumeric slug matching^[a-z0-9]+(?:-[a-z0-9]+)*$. Empty or invalid scopes are ignored (a warning is logged indebugmode). - Counts are cumulative and absolute per scope. On each recorded click the local count is
incremented, persisted to
webeye:clicks, and sent toPOST /clicksas the new total. Sending the absolute count keeps the API idempotent and self-healing: a dropped request is corrected by the next click. - If the session isn't ready yet, clicks are still counted locally and flushed once the session id arrives.
Public API
webeye-libs (core / Vanilla)
init(config: WebeyeConfig): Webeye— idempotent singleton; starts tracking and returns the instance.getWebeye(): Webeye | null— the current instance, ornullbefore init.trackClick(scope: string): void— proxies to the singleton (no-op if not initialized).slugify(input: string): string,isValidScope(input: string): boolean.Webeyeinstance methods:trackClick(scope),getVisitorId(): string | null,getSessionId(): string | null,stop(): void.- Types:
WebeyeConfig,DeviceInfo,DeviceType.
webeye-libs/react
WebeyeProvider({ config, children })— callsinitonce (client-only, SSR-safe) and provides context.useWebeye(): Webeye | null.useTrackClick(scope: string): () => void— memoized handler for imperative counting.Track—{ scope: string; as?: keyof JSX.IntrinsicElements; children; ...rest }; renders<As data-webeye-scope={slug} {...rest}>(defaultas="span").
webeye-libs/astro/*
Webeye.astro— props{ endpoint, website, publicKey, idleMs?, flushMs?, autoTrack?, debug? }; injects the client init script.Track.astro— props{ scope: string; as?: string }(defaultas="span"); renders the slugifieddata-webeye-scopewrapper around its slot.
SSR & safety
- Importing
webeye-libsin Node/SSR is safe:init()is a no-op returning a stub when there is nowindow/document. - All storage and
navigatoraccess is guarded, so nothing here can throw into the host page.
License
MIT
