tacilytics 0.0.0-stage → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +253 -2
- package/cli.js +173 -0
- package/day-salt.js +55 -0
- package/package.json +56 -4
- package/public/app.js +788 -0
- package/public/assets/DISCLAIMER-simple-icons.md +51 -0
- package/public/assets/LICENSE-lucide.txt +43 -0
- package/public/assets/LICENSE-simple-icons.md +30 -0
- package/public/assets/cosmic-bg.webp +0 -0
- package/public/assets/flags/LICENSE-flag-icons.txt +21 -0
- package/public/assets/flags/ad.svg +150 -0
- package/public/assets/flags/ae.svg +6 -0
- package/public/assets/flags/af.svg +81 -0
- package/public/assets/flags/ag.svg +14 -0
- package/public/assets/flags/ai.svg +29 -0
- package/public/assets/flags/al.svg +5 -0
- package/public/assets/flags/am.svg +5 -0
- package/public/assets/flags/ao.svg +13 -0
- package/public/assets/flags/aq.svg +5 -0
- package/public/assets/flags/ar.svg +32 -0
- package/public/assets/flags/arab.svg +109 -0
- package/public/assets/flags/as.svg +72 -0
- package/public/assets/flags/asean.svg +13 -0
- package/public/assets/flags/at.svg +4 -0
- package/public/assets/flags/au.svg +8 -0
- package/public/assets/flags/aw.svg +186 -0
- package/public/assets/flags/ax.svg +18 -0
- package/public/assets/flags/az.svg +8 -0
- package/public/assets/flags/ba.svg +12 -0
- package/public/assets/flags/bb.svg +6 -0
- package/public/assets/flags/bd.svg +4 -0
- package/public/assets/flags/be.svg +7 -0
- package/public/assets/flags/bf.svg +7 -0
- package/public/assets/flags/bg.svg +5 -0
- package/public/assets/flags/bh.svg +4 -0
- package/public/assets/flags/bi.svg +15 -0
- package/public/assets/flags/bj.svg +14 -0
- package/public/assets/flags/bl.svg +5 -0
- package/public/assets/flags/bm.svg +97 -0
- package/public/assets/flags/bn.svg +36 -0
- package/public/assets/flags/bo.svg +673 -0
- package/public/assets/flags/bq.svg +5 -0
- package/public/assets/flags/br.svg +45 -0
- package/public/assets/flags/bs.svg +13 -0
- package/public/assets/flags/bt.svg +89 -0
- package/public/assets/flags/bv.svg +13 -0
- package/public/assets/flags/bw.svg +7 -0
- package/public/assets/flags/by.svg +18 -0
- package/public/assets/flags/bz.svg +145 -0
- package/public/assets/flags/ca.svg +4 -0
- package/public/assets/flags/cc.svg +19 -0
- package/public/assets/flags/cd.svg +5 -0
- package/public/assets/flags/cefta.svg +13 -0
- package/public/assets/flags/cf.svg +15 -0
- package/public/assets/flags/cg.svg +12 -0
- package/public/assets/flags/ch.svg +9 -0
- package/public/assets/flags/ci.svg +7 -0
- package/public/assets/flags/ck.svg +9 -0
- package/public/assets/flags/cl.svg +13 -0
- package/public/assets/flags/cm.svg +15 -0
- package/public/assets/flags/cn.svg +11 -0
- package/public/assets/flags/co.svg +7 -0
- package/public/assets/flags/cp.svg +7 -0
- package/public/assets/flags/cr.svg +7 -0
- package/public/assets/flags/cu.svg +13 -0
- package/public/assets/flags/cv.svg +13 -0
- package/public/assets/flags/cw.svg +14 -0
- package/public/assets/flags/cx.svg +15 -0
- package/public/assets/flags/cy.svg +6 -0
- package/public/assets/flags/cz.svg +5 -0
- package/public/assets/flags/de.svg +5 -0
- package/public/assets/flags/dg.svg +130 -0
- package/public/assets/flags/dj.svg +13 -0
- package/public/assets/flags/dk.svg +5 -0
- package/public/assets/flags/dm.svg +152 -0
- package/public/assets/flags/do.svg +121 -0
- package/public/assets/flags/dz.svg +5 -0
- package/public/assets/flags/eac.svg +48 -0
- package/public/assets/flags/ec.svg +138 -0
- package/public/assets/flags/ee.svg +5 -0
- package/public/assets/flags/eg.svg +38 -0
- package/public/assets/flags/eh.svg +16 -0
- package/public/assets/flags/er.svg +8 -0
- package/public/assets/flags/es-ct.svg +4 -0
- package/public/assets/flags/es-ga.svg +187 -0
- package/public/assets/flags/es-pv.svg +5 -0
- package/public/assets/flags/es.svg +544 -0
- package/public/assets/flags/et.svg +14 -0
- package/public/assets/flags/eu.svg +28 -0
- package/public/assets/flags/fi.svg +5 -0
- package/public/assets/flags/fj.svg +120 -0
- package/public/assets/flags/fk.svg +90 -0
- package/public/assets/flags/fm.svg +11 -0
- package/public/assets/flags/fo.svg +12 -0
- package/public/assets/flags/fr.svg +5 -0
- package/public/assets/flags/ga.svg +7 -0
- package/public/assets/flags/gb-eng.svg +5 -0
- package/public/assets/flags/gb-nir.svg +132 -0
- package/public/assets/flags/gb-sct.svg +4 -0
- package/public/assets/flags/gb-wls.svg +9 -0
- package/public/assets/flags/gb.svg +7 -0
- package/public/assets/flags/gd.svg +27 -0
- package/public/assets/flags/ge.svg +6 -0
- package/public/assets/flags/gf.svg +5 -0
- package/public/assets/flags/gg.svg +9 -0
- package/public/assets/flags/gh.svg +6 -0
- package/public/assets/flags/gi.svg +32 -0
- package/public/assets/flags/gl.svg +4 -0
- package/public/assets/flags/gm.svg +14 -0
- package/public/assets/flags/gn.svg +7 -0
- package/public/assets/flags/gp.svg +5 -0
- package/public/assets/flags/gq.svg +23 -0
- package/public/assets/flags/gr.svg +16 -0
- package/public/assets/flags/gs.svg +133 -0
- package/public/assets/flags/gt.svg +204 -0
- package/public/assets/flags/gu.svg +19 -0
- package/public/assets/flags/gw.svg +13 -0
- package/public/assets/flags/gy.svg +9 -0
- package/public/assets/flags/hk.svg +8 -0
- package/public/assets/flags/hm.svg +8 -0
- package/public/assets/flags/hn.svg +18 -0
- package/public/assets/flags/hr.svg +58 -0
- package/public/assets/flags/ht.svg +116 -0
- package/public/assets/flags/hu.svg +7 -0
- package/public/assets/flags/ic.svg +7 -0
- package/public/assets/flags/id.svg +4 -0
- package/public/assets/flags/ie.svg +7 -0
- package/public/assets/flags/il.svg +14 -0
- package/public/assets/flags/im.svg +36 -0
- package/public/assets/flags/in.svg +25 -0
- package/public/assets/flags/io.svg +130 -0
- package/public/assets/flags/iq.svg +10 -0
- package/public/assets/flags/ir.svg +219 -0
- package/public/assets/flags/is.svg +12 -0
- package/public/assets/flags/it.svg +7 -0
- package/public/assets/flags/je.svg +62 -0
- package/public/assets/flags/jm.svg +8 -0
- package/public/assets/flags/jo.svg +16 -0
- package/public/assets/flags/jp.svg +11 -0
- package/public/assets/flags/ke.svg +23 -0
- package/public/assets/flags/kg.svg +4 -0
- package/public/assets/flags/kh.svg +61 -0
- package/public/assets/flags/ki.svg +36 -0
- package/public/assets/flags/km.svg +16 -0
- package/public/assets/flags/kn.svg +14 -0
- package/public/assets/flags/kp.svg +15 -0
- package/public/assets/flags/kr.svg +24 -0
- package/public/assets/flags/kw.svg +13 -0
- package/public/assets/flags/ky.svg +103 -0
- package/public/assets/flags/kz.svg +36 -0
- package/public/assets/flags/la.svg +12 -0
- package/public/assets/flags/lb.svg +15 -0
- package/public/assets/flags/lc.svg +8 -0
- package/public/assets/flags/li.svg +43 -0
- package/public/assets/flags/lk.svg +22 -0
- package/public/assets/flags/lr.svg +14 -0
- package/public/assets/flags/ls.svg +8 -0
- package/public/assets/flags/lt.svg +7 -0
- package/public/assets/flags/lu.svg +5 -0
- package/public/assets/flags/lv.svg +6 -0
- package/public/assets/flags/ly.svg +13 -0
- package/public/assets/flags/ma.svg +4 -0
- package/public/assets/flags/mc.svg +6 -0
- package/public/assets/flags/md.svg +70 -0
- package/public/assets/flags/me.svg +116 -0
- package/public/assets/flags/mf.svg +5 -0
- package/public/assets/flags/mg.svg +7 -0
- package/public/assets/flags/mh.svg +7 -0
- package/public/assets/flags/mk.svg +5 -0
- package/public/assets/flags/ml.svg +7 -0
- package/public/assets/flags/mm.svg +12 -0
- package/public/assets/flags/mn.svg +14 -0
- package/public/assets/flags/mo.svg +9 -0
- package/public/assets/flags/mp.svg +86 -0
- package/public/assets/flags/mq.svg +5 -0
- package/public/assets/flags/mr.svg +6 -0
- package/public/assets/flags/ms.svg +29 -0
- package/public/assets/flags/mt.svg +58 -0
- package/public/assets/flags/mu.svg +8 -0
- package/public/assets/flags/mv.svg +6 -0
- package/public/assets/flags/mw.svg +10 -0
- package/public/assets/flags/mx.svg +382 -0
- package/public/assets/flags/my.svg +26 -0
- package/public/assets/flags/mz.svg +21 -0
- package/public/assets/flags/na.svg +16 -0
- package/public/assets/flags/nc.svg +13 -0
- package/public/assets/flags/ne.svg +6 -0
- package/public/assets/flags/nf.svg +9 -0
- package/public/assets/flags/ng.svg +6 -0
- package/public/assets/flags/ni.svg +129 -0
- package/public/assets/flags/nl.svg +5 -0
- package/public/assets/flags/no.svg +7 -0
- package/public/assets/flags/np.svg +13 -0
- package/public/assets/flags/nr.svg +12 -0
- package/public/assets/flags/nu.svg +10 -0
- package/public/assets/flags/nz.svg +36 -0
- package/public/assets/flags/om.svg +115 -0
- package/public/assets/flags/pa.svg +14 -0
- package/public/assets/flags/pc.svg +33 -0
- package/public/assets/flags/pe.svg +4 -0
- package/public/assets/flags/pf.svg +19 -0
- package/public/assets/flags/pg.svg +9 -0
- package/public/assets/flags/ph.svg +6 -0
- package/public/assets/flags/pk.svg +15 -0
- package/public/assets/flags/pl.svg +6 -0
- package/public/assets/flags/pm.svg +5 -0
- package/public/assets/flags/pn.svg +53 -0
- package/public/assets/flags/pr.svg +13 -0
- package/public/assets/flags/ps.svg +6 -0
- package/public/assets/flags/pt.svg +57 -0
- package/public/assets/flags/pw.svg +11 -0
- package/public/assets/flags/py.svg +157 -0
- package/public/assets/flags/qa.svg +4 -0
- package/public/assets/flags/re.svg +5 -0
- package/public/assets/flags/ro.svg +7 -0
- package/public/assets/flags/rs.svg +292 -0
- package/public/assets/flags/ru.svg +5 -0
- package/public/assets/flags/rw.svg +13 -0
- package/public/assets/flags/sa.svg +25 -0
- package/public/assets/flags/sb.svg +13 -0
- package/public/assets/flags/sc.svg +7 -0
- package/public/assets/flags/sd.svg +13 -0
- package/public/assets/flags/se.svg +4 -0
- package/public/assets/flags/sg.svg +13 -0
- package/public/assets/flags/sh-ac.svg +689 -0
- package/public/assets/flags/sh-hl.svg +164 -0
- package/public/assets/flags/sh-ta.svg +76 -0
- package/public/assets/flags/sh.svg +7 -0
- package/public/assets/flags/si.svg +18 -0
- package/public/assets/flags/sj.svg +7 -0
- package/public/assets/flags/sk.svg +9 -0
- package/public/assets/flags/sl.svg +7 -0
- package/public/assets/flags/sm.svg +75 -0
- package/public/assets/flags/sn.svg +8 -0
- package/public/assets/flags/so.svg +11 -0
- package/public/assets/flags/sr.svg +6 -0
- package/public/assets/flags/ss.svg +8 -0
- package/public/assets/flags/st.svg +16 -0
- package/public/assets/flags/sv.svg +593 -0
- package/public/assets/flags/sx.svg +56 -0
- package/public/assets/flags/sy.svg +6 -0
- package/public/assets/flags/sz.svg +34 -0
- package/public/assets/flags/tc.svg +50 -0
- package/public/assets/flags/td.svg +7 -0
- package/public/assets/flags/tf.svg +15 -0
- package/public/assets/flags/tg.svg +14 -0
- package/public/assets/flags/th.svg +7 -0
- package/public/assets/flags/tj.svg +22 -0
- package/public/assets/flags/tk.svg +5 -0
- package/public/assets/flags/tl.svg +13 -0
- package/public/assets/flags/tm.svg +204 -0
- package/public/assets/flags/tn.svg +4 -0
- package/public/assets/flags/to.svg +10 -0
- package/public/assets/flags/tr.svg +8 -0
- package/public/assets/flags/tt.svg +5 -0
- package/public/assets/flags/tv.svg +9 -0
- package/public/assets/flags/tw.svg +34 -0
- package/public/assets/flags/tz.svg +13 -0
- package/public/assets/flags/ua.svg +6 -0
- package/public/assets/flags/ug.svg +30 -0
- package/public/assets/flags/um.svg +9 -0
- package/public/assets/flags/un.svg +16 -0
- package/public/assets/flags/us.svg +9 -0
- package/public/assets/flags/uy.svg +28 -0
- package/public/assets/flags/uz.svg +30 -0
- package/public/assets/flags/va.svg +190 -0
- package/public/assets/flags/vc.svg +8 -0
- package/public/assets/flags/ve.svg +26 -0
- package/public/assets/flags/vg.svg +59 -0
- package/public/assets/flags/vi.svg +28 -0
- package/public/assets/flags/vn.svg +11 -0
- package/public/assets/flags/vu.svg +21 -0
- package/public/assets/flags/wf.svg +5 -0
- package/public/assets/flags/ws.svg +7 -0
- package/public/assets/flags/xk.svg +5 -0
- package/public/assets/flags/xx.svg +4 -0
- package/public/assets/flags/ye.svg +7 -0
- package/public/assets/flags/yt.svg +5 -0
- package/public/assets/flags/za.svg +17 -0
- package/public/assets/flags/zm.svg +27 -0
- package/public/assets/flags/zw.svg +21 -0
- package/public/assets/fonts/Inter-OFL.txt +93 -0
- package/public/assets/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- package/public/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/public/assets/icons.svg +200 -0
- package/public/assets/nyx-logo-256.png +0 -0
- package/public/assets/tacilytics-mark.svg +16 -0
- package/public/index.html +239 -0
- package/public/style.css +415 -0
- package/public/tacilytics.js +184 -0
- package/sdk/flutter/CHANGELOG.md +6 -0
- package/sdk/flutter/LICENSE +21 -0
- package/sdk/flutter/README.md +41 -0
- package/sdk/flutter/dart_test.yaml +3 -0
- package/sdk/flutter/lib/nyx_analytics.dart +13 -0
- package/sdk/flutter/lib/src/beacon.dart +87 -0
- package/sdk/flutter/lib/src/client.dart +177 -0
- package/sdk/flutter/lib/tacilytics.dart +13 -0
- package/sdk/flutter/pubspec.lock +221 -0
- package/sdk/flutter/pubspec.yaml +18 -0
- package/sdk/flutter/test/alias_test.dart +13 -0
- package/sdk/flutter/test/beacon_test.dart +46 -0
- package/sdk/flutter/test/e2e_test.dart +18 -0
- package/server.js +896 -0
- package/tz-country.js +32 -0
package/.env.example
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ── Tacilytics configuration — copy to .env and fill in ──
|
|
2
|
+
# Port (bound to 127.0.0.1; put a reverse proxy / TLS in front)
|
|
3
|
+
PORT=3920
|
|
4
|
+
|
|
5
|
+
# Dashboard passphrase (plaintext; keep .env at chmod 600)
|
|
6
|
+
WEB_PASSWORD=change-me-to-a-strong-passphrase
|
|
7
|
+
|
|
8
|
+
# Secret for signing session tokens — long random string
|
|
9
|
+
# node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
|
|
10
|
+
SESSION_SECRET=change-me
|
|
11
|
+
|
|
12
|
+
# Salt for the daily one-way visitor hash (rotate to reset all visitor identities)
|
|
13
|
+
# node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
|
|
14
|
+
INGEST_SALT=change-me
|
|
15
|
+
|
|
16
|
+
# SQLite database path
|
|
17
|
+
DB_PATH=./data/analytics.db
|
|
18
|
+
|
|
19
|
+
# API key for agents / CLI (Bearer token) — generate a long random string
|
|
20
|
+
API_KEY=change-me
|
|
21
|
+
|
|
22
|
+
# Data minimisation: auto-purge raw events older than N days (0 = keep forever)
|
|
23
|
+
DATA_RETENTION_DAYS=0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nyx & Fabian Budde
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,254 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="public/assets/tacilytics-mark.svg" width="96" alt="Tacilytics" />
|
|
4
|
+
|
|
5
|
+
# Tacilytics
|
|
6
|
+
|
|
7
|
+
**Privacy-first, cookieless analytics for websites and apps — self-hosted in minutes.**
|
|
8
|
+
*Tacit* (adj.): understood without being said. Tacilytics counts what matters and keeps quiet about who did it — one tiny script, one Node process, one SQLite file. No cookies, no consent banner, no creepy tracking. (And in Italian, *taci* simply means "be quiet".)
|
|
9
|
+
|
|
10
|
+
> Formerly **Nyx Analytics**. Existing installs keep working: `/nyx.js`, `window.nyx()` and the old Flutter class names are still supported as aliases.
|
|
11
|
+
|
|
12
|
+
[](LICENSE)
|
|
13
|
+

