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 .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Wrangler owns this generated declaration and occasionally emits trailing spaces.
apps/collector/worker-configuration.d.ts -whitespace
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: Setup Node + pnpm
uses: ./.github/actions/setup-node-pnpm

- name: Install Chromium
run: pnpm --filter @lvbt/analytics exec playwright install --with-deps chromium

- name: Check
run: pnpm check

Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/deploy-collector.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Deploy collector

on:
push:
branches: [main]
paths:
- 'apps/collector/**'
- 'packages/analytics/src/events.ts'
- 'pnpm-lock.yaml'
- '.github/workflows/deploy-collector.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: analytics-collector-production
cancel-in-progress: false

jobs:
deploy:
name: Deploy collector
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup Node + pnpm
uses: ./.github/actions/setup-node-pnpm

- name: Install Chromium
run: pnpm --filter @lvbt/analytics exec playwright install --with-deps chromium

- name: Validate
run: pnpm check

- name: Deploy
run: pnpm --filter @lvbt/analytics-collector exec wrangler deploy
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

- name: Verify health
run: curl --fail --retry 8 --retry-delay 5 https://events.lasvegasfortransit.org/health
45 changes: 45 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Publish package

on:
release:
types: [published]

permissions:
contents: read
id-token: write

jobs:
publish:
name: Publish package
runs-on: ubuntu-latest
timeout-minutes: 15
environment: npm
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup Node + pnpm
uses: ./.github/actions/setup-node-pnpm

- name: Install Chromium
run: pnpm --filter @lvbt/analytics exec playwright install --with-deps chromium

- name: Validate
run: pnpm check

- name: Configure npm trusted publishing
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
registry-url: https://registry.npmjs.org

- name: Confirm release version
run: |
PACKAGE_VERSION="$(node -p "require('./packages/analytics/package.json').version")"
test "v${PACKAGE_VERSION}" = "${GITHUB_REF_NAME}"

- name: Publish with provenance
working-directory: packages/analytics
run: npm publish --access public
143 changes: 143 additions & 0 deletions .github/workflows/weekly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
name: Analytics weekly report

on:
schedule:
- cron: '0 14 * * 1'
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
report:
name: Verify production and update report
if: vars.LVBT_ANALYTICS_LIVE == '1' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup Node + pnpm
uses: ./.github/actions/setup-node-pnpm

- name: Build
run: pnpm build

- name: Install Chromium
run: pnpm --filter @lvbt/analytics exec playwright install --with-deps chromium

- name: Verify public sites
run: |
node packages/analytics/dist/cli/index.mjs verify https://lasvegasfortransit.org \
--site lasvegasfortransit.org --expect present
node packages/analytics/dist/cli/index.mjs verify https://labs.lasvegasfortransit.org \
--site labs.lasvegasfortransit.org --expect present
node packages/analytics/dist/cli/index.mjs verify https://fund.lasvegasfortransit.org \
--site fund.lasvegasfortransit.org --expect present
node packages/analytics/dist/cli/index.mjs verify https://map.lasvegasfortransit.org \
--site map.lasvegasfortransit.org --expect present

- name: Build reports
run: |
pnpm report --days 7 > report-7.md
pnpm report --days 30 > report-30.md
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_ANALYTICS_READ_TOKEN: ${{ secrets.CLOUDFLARE_ANALYTICS_READ_TOKEN }}

- name: Update stable report issue
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('node:fs');
const title = 'LVBT analytics weekly report';
const body = [
`Updated ${new Date().toISOString().slice(0, 10)}.`,
'',
fs.readFileSync('report-7.md', 'utf8'),
fs.readFileSync('report-30.md', 'utf8'),
'',
`Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
].join('\n');
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'analytics-report',
state: 'open',
per_page: 100,
});
const existing = issues.find((issue) => issue.title === title && !issue.pull_request);
const issue = existing
? await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body,
})
: await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['analytics-report'],
});
const pinState = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) { id isPinned }
}
}`,
{ owner: context.repo.owner, repo: context.repo.repo, number: issue.data.number },
);
if (!pinState.repository.issue.isPinned) {
await github.graphql(
`mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { id } } }`,
{ id: pinState.repository.issue.id },
);
}

