Skip to content

Commit e22156a

Browse files
committed
analytics
1 parent 92b5f88 commit e22156a

5 files changed

Lines changed: 927 additions & 4 deletions

File tree

main.jac

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,111 @@ node PreviewDoc {
915915
}
916916

917917

918+
node PostStat {
919+
# One reading-analytics bucket per (slug, day), hung off the public shared
920+
# root and granted WritePerm so anonymous readers (who run ON root.shared)
921+
# AND signed-in readers (who run on their own root but address the shared
922+
# node) can both increment it. Concurrent first-views can race and create
923+
# duplicate buckets for the same (slug, day); that's harmless — GetAnalytics
924+
# sums across every bucket, so duplicates just add up. No per-reader rows and
925+
# no PII: `visitors` holds short one-way hashes of an anonymous client id.
926+
has slug: str = "";
927+
has day: str = ""; # "YYYY-MM-DD" (UTC)
928+
has views: int = 0; # total opens (every mount += 1)
929+
has visitors: list = []; # hashed anon visitor ids (unique opens)
930+
has dwell_ms_sum: float = 0.0; # summed active reading time
931+
has dwell_samples: int = 0; # number of engagement flushes (one per visit)
932+
has scroll_pct_sum: float = 0.0; # summed deepest-scroll % (for a true average)
933+
has scroll_samples: int = 0; # number of scroll samples
934+
has scroll_buckets: list = [0, 0, 0, 0]; # [0-25), [25-50), [50-75), [75-100] depth-reached counts
935+
}
936+
937+
938+
node LinkClick {
939+
# Outbound/in-article link clicks, one bucket per (slug, href, day) on the
940+
# shared root (same race-tolerant, no-PII model as PostStat). GetAnalytics
941+
# sums across days into per-(slug, href) totals.
942+
has slug: str = "";
943+
has href: str = "";
944+
has day: str = "";
945+
has clicks: int = 0;
946+
}
947+
948+
949+
def:priv today_utc() -> str {
950+
# Date bucket key in UTC so all deployments agree regardless of server TZ.
951+
t = time.gmtime();
952+
return f"{t.tm_year:04d}-{t.tm_mon:02d}-{t.tm_mday:02d}";
953+
}
954+
955+
956+
def:priv hash_visitor(v: str) -> str {
957+
# One-way, truncated hash of the anonymous client-side visitor id. We never
958+
# store the raw id, so this can't be reversed to anything identifying.
959+
if not v {
960+
return "";
961+
}
962+
return hashlib.sha256(v.encode("utf-8")).hexdigest()[:16];
963+
}
964+
965+
966+
def:priv scroll_bucket(p: int) -> int {
967+
if p >= 75 {
968+
return 3;
969+
}
970+
if p >= 50 {
971+
return 2;
972+
}
973+
if p >= 25 {
974+
return 1;
975+
}
976+
return 0;
977+
}
978+
979+
980+
def:priv match_day_stat(nodes: list, slug: str, day: str) -> any {
981+
for n in nodes {
982+
if n.slug == slug and n.day == day {
983+
return n;
984+
}
985+
}
986+
return None;
987+
}
988+
989+
990+
def:priv match_link_click(nodes: list, slug: str, href: str, day: str) -> any {
991+
for n in nodes {
992+
if n.slug == slug and n.href == href and n.day == day {
993+
return n;
994+
}
995+
}
996+
return None;
997+
}
998+
999+
1000+
def:priv median_scroll(buckets: list) -> int {
1001+
# Bucketed median: walk the cumulative distribution to the halfway point and
1002+
# report that bucket's midpoint. Coarse by design (we only keep 4 buckets).
1003+
total: int = 0;
1004+
for b in buckets {
1005+
total += int(b);
1006+
}
1007+
if total == 0 {
1008+
return 0;
1009+
}
1010+
midpoints = [12, 37, 62, 88];
1011+
half = total / 2.0;
1012+
cum: int = 0;
1013+
for i in range(4) {
1014+
cum += int(buckets[i]);
1015+
if cum >= half {
1016+
return midpoints[i];
1017+
}
1018+
}
1019+
return midpoints[3];
1020+
}
1021+
1022+
9181023
def:priv env_str(name: str, fallback: str = "") -> str {
9191024
val = os.environ.get(name, fallback);
9201025
if val is None {
@@ -1633,6 +1738,243 @@ walker:pub GetPreview {
16331738
}
16341739

16351740

1741+
# ── Reading analytics ────────────────────────────────────────────────────────
1742+
# Two public collectors (RecordView / RecordEngagement) write anonymous counters
1743+
# to per-(slug, day) PostStat buckets on the shared root; one private reader
1744+
# (GetAnalytics) rolls them up for the reviewer-only dashboard. All three address
1745+
# root.shared explicitly so anonymous and signed-in readers land in the SAME
1746+
# store (a :pub walker runs on root.shared for anon callers but on the caller's
1747+
# own root for signed-in ones — addressing .shared directly keeps data unified).
1748+
1749+
walker:pub RecordView {
1750+
# Fired once per post page mount. Bumps the total open count and records the
1751+
# anonymous visitor id (hashed) toward the unique-opens tally.
1752+
has slug: str = "";
1753+
has visitor: str = "";
1754+
1755+
can rec with Root entry {
1756+
if not self.slug {
1757+
report {"ok": False};
1758+
return;
1759+
}
1760+
try {
1761+
shared_root = root.shared; # jac:ignore[E1030] runtime attr, no static stub
1762+
day = today_utc();
1763+
vh = hash_visitor(self.visitor);
1764+
stat = match_day_stat([shared_root -->][?:PostStat], self.slug, day);
1765+
if stat is None {
1766+
made = shared_root ++> PostStat(
1767+
slug=self.slug, day=day, views=1,
1768+
visitors=([vh] if vh else []),
1769+
);
1770+
grant(made[0], level=WritePerm);
1771+
} else {
1772+
stat.views += 1;
1773+
if vh and (vh not in stat.visitors) {
1774+
# Reassign (not in-place append) so jac-scale persists the change.
1775+
stat.visitors = stat.visitors + [vh];
1776+
}
1777+
}
1778+
report {"ok": True};
1779+
} except Exception {
1780+
report {"ok": False};
1781+
}
1782+
}
1783+
}
1784+
1785+
1786+
walker:pub RecordEngagement {
1787+
# Fired when a reader leaves a post (tab close / SPA navigation). Records one
1788+
# reading sample: active time on the page and the deepest scroll % reached.
1789+
has slug: str = "";
1790+
has dwell_ms: float = 0.0;
1791+
has scroll_pct: int = 0;
1792+
1793+
can rec with Root entry {
1794+
if not self.slug {
1795+
report {"ok": False};
1796+
return;
1797+
}
1798+
# Defense-in-depth against the same non-read samples the client filters
1799+
# (sub-second bounces, React Strict-Mode phantom unmounts): ignore a
1800+
# sample with negligible dwell AND no real scroll so it can't pollute
1801+
# the scroll-depth buckets.
1802+
if (self.dwell_ms < 1000) and (int(self.scroll_pct) < 25) {
1803+
report {"ok": True, "skipped": True};
1804+
return;
1805+
}
1806+
try {
1807+
shared_root = root.shared; # jac:ignore[E1030] runtime attr, no static stub
1808+
day = today_utc();
1809+
stat = match_day_stat([shared_root -->][?:PostStat], self.slug, day);
1810+
if stat is None {
1811+
made = shared_root ++> PostStat(slug=self.slug, day=day);
1812+
grant(made[0], level=WritePerm);
1813+
stat = made[0];
1814+
}
1815+
if self.dwell_ms > 0 {
1816+
stat.dwell_ms_sum += self.dwell_ms;
1817+
stat.dwell_samples += 1;
1818+
}
1819+
pct = int(self.scroll_pct);
1820+
stat.scroll_pct_sum += float(pct);
1821+
stat.scroll_samples += 1;
1822+
b = scroll_bucket(pct);
1823+
buckets = stat.scroll_buckets;
1824+
buckets[b] += 1;
1825+
stat.scroll_buckets = buckets; # reassign so the mutation persists
1826+
report {"ok": True};
1827+
} except Exception {
1828+
report {"ok": False};
1829+
}
1830+
}
1831+
}
1832+
1833+
1834+
walker:pub RecordLinkClick {
1835+
# Fired when a reader clicks a link inside the article body.
1836+
has slug: str = "";
1837+
has href: str = "";
1838+
1839+
can rec with Root entry {
1840+
if not self.slug or not self.href {
1841+
report {"ok": False};
1842+
return;
1843+
}
1844+
try {
1845+
shared_root = root.shared; # jac:ignore[E1030] runtime attr, no static stub
1846+
day = today_utc();
1847+
lc = match_link_click([shared_root -->][?:LinkClick], self.slug, self.href, day);
1848+
if lc is None {
1849+
made = shared_root ++> LinkClick(slug=self.slug, href=self.href, day=day, clicks=1);
1850+
grant(made[0], level=WritePerm);
1851+
} else {
1852+
lc.clicks += 1;
1853+
}
1854+
report {"ok": True};
1855+
} except Exception {
1856+
report {"ok": False};
1857+
}
1858+
}
1859+
}
1860+
1861+
1862+
walker:priv GetAnalytics {
1863+
# Reviewer-only rollup of every PostStat bucket. Re-verifies repo write
1864+
# access server-side (the client gate is UX only); rolls daily buckets up
1865+
# into per-post totals plus a per-day views series for trend charts.
1866+
has gh_enc: str = "";
1867+
1868+
can get with Root entry {
1869+
gh_token = decrypt_gh(self.gh_enc);
1870+
if not has_write_access(gh_token) {
1871+
report {"ok": False, "error": "Not authorized."};
1872+
return;
1873+
}
1874+
shared_root = root.shared; # jac:ignore[E1030] runtime attr, no static stub
1875+
stats = [shared_root -->][?:PostStat];
1876+
by_slug = {};
1877+
for n in stats {
1878+
s = n.slug;
1879+
if s not in by_slug {
1880+
by_slug[s] = {
1881+
"slug": s, "views": 0, "visitors": [],
1882+
"dwell_ms_sum": 0.0, "dwell_samples": 0,
1883+
"scroll_pct_sum": 0.0, "scroll_samples": 0,
1884+
"scroll_buckets": [0, 0, 0, 0], "days": {},
1885+
};
1886+
}
1887+
agg = by_slug[s];
1888+
agg["views"] += n.views;
1889+
for vh in n.visitors {
1890+
if vh not in agg["visitors"] {
1891+
agg["visitors"].append(vh);
1892+
}
1893+
}
1894+
agg["dwell_ms_sum"] += float(n.dwell_ms_sum);
1895+
agg["dwell_samples"] += int(n.dwell_samples);
1896+
agg["scroll_pct_sum"] += float(n.scroll_pct_sum);
1897+
agg["scroll_samples"] += int(n.scroll_samples);
1898+
for i in range(4) {
1899+
agg["scroll_buckets"][i] += int(n.scroll_buckets[i]);
1900+
}
1901+
agg["days"][n.day] = int(agg["days"].get(n.day, 0)) + int(n.views);
1902+
}
1903+
# Roll link clicks up into per-(slug, href) totals.
1904+
links_by_slug = {};
1905+
for lc in [shared_root -->][?:LinkClick] {
1906+
s = lc.slug;
1907+
if s not in links_by_slug {
1908+
links_by_slug[s] = {};
1909+
}
1910+
m = links_by_slug[s];
1911+
m[lc.href] = int(m.get(lc.href, 0)) + int(lc.clicks);
1912+
}
1913+
out = [];
1914+
totals = {"views": 0, "uniques": 0};
1915+
for s in by_slug {
1916+
agg = by_slug[s];
1917+
uniques = len(agg["visitors"]);
1918+
avg_dwell_ms = (agg["dwell_ms_sum"] / agg["dwell_samples"]) if agg["dwell_samples"] else 0.0;
1919+
avg_scroll = (agg["scroll_pct_sum"] / agg["scroll_samples"]) if agg["scroll_samples"] else 0.0;
1920+
totals["views"] += int(agg["views"]);
1921+
totals["uniques"] += int(uniques);
1922+
post_links = [];
1923+
link_clicks = 0;
1924+
if s in links_by_slug {
1925+
for href in links_by_slug[s] {
1926+
c = int(links_by_slug[s][href]);
1927+
link_clicks += c;
1928+
post_links.append({"href": href, "clicks": c});
1929+
}
1930+
post_links.sort(key=lambda r: dict -> int { return int(r["clicks"]); }, reverse=True);
1931+
}
1932+
out.append({
1933+
"slug": s,
1934+
"views": agg["views"],
1935+
"uniques": uniques,
1936+
"avg_read_sec": round(avg_dwell_ms / 1000.0, 1),
1937+
"samples": agg["dwell_samples"],
1938+
"scroll_buckets": agg["scroll_buckets"],
1939+
"avg_scroll": round(avg_scroll),
1940+
"median_scroll": median_scroll(agg["scroll_buckets"]),
1941+
"links": post_links,
1942+
"link_clicks": link_clicks,
1943+
"days": agg["days"],
1944+
});
1945+
}
1946+
out.sort(key=lambda r: dict -> int { return int(r["views"]); }, reverse=True);
1947+
report {"ok": True, "posts": out, "totals": totals};
1948+
}
1949+
}
1950+
1951+
1952+
walker:priv ResetAnalytics {
1953+
# Reviewer-only: wipe every reading-analytics bucket and start fresh. Useful
1954+
# after a measurement bug, or to clear test data on a dev deployment.
1955+
has gh_enc: str = "";
1956+
1957+
can clear with Root entry {
1958+
gh_token = decrypt_gh(self.gh_enc);
1959+
if not has_write_access(gh_token) {
1960+
report {"ok": False, "error": "Not authorized."};
1961+
return;
1962+
}
1963+
shared_root = root.shared; # jac:ignore[E1030] runtime attr, no static stub
1964+
removed = 0;
1965+
for n in [shared_root -->][?:PostStat] {
1966+
del n;
1967+
removed += 1;
1968+
}
1969+
for lc in [shared_root -->][?:LinkClick] {
1970+
del lc;
1971+
removed += 1;
1972+
}
1973+
report {"ok": True, "removed": removed};
1974+
}
1975+
}
1976+
1977+
16361978
walker:pub GithubOAuthConfig {
16371979
# Hands the browser the OAuth client id so it can build the authorize URL.
16381980
can get with Root entry {

0 commit comments

Comments
 (0)