|
|
14
|
+

|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
<img src="docs/dashboard.png" width="820" alt="Tacilytics dashboard" />
|
|
18
|
+
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Why Tacilytics
|
|
24
|
+
|
|
25
|
+
- 🍪 **Cookieless & banner-free.** No cookies, no `localStorage`, no fingerprinting — nothing to consent to.
|
|
26
|
+
- 🛡️ **IPs are never stored.** Unique visitors are counted with a *daily, salted, one-way hash* that’s irreversible and rotated every day.
|
|
27
|
+
- 🌍 **Region from the browser timezone**, never from IP geolocation.
|
|
28
|
+
- 🔍 **Segment everything.** Click any country, page, source, browser or campaign to filter the whole dashboard — stacked, with one-click chips.
|
|
29
|
+
- 🚦 **Campaigns & funnels.** Full UTM (`source` / `medium` / `campaign`), entry & exit pages, **goal conversion rates** and **custom event properties**.
|
|
30
|
+
- ⏱️ **Engagement time** — average time-on-page, measured privately (visible seconds only, no extra identifier).
|
|
31
|
+
- 🤖 **Bots & crawlers dropped** automatically, so your numbers stay honest.
|
|
32
|
+
- ⚡ **~2 KB tracker**, SPA-aware (tracks `pushState` / `popstate` route changes), optional **DNT / Global Privacy Control** honoring.
|
|
33
|
+
- 🗓️ **Data minimisation built in** — optional auto-purge of raw events after a retention window you choose.
|
|
34
|
+
- 🎯 **Custom events & goals**, **live visitors**, **trends**, **custom date ranges**, **CSV export**.
|
|
35
|
+
- 🛰️ **Agent-ready:** a clean `/api/v1` REST API (with segmentation params) + a `tacilytics` CLI (with `--json`).
|
|
36
|
+
- 🗄️ **Zero infra:** Express + SQLite. No Redis, no ClickHouse, no cloud.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
**With npm** (fastest):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
mkdir my-analytics && cd my-analytics
|
|
44
|
+
npx tacilytics serve # http://127.0.0.1:3920 — data/ and .env live in this folder
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or install it globally: `npm i -g tacilytics`, then `tacilytics serve`.
|
|
48
|
+
Set your own `WEB_PASSWORD`, `SESSION_SECRET`, `INGEST_SALT` and `API_KEY` in a `.env` file in that folder (see [`.env.example`](.env.example)).
|
|
49
|
+
|
|
50
|
+
**From source:**
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone https://github.com/Open-Cosmic-Software/tacilytics.git
|
|
54
|
+
cd tacilytics
|
|
55
|
+
npm install
|
|
56
|
+
cp .env.example .env # set WEB_PASSWORD, SESSION_SECRET, INGEST_SALT, API_KEY
|
|
57
|
+
npm start # http://127.0.0.1:3920
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The server binds to `127.0.0.1` — put TLS in front. Example **Caddy**:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
analytics.example.com {
|
|
64
|
+
encode gzip
|
|
65
|
+
reverse_proxy 127.0.0.1:3920
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Open the dashboard, log in, click **+ Add site**, and drop the snippet into your `<head>`:
|
|
70
|
+
|
|
71
|
+
```html
|
|
72
|
+
<script defer data-domain="example.com" src="https://analytics.example.com/tacilytics.js"></script>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
That’s it. Pageviews start flowing in — no cookie banner required.
|
|
76
|
+
|
|
77
|
+
## Custom events, goals & properties
|
|
78
|
+
|
|
79
|
+
Track conversions from anywhere in your code — optionally with **properties** to break them down:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
tacilytics('Signup');
|
|
83
|
+
tacilytics('Upgrade', { plan: 'pro' });
|
|
84
|
+
tacilytics('Purchase', { plan: 'pro', amount: '49' });
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Goals show up in the **Goals & conversions** panel with a **conversion rate** (% of visitors). Click a goal to drill into it — the whole dashboard re-segments to converters, and each property’s top values appear as their own breakdown. Pageviews stay separate, so your view counts stay clean.
|
|
88
|
+
|
|
89
|
+
## Segment everything
|
|
90
|
+
|
|
91
|
+
Click any row — a country, page, source, browser, OS, device, language or campaign — to **filter the entire dashboard** to that segment. Filters stack and show as removable chips. The same segmentation is available over the API and CLI:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
tacilytics stats example.com 30d --country=DE --source=twitter.com
|
|
95
|
+
curl -H "Authorization: Bearer $TACILYTICS_API_KEY" \
|
|
96
|
+
"https://analytics.example.com/api/v1/stats?site=example.com&period=30d&country=DE&medium=email"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Campaigns, entry/exit & engagement
|
|
100
|
+
|
|
101
|
+
- **UTM campaigns** — `utm_source` / `utm_medium` / `utm_campaign` are captured automatically; switch the Sources panel between **Source / Medium / Campaign**.
|
|
102
|
+
- **Entry & exit pages** — switch the Pages panel between **Top / Entry / Exit** to see where visits start and end.
|
|
103
|
+
- **Engagement time** — the tracker reports how long each page was actually *visible* (a follow-up beacon, no new identifier), surfaced as **Avg. visit time**.
|
|
104
|
+
- **Custom date range** — pick any `from`/`to` window in the toolbar, or pass `--from`/`--to` to the API/CLI.
|
|
105
|
+
|
|
106
|
+
## Privacy controls on the tag
|
|
107
|
+
|
|
108
|
+
Opt-in flags on the `<script>` tag:
|
|
109
|
+
|
|
110
|
+
```html
|
|
111
|
+
<script defer data-domain="example.com"
|
|
112
|
+
data-honor-dnt <!-- skip tracking if DNT / Global Privacy Control is on -->
|
|
113
|
+
data-track-outbound <!-- auto-record clicks to other domains as events -->
|
|
114
|
+
src="https://analytics.example.com/tacilytics.js"></script>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Mobile & desktop apps
|
|
118
|
+
|
|
119
|
+
Track your **native app in the same dashboard as your website**. Every row gets a platform
|
|
120
|
+
(`web`, `android`, `ios`, `macos`, `windows`, `linux`), so you can see everything together or
|
|
121
|
+
click a platform to filter. Once an app reports in, you get a **Platforms** panel (plus app
|
|
122
|
+
versions) and **Revenue by platform**.
|
|
123
|
+
|
|
124
|
+
**Flutter** — a ready-made package lives in [`sdk/flutter`](sdk/flutter):
|
|
125
|
+
|
|
126
|
+
```dart
|
|
127
|
+
Tacilytics.init(endpoint: 'https://analytics.example.com', domain: 'example.com', appVersion: '1.4.0');
|
|
128
|
+
Tacilytics.instance.setEnabled(userConsented); // off until you say so — wire it to your consent
|
|
129
|
+
MaterialApp(navigatorObservers: [TacilyticsRouteObserver()]); // automatic screen views
|
|
130
|
+
Tacilytics.instance.search('vinegar', 6);
|
|
131
|
+
Tacilytics.instance.purchase(value: 49.90, currency: 'EUR', items: 2);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Any other stack** (Swift, Kotlin, React Native, …) — it is one JSON `POST /api/collect`:
|
|
135
|
+
|
|
136
|
+
| field | meaning |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `d` | site domain (as registered) |
|
|
139
|
+
| `p` | screen, written like a path (`/product/42`) |
|
|
140
|
+
| `pf` | platform: `android` · `ios` · `macos` · `windows` · `linux` |
|
|
141
|
+
| `sid` | random id drawn **per app launch**, kept in memory only (16–64 chars `A-Za-z0-9_-`) |
|
|
142
|
+
| `av` | app version (optional) |
|
|
143
|
+
| `dv` | `mobile` · `tablet` · `desktop` (optional) |
|
|
144
|
+
| `l` / `cc` | language tag / 2-letter region of the device locale (optional) |
|
|
145
|
+
| `n` / `pr` | event name + flat properties, same as the web `tacilytics()` call (optional) |
|
|
146
|
+
| `s` / `um` / `uc` | utm source / medium / campaign, e.g. from a deep link (optional) |
|
|
147
|
+
|
|
148
|
+
Send any non-empty, non-bot `User-Agent` (e.g. `MyApp/1.4 (android)`).
|
|
149
|
+
|
|
150
|
+
Privacy stays the same as on the web: **no device or advertising id, no cookies, no IP stored.**
|
|
151
|
+
The server hashes the launch id with a random daily salt that is deleted when the day ends, so a visitor is never followed across days.
|
|
152
|
+
Without `sid`, the usual IP + user-agent daily hash is used. Region comes from the timezone or
|
|
153
|
+
device locale, never from IP geolocation.
|
|
154
|
+
|
|
155
|
+
## Live visitors
|
|
156
|
+
|
|
157
|
+
Click **“● online now”** for a realtime view: a 30-minute pulse and a live feed of
|
|
158
|
+
who’s on the site right now — page, goal, country, browser/device and source —
|
|
159
|
+
auto-refreshing every few seconds.
|
|
160
|
+
|
|
161
|
+
<div align="center"><img src="docs/live.png" width="560" alt="Live visitors" /></div>
|
|
162
|
+
|
|
163
|
+
## Agent API
|
|
164
|
+
|
|
165
|
+
Everything the dashboard shows is available over a stable REST API, authenticated with a Bearer key:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
curl -H "Authorization: Bearer $TACILYTICS_API_KEY" \
|
|
169
|
+
"https://analytics.example.com/api/v1/stats?site=example.com&period=30d"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| Method & path | Description |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `GET /api/v1` | API discovery |
|
|
175
|
+
| `GET /api/v1/sites` | list tracked sites |
|
|
176
|
+
| `POST /api/v1/sites` | add a site `{ domain }` |
|
|
177
|
+
| `GET /api/v1/stats?site=&period=` | aggregated stats — `today \| 7d \| 30d \| 90d \| 12mo`, or `from=YYYY-MM-DD&to=YYYY-MM-DD` |
|
|
178
|
+
| `GET /api/v1/realtime?site=` | online count, 30-min pulse + recent events |
|
|
179
|
+
|
|
180
|
+
Add any of `country, source, page, browser, os, device, lang, medium, campaign, goal` to `/stats` to segment, e.g. `…/stats?site=example.com&period=30d&country=DE&medium=email`.
|
|
181
|
+
|
|
182
|
+
## CLI
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
export TACILYTICS_URL=https://analytics.example.com
|
|
186
|
+
export TACILYTICS_API_KEY=xxx
|
|
187
|
+
|
|
188
|
+
tacilytics sites
|
|
189
|
+
tacilytics stats example.com 30d
|
|
190
|
+
tacilytics live example.com --watch
|
|
191
|
+
tacilytics add example.com
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Add `--json` to any command for machine-readable output — perfect for agents and scripts.
|
|
195
|
+
|
|
196
|
+
## How it works
|
|
197
|
+
|
|
198
|
+
`tacilytics.js` sends a tiny beacon (`navigator.sendBeacon`, `text/plain` so the cross-origin
|
|
199
|
+
request stays preflight-free) with the path, referrer, timezone and language — no
|
|
200
|
+
personal data, no cookies. The server parses the User-Agent (browser/OS/device),
|
|
201
|
+
drops bots, maps timezone → country, derives the **daily visitor hash**, and stores one
|
|
202
|
+
row per pageview. The dashboard aggregates it with plain SQL.
|
|
203
|
+
|
|
204
|
+
**The cookieless trade-off, honestly:** because there’s no persistent identifier, Tacilytics
|
|
205
|
+
counts unique visitors *per day* and can’t follow the same person across days or
|
|
206
|
+
devices. You get the 95 % that matters (how many, which pages, from where) with 0 %
|
|
207
|
+
cookie banner — the same approach as Plausible/Fathom.
|
|
208
|
+
|
|
209
|
+
## Configuration
|
|
210
|
+
|
|
211
|
+
| Env var | Default | Purpose |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `PORT` | `3920` | Port (bound to 127.0.0.1) |
|
|
214
|
+
| `WEB_PASSWORD` | `tacilytics` | Dashboard passphrase |
|
|
215
|
+
| `SESSION_SECRET` | random | Session-token secret |
|
|
216
|
+
| `INGEST_SALT` | fixed | Extra server-side secret mixed into the visitor hash (on top of the random daily salt) |
|
|
217
|
+
| `API_KEY` | — | Bearer key for the API / CLI |
|
|
218
|
+
| `DB_PATH` | `./data/analytics.db` | SQLite path |
|
|
219
|
+
| `DATA_RETENTION_DAYS` | `0` | `0` = keep forever; `>0` purges raw events older than N days, daily (data minimisation) |
|
|
220
|
+
|
|
221
|
+
## Privacy & security
|
|
222
|
+
|
|
223
|
+
- No cookies, no cross-site tracking, **IPs never persisted**.
|
|
224
|
+
- Daily one-way visitor hash with a **random salt per day**. It lives in its own small file (`data/.day-salt`, mode 600), never in the database, and is overwritten in place when the next day starts. Past days can't be re-linked afterwards, not even with the database, a known IP or user agent, or the server's own secrets. Only the server clock picks the day; late or out-of-day events get an unlinkable one-off hash.
|
|
225
|
+
- **Honest limits of "overwritten":** on ext4 an in-place overwrite really replaces the bytes. On copy-on-write filesystems (btrfs, ZFS), in filesystem or VM snapshots, and on SSDs (wear levelling) the old block can physically survive. **Backups of `data/` contain the salt of the day they were taken**, so treat them like the live server or exclude `data/.day-salt` from them.
|
|
226
|
+
- **One process only.** The salt is drawn by the single Node process. Running several processes against the same `data/` would let each draw its own salt at midnight.
|
|
227
|
+
- **Data minimisation:** set `DATA_RETENTION_DAYS` to auto-purge raw events after your chosen window.
|
|
228
|
+
- **Respects DNT / Global Privacy Control** when you add `data-honor-dnt` to the tag.
|
|
229
|
+
- Engagement time adds **no new identifier** — it’s matched to the visitor’s own daily hash server-side.
|
|
230
|
+
- Custom event properties are capped (scalars only, size-limited) so they can’t become a PII dumping ground.
|
|
231
|
+
- Timing-safe auth; login and the collect endpoint are rate-limited.
|
|
232
|
+
- All visitor-supplied values are HTML-escaped before they reach the DOM.
|
|
233
|
+
- Run behind TLS; keep `.env` at `chmod 600`.
|
|
234
|
+
|
|
235
|
+
## Tacilytics vs. Google Analytics
|
|
236
|
+
|
|
237
|
+
| | Tacilytics | Google Analytics |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| Cookies / consent banner | ❌ none | ✅ required |
|
|
240
|
+
| Stores IPs / personal data | ❌ never | ✅ yes |
|
|
241
|
+
| Self-hosted, you own the data | ✅ | ❌ |
|
|
242
|
+
| Weight | ~2 KB | ~45 KB+ |
|
|
243
|
+
| Setup | 1 script + 1 process | tag manager, config… |
|
|
244
|
+
|
|
245
|
+
## Credits
|
|
246
|
+
|
|
247
|
+
- UI icons: [Lucide](https://lucide.dev) (ISC)
|
|
248
|
+
- Brand marks: [Simple Icons](https://simpleicons.org) (CC0). Brand names and logos are trademarks of their owners; see `public/assets/DISCLAIMER-simple-icons.md`. Windows and Edge have no free official mark, so a neutral icon is shown.
|
|
249
|
+
- Flags: [flag-icons](https://flagicons.lipis.dev) (MIT)
|
|
250
|
+
- Font: [Inter](https://rsms.me/inter/) (SIL OFL 1.1), self-hosted
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
[MIT](LICENSE) — built by **Nyx & Fabian** 🦞
|
package/cli.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Tacilytics CLI — query your analytics from the terminal or an agent.
|
|
3
|
+
*
|
|
4
|
+
* export TACILYTICS_URL=https://analytics.example.com
|
|
5
|
+
* export TACILYTICS_API_KEY=xxx
|
|
6
|
+
* tacilytics serve (start the server; data/ and .env in the current directory)
|
|
7
|
+
* tacilytics serve start the server (data/ and .env in the current folder)
|
|
8
|
+
* tacilytics sites
|
|
9
|
+
* tacilytics stats example.com 30d
|
|
10
|
+
* tacilytics live example.com --watch
|
|
11
|
+
* tacilytics add example.com
|
|
12
|
+
*
|
|
13
|
+
* NYX_URL / NYX_API_KEY are still read for setups from before the rename.
|
|
14
|
+
*
|
|
15
|
+
* Add --json to any command for raw machine-readable output (for agents).
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const URL_BASE = (process.env.TACILYTICS_URL || process.env.NYX_URL || 'http://localhost:3920').replace(/\/$/, '');
|
|
20
|
+
const KEY = process.env.TACILYTICS_API_KEY || process.env.NYX_API_KEY || '';
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const JSON_OUT = args.includes('--json');
|
|
23
|
+
const WATCH = args.includes('--watch');
|
|
24
|
+
const pos = args.filter((a) => !a.startsWith('--'));
|
|
25
|
+
const cmd = pos[0];
|
|
26
|
+
// --key=value flags → extra query params (filters: country,source,page,browser,os,device,lang,medium,campaign,goal · range: from,to)
|
|
27
|
+
const flags = {};
|
|
28
|
+
args.filter((a) => a.startsWith('--') && a.includes('=')).forEach((a) => { const i = a.indexOf('='); flags[a.slice(2, i)] = a.slice(i + 1); });
|
|
29
|
+
function extraQuery() { return Object.keys(flags).filter((k) => k !== 'json' && k !== 'watch').map((k) => '&' + encodeURIComponent(k) + '=' + encodeURIComponent(flags[k])).join(''); }
|
|
30
|
+
const fmtDur = (s) => (s == null ? '—' : s < 60 ? s + 's' : Math.floor(s / 60) + 'm' + (s % 60 ? ' ' + (s % 60) + 's' : ''));
|
|
31
|
+
|
|
32
|
+
// ── tiny ANSI helpers ──
|
|
33
|
+
const C = process.stdout.isTTY && !JSON_OUT;
|
|
34
|
+
const c = (n, s) => (C ? `\x1b[${n}m${s}\x1b[0m` : s);
|
|
35
|
+
const pink = (s) => c('38;5;211', s), purple = (s) => c('38;5;141', s), cyan = (s) => c('38;5;87', s);
|
|
36
|
+
const dim = (s) => c('2', s), bold = (s) => c('1', s), green = (s) => c('38;5;120', s), red = (s) => c('38;5;210', s);
|
|
37
|
+
|
|
38
|
+
function die(msg) { console.error(red('✖ ' + msg)); process.exit(1); }
|
|
39
|
+
|
|
40
|
+
async function api(path) {
|
|
41
|
+
if (!KEY) die('Set TACILYTICS_API_KEY (and optionally TACILYTICS_URL).');
|
|
42
|
+
const res = await fetch(URL_BASE + path, { headers: { Authorization: 'Bearer ' + KEY } });
|
|
43
|
+
if (res.status === 401) die('Unauthorized — check TACILYTICS_API_KEY.');
|
|
44
|
+
const j = await res.json().catch(() => ({}));
|
|
45
|
+
if (!res.ok) die(j.error || ('HTTP ' + res.status));
|
|
46
|
+
return j;
|
|
47
|
+
}
|
|
48
|
+
async function post(path, body) {
|
|
49
|
+
if (!KEY) die('Set TACILYTICS_API_KEY (and optionally TACILYTICS_URL).');
|
|
50
|
+
const res = await fetch(URL_BASE + path, { method: 'POST', headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
51
|
+
const j = await res.json().catch(() => ({}));
|
|
52
|
+
if (!res.ok) die(j.error || ('HTTP ' + res.status));
|
|
53
|
+
return j;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const flag = (cc) => (cc && cc.length === 2 ? cc.toUpperCase().replace(/./g, (x) => String.fromCodePoint(127397 + x.charCodeAt(0))) : '🌐');
|
|
57
|
+
const pad = (s, n) => (String(s) + ' '.repeat(n)).slice(0, n);
|
|
58
|
+
function bar(rows, key) {
|
|
59
|
+
if (!rows || !rows.length) return ' ' + dim('(none)');
|
|
60
|
+
const max = Math.max.apply(null, rows.map((r) => r[key] || 0)) || 1;
|
|
61
|
+
return rows.map((r) => {
|
|
62
|
+
const w = Math.round(((r[key] || 0) / max) * 22);
|
|
63
|
+
return ' ' + pad(r.label || r.name, 30) + ' ' + purple('▇'.repeat(w) || '▏') + ' ' + bold(r[key]);
|
|
64
|
+
}).join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function banner(t) { if (!JSON_OUT) console.log('\n' + pink('🦞 ') + bold(t)); }
|
|
68
|
+
|
|
69
|
+
async function cmdSites() {
|
|
70
|
+
const sites = await api('/api/v1/sites');
|
|
71
|
+
if (JSON_OUT) return console.log(JSON.stringify(sites, null, 2));
|
|
72
|
+
banner('Sites');
|
|
73
|
+
if (!sites.length) return console.log(' ' + dim('No sites yet. Add one: tacilytics add example.com'));
|
|
74
|
+
sites.forEach((s) => console.log(' ' + cyan(pad(s.domain, 28)) + dim(' id ' + s.id)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function cmdStats() {
|
|
78
|
+
const domain = pos[1] || die('Usage: tacilytics stats <domain> [period] [--country=DE] [--from=YYYY-MM-DD --to=YYYY-MM-DD]');
|
|
79
|
+
const period = pos[2] || '7d';
|
|
80
|
+
const range = flags.from && flags.to ? '' : `&period=${period}`;
|
|
81
|
+
const s = await api(`/api/v1/stats?site=${encodeURIComponent(domain)}${range}${extraQuery()}`);
|
|
82
|
+
if (JSON_OUT) return console.log(JSON.stringify(s, null, 2));
|
|
83
|
+
const t = s.totals, tr = s.trend || {};
|
|
84
|
+
const trend = (v) => (v == null ? '' : ' ' + (v >= 0 ? green('▲' + v + '%') : red('▼' + Math.abs(v) + '%')));
|
|
85
|
+
const label = s.range ? `${s.range.from} → ${s.range.to}` : period;
|
|
86
|
+
banner(`${domain} · ${label}`);
|
|
87
|
+
const active = Object.keys(s.filters || {});
|
|
88
|
+
if (active.length) console.log(' ' + dim('filtered by ' + active.map((k) => k + '=' + s.filters[k]).join(', ')));
|
|
89
|
+
console.log(' ' + pad('Visitors', 16) + bold(t.visitors) + trend(tr.visitors));
|
|
90
|
+
console.log(' ' + pad('Pageviews', 16) + bold(t.pageviews) + trend(tr.pageviews));
|
|
91
|
+
console.log(' ' + pad('Bounce', 16) + bold(t.bounce + '%'));
|
|
92
|
+
console.log(' ' + pad('Views/visitor', 16) + bold(t.viewsPerVisitor));
|
|
93
|
+
console.log(' ' + pad('Avg. visit time', 16) + bold(fmtDur(t.duration)));
|
|
94
|
+
console.log('\n' + dim(' Top pages')); console.log(bar(s.pages, 'visitors'));
|
|
95
|
+
if (s.entryPages && s.entryPages.length) { console.log('\n' + dim(' Entry pages')); console.log(bar(s.entryPages, 'visitors')); }
|
|
96
|
+
console.log('\n' + dim(' Sources')); console.log(bar(s.sources, 'visitors'));
|
|
97
|
+
if (s.campaigns && s.campaigns.length) { console.log('\n' + dim(' Campaigns')); console.log(bar(s.campaigns, 'visitors')); }
|
|
98
|
+
console.log('\n' + dim(' Locations')); console.log(bar((s.countries || []).map((r) => ({ name: flag(r.name) + ' ' + r.name, visitors: r.visitors })), 'visitors'));
|
|
99
|
+
if (s.goals && s.goals.length) {
|
|
100
|
+
console.log('\n' + dim(' Goals ' + dim('(conversion)')));
|
|
101
|
+
console.log(bar(s.goals.map((g) => ({ name: g.name + dim(' ' + (g.cr != null ? g.cr + '%' : '')), visitors: g.visitors })), 'visitors'));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function renderLive(domain) {
|
|
106
|
+
const r = await api(`/api/v1/realtime?site=${encodeURIComponent(domain)}`);
|
|
107
|
+
if (JSON_OUT) return console.log(JSON.stringify(r, null, 2));
|
|
108
|
+
if (WATCH) process.stdout.write('\x1b[2J\x1b[H'); // clear
|
|
109
|
+
banner(`Live · ${domain}`);
|
|
110
|
+
console.log(' ' + green('● ') + bold(r.online) + ' online now' + dim(' (last 5 min)'));
|
|
111
|
+
const spark = '▁▂▃▄▅▆▇█';
|
|
112
|
+
const max = Math.max.apply(null, r.minutes.map((m) => m.pageviews).concat([1]));
|
|
113
|
+
const line = r.minutes.map((m) => (m.pageviews ? spark[Math.min(7, Math.round((m.pageviews / max) * 7))] : dim('·'))).join('');
|
|
114
|
+
console.log(' ' + purple(line) + dim(' ← 30 min'));
|
|
115
|
+
console.log('\n' + dim(' Recent activity'));
|
|
116
|
+
if (!r.recent.length) return console.log(' ' + dim('(quiet)'));
|
|
117
|
+
r.recent.slice(0, 20).forEach((e) => {
|
|
118
|
+
const ago = Math.round((r.now - e.ts) / 1000);
|
|
119
|
+
const t = ago < 8 ? 'now' : ago < 60 ? ago + 's' : Math.round(ago / 60) + 'm';
|
|
120
|
+
const what = e.name ? pink('🎯 ' + e.name) : e.path;
|
|
121
|
+
const src = e.source && e.source !== 'Direct' ? dim(' ↗ ' + e.source) : '';
|
|
122
|
+
console.log(' ' + flag(e.country) + ' ' + pad(what, 32) + ' ' + dim(pad((e.browser || '') + ' · ' + (e.device || ''), 20)) + src + dim(' ' + t));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function cmdLive() {
|
|
126
|
+
const domain = pos[1] || die('Usage: tacilytics live <domain> [--watch]');
|
|
127
|
+
await renderLive(domain);
|
|
128
|
+
if (WATCH && !JSON_OUT) setInterval(() => renderLive(domain).catch(() => {}), 5000);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function cmdAdd() {
|
|
132
|
+
const domain = pos[1] || die('Usage: tacilytics add <domain>');
|
|
133
|
+
const s = await post('/api/v1/sites', { domain });
|
|
134
|
+
if (JSON_OUT) return console.log(JSON.stringify(s, null, 2));
|
|
135
|
+
banner('Site added: ' + s.domain);
|
|
136
|
+
console.log('\n Embed this in your site <head>:\n');
|
|
137
|
+
console.log(' ' + cyan(`<script defer data-domain="${s.domain}" src="${URL_BASE}/tacilytics.js"></script>`));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function help() {
|
|
141
|
+
console.log(`
|
|
142
|
+
${pink('Tacilytics CLI')}
|
|
143
|
+
|
|
144
|
+
${bold('Usage')} tacilytics <command> [--json] [--watch]
|
|
145
|
+
|
|
146
|
+
${cyan('serve')} start the server (data/ + .env in the current folder)
|
|
147
|
+
${cyan('sites')} list tracked sites
|
|
148
|
+
${cyan('stats')} <domain> [period] key metrics + breakdowns (today|7d|30d|90d|12mo)
|
|
149
|
+
${cyan('live')} <domain> [--watch] online visitors + last-30-min feed
|
|
150
|
+
${cyan('add')} <domain> add a site, print the embed snippet
|
|
151
|
+
|
|
152
|
+
${bold('Segment & range')} (on stats)
|
|
153
|
+
${dim('--country=DE --source=… --page=… --browser=… --medium=… --campaign=… --goal=…')}
|
|
154
|
+
${dim('--from=2026-01-01 --to=2026-01-31')} custom date range
|
|
155
|
+
|
|
156
|
+
${bold('Config')} (env)
|
|
157
|
+
TACILYTICS_URL ${dim(URL_BASE)}
|
|
158
|
+
TACILYTICS_API_KEY ${KEY ? green('set') : red('not set')}
|
|
159
|
+
|
|
160
|
+
${dim('Add --json for machine-readable output (agents).')}
|
|
161
|
+
`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
(async () => {
|
|
165
|
+
try {
|
|
166
|
+
if (cmd === 'serve' || cmd === 'start') { require('./server.js'); return; }
|
|
167
|
+
if (cmd === 'sites') await cmdSites();
|
|
168
|
+
else if (cmd === 'stats') await cmdStats();
|
|
169
|
+
else if (cmd === 'live' || cmd === 'realtime') await cmdLive();
|
|
170
|
+
else if (cmd === 'add') await cmdAdd();
|
|
171
|
+
else help();
|
|
172
|
+
} catch (e) { die(e.message || String(e)); }
|
|
173
|
+
})();
|
package/day-salt.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Daily salt (13.10.2026, hardened after two reviews by Tyto 🦉): a fresh random secret per
|
|
3
|
+
// UTC day, kept in its own small file, never in the database. See README "Privacy & security".
|
|
4
|
+
//
|
|
5
|
+
// Rules:
|
|
6
|
+
// - Only the server clock decides the day. A salt is only ever created for *today*.
|
|
7
|
+
// - A file with today's date but an invalid salt (e.g. truncated by a crash) is repaired by
|
|
8
|
+
// drawing a new salt. Otherwise every visitor would get a one-off hash until midnight.
|
|
9
|
+
// - Only a file from a *later* day than our clock (clock went backwards) is left alone: then
|
|
10
|
+
// we return null and the caller uses an unlinkable one-off hash.
|
|
11
|
+
// - Single process only. nyx-analytics runs one Node process (one app.listen, no cluster).
|
|
12
|
+
// With several processes, each would draw its own salt at midnight and the last writer
|
|
13
|
+
// would win; that would need an O_EXCL create + re-read instead.
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
const SIZE = 128; // fixed size → the old value is fully overwritten in place
|
|
18
|
+
const VALID = /^[0-9a-f]{64}$/;
|
|
19
|
+
|
|
20
|
+
function dayStr(ts) { return new Date(ts).toISOString().slice(0, 10); }
|
|
21
|
+
|
|
22
|
+
function createDaySalt(file, now = Date.now) {
|
|
23
|
+
let cache = { day: null, salt: null };
|
|
24
|
+
|
|
25
|
+
function writeInPlace(content) {
|
|
26
|
+
const buf = Buffer.alloc(SIZE, 0); buf.write(content);
|
|
27
|
+
const fd = fs.openSync(file, fs.existsSync(file) ? 'r+' : 'w', 0o600);
|
|
28
|
+
try { fs.fchmodSync(fd, 0o600); fs.writeSync(fd, buf, 0, buf.length, 0); fs.fsyncSync(fd); }
|
|
29
|
+
finally { fs.closeSync(fd); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function read() {
|
|
33
|
+
try { return fs.readFileSync(file, 'utf8').replace(/\0+$/, '').split(' '); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return function todaysSalt() {
|
|
37
|
+
const today = dayStr(now());
|
|
38
|
+
if (cache.day === today) return cache.salt;
|
|
39
|
+
const stored = read();
|
|
40
|
+
const storedDay = stored && stored[0];
|
|
41
|
+
if (storedDay === today && VALID.test(stored[1] || '')) {
|
|
42
|
+
cache = { day: today, salt: stored[1] };
|
|
43
|
+
} else if (!storedDay || storedDay <= today) {
|
|
44
|
+
// missing, older day, or today's date with a broken salt → draw a new one
|
|
45
|
+
const salt = crypto.randomBytes(32).toString('hex');
|
|
46
|
+
writeInPlace(`${today} ${salt}`);
|
|
47
|
+
cache = { day: today, salt };
|
|
48
|
+
} else {
|
|
49
|
+
return null; // file is from a later day than our clock: never overwrite a newer salt
|
|
50
|
+
}
|
|
51
|
+
return cache.salt;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { createDaySalt, dayStr };
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tacilytics",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.2",
|
|
4
|
+
"description": "Privacy-first, cookieless analytics for websites and apps. Self-hosted: one Node process, one SQLite file.",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js",
|
|
9
|
+
"cli": "node cli.js",
|
|
10
|
+
"test": "node --test test/*.test.js"
|
|
11
|
+
},
|
|
12
|
+
"author": "Nyx 🦞 & Fabian Budde",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"better-sqlite3": "^12.10.0",
|
|
16
|
+
"express": "^4.21.2"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"tacilytics": "cli.js",
|
|
20
|
+
"nyx": "cli.js"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/Open-Cosmic-Software/tacilytics.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/Open-Cosmic-Software/tacilytics#readme",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"analytics",
|
|
29
|
+
"privacy",
|
|
30
|
+
"cookieless",
|
|
31
|
+
"gdpr",
|
|
32
|
+
"self-hosted",
|
|
33
|
+
"web-analytics",
|
|
34
|
+
"sqlite",
|
|
35
|
+
"plausible-alternative",
|
|
36
|
+
"flutter",
|
|
37
|
+
"app-analytics"
|
|
38
|
+
],
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/Open-Cosmic-Software/tacilytics/issues"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"server.js",
|
|
47
|
+
"cli.js",
|
|
48
|
+
"day-salt.js",
|
|
49
|
+
"tz-country.js",
|
|
50
|
+
"public/",
|
|
51
|
+
"sdk/flutter/",
|
|
52
|
+
"!sdk/flutter/.dart_tool/",
|
|
53
|
+
"!sdk/flutter/build/",
|
|
54
|
+
".env.example",
|
|
55
|
+
"LICENSE",
|
|
56
|
+
"README.md"
|
|
57
|
+
]
|
|
58
|
+
}
|