📚 Documentation: taktlytics.com/docs/wrappers/core
Tiny, privacy-friendly analytics SDK for Takt.
- Zero dependencies, tree-shakeable ESM module.
- ~1 kB gzip drop-in snippet. No build step required.
- Privacy first: honours opt-out and Do Not Track, strips query strings, and excludes localhost / private IPs by default.
- Hexagonal core: a pure domain wrapped in ports & adapters. It is easy to test and easy to extend.
<script defer src="https://cdn.jsdelivr.net/npm/@vskstudio/takt-core/dist/takt.js" data-domain="example.com"></script>Pin a version in production, e.g.
@vskstudio/takt-core@0.5.0. jsDelivr and unpkg both serve the snippet straight from npm. No extra hosting is required.
Then, anywhere on the page:
window.takt('Signup', { props: { plan: 'pro' } })Calls made before the script finishes loading are queued and replayed. Install a tiny stub first if you need that:
<script>
window.takt = window.takt || function () { (window.takt.q = window.takt.q || []).push(arguments) }
</script>| Attribute | Effect | Default |
|---|---|---|
data-domain |
Site identifier sent with every event | location.hostname |
data-script-origin |
First-party origin to derive the endpoint from ({origin}/api/event): a custom domain you proxy through to dodge ad-blockers |
hosted Takt origin |
data-endpoint |
Ingestion endpoint (wins over data-script-origin); set /api/event for a same-origin first-party proxy |
https://taktlytics.com/api/event |
data-exclude-localhost="false" |
Track localhost / private IPs | excluded |
data-enabled="false" |
Kill-switch: the tracker does nothing | enabled |
data-respect-dnt="false" |
Opt out of the Do Not Track short-circuit | respected |
data-sample-rate="0.5" |
Send only this fraction (0 to 1) of events | 1 (all) |
data-track-query |
Keep the full query string and hash (default strips both) | stripped |
data-query-params="utm_source,utm_medium" |
Keep only these query params | none |
The base snippet stays around 1 kB gzip: pageviews, SPA navigation, window.takt(), and the privacy guards: nothing more. It respects Do Not Track and strips the query string and hash from URLs by default; the attributes above tune both. For a custom scrubber function, use the npm build (scrubUrl below).
The base snippet has neither exclude nor redactRoutes: its pageviews send the real path of every page, including /verify/abc123. Use the npm SDK when a route must never reach analytics. Browsers that send Sec-GPC: 1 (Global Privacy Control) are still honoured: the ingest drops their events.
Need outbound clicks, file downloads, HTML-declared events, or 404 detection without writing code? Swap takt.js for the opt-in takt.auto.js bundle and list what you want in data-auto:
<script defer
src="https://cdn.jsdelivr.net/npm/@vskstudio/takt-core/dist/takt.auto.js"
data-domain="example.com"
data-auto="outbound,downloads,tagged,404"></script>Without data-auto, takt.auto.js behaves exactly like takt.js. Each extension is opt-in. Loading the tracker twice is safe: takt.js and takt.auto.js on the same page share one installation, so pageviews are never sent twice. Loading takt.auto.js after takt.js adds its extensions and its redaction to the existing tracker, and a second takt.auto.js is ignored.
data-auto value |
Event sent | Property |
|---|---|---|
outbound |
Outbound Link: Click |
url |
downloads |
File Download |
url |
404 |
404 |
path |
tagged |
custom (data-takt-event) |
from data-takt-prop-* |
- downloads default extensions:
pdf, zip, dmg, xlsx, csv, doc, docx, ppt, pptx, rar, 7z, gz, mp3, mp4, wav, avi, mov, exe, apk, mkv, txt, the same list as the default of the SDK'senableFiles(). Override withdata-downloads-ext="pdf,csv,epub". - tagged: add
data-takt-event="Cta"to any clickable element;data-takt-prop-<key>attributes become props (empty keys/values are ignored). The reserved namepageviewis refused. Identical toinit({ tagged: true })on the SDK. - 404: detected at load via the Navigation Timing API, or by adding
data-takt-404to<body>/ a<meta name="takt:404">tag on server-rendered error pages. - data-redact-routes: comma-separated route patterns (
data-redact-routes="/reset/[token],/verify/[token]"), same syntax asredactRoutes. Applied to the pageview and SPA navigation path, the404path, and theOutbound Link: ClickandFile Downloaddestinations (matched on their path, whatever the host). The basetakt.jsalone has no redaction: a route with a secret in its path needstakt.auto.jswith this attribute, or the npm SDK'sredactRoutes. Whentakt.jsis loaded first, the pageview it already sent is not redacted; only navigation aftertakt.auto.jsruns is masked, so loadtakt.auto.jsalone on pages that carry a secret route. - data-redact-undecodable-paths: only the literal
"false"decodes a path that cannot be decoded leniently (invalid escapes kept) and matches it, instead of sending/[redacted]. Any other value, or no attribute, keeps the masking (default: masked when a route is configured). See Route redaction.
pnpm add @vskstudio/takt-coreimport { init, track, pageview } from '@vskstudio/takt-core'
init({ domain: 'example.com', outbound: true, files: true, notFound: true })
track('Signup', {
props: { plan: 'pro' },
revenue: { amount: '29.00', currency: 'EUR' },
})init() creates a single shared instance, fires an automatic pageview, and wires SPA navigation. track and pageview delegate to it. optOut, optIn, and isOptedOut work with or without it (see Privacy).
The autocapture toggles outbound, files, notFound, and tagged opt into the same extensions as the snippet's data-auto: outbound-link clicks, file downloads, 404 detection, and data-takt-event custom events. tagged: true tracks clicks on elements carrying data-takt-event (with data-takt-prop-* becoming props), matching data-auto=tagged.
For full control (multiple instances, no globals, explicit teardown), construct an instance directly:
import { createTakt } from '@vskstudio/takt-core'
const takt = createTakt({ domain: 'example.com' })
takt.pageview()
takt.track('Signup', { props: { plan: 'pro' } })
// Each enableX returns a disposer for teardown.
const stopSpa = takt.enableSpa()
const stopOutbound = takt.enableOutbound()
const stopFiles = takt.enableFiles(['pdf', 'zip', 'csv'])
const stop404 = takt.enable404() // detects a 404 page once and reports it
const stopTagged = takt.enableTagged() // custom events from data-takt-event
// later…
stopSpa()
stopOutbound()
stopFiles()
stop404()
stopTagged()createTakt() is a pure factory (no side effects until you call a method), so it tree-shakes cleanly.
init() and createTakt() accept the same options:
| Option | Type | Default | Effect |
|---|---|---|---|
domain |
string |
location.hostname |
Site identifier sent with every event |
scriptOrigin |
string |
hosted Takt origin | First-party origin to derive the endpoint from ({origin}/api/event): a custom domain you proxy through to dodge ad-blockers |
endpoint |
string |
https://taktlytics.com/api/event |
Ingestion endpoint (wins over scriptOrigin); set /api/event for a same-origin first-party proxy |
enabled |
boolean |
true |
Master switch: when false, nothing is sent |
debug |
boolean |
false |
Log each payload to the console before sending |
sampleRate |
number |
1 |
Keep this fraction of events (e.g. 0.25 ≈ 25%) |
respectDnt |
boolean |
true |
Suppress events when Do Not Track is on |
excludeLocalhost |
boolean |
true |
Suppress events on localhost / private IPs |
exclude |
string[] |
none | Path prefixes never tracked (e.g. ['/app', '/account']). Segment-bounded: /app matches /app and /app/… but not /application. Case-insensitive, blind to a trailing slash (/App/ excludes /app) and to percent-encoding, segment by segment (/%61dmin excludes /admin; a malformed segment such as %ZZ is kept as is, and the other segments are still decoded). Checked at send time, so it holds across SPA navigation |
trackQuery |
boolean |
false |
Keep the full query string and hash on URLs |
queryParams |
string[] |
n/a | Allowlist: keep only these query params, drop the rest |
scrubUrl |
(url: string) => string |
n/a | Custom scrubber; overrides trackQuery / queryParams. Also applied to the url prop of outbound-link and file-download events |
redactRoutes |
string[] |
n/a | Route patterns whose real path is replaced by the pattern (e.g. ['/verify/[token]'] sends /verify/[token]). See Route redaction |
redactUndecodablePaths |
boolean |
true |
When a route is configured, a path that cannot be decoded is sent as /[redacted]; false decodes it leniently and matches it, as takt-core-php does |
routeTemplates |
boolean |
false |
Send every URL as its route template instead of the real path. Needs a routeTemplate resolver too (the flag alone masks nothing); the framework wrappers provide one |
routeTemplate |
() => string | null |
n/a | Returns the current route template (/blog/[slug], /users/:id). Used when routeTemplates is on |
By default the query string and hash are stripped from every URL (page, referrer, and autocaptured link destinations) before sending. Secrets in ?token=… or #access_token=… never leave the browser. Opt back in with trackQuery: true, narrow it with a queryParams allowlist, or take full control with scrubUrl. Props and revenue are sanitized too: props are coerced to strings, capped (30 keys, 64-char keys, 1024-char values), and revenue is dropped unless the amount and 3-letter currency are well-formed.
import { optOut, optIn, isOptedOut } from '@vskstudio/takt-core'
optOut() // sets localStorage `takt_ignore` = '1'; no events are sent
optIn() // resumes tracking
isOptedOut() // true after optOut(), e.g. to render an opt-out toggleThese three functions need no instance: call them before init() (a consent banner rendered first) and every instance created later honours the choice. An instance from createTakt() exposes the same optOut(), optIn(), and isOptedOut() methods over the same stored state.
Events are suppressed, in order, when: the visitor has opted out, or Do Not Track is enabled (respectDnt), or the host is localhost / a private IP (excludeLocalhost), or the path matches an exclude prefix, or the event is dropped by sampleRate.
Query strings are stripped by default, but path segments are sent as they are: /verify/abc123 leaks the token. Two opt-in options replace real paths with route templates.
createTakt({ redactRoutes: ['/verify/[token]', '/reset/:code', '/invoices/[id].pdf'] })redactRoutes lists the sensitive routes. A matching path is sent as the pattern (/verify/[token]), every other path keeps its real value, so per-page stats stay intact. Patterns accept SvelteKit / Astro / Next syntax ([param], [[optional]], [...rest], (group)), Vue / React / Angular / Solid syntax (:param, :param?, *, **) and the Laravel / Symfony braces of the PHP SDK ({param}, {param?}, {param<regex>}). A brace parameter with a <regex> constraint ({id<\d+>}) only matches a segment that satisfies it, anchored on the whole segment and never crossing /. The constraint is not a free regular expression: it must follow this grammar, checked character by character:
- Only printable ASCII is allowed: any non-ASCII or control character refuses the constraint.
constraint := branch ('|' branch)*andbranch := piece+, wherepiece := atom quantifier?. Alternation exists only at the top level, and an empty branch is refused.atomis a literal (any character except( ) [ ] { } | * + ? . \ ^ $ / < >),.(any character but/), an escape\d \D \w \W \s \S,\followed by an ASCII punctuation character other than/, or a class[...].\0and\followed by a digit are refused.- A class takes an optional
^at its start only, and contains the same literals as outside a class (so.,$,(,{,},|,^,*,+,?,/are refused). A-is literal only at the start or end of the class, or inside a range such asa-zwhose two ends are literals. A range must go up ([z-a]is refused) and cannot end on-. So[\d-z],[a-\d],[--a],[!--]are refused. A class cannot be empty ([], and[]a]is refused) or nested. quantifieris*,+,?,{n},{n,}or{n,m}, with n ≤ m ≤ 100. Lazy (*?) and possessive (*+) forms and stacked quantifiers are refused.- The constraint is 1 to 100 characters long and has at most two variable quantifiers (
{n}does not count,{n,n}does, so[0-9a-f]{8}-[0-9a-f]{4}is fine). Matching is case-insensitive. .matches any byte except/, as[^/].\sand\Sare ASCII-only (space, tab, newline, vertical tab, form feed, carriage return). Matching runs on the UTF-8 bytes of the decoded segment, one character per byte, as PCRE without theuflag:éis two bytes, so.{2}matcheséand.{2}does not match an emoji.- Bounds per route and per segment: a route has at most two variable quantifiers in total, and a segment holds at most one constrained parameter and at most two variable quantifiers. Each segment adds its variable quantifiers minus one (parameters without a constraint and an optional
{x?}count too: a segment with a single plain parameter adds nothing, while an optional{x?}adds one), and the sum over the route must stay at most 1, so/{a}{b}/{c}is accepted and/{a}{b}/{c}{d}is not. A route has at most one catch-all segment (**,*name,:name*,:name+,[...name]). A route over any of these bounds is unusable. - A constraint outside this grammar makes the route unusable: every matching path is masked as
/[redacted], never sent raw. A brace segment the parser cannot read (for example{id<[>]>}) is treated the same way. A constrained segment longer than 256 UTF-8 bytes is masked as/[redacted]too. An unusable route is matched by its shape instead, with every parameter as a wildcard, so a path of the same shape is masked as/[redacted]:/x/{p<\d>}/{q<[z-a]>}masks/x/abc/q. Inline examples:/x/{p<\d+>}x{q}on/x/1x2x3gives/x/{p}x{q}, and{p<.{3}>}on/x/a%0Abgives/x/{p}. Refused constraints include[z-a],[\d-z],a{3,2},\/and control characters.
Examples: \d+, [a-z0-9-]+, [0-9a-f]{8}, fr|en. The same grammar must apply in takt-core-php, so both SDKs mask the same paths. The rule covers the page URL, same-origin referrers, outbound link destinations and download links (matched on their path, whatever the host), and 404 paths.
Matching is case-insensitive: /Orders/123 matches /orders/[id].
The path is decoded again and again until it stops changing, with at most five changes, so /orders%252F123 also matches /orders/[id]. Each decoded form is compared. Dot segments (. and .., encoded or not) are resolved as RFC 3986 specifies, repeated slashes are collapsed, and matrix parameters (;a=1) are dropped from each segment before the comparison.
A path that cannot be decoded (a malformed % escape or invalid UTF-8) is sent as /[redacted] when at least one route is configured, so the browser never sends a path it could not check. Without a route, such a path is sent unchanged. Set redactUndecodablePaths: false (or data-redact-undecodable-paths="false" on takt.auto.js) to decode it leniently instead, as takt-core-php does: an invalid escape stays as written and the path is still matched against the routes. An invalid byte glued to a route literal (for example /%FForders/123 with the route /orders/[id]) does not match and is sent raw, as in takt-core-php. Keep the default true for a guaranteed masking. A path that still changes after five decoding passes is masked as /[redacted] whatever the option.
When a path is masked, its query string and fragment are dropped with it, so a token in ?token= never leaves the browser.
createTakt({ routeTemplates: true, routeTemplate: () => currentRoute.id })routeTemplates: true sends every page as its route template: /blog/hello becomes /blog/[slug]. It suits fully private apps where no path is worth reading as is; on a public site it merges every article into one row. Template mode needs both routeTemplates: true and a routeTemplate resolver: the core cannot know the router, so the framework wrappers wire the resolver for you. routeTemplate alone masks nothing, and routeTemplates: true without a resolver is off. When the resolver returns nothing (no matched route), the route fails closed, for the pageview u and for the 404 path alike: it is sent with its redactRoutes pattern when one covers it, and otherwise as https://<host>/[redacted], without its query or hash. The fail-closed rule applies to pageviews and 404s only. Outside template mode nothing changes: a path that no pattern covers is sent as is. Outbound link destinations never use the resolver, since it describes the current page, not the link. Every file download is therefore sent as https://<host>/[redacted], whatever its host, since its path can carry a token; the npm SDK still sends its file type as the extension prop of File Download. An outbound link to another host stays under redactRoutes alone, so any pattern that must hide a token in a partner's URL has to be declared in redactRoutes. The tracker does not report an outbound link to exactly the page's host, so the masking of an outbound link on the page's host only reaches that host written with a trailing dot: on a page of example.com, https://example.com./download/tok123 is sent as https://example.com./[redacted]. www. is another host: on a page of example.com, https://www.example.com/download/tok123 keeps its path, as the specification requires. Every referrer is reduced to its origin in this mode, whatever its host: this is a deliberate choice, since a referrer from another site can still carry a path tied to the page (a token, an internal route), so the path is dropped even for an external referrer. A referrer over http: or https: keeps only its origin, and a referrer with any other scheme (for example android-app://) is sent as an empty referrer.
Send pageviews and events from Node (visitors without JavaScript, crawlers, webhooks, queued jobs) with the same payload rules as the browser SDK:
import { createServerTakt } from '@vskstudio/takt-core/server'
const takt = createServerTakt({
domain: 'example.com',
apiKey: process.env.TAKT_API_KEY,
})
await takt.pageview({
url: 'https://example.com/pricing',
referrer: request.headers.get('referer') ?? undefined,
visitor: { ip: clientIp, userAgent: request.headers.get('user-agent') ?? undefined },
})
await takt.event('Purchase', {
props: { plan: 'pro' },
revenue: { amount: '29.00', currency: 'EUR' },
})| Option | Type | Default | Effect |
|---|---|---|---|
domain |
string |
required | Site identifier, must match the site of the API key |
apiKey |
string |
n/a | Site API key with events:write, sent as Authorization: Bearer |
endpoint / scriptOrigin |
string |
hosted Takt origin | Same resolution as createTakt(). Must be https://: any http:// endpoint throws at construction, loopback included (breaking change: localhost, 127.0.0.1 and [::1] used to be accepted over http) |
trackQuery / queryParams / scrubUrl |
query stripped | Same URL scrubbing as the browser SDK, applied to url and referrer |
|
redactRoutes |
string[] |
n/a | Same route patterns as the browser SDK. A route passed to pageview() / event() replaces the path of that call |
redactUndecodablePaths |
boolean |
true |
Same option as the browser SDK |
strict |
boolean |
false |
Throw on network errors and non-202 answers instead of swallowing them |
fetch |
typeof fetch |
global fetch |
Custom fetch implementation (Node 18+ ships one) |
Without a url, the event is attached to the site home (https://{domain}/), since the ingest rejects events without an absolute URL. visitor.ip is sent as X-Forwarded-For and visitor.userAgent as User-Agent. Because the request is authenticated by an events:write key, the ingest derives the visitor and country from that forwarded IP, not from the calling server's IP.
Besides tracking, the package ships framework-agnostic helpers for Takt's
server-rendered widgets and its public stats API. These are tree-shakeable and
re-exported by the framework wrappers (@vskstudio/takt-react, -vue, etc.).
import { badgeUrl, embedUrl, createStats } from '@vskstudio/takt-core'
// URL builders for the server-rendered badge SVG and embed iframe.
badgeUrl('example.com', { variant: 'd', glyph: 'off', lang: 'en' })
// → https://taktlytics.com/public/example.com/badge.svg?variant=d&glyph=off&lang=en
embedUrl('example.com', { theme: 'dark' })
// → https://taktlytics.com/embed/example.com?theme=dark
// Anonymous client for the public stats API. Pass `host` to target a custom host (first-party proxy or test environment).
const stats = createStats({ host: 'https://takt.example.com', domain: 'example.com' })
await stats.summary(undefined, { period: '30d', compare: 'previous' })
await stats.timeseries()
await stats.realtime()
await stats.breakdown('page')host defaults to the hosted Takt origin, matching the SDK's ingest default.
When set, it must be an absolute http(s):// origin. Anything else
(javascript:, data:, protocol-relative //…) is rejected, so a host
value can never smuggle a non-http scheme into a widget src or a fetch.
The value is reduced to its origin: any path, query, or fragment is dropped
(https://takt.example.com/x?a=1 → https://takt.example.com).
Errors surface as PublicApiError (carrying the HTTP status).
Every event is posted to the endpoint as a compact JSON object. The keys are frozen: the Takt backend ingestion depends on them:
| Key | Meaning |
|---|---|
n |
event name (pageview for pageviews) |
d |
domain |
u |
URL (query + hash stripped by default) |
r |
referrer (query + hash stripped by default) |
w |
viewport width |
p |
props (object, omitted if empty) |
$ |
revenue { a: amount, c: currency } (currency uppercased) |
@vskstudio/takt-core follows a hexagonal (ports & adapters) layout:
domain/ Pure business core, zero I/O. Value objects (EventName, Props,
Revenue, AnalyticsEvent), payload mapping, and the URL scrubber.
application/ Use cases: the Analytics service, the TrackingPolicy (consent +
sampling), and autocapture trackers, which depend only on small
single-method port interfaces.
infrastructure/ Driven adapters: a resilient fetch/beacon transport, localStorage
consent, and browser providers (DNT, environment, history, clicks).
composition/ createTakt() factory, the ESM entry, and the snippet adapter.
The domain never reaches outward; adapters are injected at the composition root (createTakt). This keeps the core testable with fakes and lets you swap transports or storage without touching business logic.
MIT