@streamscloud/analytics-tracker
v0.1.0
Published
Anonymous web analytics tracker for StreamsCloud consumer hosts
Readme
@streamscloud/analytics-tracker
Anonymous web analytics tracker for StreamsCloud consumer surfaces. Collects post views and card clicks, qualifies them client-side, and ships them in batches to a StreamsCloud ingest endpoint.
- Anonymous by construction — no cookies, no Web Storage, no device ids, nothing derived from identity. The only identifier is a random session UUID held in a JS variable: it dies on page reload and survives client-side navigation.
- Never breaks the host page — no method ever throws into the caller, there are zero runtime dependencies, and losing an event is always preferred over interfering with the visitor's page.
- Explicit wiring only — nothing auto-initializes and there is no global instance. You create a tracker, you pass it where it is needed, you dispose of it when the surface unmounts.
Install
npm install @streamscloud/analytics-trackerQuick start
import { AnalyticsTracker } from '@streamscloud/analytics-tracker';
const tracker = new AnalyticsTracker({
endpoint: 'https://<your-streamscloud-host>/events',
platform: 'WEBSITE', // 'CHANNEL' | 'WEBSITE' | 'WEB_EMBED'
});
// Low-level API — for hosts with their own UI and their own view logic:
tracker.track({ eventType: 'VIEW', entityId: postId });
tracker.track({ eventType: 'CTA_CLICK', entityId: postId, ctaId: cardId });
tracker.track({ eventType: 'PRODUCT_CLICK', entityId: postId, ctaId: productCardId });
// When the surface that owns this tracker unmounts (e.g. an SPA route teardown):
tracker.dispose();All ids (entityId, ctaId) must be UUIDs — the ingest endpoint rejects anything else.
entityId is always the post id.
Wiring a UI through raw signals
If your UI can report raw signals — visibility changes, playback position, clicks — you don't need
your own view logic. createPostSignals returns a sink whose built-in sensors decide when a post
counts as viewed and emit the events for you:
import { AnalyticsTracker, createPostSignals } from '@streamscloud/analytics-tracker';
const tracker = new AnalyticsTracker({ endpoint, platform: 'CHANNEL' });
const signals = createPostSignals(tracker);
// From an IntersectionObserver callback (report threshold crossings).
// rootBounds is null in cross-origin iframes — guard it, or coverage-based
// qualification silently turns off there (ratio-based still works):
signals.visibilityChanged(post, {
ratio: entry.intersectionRatio,
viewportCoverage: entry.rootBounds ? entry.intersectionRect.height / entry.rootBounds.height : 0,
});
// From a video element's timeupdate (raw position samples, ~4/s):
signals.playbackTime(post, { seconds: video.currentTime, mediaId });
// From click handlers:
signals.clicked(post, { kind: 'cta', id: ctaCardId });where post is { id: string; type: PostType } — id is the post's UUID and type is one of
'ARTICLE' | 'ARTICLES_COLLECTION' | 'GALLERY' | 'PRODUCTS_COLLECTION' | 'SHORT_VIDEO' | 'VIDEO'.
The sink is consumed by shape (structural typing): a UI library can declare the same interface locally and accept it as an optional prop without depending on this package.
When does a view count?
The sensors apply these rules, once per (session, post):
- The card must be fully visible, or cover ≥ 75 % of the viewport height, for an accumulated ≥ 1 second. The coverage rule exists for cards taller than the viewport (their intersection ratio can never reach 1) but applies to every card. Scrolling away pauses the clock; scrolling back resumes it. A qualified view never re-fires.
- Video posts (
VIDEO,SHORT_VIDEO) additionally require ≥ 3 seconds of accumulated real playback: missing samples mean paused, a forward jump beyond ~2 s is a seek and a negative delta is a loop wrap — neither counts toward the 3 s, neither disqualifies. - Surfaces where the post is on screen by definition (an opened post page, the active item of a
player) must report one fully-visible sample on activation — e.g.
signals.visibilityChanged(post, { ratio: 1, viewportCoverage: 1 }).
Clicks are simpler: { kind: 'cta' | 'product', id } maps to a click event; a plain card tap
({ kind: 'card' }) produces no event.
Delivery semantics
- Batching: events are stamped at event time (
occurredAt, ISO-8601 UTC) and flushed every ~10 seconds or 20 events, whichever comes first. When the page is hidden or unloaded, pending events go out throughnavigator.sendBeacon. - Dedup:
VIEWfires at most once per (session, post) — the sensors and the tracker core both enforce it. Clicks are never deduplicated: two taps are two events. - Retry: a batch is sealed at first send and retried verbatim under the same batch id on
429/5xx/network failure, honoring
Retry-After(clamped to 1 s – 1 h), with exponential backoff otherwise. The retry queue is bounded (oldest batches drop first), a batch older than 24 hours is abandoned, and a 400 response drops the batch permanently. The server deduplicates whole batches by id, so an ambiguous delivery (e.g. a beacon plus a retry) never double-counts. - A session is one page load. The retry queue lives in memory only — closing the tab loses whatever the final beacon couldn't carry. That is by design.
Content-Security-Policy
The tracker POSTs to your ingest endpoint with Content-Type: text/plain (no CORS preflight). If
the embedding page sets a CSP, its connect-src must allow the ingest host.
API
| Export | Description |
| --- | --- |
| new AnalyticsTracker(config) | An isolated tracker instance with its own session. Multiple instances on one page are legal. config: { endpoint: string; platform: 'CHANNEL' \| 'WEBSITE' \| 'WEB_EMBED' }. |
| tracker.track(input) | Enqueue one event. input is a discriminated union: { eventType: 'VIEW', entityId } or { eventType: 'CTA_CLICK' \| 'PRODUCT_CLICK', entityId, ctaId }. Never throws. |
| tracker.dispose() | Last-chance beacon flush, then stops timers and removes listeners so the instance can be collected. Further track calls are ignored. |
| createPostSignals(tracker) | The raw-signal sink described above. Never throws. |
Exported types: TrackerConfig, TrackInput, WebPlatform, PostType, PostSignals,
PostSignalRef, VisibilitySample, PlaybackSample, ClickTarget.
Development
npm run check # typecheck + lint + format check
npm test # vitest
npm run build # dist/ (ESM + CJS + declarations)Release
Always via the npm scripts, never raw npm publish. Both scripts require NPM_TOKEN in the
environment (.npmrc reads it), a clean working tree, and run check, test and build first.
npm version patch --no-git-tag-version # or minor / major — then commit the bump
npm run publish:prod # publishes package.json version with the `latest` tag
npm run publish:dev # publishes `X.Y.Z-<timestamp>` with the `next` tag, restores package.json
npm run pack # builds and produces a local tarball for inspectionStandard workflow: bump version → commit → npm run publish:prod → push → PR → merge → git tag
@streamscloud/analytics-tracker@{version}. Consumers install explicit versions, never @latest.