notify:
name: Update maintenance issue on failure
needs: report
if: failure() && github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Update stable maintenance issue
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = 'Analytics weekly check needs attention';
const body = [
`The check failed on ${new Date().toISOString().slice(0, 10)}.`,
'',
`Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
].join('\n');
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'analytics,maintenance',
state: 'open',
per_page: 100,
});
const existing = issues.find((issue) => issue.title === title && !issue.pull_request);
if (existing) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.number,
body,
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['analytics', 'maintenance'],
});
}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules/
dist/
coverage/
playwright-report/
test-results/
.turbo/
.wrangler/
.env
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
pnpm-lock.yaml
.lvbt/web-platform/
.claude/worktrees/
**/playwright-report/
**/test-results/
# Generated by `wrangler types`; regenerate it, never hand-edit it.
**/worker-configuration.d.ts
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Las Vegans for Better Transit

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
61 changes: 46 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,56 @@
# LVBT package repository
# LVBT Analytics

A Turborepo workspace following the LVBT repository standard. It was created with:
LVBT Analytics gives Las Vegas for Better Transit one privacy-preserving measurement standard across
its public websites and tools. Cloudflare Web Analytics records page use and performance; the
first-party collector records a small, typed set of conversion events.

```bash
npx create-turbo@latest --example https://github.com/LasVegasForTransit/repository-tooling/tree/main/examples/basic
The system does not set cookies, create visitor identifiers, fingerprint browsers, or retain IP
addresses and user agents. Global Privacy Control and Do Not Track disable both measurement paths.
Analytics stays off on localhost, preview deployments, retired archives, and framed embeds.

## Choose an integration

| Project | Entry point | Setup |
| -------------------- | ----------------------- | ---------------------------------------------------------- |
| Astro | `@lvbt/analytics/astro` | Add `lvbtAnalytics({ site })` to `astro.config.ts` |
| Vite or React | `@lvbt/analytics` | Call `init({ site, token })` before rendering |
| React component tree | `@lvbt/analytics/react` | Render `<Analytics>` and call `useTrack()` |
| Plain HTML | generated client | Run `lvbt-analytics client --out public/lvbt-analytics.js` |

Production builds receive `PUBLIC_LVBT_CWA_TOKEN` and set `LVBT_REQUIRE_ANALYTICS=1`. Preview and
local builds receive neither value, so the client is absent rather than merely pointed at a test
property.

```ts
import lvbtAnalytics from '@lvbt/analytics/astro';

export default defineConfig({
integrations: [lvbtAnalytics({ site: 'labs.lasvegasfortransit.org' })],
});
```

## Getting started
```ts
import { init } from '@lvbt/analytics';

```bash
pnpm bootstrap # install, wire git hooks, run preflight
pnpm check # the same check CI runs
init({
site: 'map.lasvegasfortransit.org',
token: import.meta.env.PUBLIC_LVBT_CWA_TOKEN,
noPageviews: [/^\/s\//],
});
```

Then rename the root package and `packages/example`, and replace the scopes in
`.lvbt/commit-scopes.txt` with this repository's boundaries.
Start with the [integration tutorial](docs/development/tutorials/add-analytics-to-a-new-site.md).
The [privacy contract](docs/security/reference/privacy-contract.md) defines the non-negotiable data
boundary, and the [operations runbook](docs/operations/runbooks/dashboard-shows-zero.md) covers a
quiet dashboard or failed collector.

## Layout
## Work in this repository

- `apps/` for deployable applications and services
- `packages/` for libraries and tools; `packages/example` shows the shape of one
```bash
pnpm bootstrap
pnpm check
```

Lint, format, TypeScript, and test settings extend the `@lvbt/*` packages from
[`LasVegasForTransit/repository-tooling`](https://github.com/LasVegasForTransit/repository-tooling).
The workspace contains the published client in `packages/analytics`, the collector Worker in
`apps/collector`, and the fixed-query report command in `tools/report`. The
[documentation index](docs/README.md) groups contributor material by task.
File renamed without changes.
29 changes: 29 additions & 0 deletions apps/collector/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@lvbt/analytics-collector",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "wrangler deploy --dry-run --outdir dist",
"dev": "wrangler dev",
"lint": "eslint . --max-warnings 0",
"check-types": "tsc --noEmit",
"test": "vitest run",
"types": "wrangler types",
"validate": "wrangler deploy --dry-run --outdir dist"
},
"dependencies": {
"@lvbt/analytics": "workspace:*",
"zod": "catalog:"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "catalog:",
"@lvbt/eslint-config": "github:LasVegasForTransit/repository-tooling#v0.2.7&path:/packages/eslint-config",
"@lvbt/typescript-config": "github:LasVegasForTransit/repository-tooling#v0.2.7&path:/packages/typescript-config",
"@types/node": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"wrangler": "catalog:"
}
}
Loading
Loading