Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/weekly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ jobs:
--site fund.lasvegasfortransit.org --expect present
node packages/analytics/dist/cli/index.mjs verify https://map.lasvegasfortransit.org \
--site map.lasvegasfortransit.org --expect present
node packages/analytics/dist/cli/index.mjs verify https://lvwwd.org \
--site lvwwd.org --expect present

- name: Build reports
run: |
Expand Down
48 changes: 48 additions & 0 deletions apps/collector/tests/collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,54 @@ describe('analytics collector', () => {
);
});

test('writes an lvwwd.org campaign event with its properties in declared order', async () => {
const env = testEnv();
const response = await handle(
new Request(endpoint, {
body: JSON.stringify({
site: 'lvwwd.org',
name: 'trip_entry_submitted',
props: { method: 'screenshot', day: '5' },
}),
headers: { 'Content-Type': 'text/plain', Origin: 'https://lvwwd.org' },
method: 'POST',
}),
env,
);

expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://lvwwd.org');
expect(env.EVENTS.writeDataPoint).toHaveBeenCalledOnce();
const point = env.EVENTS.writeDataPoint.mock.calls[0]?.[0];
expect(point?.indexes).toEqual(['lvwwd.org']);
expect(point?.blobs?.slice(0, 2)).toEqual(['lvwwd.org', 'trip_entry_submitted']);
expect(point?.blobs?.slice(6)).toEqual(['5', 'screenshot']);
});

test('keeps LVBT events and campaign events on their own sites', async () => {
const campaignJoin = await workerFetch(endpoint, {
body: JSON.stringify({
site: 'lvwwd.org',
name: 'join_click',
props: { placement: 'header' },
}),
headers: { 'Content-Type': 'text/plain', Origin: 'https://lvwwd.org' },
method: 'POST',
});
const labsSignup = await workerFetch(endpoint, {
body: JSON.stringify({
site: 'labs.lasvegasfortransit.org',
name: 'campaign_signup',
props: {},
}),
headers: clientHeaders,
method: 'POST',
});

expect(campaignJoin.status).toBe(400);
expect(labsSignup.status).toBe(400);
});

