Skip to content

Commit b348328

Browse files
authored
Merge pull request #5 from webmaxru/webmaxru-cookieless-insights-instrumentation
Cookieless Azure Application Insights RUM for the book site
2 parents 92eb61d + 76bc823 commit b348328

27 files changed

Lines changed: 1173 additions & 18 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Node (analytics beacon build tooling)
2+
node_modules/
3+
14
# Python
25
.venv/
36
__pycache__/

README.md

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,15 @@ site/
164164
generate.py # scaffolds the site from content/toc.yml
165165
index.html # generated
166166
chapters/*.html # generated: one page per chapter in toc.yml
167-
assets/ # style.css + app.js
167+
assets/ # style.css + app.js + analytics.js (built beacon)
168+
analytics/
169+
analytics.entry.js # cookieless App Insights beacon source (bundled → site/assets/analytics.js)
170+
azure/
171+
dashboard.json # Portal engagement dashboard (ARM template)
172+
package.json # analytics build tooling (esbuild bundle + report command)
168173
scripts/
169174
run-fleet.ps1 # convenience launcher
175+
report.ps1 # engagement report (PowerShell)
170176
```
171177

172178
> The book content itself (`content/research/`, `examples/`, generated `site/` pages) is produced by
@@ -205,7 +211,65 @@ gh aw compile examples/<chapter>/<workflow>.md
205211
206212
---
207213

208-
## Chapter arc
214+
## Privacy-friendly analytics (cookieless RUM)
215+
216+
The published site is instrumented with **cookieless Real User Monitoring** via Azure Application
217+
Insights, using [`@webmaxru/cookieless-insights`](https://www.npmjs.com/package/@webmaxru/cookieless-insights).
218+
It uses **no cookies, no local/session storage, and no persistent identifier** — so there is **no
219+
consent banner** — and stays inside Azure's **free tier** (workspace-based, 30-day retention,
220+
0.16 GB/day cap). Telemetry is sent with the browser **beacon** transport (`navigator.sendBeacon`).
221+
222+
**How it's wired**
223+
224+
- `analytics/analytics.entry.js` is the beacon source — it initializes tracking, records an
225+
automatic **page view**, and wires **key interactions** (theme change, sidebar toggle, code copy,
226+
in-page section nav, internal/outbound link clicks, "opened via shared link", and debounced
227+
input changes for any sliders/typing). It is bundled to `site/assets/analytics.js` with esbuild:
228+
```powershell
229+
npm ci
230+
npm run build:analytics
231+
```
232+
- The Application Insights **connection string is a public client key**, injected at **build time**,
233+
never committed as source. `site/generate.py` reads the `APPINSIGHTS_CONNECTION_STRING`
234+
environment variable and embeds it into each page's `<head>` as
235+
`window.__APPINSIGHTS_CONNECTION_STRING__`; the beacon reads that global. The committed HTML
236+
carries an **empty** string (a safe no-op).
237+
- In CI (`.github/workflows/deploy-pages.yml`) the value comes from the repository **Actions
238+
variable** `APPINSIGHTS_CONNECTION_STRING` (Settings → Secrets and variables → Actions →
239+
Variables — *not* a secret), forwarded into the site-generation step and injected only into the
240+
`gh-pages` build. The one line the deploy job needs on its **Regenerate site from content** step:
241+
```yaml
242+
env:
243+
APPINSIGHTS_CONNECTION_STRING: ${{ vars.APPINSIGHTS_CONNECTION_STRING }}
244+
```
245+
Until that env line is present, the published HTML carries an empty string and analytics stays a
246+
safe no-op.
247+
248+
**Kill switch (one line).** Flip the constant at the top of `analytics/analytics.entry.js` and
249+
rebuild:
250+
251+
```js
252+
const ANALYTICS_ENABLED = false; // disables all telemetry
253+
```
254+
255+
Clearing the `APPINSIGHTS_CONNECTION_STRING` repo variable (empty string) also disables tracking on
256+
the next deploy — `init()` self-disables when the connection string is empty.
257+
258+
**Engagement report & dashboard**
259+
260+
```powershell
261+
# print engagement (page views, sessions, per-visit dwell, key events, top pages, geo, browser/OS)
262+
npm run report # wraps the command below and opens the Portal dashboard
263+
npx cookieless-insights report --resource-group is-ai-native-rg --app-insights aw-book-ai --days 30
264+
# PowerShell equivalent
265+
./scripts/report.ps1 -ResourceGroup is-ai-native-rg -AppInsights aw-book-ai
266+
```
267+
268+
The Azure Portal **engagement dashboard** (`azure/dashboard.json`) is deployed in resource group
269+
`is-ai-native-rg`. Data appears within ~1–3 minutes of a real visit.
270+
271+
---
272+
209273

210274
The concrete chapter map is **designed by `playbook-architect`** from
211275
[`content/playbook-brief.md`](content/playbook-brief.md) and recorded in

analytics/analytics.entry.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Cookieless Azure Application Insights (RUM) for the gh-aw book.
2+
//
3+
// Uses the dependency-free BEACON transport from @webmaxru/cookieless-insights:
4+
// no cookies, no local/session storage, no persistent id -> no consent banner.
5+
// The connection string is a PUBLIC client key injected at build time by
6+
// site/generate.py as `window.__APPINSIGHTS_CONNECTION_STRING__` (empty in local
7+
// builds, so analytics is a no-op unless a key is present).
8+
//
9+
// Bundled to site/assets/analytics.js via `npm run build:analytics` (esbuild).
10+
11+
import {
12+
init,
13+
trackEvent,
14+
trackChangeDebounced,
15+
getClient,
16+
} from '@webmaxru/cookieless-insights';
17+
18+
// ─────────────────────────────────────────────────────────────────────────
19+
// KILL SWITCH: set to false to disable ALL telemetry (no beacon, no events).
20+
const ANALYTICS_ENABLED = true;
21+
// ─────────────────────────────────────────────────────────────────────────
22+
23+
const connectionString =
24+
(typeof window !== 'undefined' && window.__APPINSIGHTS_CONNECTION_STRING__) || '';
25+
26+
// Safe no-op when disabled or when no connection string is present. Sends an
27+
// automatic page view on init (cookieless, in-memory session id per page load).
28+
init({
29+
connectionString,
30+
enabled: ANALYTICS_ENABLED,
31+
cloudRole: 'gh-aw-book',
32+
});
33+
34+
// Coarse, non-PII page context attached to every custom event.
35+
function context() {
36+
const isHome = !!(document.body && document.body.classList.contains('home'));
37+
return { page: isHome ? 'home' : 'chapter', path: location.pathname };
38+
}
39+
40+
function langOf(codeEl) {
41+
const cls = (codeEl && codeEl.className) || '';
42+
const match = cls.match(/(?:language|lang)-([a-z0-9]+)/i);
43+
return match ? match[1].toLowerCase() : 'text';
44+
}
45+
46+
function trackLink(link) {
47+
const href = link.getAttribute('href') || '';
48+
if (!href || href.startsWith('javascript:')) return;
49+
50+
// In-page anchor: reader jumped to a section (TOC, "Contents", section links).
51+
if (href.startsWith('#')) {
52+
trackEvent('Section Navigated', { ...context(), hash: href.slice(0, 80) });
53+
return;
54+
}
55+
56+
let url;
57+
try {
58+
url = new URL(href, location.href);
59+
} catch {
60+
return;
61+
}
62+
63+
if (url.origin !== location.origin) {
64+
// Outbound: book repo, gh-aw, docs, author links, etc.
65+
trackEvent('Outbound Link Clicked', { ...context(), host: url.host, href: url.href });
66+
return;
67+
}
68+
69+
// Same-origin navigation between book pages (chapter cards, pager, brand).
70+
const label = (link.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60);
71+
trackEvent('Internal Link Clicked', { ...context(), to: url.pathname + url.hash, label });
72+
}
73+
74+
function onClick(event) {
75+
const el = event.target instanceof Element ? event.target : null;
76+
if (!el) return;
77+
78+
const themeBtn = el.closest('.theme-opt');
79+
if (themeBtn) {
80+
trackEvent('Theme Changed', { ...context(), theme: themeBtn.dataset.themeValue || 'unknown' });
81+
return;
82+
}
83+
84+
const sidebarBtn = el.closest('.sidebar-toggle');
85+
if (sidebarBtn) {
86+
// aria-expanded still holds the pre-toggle state at click time; invert it.
87+
const willOpen = sidebarBtn.getAttribute('aria-expanded') !== 'true';
88+
trackEvent('Chapter Sidebar Toggled', { ...context(), open: String(willOpen) });
89+
return;
90+
}
91+
92+
const copyBtn = el.closest('.copy-code');
93+
if (copyBtn) {
94+
const host = copyBtn.closest('figure.code, .code-block');
95+
const codeEl = host ? host.querySelector('code') : null;
96+
trackEvent('Code Copied', { ...context(), language: codeEl ? langOf(codeEl) : 'text' });
97+
return;
98+
}
99+
100+
const link = el.closest('a[href]');
101+
if (link) trackLink(link);
102+
}
103+
104+
// Debounced typing / slider drags. The site has no such inputs today; this
105+
// future-proofs any range slider, search box, or textarea by collapsing rapid
106+
// changes into a single event per control.
107+
function onInput(event) {
108+
const el = event.target instanceof Element ? event.target : null;
109+
if (!el) return;
110+
const tag = el.tagName;
111+
const type = (el.getAttribute && el.getAttribute('type')) || '';
112+
const isTextInput =
113+
tag === 'INPUT' && ['range', 'text', 'search', 'number', ''].includes(type);
114+
if (isTextInput || tag === 'TEXTAREA' || el.getAttribute('contenteditable') === 'true') {
115+
const key = el.id || el.getAttribute('name') || type || tag.toLowerCase();
116+
trackChangeDebounced('Input Changed', String(key));
117+
}
118+
}
119+
120+
function wire() {
121+
// "Opened via shared link": arrived on a deep link to a specific section.
122+
if (location.hash && location.hash.length > 1) {
123+
trackEvent('Opened Via Shared Link', { ...context(), hash: location.hash.slice(0, 80) });
124+
}
125+
// Capture phase so we still see events even if a handler stops propagation.
126+
document.addEventListener('click', onClick, true);
127+
document.addEventListener('input', onInput, true);
128+
}
129+
130+
// Only attach listeners when telemetry is actually live (no-op otherwise).
131+
if (getClient() && getClient().enabled) {
132+
if (document.readyState === 'loading') {
133+
document.addEventListener('DOMContentLoaded', wire, { once: true });
134+
} else {
135+
wire();
136+
}
137+
}

0 commit comments

Comments
 (0)