Skip to content

Commit 386442a

Browse files
committed
Revert "blog analytics"
This reverts commit d460d45.
1 parent d460d45 commit 386442a

5 files changed

Lines changed: 4 additions & 927 deletions

File tree

main.jac

Lines changed: 0 additions & 342 deletions
Original file line numberDiff line numberDiff line change
@@ -915,111 +915,6 @@ 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-
1023918
def:priv env_str(name: str, fallback: str = "") -> str {
1024919
val = os.environ.get(name, fallback);
1025920
if val is None {
@@ -1738,243 +1633,6 @@ walker:pub GetPreview {
17381633
}
17391634

17401635

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-
19781636
walker:pub GithubOAuthConfig {
19791637
# Hands the browser the OAuth client id so it can build the authorize URL.
19801638
can get with Root entry {

0 commit comments

Comments
 (0)