test.each(['Sec-GPC', 'DNT'])('honors %s before processing the event', async (header) => {
const response = await workerFetch(endpoint, {
body: 'not json',
Expand Down
4 changes: 3 additions & 1 deletion docs/development/how-to/add-a-conversion-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ A conversion event represents a small, durable product action that cannot be ans
pageviews. Confirm that the question needs a custom event before changing the allowlist.

1. Add one entry to `packages/analytics/src/events.ts`. Choose `client` or `server`, list the exact
production sites, and use enum properties only.
production sites, and use enum properties only. Name the event in lowercase snake case, such as
`trip_entry_submitted`. Write each value as a short lowercase label or a small count, such as
`screenshot` or `3`; a unit test rejects anything else.
2. Add valid and invalid cases to `packages/analytics/tests/events.test.ts` and the collector
workerd suite.
3. Run `pnpm exec lvbt-analytics events --markdown` and update the event reference with the exact
Expand Down
23 changes: 19 additions & 4 deletions docs/development/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,29 @@ HTML elements use the same typed contract without application code:
<a href="/join" data-lvbt-event="join_click" data-lvbt-placement="header">Join</a>
```

Unknown names, property keys, and enum values are ignored by delegated click tracking and rejected
by direct `track` calls.
Each declared property reads from the `data-lvbt-` attribute of the same name, so `material_printed`
reads `data-lvbt-item` and an event without properties needs only `data-lvbt-event`. Unknown names,
property keys, and enum values are ignored by delegated click tracking and rejected by direct
`track` calls. Server-only events are rejected in the browser.

Classic scripts that cannot import the package call the same function through `window.lvbt`, which
exists only after the gate enables analytics. Wrap the call so an analytics mistake never stops the
page:

```js
try {
window.lvbt?.track('bus_finder_used', { method: 'place' });
} catch {
// An event outside the allowlist is dropped.
}
```

## Framework entry points

`@lasvegasfortransit/analytics/astro` exports `lvbtAnalytics(options)`. It reads the standard
environment, injects an external page module only when a token exists, and disables JavaScript asset
inlining so the site CSP remains enforceable.
environment, injects an external page module only when a token exists, and keeps script chunks out
of the HTML so a `script-src 'self'` policy still runs them. Stylesheets and other assets keep the
site's own inlining rule, so small page styles stay inline.

`@lasvegasfortransit/analytics/react` exports `Analytics`, `useAnalytics`, and `useTrack`. The
provider initializes the shared client after mount. Hooks return the disabled no-op handle during
Expand Down
5 changes: 5 additions & 0 deletions docs/development/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,10 @@ Site identifiers are production hostnames without a scheme or path:
- `labs.lasvegasfortransit.org`
- `fund.lasvegasfortransit.org`
- `map.lasvegasfortransit.org`
- `lvwwd.org`, the Week Without Driving Las Vegas campaign site

The client accepts the exact hostname and its `www.` form. Other hostnames fail the production gate.

`lvwwd.org` is not a `lasvegasfortransit.org` subdomain, so the organization's Web Analytics site
does not cover it. It has its own Web Analytics site, and its token lives in the
week-without-driving repository's `production` environment rather than in the organization variable.
43 changes: 36 additions & 7 deletions docs/development/reference/event-allowlist.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,44 @@ terms, share identifiers, and arbitrary campaign values do not belong in an anal

<!-- generated-events:start -->

| Event | Source | Sites | Properties |
| ------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `newsletter_signup` | server | `lasvegasfortransit.org` | `method`: `site_form`, `membership_form` |
| `membership_intake` | server | `lasvegasfortransit.org` | None |
| `join_click` | client | `lasvegasfortransit.org`<br>`labs.lasvegasfortransit.org`<br>`fund.lasvegasfortransit.org`<br>`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` |
| `donate_click` | client | `lasvegasfortransit.org`<br>`labs.lasvegasfortransit.org`<br>`fund.lasvegasfortransit.org`<br>`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` |
| `tool_feature_used` | client | `labs.lasvegasfortransit.org`<br>`fund.lasvegasfortransit.org`<br>`map.lasvegasfortransit.org` | `feature`: `share_created`, `share_opened`, `export_png`, `export_svg`, `export_json`, `gtfs_import`, `sim_started`, `fuel_lever_moved`, `scenario_changed` |
| Event | Source | Sites | Properties |
| ---------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `newsletter_signup` | server | `lasvegasfortransit.org` | `method`: `site_form`, `membership_form` |
| `membership_intake` | server | `lasvegasfortransit.org` | None |
| `join_click` | client | `lasvegasfortransit.org`<br>`labs.lasvegasfortransit.org`<br>`fund.lasvegasfortransit.org`<br>`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` |
| `donate_click` | client | `lasvegasfortransit.org`<br>`labs.lasvegasfortransit.org`<br>`fund.lasvegasfortransit.org`<br>`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` |
| `tool_feature_used` | client | `labs.lasvegasfortransit.org`<br>`fund.lasvegasfortransit.org`<br>`map.lasvegasfortransit.org` | `feature`: `share_created`, `share_opened`, `export_png`, `export_svg`, `export_json`, `gtfs_import`, `sim_started`, `fuel_lever_moved`, `scenario_changed` |
| `campaign_signup` | client | `lvwwd.org` | None |
| `week_link_requested` | client | `lvwwd.org` | `method`: `link_form`, `signup_form` |
| `trip_entry_submitted` | client | `lvwwd.org` | `day`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`<br>`method`: `link`, `screenshot`, `link_and_screenshot` |
| `trip_picture_shared` | client | `lvwwd.org` | `method`: `share_sheet`, `download` |
| `bingo_square_marked` | client | `lvwwd.org` | `marked`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11`, `12`, `13`, `14`, `15`, `16`, `17`, `18`, `19`, `20`, `21`, `22`, `23`, `24` |
| `bingo_completed` | client | `lvwwd.org` | `lines`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11`, `12` |
| `bus_finder_used` | client | `lvwwd.org` | `method`: `my_location`, `place` |
| `app_installed` | client | `lvwwd.org` | `method`: `browser`, `home_screen` |
| `material_printed` | client | `lvwwd.org` | `item`: `partner_flyer`, `bingo_card` |
| `mail_in_viewed` | client | `lvwwd.org` | None |

<!-- generated-events:end -->

## lvwwd.org campaign events

lvwwd.org, the Week Without Driving Las Vegas campaign site, counts the steps of its campaign. Every
value is a day of the week, a running count, or a fixed label, so no event says who a person is,
what they wrote, or where they were.

| Event | Sent when | Properties |
| ---------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `campaign_signup` | A new sign-up is saved | None |
| `week_link_requested` | Someone asks for their "Open my week" link | `method`: the Get my link form, or a sign-up that already existed |
| `trip_entry_submitted` | A day's trip entry is saved | `day`: the campaign day, 1 to 8; `method`: a post link, a screenshot, or both. The link itself is never sent |
| `trip_picture_shared` | The trip picture goes to the phone's share sheet or is downloaded | `method`: `share_sheet` or `download` |
| `bingo_square_marked` | A bingo square is marked | `marked`: how many squares are marked now, 1 to 24 |
| `bingo_completed` | Marking a square finishes a line | `lines`: how many lines are complete now, 1 to 12; 12 is the whole card |
| `bus_finder_used` | Find a bus lists stops | `method`: the phone's location or a chosen place. The location itself is never sent |
| `app_installed` | The browser reports an install, or the site first opens from the home screen where browsers report none | `method`: `browser` or `home_screen` |
| `material_printed` | A print starts on the partner flyer or the paper bingo card | `item`: `partner_flyer` or `bingo_card` |
| `mail_in_viewed` | The enter-by-mail instructions come into view | None |

Cloudflare Web Analytics owns pageviews, referrers, UTM attribution, and Core Web Vitals.
Duplicating those values as custom events creates a second, less reliable source of truth.
5 changes: 3 additions & 2 deletions docs/public/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ Las Vegas for Better Transit uses privacy-preserving analytics to understand whi
tools are useful and whether the sites perform well.

Cloudflare Web Analytics records aggregate page use and performance without cookies. A small
LVBT-operated service records a limited set of actions such as selecting a join link or using an
export feature. Those actions contain predefined categories, not form contents or other free text.
LVBT-operated service records a limited set of actions such as selecting a join link, using an
export feature, or finishing a campaign step like signing up. Those actions contain predefined
categories, not form contents or other free text.

The system does not create visitor or session identifiers, fingerprint browsers, or store IP
addresses and user-agent strings with events. It does not collect names, email addresses, search
Expand Down
15 changes: 14 additions & 1 deletion packages/analytics/astro/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,20 @@ export function lvbtAnalytics(options: LvbtAnalyticsOptions): AstroIntegration {
'page',
`import { init } from '@lasvegasfortransit/analytics'; const options = ${JSON.stringify({ ...serializable, collector, token })}; ${patternAssignments} init(options);`,
);
updateConfig({ vite: { build: { assetsInlineLimit: 0 } } });
// Keep script chunks external so a `script-src 'self'` policy still runs them, and leave
// every other asset, such as small stylesheets, to the site's own inlining rule.
const siteLimit = config.vite.build?.assetsInlineLimit;
updateConfig({
vite: {
build: {
assetsInlineLimit: (filePath: string, content: Buffer) => {
if (/\.m?js$/.test(filePath)) return false;
if (typeof siteLimit === 'function') return siteLimit(filePath, content);
return siteLimit === undefined ? undefined : content.byteLength < siteLimit;
},
},
},
});
},
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/analytics/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lasvegasfortransit/analytics",
"version": "0.1.0",
"version": "0.2.0",
"description": "Privacy-preserving analytics for Las Vegas for Better Transit web properties.",
"license": "MIT",
"repository": {
Expand Down
5 changes: 4 additions & 1 deletion packages/analytics/scripts/check-size.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { readFile } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';

// The standalone client carries the client half of the event allowlist so a
// plain-HTML page rejects undeclared data before it leaves the browser. The
// allowlist grows with each site, so its budget covers five sites' events.
const budgets = [
['standalone client', 'dist/standalone/client.iife.js', 1024],
['standalone client', 'dist/standalone/client.iife.js', 1792],
['package entry', 'dist/index.mjs', 1536],
];

Expand Down
Loading
Loading