diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 733f0abb..dc21d958 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -2,11 +2,22 @@ name: main on: workflow_dispatch: + schedule: + - cron: "23 6 * * *" push: branches: [main] pull_request: branches: [main] +# One lock covers checkout, snapshot restore, refresh, archive and deployment. +# PRs never hold the production lock. Do not cancel an in-flight publication. +concurrency: + group: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.ref) || 'osl-production-pages' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + jobs: check-branch: if: ${{ github.event_name == 'pull_request' }} @@ -28,16 +39,32 @@ jobs: pr_sha: ${{ github.event.pull_request.head.sha }} build: + if: >- + github.event_name == 'pull_request' || + (github.repository == 'OpenScienceLabs/opensciencelabs.github.io' && + github.ref == 'refs/heads/main') runs-on: ubuntu-latest - concurrency: - group: ci-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true + timeout-minutes: 45 + permissions: + contents: write + id-token: write + outputs: + refresh_failed: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && steps.refresh.outcome != 'success' }} + env: + # Non-secret identifiers; credentials are generated only in the auth step. + GA4_PROPERTY_ID: "365530978" + GA4_HOSTNAMES: "opensciencelabs.org" + GA4_SERVICE_ACCOUNT: "osl-analytics-exporter@osl-general.iam.gserviceaccount.com" + GA4_WIF_PROVIDER: "projects/11701823742/locations/global/workloadIdentityPools/osl-analytics/providers/github" defaults: run: # bash -el required so conda activation persists (README: IMPORTANT) shell: bash -el {0} steps: + # Pending runs check out current main, not a superseded content commit. - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.sha || 'main' }} - uses: conda-incubator/setup-miniconda@v3 with: @@ -53,8 +80,18 @@ jobs: run: | poetry check poetry install + python -m pip install -r requirements-analytics.txt python -m nltk.downloader punkt + - name: Analytics tests (no Google credentials) + run: | + python -m unittest discover -s tests -v + node tests/analytics-js.test.cjs + + - name: Restore last successful analytics snapshot + if: ${{ github.event_name != 'pull_request' }} + run: python -m scripts.analytics.restore + # Render blog .qmd → .md so Build uses correct index.md (with YAML) - name: Pre-build blog (quarto + inject) run: makim pages.pre-build @@ -63,20 +100,101 @@ jobs: - name: Linter if: ${{ github.event_name == 'pull_request' }} env: - PRE_COMMIT_SKIP: mkdocs-build + SKIP: mkdocs-build run: | pre-commit install pre-commit run --all-files --verbose + # Authenticate immediately before the API calls, after slow pre-builds. + - name: Check refresh configuration + id: analytics_config + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + run: | + if [[ -n "$GA4_PROPERTY_ID" && -n "$GA4_HOSTNAMES" && -n "$GA4_WIF_PROVIDER" && -n "$GA4_SERVICE_ACCOUNT" ]]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::GA4 configuration missing; preserving the previous report. See docs/analytics.md." + fi + + - name: Authenticate to Google (OIDC, no credential file) + id: google_auth + if: ${{ steps.analytics_config.outputs.ready == 'true' }} + continue-on-error: true + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ env.GA4_WIF_PROVIDER }} + service_account: ${{ env.GA4_SERVICE_ACCOUNT }} + token_format: access_token + access_token_scopes: https://www.googleapis.com/auth/analytics.readonly + access_token_lifetime: 900s + create_credentials_file: false + export_environment_variables: false + + - name: Refresh analytics aggregates + id: refresh + if: ${{ steps.google_auth.outcome == 'success' }} + continue-on-error: true + env: + GA4_ACCESS_TOKEN: ${{ steps.google_auth.outputs.access_token }} + run: python -m scripts.analytics.export + + - name: Analytics refresh summary + if: ${{ github.event_name != 'pull_request' }} + env: + REFRESH_OUTCOME: ${{ steps.refresh.outcome }} + run: | + echo "### Public analytics" >> "$GITHUB_STEP_SUMMARY" + echo "Refresh: $REFRESH_OUTCOME. Failed/skipped refreshes never change the previous report's timestamp." >> "$GITHUB_STEP_SUMMARY" + echo "See docs/analytics.md for configuration and troubleshooting." >> "$GITHUB_STEP_SUMMARY" + - name: Build the book run: | makim pages.build echo "opensciencelabs.org" > build/CNAME - # Push the book's HTML to github-pages - - name: GitHub Pages action - uses: peaceiris/actions-gh-pages@v3.5.9 - if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + - name: Validate endpoint and audit published files + run: python -m scripts.analytics.audit + + # Durable storage, not a trigger for another workflow. Archive the whole + # validated site as before; analytics/data.json survives all content builds. + - name: Archive validated site and analytics snapshot + uses: peaceiris/actions-gh-pages@v4 + if: ${{ github.event_name != 'pull_request' }} with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./build + publish_branch: gh-pages + + - name: Upload Pages artifact + if: ${{ github.event_name != 'pull_request' }} + uses: actions/upload-pages-artifact@v3 + with: + path: build + + deploy: + needs: build + if: >- + github.repository == 'OpenScienceLabs/opensciencelabs.github.io' && + github.ref == 'refs/heads/main' && + (github.event_name == 'push' || github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + # Direct deployment in this run: GITHUB_TOKEN commits do not trigger a + # second workflow. One-time setup: Pages source must be GitHub Actions. + - name: Publish to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + - name: Flag failed refresh after safely publishing retained data + if: ${{ needs.build.outputs.refresh_failed == 'true' }} + run: | + echo "::error::Analytics refresh did not succeed. The retained report (or first-run unavailable state) was published. See the build job and docs/analytics.md." + exit 1 diff --git a/.gitignore b/.gitignore index ffba0961..9ffaedd4 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,6 @@ dmypy.json # llm .codex PLAN.md + +# Short-lived Google Actions credentials must never be committed. +gha-creds-*.json diff --git a/README.md b/README.md index fd998402..ee7bad6c 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,9 @@ Ensure you have installed the pre-commit config locally: # with your conda env active, run: $ pre-commit install ``` + +## Public analytics + +See [analytics setup and operations](docs/analytics.md) for the GA4 exporter, +keyless Google/GitHub configuration, snapshot retention, local fixture tests, +and live/browser acceptance checks. diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 00000000..d840d1a0 --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,606 @@ +# Public analytics: operations and setup + +This is the maintained setup and operations guide. The four non-secret analytics +identifiers are already in `.github/workflows/main.yaml`; **no GA4 repository +variables or secrets are required**. Their presence does not verify Google +permissions or successful traffic collection. + +If the service account and WIF provider already exist, verify their restrictions +in [Google setup](#one-time-google-configuration), complete the service-account +binding and GA4 Viewer access, then follow +[GitHub setup](#one-time-github-configuration) and +[the first refresh checks](#first-refresh-and-acceptance-checks). Do not +recreate working resources or generate private keys. + +## Design and deployment + +- `/analytics/` uses the existing MkDocs custom theme and its light/dark tokens. + The cards, CSS bar chart, and accessible table are server-rendered from the + same validated report as `/analytics/data.json`. No authenticated report + request or token reaches the browser. Analytics-page JavaScript only updates + the three-day stale notice; existing site tracking is separate. +- `scripts/analytics/export.py` uses Google's Python GA4 Data API client. A + small filtered report discovers the property's IANA timezone from response + metadata; no Admin API or manually synchronized timezone variable is needed. + The next query requests `screenPageViews`, `activeUsers`, and `sessions` + without dimensions across the entire 30-day window ending yesterday. The third + query requests `screenPageViews` by `yearMonth` for the prior 12 completed + months. +- Every request applies an exact, case-insensitive `hostName` allowlist AND + `platform = web`. Never put previews, localhost or unrelated domains in the + allowlist. Hostnames are not inferred from the property, URL, or site tag. +- This is **OSL-published data sourced from Google Analytics**, not a Google + certification of audience size. Consent choices and blockers can prevent + measurement, and recent figures may change during GA4 processing. The public + page explains these limitations and links to OSL's sponsorship page. +- All windows are inclusive property-local calendar dates. Every refresh + re-queries the entire rolling and historical windows to capture late + processing and revisions. A timezone change or midnight crossing mid-export + fails safely. +- Monthly rows absent from GA4 are omitted, not imputed as zeros. Explicit zero + rows remain zero. A successful unrestricted empty summary means zero measured + events for that scope. An empty reason, restriction, sampling, thresholding, + truncation, malformed response, timeout, or failed request instead aborts the + refresh. Counts are not a census of people or all actual visits. +- `pages/analytics/schema.json` is the closed, versioned public contract. + `additionalProperties: false` and fixed source fields prevent raw responses, + property IDs, credentials, or visitor details leaking into the JSON. Further + validation checks calendar boundaries and unique, chronological months. + `status: unavailable` has null dates, timezone and metrics, not fabricated + zeros. + +The public metric mapping follows the +[GA4 API schema](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema): + +| JSON summary field | GA4 metric | Meaning within the configured Web/hostname scope | +| ------------------ | ----------------- | ------------------------------------------------------------------------------------------------------- | +| `pageviews` | `screenPageViews` | Measured page views, including repeated views. | +| `active_users` | `activeUsers` | GA4's distinct active-user count over the entire reporting period, never a sum of daily/monthly counts. | +| `sessions` | `sessions` | Sessions that began during the reporting period. | + +### Durable snapshot storage + +The existing **`gh-pages` Git branch** stores the validated generated site, +including `analytics/data.json`. It is durable Git history, not an expiring +Actions cache or artifact. Do not delete or force-reset it. The local working +copy `.cache/analytics/data.json` is ignored and disposable. + +Every trusted production run acquires the **same workflow-level concurrency +lock** before checking out current `main`, restoring the branch snapshot, +optionally refreshing, building, archiving, and deploying. Ordinary `push` +builds restore and reuse the snapshot without Google authentication. Schedules +and manual runs try to refresh it. A retrieval/network error or invalid stored +report **blocks publication** rather than risking data loss. Only a genuinely +absent branch/file means first deployment. PRs have a separate lock, never +restore or refresh production data, and build the honest unavailable state. + +The archive is updated only after a successful build and credential audit. A +report's `generated_at` always means **last successful GA4 export**, not last +build, attempt, Git commit, or Pages deployment. If Pages deployment fails after +archiving, the valid export survives in Git and will be reused next time; the +live site remains on its previous deployment until publication succeeds. + +Publication now uses `upload-pages-artifact` and `deploy-pages` **in the same +workflow run**, including `schedule`. The branch commit is storage, not a +request for a second workflow. This explicitly avoids depending on +`GITHUB_TOKEN` pushes triggering another workflow. Set Pages' source to **GitHub +Actions** once, below. All production writers must use this workflow/lock; do +not run `mkdocs gh-deploy` or a separate publishing workflow against this +branch. Existing Netlify settings are not used for this GitHub Pages production +deployment. + +Production runs are not canceled in progress. GitHub may replace pending runs; +each surviving run checks out the latest `main`, and loads the current snapshot +inside the lock, so superseded content or analytics do not overwrite newer data. +A pending scheduled refresh replaced by a content build is retried the next day +(or manually); the content build still preserves the existing report. + +Refresh errors are continued only long enough to build and publish the retained +report (or the initial unavailable state). After publication the workflow is +marked failed, with annotations and a job summary, so failure is observable. A +restore, build, audit, or archive error stops publication entirely. + +## One-time Google configuration + +Do this as an authorized Google Cloud and GA4 administrator. No private keys are +needed, and none should be pasted into chat, committed, or saved in repository +secrets. Identifiers below are **not** credentials. + +Use **Bash** in Google Cloud Shell or a local shell with `gcloud` installed and +signed in to the intended administrator account (`gcloud auth list`). Run the +blocks in order in the **same shell**; rerun the variable block after opening a +new shell. The administrator needs permission to enable APIs, create/manage the +service account and workload identity pool/provider, and edit the service +account's IAM policy. GA4 property access management is a separate permission. +Do not grant these administrative permissions to the exporter account. + +Google's +[WIF prerequisites](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines) +include a billing-enabled Cloud project; confirm this with the project +administrator. This exporter does not provision a VM, Cloud Run service, or +BigQuery dataset. + +### 1. Verify collection and find the property ID + +1. In Google Analytics, select OSL's **GA4 property**, then **Admin → Property + details**. Confirm the numeric **Property ID** matches `365530978`, which is + configured in the workflow from the supplied Analytics URL. This is not a + `G-...` Measurement ID, `UA-...` ID, account ID, stream ID, or Google Cloud + project number. Confirm the reporting timezone and that this is the intended + property; the URL alone does not verify collection. +2. Under **Admin → Data streams**, open the intended Web stream and verify that + it is actually collecting the production website in this property. Review + recent reports with the exact production hostname scope. +3. **Important existing-site finding:** `theme/base.html` currently contains a + legacy `UA-213158050-1` Universal Analytics tag. This exporter does not + create a GA4 property, migrate old data, or invent a GA4 measurement ID. + Confirm whether GA4 is installed elsewhere (for example through Tag Manager). + If not, install the correct Web stream's Google tag using **Data streams → + Web stream → View tag instructions**, replacing the legacy tag through the + normal site review/deployment process and respecting consent requirements. + Avoid duplicate tags. The exporter can only publish data GA4 actually has; + historical data is not backfilled by enabling it. Do not turn consent + controls off for reporting. + +### 2. Enable APIs and create a dedicated service account + +Run these commands in an administrator's own authenticated shell. The workflow +uses project `osl-general` (project number `11701823742`). If using another +project or different pool/account names, update the workflow identifiers as +well. The Cloud project may differ from the GA4 property/account. Reuse existing +resources rather than repeating their creation commands. + +```bash +export PROJECT_ID='osl-general' +export REPO='OpenScienceLabs/opensciencelabs.github.io' +export PROJECT_NUMBER="$( + gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)' +)" +export SA="osl-analytics-exporter@${PROJECT_ID}.iam.gserviceaccount.com" +export POOL="projects/${PROJECT_NUMBER}/locations/global" +POOL+="/workloadIdentityPools/osl-analytics" + +# OSL's numeric GitHub IDs prevent repository/organization name-reuse risks. +export REPO_ID='540982912' +export OWNER_ID='56703773' + +printf 'PROJECT_NUMBER=%s\nGA4_SERVICE_ACCOUNT=%s\n' "$PROJECT_NUMBER" "$SA" +``` + +Expect project number `11701823742` and service account +`osl-analytics-exporter@osl-general.iam.gserviceaccount.com`. Stop if project +lookup fails or the values differ unexpectedly. If adapting this setup for a +different repository/owner, look up its numeric IDs rather than reusing OSL's. +With the GitHub CLI (`gh`) installed and authenticated, verify the IDs using: + +```bash +gh api "repos/$REPO" --jq '{repository_id: .id, owner_id: .owner.id}' +``` + +Enable the APIs, then create the account **only if it does not exist**: + +```bash +gcloud services enable \ + analyticsdata.googleapis.com iam.googleapis.com \ + cloudresourcemanager.googleapis.com \ + iamcredentials.googleapis.com sts.googleapis.com \ + --project="$PROJECT_ID" + +gcloud iam service-accounts create osl-analytics-exporter \ + --project="$PROJECT_ID" \ + --display-name='OSL aggregate analytics exporter' +``` + +Inspect an existing or newly created account with: + +```bash +gcloud iam service-accounts describe "$SA" \ + --project="$PROJECT_ID" --format='yaml(email,disabled)' +``` + +In the **specific GA4 property's** **Admin → Property access management**, add +the email printed as `$SA` as a user with **Viewer** access (not the literal +text `$SA`). Disable email notification for this service account if offered. Do +not grant account-wide Analytics access, Editor, or Administrator. A Google +Cloud project Viewer role is **not** a substitute for GA4 property access; the +exporter needs no broad project data role. + +### 3. Create repository-and-branch-restricted Workload Identity Federation + +The following is a dedicated pool/provider for this export. Do not reuse an +unrestricted provider. Keep `main` as the protected, trusted deployment/default +branch. The condition restricts the immutable owner/repository IDs, current repo +name, branch, workflow file, and scheduled/manual events. + +Create the pool **only if it does not exist**: + +```bash +gcloud iam workload-identity-pools create osl-analytics \ + --project="$PROJECT_ID" --location=global \ + --display-name='OSL analytics GitHub Actions' +``` + +Assemble the arguments below in Bash. Copy the code blocks, not visually wrapped +terminal output. Never split a flag such as `--attribute-mapping`, a claim name, +or the `refs/heads/main` string across lines. A continuation backslash must be +the last character on its line. + +```bash +: "${PROJECT_ID:?Run the variable block in step 2 first}" +: "${REPO_ID:?Run the variable block in step 2 first}" +: "${OWNER_ID:?Run the variable block in step 2 first}" +: "${REPO:?Run the variable block in step 2 first}" + +MAPPINGS=( + 'google.subject=assertion.sub' + 'attribute.repository_id=assertion.repository_id' + 'attribute.repository_owner_id=assertion.repository_owner_id' + 'attribute.repository=assertion.repository' + 'attribute.ref=assertion.ref' + 'attribute.workflow_ref=assertion.workflow_ref' + 'attribute.event_name=assertion.event_name' +) +ATTRIBUTE_MAPPING="$(IFS=,; echo "${MAPPINGS[*]}")" +WORKFLOW_REF="${REPO}/.github/workflows/main.yaml@refs/heads/main" +ATTRIBUTE_CONDITION="assertion.repository_id == '${REPO_ID}'" +ATTRIBUTE_CONDITION+=" && assertion.repository_owner_id == '${OWNER_ID}'" +ATTRIBUTE_CONDITION+=" && assertion.repository == '${REPO}'" +ATTRIBUTE_CONDITION+=" && assertion.ref == 'refs/heads/main'" +ATTRIBUTE_CONDITION+=" && assertion.workflow_ref == '${WORKFLOW_REF}'" +ATTRIBUTE_CONDITION+=" && assertion.event_name in " +ATTRIBUTE_CONDITION+="['schedule', 'workflow_dispatch']" + +PROVIDER_ARGS=( + --project="$PROJECT_ID" + --location=global + --workload-identity-pool=osl-analytics + --issuer-uri='https://token.actions.githubusercontent.com' + --attribute-mapping="$ATTRIBUTE_MAPPING" + --attribute-condition="$ATTRIBUTE_CONDITION" +) +``` + +For a **new** provider, run: + +```bash +gcloud iam workload-identity-pools providers create-oidc github \ + "${PROVIDER_ARGS[@]}" +``` + +For an **existing** provider, inspect it first: + +```bash +gcloud iam workload-identity-pools providers describe github \ + --project="$PROJECT_ID" --location=global \ + --workload-identity-pool=osl-analytics \ + --format='yaml(name,state,disabled,oidc,attributeMapping,attributeCondition)' +``` + +The provider must be active, not disabled, with the issuer, mapping and +condition assembled above. Leave allowed audiences at the default; the auth +action uses the provider resource name. If this dedicated provider has a broken +mapping or condition (for example from a pasted line break), repair it with the +same arguments rather than deleting the pool: + +```bash +gcloud iam workload-identity-pools providers update-oidc github \ + "${PROVIDER_ARGS[@]}" +``` + +See Google's +[provider update reference](https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/providers/update-oidc). +Do not overwrite a shared provider without reviewing its other consumers. + +### 4. Bind the repository identity and verify configuration + +Provider creation alone does **not** authorize service-account impersonation. +Add this binding even when reusing an existing pool/provider, then inspect it: + +```bash +: "${POOL:?Run the variable block in step 2 first}" +: "${SA:?Run the variable block in step 2 first}" +: "${REPO_ID:?Run the variable block in step 2 first}" +MEMBER="principalSet://iam.googleapis.com/${POOL}" +MEMBER+="/attribute.repository_id/${REPO_ID}" + +gcloud iam service-accounts add-iam-policy-binding "$SA" \ + --project="$PROJECT_ID" \ + --role='roles/iam.workloadIdentityUser' \ + --member="$MEMBER" + +gcloud iam service-accounts get-iam-policy "$SA" \ + --project="$PROJECT_ID" --format='yaml(bindings)' + +gcloud iam workload-identity-pools describe osl-analytics \ + --project="$PROJECT_ID" --location=global \ + --format='yaml(name,state,disabled)' + +gcloud iam workload-identity-pools providers describe github \ + --project="$PROJECT_ID" --location=global \ + --workload-identity-pool=osl-analytics --format='value(name)' +``` + +Confirm the IAM policy grants `roles/iam.workloadIdentityUser` to exactly the +repository-scoped `$MEMBER`, the pool/account are not disabled, and the provider +condition still rejects PRs, pushes and other branches. Check for unexpected +broader impersonation bindings with the administrator. Cloud IAM checks cannot +confirm GA4 Viewer access; verify that separately in the property's access +management screen. + +Compare the last command's full resource name with `GA4_WIF_PROVIDER` in +`jobs.build.env` in `.github/workflows/main.yaml`. Allow several minutes for IAM +propagation. No service-account key, domain-wide delegation, Cloud project Owner +role, or broad Token Creator grant is required for this service-account +impersonation setup. The auth action requests a 15-minute access token scoped +only to `https://www.googleapis.com/auth/analytics.readonly`. It creates **no +credential file** and exports no global credential environment. The token is +passed only to the exporter step; the build receives no token. + +This follows the auth action's +[WIF through a service account setup](https://github.com/google-github-actions/auth#workload-identity-federation-through-a-service-account). + +## One-time GitHub configuration + +In **OpenScienceLabs/opensciencelabs.github.io**, not a fork: + +1. Protect `main`, require code review for workflow/exporter changes, and keep + it the default branch. Authentication and publication are both hard-gated to + this repository and branch. PRs, forks and manual runs on other branches do + not authenticate or publish. Do not use `pull_request_target` for this work. +2. Verify the four **non-secret identifiers** already configured once in + `.github/workflows/main.yaml` under `jobs.build.env`: + + ```yaml + GA4_PROPERTY_ID: "365530978" + GA4_HOSTNAMES: "opensciencelabs.org" + GA4_SERVICE_ACCOUNT: "osl-analytics-exporter@osl-general.iam.gserviceaccount.com" + GA4_WIF_PROVIDER: "projects/11701823742/locations/global/workloadIdentityPools/osl-analytics/providers/github" + ``` + + You do **not** need GitHub repository variables or secrets for these values. + Any existing repository variables with these names are no longer used and may + be removed. To change an identifier, edit the workflow through normal code + review and ensure the corresponding Google configuration and GA4 property + access match. Add `www.opensciencelabs.org` only if it actually serves + measured production traffic; never include previews or localhost. These + values remain workflow configuration, not hard-coded Python values. + + There is intentionally no timezone variable: GA4 supplies it. No private + credential secret is required. The access token is generated at runtime by + OIDC/WIF, not stored in the workflow. Do not configure `GA4_ACCESS_TOKEN` + manually or use `credentials_json`. + +3. Under **Settings → Actions → General**, allow the actions used by the + workflow and repository `GITHUB_TOKEN` write permissions where organization + policy requires enabling them. The workflow explicitly requests + `contents: write` for archiving, `id-token: write` for OIDC, and + `pages: write` for deployment. Fork PR tokens remain read-only; the WIF + condition also rejects all PR refs. Permit the Actions bot to update + `gh-pages` under any applicable ruleset, without granting it a bypass for + `main` review requirements. +4. **Before the first production run**, set **Settings → Pages → Build and + deployment → Source: GitHub Actions**. Keep the custom domain + `opensciencelabs.org`, DNS, and HTTPS enforcement intact. In **Environments → + github-pages**, permit deployments only from `main`. If unattended daily + publication is desired, do not require a manual environment approval on each + run. The old branch remains the snapshot archive, not the Pages build source. +5. Enable the `main` workflow on the default branch. Daily cron is `23 6 * * *` + (06:23 UTC). GitHub schedules can be delayed or dropped, and + public-repository schedules can be disabled after 60 days without activity. + Monitor workflow notifications and the endpoint timestamp; re-enable the + workflow if needed. + +## First refresh and acceptance checks + +1. Merge the feature into `main` after configuring Pages. The ordinary push + publishes either the retained snapshot or “Analytics data is not available + yet”; it does not require Google configuration. +2. In **Actions → main → Run workflow**, choose **main**. Or use: + + ```bash + gh workflow run main.yaml --ref main \ + --repo OpenScienceLabs/opensciencelabs.github.io + ``` + +3. Check the configuration, Google auth, export, audit, archive, and direct + Pages deployment steps. The first successful export should log only the + success message, not raw responses. An unsuccessful refresh leaves a failed + workflow after safely publishing the retained/unavailable report. +4. Open `https://opensciencelabs.org/analytics/` and download + `https://opensciencelabs.org/analytics/data.json`. Check `status: available`, + `data_kind: production`, exact hostnames and property timezone, the dates + ending yesterday **in that timezone at export time**, and `generated_at`. The + page cards/table must match the JSON. A successful export with all zeros + should prompt verification of collection/property/hostname configuration; it + does not prove zero actual visitors. +5. Compare the summary with GA4 using the identical completed dates, timezone, + Web platform and hostname filters. Check `activeUsers` as one period total, + not a sum. Compare available monthly `screenPageViews` separately. This is + **live GA4 validation**; mocked tests cannot replace it. +6. Make an ordinary content deployment and confirm that the JSON, including + `generated_at`, remains identical. Confirm the next scheduled/manual success + updates it. If refreshes stop, the browser shows a stale notice after three + days even without another deployment. + +To download and validate the **published public aggregate report**, run from the +repository root with the local Python dependencies installed. This is not a +Google API call and requires no credentials: + +```bash +mkdir -p .cache +curl --fail --silent --show-error --location \ + https://opensciencelabs.org/analytics/data.json \ + --output .cache/analytics-published.json && \ +python - <<'PY' +from pathlib import Path +from scripts.analytics.report import read_snapshot + +report = read_snapshot(Path('.cache/analytics-published.json')) +if report['status'] != 'available': + raise SystemExit('No successful report has been published yet.') +print('Last successful refresh (UTC):', report['generated_at']) +print('Reporting timezone:', report['timezone']) +print('Hostname scope:', report['hostnames']) +print('Reporting period:', report['reporting_period']) +print('Summary:', report['summary']) +PY +``` + +`read_snapshot` rejects fixtures and validates both the closed schema and +calendar invariants. Leave the downloaded file separate from +`.cache/analytics/data.json`, which is the build's restored snapshot. A valid +but old report still needs its `generated_at` checked for staleness. + +## Local verification (no Google authentication) + +In the existing `osl-web` environment: + +```bash +poetry install +python -m pip install -r requirements-analytics.txt +python -m unittest discover -s tests -v +node tests/analytics-js.test.cjs +poetry check +ruff check scripts/analytics tests +ruff format --check scripts/analytics tests +makim pages.build +python -m scripts.analytics.audit +``` + +The Google SDK compatibility test is skipped only if the SDK is unavailable +locally; CI installs the pinned extras and runs it. Offline tests use synthetic +SDK-shaped responses, not recordings or credentials. The website already +receives `jsonschema` through its locked notebook dependencies; the extras file +also declares that dependency explicitly and pins it to the existing lock. + +For an **explicit fixture preview**, use a separate output directory: + +```bash +ANALYTICS_PREVIEW_FIXTURE=tests/fixtures/analytics.json \ + mkdocs build --site-dir .cache/analytics-preview +python -m http.server 8000 --directory .cache/analytics-preview +``` + +Visit `http://localhost:8000/analytics/`. The prominent TEST FIXTURE banner and +`data_kind: fixture` must be present. Never deploy this preview directory. CI +rejects the preview environment variable, and the publication audit rejects +fixture JSON even if copied to `build/`. Without the variable, normal builds use +only the ignored restored snapshot or the unavailable state. The live exporter +refuses to authenticate outside trusted CI. + +For reproducible real-browser screenshots, with the preview server running in +another terminal: + +```bash +python -m pip install playwright +python -m playwright install chromium +python tests/browser_analytics.py \ + --output .cache/analytics-screenshots/fixture +``` + +This optional smoke check uses +[Playwright](https://playwright.dev/python/docs/emulation), checks both modes at +1440px, 390px and 320px, rejects horizontal page overflow, checks the download +link's keyboard focus and a JavaScript-disabled page, and writes screenshots +under `.cache/analytics-screenshots/fixture/`. It blocks Google tracking +requests in its color-mode checks, and accepts only localhost URLs. Stop the +fixture server, serve the normal `build/` with +`python -m http.server 8000 --directory build`, then run the smoke check again +with `--output .cache/analytics-screenshots/normal`. When no restored snapshot +exists, this covers the honest unavailable state. Inspect the PNGs rather than +assuming automated checks prove visual quality. Ordinary browser previews may +execute the base theme's tracking tag; block analytics requests locally rather +than sending synthetic preview visits to Google. + +Before release, inspect desktop (1440px) and mobile (390px and 320px) in both +shared color modes, keyboard focus, table scrolling, browser zoom, the zero-data +chart, stale notice, and unavailable state. All figures and the table must also +work with JavaScript disabled. The base viewport now permits user zoom. + +### Release evidence checklist + +Record the commit, workflow run URL, results and any skipped checks in the PR or +release discussion. Keep disposable local logs and screenshots under ignored +`.cache/`, not as a second tracked verification report. Never include tokens, +raw API responses, or private account information in those records. + +- [ ] Python/JavaScript tests, lint, the full `makim pages.build`, and the + whole-build credential audit pass. CI must run the real Google SDK + compatibility test, not skip it. +- [ ] Normal build includes `build/analytics/index.html`, `data.json` and + `schema.json`, with no fixture values. The explicit fixture preview is + clearly labeled and cannot pass the production audit. +- [ ] Desktop/mobile, light/dark, keyboard, zoom, horizontal scrolling and + JavaScript-disabled views are inspected, including zero and stale states. +- [ ] A real manual refresh authenticates, publishes, and matches GA4 with the + same reporting periods and filters. Check the actual GA4 collection tag; + the legacy UA tag alone cannot supply GA4 data. +- [ ] A content-only deployment preserves the snapshot/timestamp; a later + scheduled or manual refresh updates it. The retained/unavailable state and + failed-run signal are checked for refresh failures. + +Mocked tests and a populated workflow are **not live GA4 verification**. Static +markup tests are **not browser visual verification**. If SDK downloads, a +browser or localhost sockets are unavailable in the testing environment, record +those checks as skipped and run them in a capable environment before release. Do +not replace missing evidence with fixture statistics or an assumed successful +run. + +## Troubleshooting + +| Symptom | Checks / action | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `unrecognized arguments: --attribute-` / Bash “No such file” | A copy/paste inserted newlines inside a flag or value. Rerun the Bash argument-assembly block in Google setup step 3, then create or update the provider as appropriate. Do not split `--attribute-mapping`, claim names or `refs/heads/main`. | +| Resource already exists | Inspect and reuse it. Use `providers update-oidc` only to repair the dedicated provider's reviewed configuration; still complete the service-account binding and GA4 Viewer access. Do not delete the pool to retry setup. | +| Configuration missing / first report unavailable | Verify all four identifiers in `jobs.build.env`, the WIF service-account binding and GA4 property access; run the workflow on `main`. Do not fill the JSON with example numbers. | +| OIDC denied | Check numeric repository/owner IDs, exact repository case, ref, workflow path, issuer, full provider resource name, WIF service-account binding, Actions `id-token: write`, and IAM propagation. Do not weaken the branch condition to make a PR work. | +| `PermissionDenied` / 403 | Enable the Data API in the Cloud project; grant the service account Viewer on the specific GA4 property; check the readonly scope and property ID. Cloud IAM Viewer alone is insufficient. | +| `Unauthenticated` / 401 | Re-run to get a fresh token; do not copy tokens out of logs. Authentication should remain immediately before the export, not before dependency installation. | +| `InvalidArgument` / `ValueError` | Check property ID, comma-separated hostnames (no URL, wildcard, port, empty entry), the SDK version, and the report schema. The exporter also conservatively rejects GA4 thresholding/sampling/restrictions/empty reasons/truncation and timezone changes. Check these in the authenticated GA4 UI, not by logging raw responses. | +| Timeout / quota / service unavailable | Transient retries are bounded; keep the last report and retry later. Check Google API quotas and service health. Do not substitute zeros. | +| Snapshot restore or validation failure | Stop publication. Check repository access and `gh-pages:analytics/data.json`. Recover a known valid report from that branch's Git history through an authorized maintenance change. Never delete the snapshot simply to make CI green. | +| Pages deployment failed | Check Pages source is GitHub Actions, environment permits `main`, custom domain, and `pages: write`. A successfully archived report remains recoverable; rerun publication. | +| Schedule stopped / report stale | Check default branch, Actions enabled, inactivity auto-disable, queue delays and failed runs. Re-enable and dispatch manually. The old timestamp is intentionally retained. | +| Local Quarto failure | Repair the local Quarto installation; `mkdocs build` can separately verify committed Markdown and the analytics page, but does not replace the full blog pre-build check. | + +The exporter logs an exception **class**, never a potentially sensitive raw +error message. Avoid `set -x`, SDK debug logging, or artifact uploads of the +repository root, `.cache`, environment variables, credentials, or raw API +responses. Only `build/` is published, after the aggregate schema and credential +audit pass. The audit is defense in depth, not permission to place secrets in +content. + +### Local Quarto runtime lookup failures + +If the Conda Quarto launcher looks for a missing bundled `deno` or `pandoc`, +repair the environment. If `quarto`, `deno` and `pandoc` are already installed +in the active environment, this local-only override uses those executables and +keeps caches in the workspace, without changing the site or CI configuration: + +```bash +mkdir -p .cache/tmp +QUARTO_PREFIX="$(dirname "$(dirname "$(command -v quarto)")")" +TMPDIR="$PWD/.cache/tmp" \ +QUARTO_DENO="$(command -v deno)" \ +QUARTO_SHARE_PATH="$QUARTO_PREFIX/share/quarto" \ +QUARTO_PANDOC="$(command -v pandoc)" \ +DENO_DIR="$PWD/.cache/deno" XDG_CACHE_HOME="$PWD/.cache" \ + makim pages.build +python -m scripts.analytics.audit +``` + +The full build re-renders blog Markdown. Review `git diff` afterward and do not +include unrelated generated blog changes in an analytics/documentation change. +`mkdocs build --clean` can check the static site separately, but does not verify +the Quarto pre-build step. + +## Official references + +- [GA4 Data API and client quickstart](https://developers.google.com/analytics/devguides/reporting/data/v1) +- [GA4 metrics and dimensions](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema) +- [Response metadata and property timezone](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/ResponseMetaData) +- [Google authentication for GitHub Actions](https://github.com/google-github-actions/auth) +- [WIF deployment pipeline security and configuration](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines) +- [GitHub scheduled workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) +- [GitHub custom Pages deployments](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) +- [GitHub token-trigger behavior](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow) diff --git a/mkdocs.yml b/mkdocs.yml index 41db92b8..19489f0a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,6 +4,9 @@ site_url: https://opensciencelabs.org docs_dir: pages site_dir: build +hooks: + - scripts/analytics/hook.py + extra: enumerate: !!python/name:builtins.enumerate @@ -92,6 +95,7 @@ nav: - Team: about/team/index.md - Fiscal Sponsor: about/fiscal-sponsor/index.md - Sponsorship: about/sponsorship/index.md + - Public Analytics: analytics/index.md - Guidelines: - Overview: about/guidelines/index.md - Articles: about/guidelines/articles/index.md diff --git a/pages/about/sponsorship/index.md b/pages/about/sponsorship/index.md index 82f9fbe9..b02cae6b 100644 --- a/pages/about/sponsorship/index.md +++ b/pages/about/sponsorship/index.md @@ -50,6 +50,9 @@ Your sponsorship can help us: Sponsorship is also a meaningful way for organizations to show commitment to open source, education, research, social impact, and talent development. +See our [public website analytics](../../analytics/index.md) for OSL-published +audience metrics and a downloadable aggregate report. + ## Community and Track Record OSL has a growing international community and a track record of mentoring diff --git a/pages/analytics/index.md b/pages/analytics/index.md new file mode 100644 index 00000000..76c3839e --- /dev/null +++ b/pages/analytics/index.md @@ -0,0 +1,64 @@ +--- +title: Public analytics +description: + A transparent view of our measured website audience, for our community and + potential sponsors. +template: analytics.html +section_label: Open Science Labs · Transparency +page_theme: about +hero_words: ["Measure", "Share", "Support"] +--- + +## How we measure + +This is **OSL-published data sourced from Google Analytics 4 (GA4)** via the +Google Analytics Data API. It is not an independent audit or a guarantee of +sponsorship reach. Only the aggregate fields in the downloadable report are +published; no visitor identifiers or raw API responses are included. + +- **Pageviews** (`screenPageViews`): counted website page views, including + repeated views of the same page. The Web platform filter excludes app screens. +- **Active users** (`activeUsers`): distinct users GA4 classifies as active, + based on engagement and applicable first-visit or engagement signals. This is + queried once over the entire 30-day period, not added from daily totals. It is + not a count of all visitors or a census of individual people. +- **Sessions** (`sessions`): sessions that began during the reporting period, as + measured by GA4. A user can have more than one session. + +All dates are inclusive calendar dates in the **GA4 property's reporting +timezone**, shown above. The summary covers 30 completed days ending yesterday +at the time of the last successful refresh. Monthly history covers up to 12 +completed calendar months; the current month is excluded. Only months returned +by GA4 are shown. Missing months are not invented as zero: they may predate +collection or have no available rows. Returned zero counts mean no measured +traffic for that query, not necessarily no actual visits. + +The hostname scope above is an exact allowlist, combined with a Web-only filter. +Other hostnames, including previews and localhost, and app traffic are excluded. +This is website traffic, not OSL's combined audience across social media, +community platforms, or other websites. + +### Limitations and freshness + +Measured traffic can exclude visits affected by **consent choices, blockers**, +disabled JavaScript, or collection errors. Identity settings, estimation, and +cross-device behavior can affect user counts. Recent figures may change during +GA4 processing. Every refresh re-queries both the rolling window and the full +historical window, so earlier figures can be revised too. + +Refreshes are scheduled daily at **06:23 UTC**, but GitHub Actions scheduling +may be delayed. If a refresh fails, we retain the previous successful report and +its original timestamp. A report more than three days old is marked stale. If +GA4 signals thresholding, sampling, truncation, or unavailable data, we do not +replace the previous report with incomplete results or fabricated zeros. + +Metric definitions follow Google's +[GA4 Data API schema](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema). +See also Google's +[data freshness guidance](https://support.google.com/analytics/answer/12233314). +The endpoint follows our [version 1 JSON schema](schema.json). + +## Support open science + +Help sustain our open-source tools, mentorship, and community programs. +[Explore OSL sponsorship](../about/sponsorship/index.md). diff --git a/pages/analytics/schema.json b/pages/analytics/schema.json new file mode 100644 index 00000000..4be9673a --- /dev/null +++ b/pages/analytics/schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opensciencelabs.org/analytics/schema.json", + "title": "OSL public analytics report v1", + "description": "Closed aggregate-only contract. Nulls mean unavailable, never zero. Dates are inclusive in the property timezone. Missing historical months are omitted, not imputed. generated_at is the last successful export in UTC.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "data_kind", + "status", + "source", + "site", + "hostnames", + "timezone", + "generated_at", + "reporting_period", + "history_period", + "summary", + "monthly_history" + ], + "properties": { + "schema_version": { "const": 1 }, + "data_kind": { "enum": ["production", "fixture"] }, + "status": { "enum": ["available", "unavailable"] }, + "source": { + "const": { + "publisher": "Open Science Labs", + "system": "Google Analytics 4", + "api": "Google Analytics Data API v1beta" + } + }, + "site": { "const": "https://opensciencelabs.org" }, + "hostnames": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "format": "hostname" } + }, + "timezone": { "type": ["string", "null"], "minLength": 1 }, + "generated_at": { + "type": ["string", "null"], + "format": "date-time", + "pattern": "Z$" + }, + "reporting_period": { + "oneOf": [{ "$ref": "#/$defs/period" }, { "type": "null" }] + }, + "history_period": { + "oneOf": [{ "$ref": "#/$defs/period" }, { "type": "null" }] + }, + "summary": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["pageviews", "active_users", "sessions"], + "properties": { + "pageviews": { "$ref": "#/$defs/count" }, + "active_users": { "$ref": "#/$defs/count" }, + "sessions": { "$ref": "#/$defs/count" } + } + } + ] + }, + "monthly_history": { + "type": "array", + "maxItems": 12, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["month", "start", "end", "pageviews"], + "properties": { + "month": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$" + }, + "start": { "type": "string", "format": "date" }, + "end": { "type": "string", "format": "date" }, + "pageviews": { "$ref": "#/$defs/count" } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "available" } } }, + "then": { + "properties": { + "hostnames": { "minItems": 1 }, + "timezone": { "type": "string" }, + "generated_at": { "type": "string" }, + "reporting_period": { "$ref": "#/$defs/period" }, + "history_period": { "$ref": "#/$defs/period" }, + "summary": { "type": "object" } + } + }, + "else": { + "properties": { + "hostnames": { "maxItems": 0 }, + "timezone": { "type": "null" }, + "generated_at": { "type": "null" }, + "reporting_period": { "type": "null" }, + "history_period": { "type": "null" }, + "summary": { "type": "null" }, + "monthly_history": { "maxItems": 0 } + } + } + } + ], + "$defs": { + "count": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, + "period": { + "type": "object", + "additionalProperties": false, + "required": ["start", "end"], + "properties": { + "start": { "type": "string", "format": "date" }, + "end": { "type": "string", "format": "date" } + } + } + } +} diff --git a/requirements-analytics.txt b/requirements-analytics.txt new file mode 100644 index 00000000..87fefcf3 --- /dev/null +++ b/requirements-analytics.txt @@ -0,0 +1,3 @@ +# CI exporter extras, separate from the existing website's Poetry environment. +google-analytics-data==0.23.0 +jsonschema==4.23.0 diff --git a/scripts/analytics/__init__.py b/scripts/analytics/__init__.py new file mode 100644 index 00000000..e33410f5 --- /dev/null +++ b/scripts/analytics/__init__.py @@ -0,0 +1 @@ +"""Aggregate-only public analytics generation and publication.""" diff --git a/scripts/analytics/audit.py b/scripts/analytics/audit.py new file mode 100644 index 00000000..e0e7e65e --- /dev/null +++ b/scripts/analytics/audit.py @@ -0,0 +1,43 @@ +"""Fail publication if the generated build includes credential material.""" + +import re + +from pathlib import Path + +from scripts.analytics.report import read_snapshot + +BAD_CONTENT = re.compile( + rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|" + rb'"(?:private_key|private_key_id|refresh_token|client_secret|' + rb'credential_source|subject_token|access_token)"\s*:|' + rb'"type"\s*:\s*"(?:service_account|external_account|authorized_user)"|' + rb"ya29\.[A-Za-z0-9_-]{15,}" +) + + +def audit(directory: Path) -> None: + """Check the endpoint and every published file before artifact upload.""" + endpoint = directory / "analytics/data.json" + if not endpoint.is_file(): + raise ValueError("Missing public analytics endpoint") + read_snapshot(endpoint) + for path in directory.rglob("*"): + if path.is_symlink(): + raise ValueError("Symlinks are forbidden in the published build") + if not path.is_file(): + continue + if ( + path.name.startswith(("gha-creds-", ".env")) + or path.suffix in {".pem", ".key", ".p12"} + or any( + part in {".git", ".cache", ".venv"} + for part in path.relative_to(directory).parts + ) + or BAD_CONTENT.search(path.read_bytes()) + ): + raise ValueError(f"Potential credential material in {path.name}") + + +if __name__ == "__main__": + audit(Path("build")) + print("Public endpoint validated; build credential audit passed.") diff --git a/scripts/analytics/export.py b/scripts/analytics/export.py new file mode 100644 index 00000000..1cedf805 --- /dev/null +++ b/scripts/analytics/export.py @@ -0,0 +1,244 @@ +"""Export only aggregate GA4 statistics, using a CI-only read-only token.""" + +from __future__ import annotations + +import os +import re +import sys + +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from scripts.analytics.report import ( + METRICS, + SNAPSHOT, + hostnames, + month_period, + periods, + unavailable, + validate, + write_snapshot, +) + +SCOPE = "https://www.googleapis.com/auth/analytics.readonly" +REPOSITORY = "OpenScienceLabs/opensciencelabs.github.io" + + +class GoogleClient: + """Small SDK boundary so offline tests never need Google authentication.""" + + def __init__(self, token): + """Load the optional CI dependency only for authenticated exports.""" + from google.analytics.data_v1beta import BetaAnalyticsDataClient + from google.oauth2.credentials import Credentials + + self.client = BetaAnalyticsDataClient( + credentials=Credentials(token=token, scopes=[SCOPE]) + ) + + def run_report(self, *, request): + """Bound transient retries to keep the short-lived token sufficient.""" + from google.api_core.retry import Retry + + return self.client.run_report( + request=request, + retry=Retry(initial=1, maximum=8, deadline=60), + timeout=30, + ) + + +@dataclass(frozen=True) +class Settings: + """Private query configuration, deliberately absent from the report.""" + + property_id: str + hosts: list[str] + + @classmethod + def from_env(cls, env): + """Fail closed on missing or malformed production scope.""" + property_id = env.get("GA4_PROPERTY_ID", "") + if not re.fullmatch(r"[1-9][0-9]*", property_id): + raise ValueError("GA4_PROPERTY_ID must be a numeric property ID") + return cls(property_id, hostnames(env.get("GA4_HOSTNAMES", ""))) + + +def dimension_filter(hosts: list[str]) -> dict: + """AND exact allowed hostnames with Web to exclude apps and other sites.""" + return { + "and_group": { + "expressions": [ + { + "filter": { + "field_name": "hostName", + "in_list_filter": { + "values": hosts, + "case_sensitive": False, + }, + } + }, + { + "filter": { + "field_name": "platform", + "string_filter": { + "match_type": "EXACT", + "value": "web", + "case_sensitive": False, + }, + } + }, + ] + } + } + + +def query(client, settings, period, metrics, dimensions=()): + """Use bounded retries and never request visitor-level dimensions.""" + request = dict( + property=f"properties/{settings.property_id}", + date_ranges=[ + {"start_date": period["start"], "end_date": period["end"]} + ], + metrics=[{"name": name} for name in metrics], + dimensions=[{"name": name} for name in dimensions], + dimension_filter=dimension_filter(settings.hosts), + keep_empty_rows=True, + limit=100, + ) + response = client.run_report(request=request) + metadata = response.metadata + if ( + metadata.empty_reason + or metadata.subject_to_thresholding + or metadata.data_loss_from_other_row + or metadata.sampling_metadatas + or getattr(metadata, "data_truncation_reasons", ()) + or metadata.schema_restriction_response.active_metric_restrictions + ): + raise ValueError("GA4 report is unavailable, restricted or incomplete") + if response.row_count != len(response.rows): + raise ValueError("Incomplete GA4 rows") + if [header.name for header in response.dimension_headers] != list( + dimensions + ): + raise ValueError("Unexpected GA4 dimensions") + names = [header.name for header in response.metric_headers] + if len(names) != len(metrics) or set(names) != set(metrics): + raise ValueError("Unexpected GA4 metrics") + result = [] + for row in response.rows: + if len(row.metric_values) != len(names): + raise ValueError("Incomplete GA4 metrics") + if len(row.dimension_values) != len(dimensions): + raise ValueError("Incomplete GA4 dimensions") + values = [item.value for item in row.metric_values] + if any(not re.fullmatch(r"[0-9]+", value) for value in values): + raise ValueError("Invalid GA4 count") + result.append( + ( + [item.value for item in row.dimension_values], + dict(zip(names, map(int, values), strict=True)), + ) + ) + return metadata.time_zone, result + + +def collect(client, settings: Settings, now: datetime | None = None) -> dict: + """Re-query all periods; activeUsers is one period-level distinct total.""" + # Relative dates are interpreted by GA4 in its property timezone. This + # small probe discovers that timezone without requiring the Admin API. + zone, _ = query( + client, + settings, + {"start": "yesterday", "end": "yesterday"}, + ["screenPageViews"], + ) + started = now or datetime.now(timezone.utc) + rolling, history = periods(started, zone) + summary_zone, summary_rows = query( + client, settings, rolling, list(METRICS) + ) + history_zone, history_rows = query( + client, settings, history, ["screenPageViews"], ["yearMonth"] + ) + if zone != summary_zone or zone != history_zone: + raise ValueError("Property timezone changed during export; retry") + if len(summary_rows) > 1: + raise ValueError("Expected one period-level summary, not daily users") + # A successful empty report without an emptyReason/restriction means no + # measured events for this scope, NOT a failed or unconfigured request. + counts = summary_rows[0][1] if summary_rows else dict.fromkeys(METRICS, 0) + report = unavailable() + report.update( + status="available", + hostnames=settings.hosts, + timezone=zone, + reporting_period=rolling, + history_period=history, + summary={public: counts[api] for api, public in METRICS.items()}, + ) + for dimensions, values in history_rows: + value = dimensions[0] + if not re.fullmatch(r"[0-9]{6}", value): + raise ValueError("Invalid GA4 month") + month = value[:4] + "-" + value[4:] + report["monthly_history"].append( + { + "month": month, + **month_period(month), + "pageviews": values["screenPageViews"], + } + ) + # Missing months remain absent: GA4 cannot prove whether collection was + # enabled then. Explicit returned zeros, however, are preserved. + report["monthly_history"].sort(key=lambda item: item["month"]) + finished = now or datetime.now(timezone.utc) + if periods(finished, zone) != (rolling, history): + raise ValueError("Property midnight crossed during export; retry") + report["generated_at"] = ( + finished.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + ) + validate(report) + return report + + +def refresh(path: Path, client, settings: Settings, now=None) -> dict: + """Replace a snapshot only after every request and validation succeeds.""" + report = collect(client, settings, now) + write_snapshot(path, report) + return report + + +def main() -> int: + """Export in trusted CI without logging raw responses or tokens.""" + try: + if ( + os.environ.get("GITHUB_ACTIONS") != "true" + or os.environ.get("GITHUB_REPOSITORY") != REPOSITORY + or os.environ.get("GITHUB_REF") != "refs/heads/main" + or os.environ.get("GITHUB_EVENT_NAME") + not in {"schedule", "workflow_dispatch"} + ): + raise ValueError("Live exports are restricted to trusted CI") + settings = Settings.from_env(os.environ) + token = os.environ.get("GA4_ACCESS_TOKEN") + if not token: + raise ValueError("Missing short-lived analytics access token") + client = GoogleClient(token) + refresh(SNAPSHOT, client, settings) + except Exception as error: + # Library exception messages can include private request details. + print( + "::warning::Analytics refresh failed " + f"({type(error).__name__}); previous snapshot left unchanged. " + "See docs/analytics.md for troubleshooting.", + file=sys.stderr, + ) + return 1 + print("Analytics snapshot refreshed and validated successfully.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analytics/hook.py b/scripts/analytics/hook.py new file mode 100644 index 00000000..7d66fe49 --- /dev/null +++ b/scripts/analytics/hook.py @@ -0,0 +1,41 @@ +"""Render the page and JSON from one validated snapshot; never call Google.""" + +import os +import sys + +from pathlib import Path + +# MkDocs loads hooks by filename, not as modules in the project package. +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.analytics.report import ( + SNAPSHOT, + is_stale, + read_snapshot, + write_snapshot, +) + + +def on_config(config): + """Load aggregates with a guarded opt-in for local fixture previews.""" + fixture = os.environ.get("ANALYTICS_PREVIEW_FIXTURE") + if fixture and (os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS")): + raise ValueError("Analytics fixture previews are forbidden in CI") + report = read_snapshot( + Path(fixture) if fixture else SNAPSHOT, allow_fixture=bool(fixture) + ) + if fixture and report["data_kind"] != "fixture": + raise ValueError("Preview input must be explicitly labeled fixture") + config.extra["analytics_report"] = report + config.extra["analytics_stale"] = is_stale(report) + return config + + +def on_post_build(config): + """Write only the validated public contract.""" + report = config.extra["analytics_report"] + write_snapshot( + Path(config.site_dir) / "analytics/data.json", + report, + allow_fixture=report["data_kind"] == "fixture", + ) diff --git a/scripts/analytics/report.py b/scripts/analytics/report.py new file mode 100644 index 00000000..4fa5b109 --- /dev/null +++ b/scripts/analytics/report.py @@ -0,0 +1,159 @@ +"""Public analytics contract and snapshot handling (no Google credentials).""" + +from __future__ import annotations + +import json +import re + +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +from jsonschema import Draft202012Validator, FormatChecker + +ROOT = Path(__file__).resolve().parents[2] +SNAPSHOT = ROOT / ".cache/analytics/data.json" +SCHEMA = ROOT / "pages/analytics/schema.json" +SOURCE = { + "publisher": "Open Science Labs", + "system": "Google Analytics 4", + "api": "Google Analytics Data API v1beta", +} +SITE = "https://opensciencelabs.org" +METRICS = { + "screenPageViews": "pageviews", + "activeUsers": "active_users", + "sessions": "sessions", +} +WINDOW_DAYS = 30 +STALE_DAYS = 3 +MAX_HOSTNAME_LENGTH = 253 + + +def hostnames(value: str) -> list[str]: + """Require explicit, exact public DNS names, never URLs or wildcards.""" + names = [name.strip().lower() for name in value.split(",")] + pattern = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" + for name in names: + if ( + len(name) > MAX_HOSTNAME_LENGTH + or not re.fullmatch(rf"(?:{pattern}\.)+{pattern}", name) + or name.endswith((".localhost", ".local", ".test", ".invalid")) + or not re.search(r"[a-z]", name.rsplit(".", 1)[-1]) + ): + raise ValueError("Invalid analytics hostname allowlist") + return sorted(set(names)) + + +def periods(now: datetime, zone: str) -> tuple[dict, dict]: + """Return inclusive calendar dates, not elapsed 24-hour intervals.""" + if now.tzinfo is None: + raise ValueError("An aware datetime is required") + today = now.astimezone(ZoneInfo(zone)).date() + first = today.replace(day=1) + summary = { + "start": (today - timedelta(days=WINDOW_DAYS)).isoformat(), + "end": (today - timedelta(days=1)).isoformat(), + } + history = { + "start": first.replace(year=first.year - 1).isoformat(), + "end": (first - timedelta(days=1)).isoformat(), + } + return summary, history + + +def month_period(month: str) -> dict: + """Expand a YYYY-MM month to inclusive calendar boundaries.""" + first = date.fromisoformat(month + "-01") + following = (first.replace(day=28) + timedelta(days=4)).replace(day=1) + return { + "start": first.isoformat(), + "end": (following - timedelta(days=1)).isoformat(), + } + + +def unavailable() -> dict: + """Represent an unconfigured first deployment without invented values.""" + return { + "schema_version": 1, + "data_kind": "production", + "status": "unavailable", + "source": dict(SOURCE), + "site": SITE, + "hostnames": [], + "timezone": None, + "generated_at": None, + "reporting_period": None, + "history_period": None, + "summary": None, + "monthly_history": [], + } + + +def validate(report: dict, *, allow_fixture: bool = False) -> None: + """Validate the closed JSON schema and cross-field date invariants.""" + schema = json.loads(SCHEMA.read_text()) + Draft202012Validator(schema, format_checker=FormatChecker()).validate( + report + ) + if report["data_kind"] == "fixture" and not allow_fixture: + raise ValueError("Fixture reports are forbidden in production") + if report["status"] == "unavailable": + return + if hostnames(",".join(report["hostnames"])) != report["hostnames"]: + raise ValueError("Noncanonical hostname scope") + generated = datetime.fromisoformat( + report["generated_at"].replace("Z", "+00:00") + ) + expected_summary, expected_history = periods(generated, report["timezone"]) + if report["reporting_period"] != expected_summary: + raise ValueError("Incorrect rolling reporting period") + if report["history_period"] != expected_history: + raise ValueError("Incorrect completed-month history period") + months = [] + for item in report["monthly_history"]: + bounds = month_period(item["month"]) + if any(item[key] != bounds[key] for key in bounds): + raise ValueError("Incorrect monthly boundaries") + if not ( + expected_history["start"] + <= item["start"] + <= item["end"] + <= expected_history["end"] + ): + raise ValueError("Month outside historical reporting period") + months.append(item["month"]) + if months != sorted(set(months)): + raise ValueError("Monthly history must be unique and chronological") + + +def read_snapshot(path: Path, *, allow_fixture: bool = False) -> dict: + """Treat absence as unavailable; never silently discard corrupt data.""" + if not path.exists(): + return unavailable() + report = json.loads(path.read_text()) + validate(report, allow_fixture=allow_fixture) + return report + + +def write_snapshot(path: Path, report: dict, *, allow_fixture=False) -> None: + """Validate before atomically replacing any previous successful report.""" + validate(report, allow_fixture=allow_fixture) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".json.tmp") + temporary.write_text( + json.dumps(report, indent=2, ensure_ascii=True) + "\n" + ) + temporary.replace(path) + + +def is_stale(report: dict, now: datetime | None = None) -> bool: + """Determine freshness from the successful export, never the build time.""" + if report["generated_at"] is None: + return False + generated = datetime.fromisoformat( + report["generated_at"].replace("Z", "+00:00") + ) + return (now or datetime.now(timezone.utc)) - generated > timedelta( + days=STALE_DAYS + ) diff --git a/scripts/analytics/restore.py b/scripts/analytics/restore.py new file mode 100644 index 00000000..6da9b837 --- /dev/null +++ b/scripts/analytics/restore.py @@ -0,0 +1,55 @@ +"""Restore the durable gh-pages report; fail closed on retrieval errors.""" + +import subprocess + +from scripts.analytics.report import SNAPSHOT, validate, write_snapshot + +MISSING_REF = 2 + + +def git(*args): + """Run git without a shell, hiding remote diagnostics from public logs.""" + return subprocess.run( + ["git", *args], capture_output=True, check=False, text=True + ) + + +def restore(path=SNAPSHOT, run=git): + """Distinguish first deployments from retrieval errors and corruption.""" + import json + + lookup = run("ls-remote", "--exit-code", "--heads", "origin", "gh-pages") + if lookup.returncode == MISSING_REF: + if path.exists(): + raise RuntimeError("Unexpected local snapshot on first deployment") + return False + if lookup.returncode: + raise RuntimeError( + "Cannot inspect snapshot branch; refusing to deploy" + ) + fetched = run("fetch", "--no-tags", "--depth=1", "origin", "gh-pages") + if fetched.returncode: + raise RuntimeError("Cannot fetch snapshot branch; refusing to deploy") + listing = run( + "ls-tree", "--name-only", "FETCH_HEAD", "analytics/data.json" + ) + if listing.returncode: + raise RuntimeError("Cannot inspect snapshot tree; refusing to deploy") + if not listing.stdout.strip(): + if path.exists(): + raise RuntimeError("Unexpected local snapshot without remote copy") + return False + blob = run("show", "FETCH_HEAD:analytics/data.json") + if blob.returncode: + raise RuntimeError("Cannot read snapshot; refusing to deploy") + report = json.loads(blob.stdout) + validate(report) + write_snapshot(path, report) + return True + + +if __name__ == "__main__": + restored = restore() + print( + "Analytics snapshot restored." if restored else "No previous snapshot." + ) diff --git a/tests/analytics-js.test.cjs b/tests/analytics-js.test.cjs new file mode 100644 index 00000000..914e98e8 --- /dev/null +++ b/tests/analytics-js.test.cjs @@ -0,0 +1,62 @@ +/* Synthetic DOM/time tests; no browser or third-party dependencies required. */ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const code = fs.readFileSync("theme/js/analytics.js", "utf8"); +const generated = Date.parse("2026-01-01T06:23:00Z"); +const threeDays = 3 * 24 * 60 * 60 * 1000; + +function run(age, available = true) { + let now = generated + age; + const notice = { hidden: false }; + let interval; + let visibility; + vm.runInNewContext(code, { + Date: { parse: Date.parse, now: () => now }, + Number, + window: { + setInterval: (fn) => { + interval = fn; + }, + }, + document: { + getElementById: (id) => + !available + ? null + : id === "analytics-stale" + ? notice + : { dateTime: new Date(generated).toISOString() }, + addEventListener: (_, fn) => { + visibility = fn; + }, + }, + }); + return { + notice, + interval, + visibility, + setAge: (age) => { + now = generated + age; + }, + }; +} + +test("stale only after three days", () => { + assert.equal(run(threeDays).notice.hidden, true); + assert.equal(run(threeDays + 1).notice.hidden, false); +}); + +test("already open and background tabs age without another deployment", () => { + const page = run(0); + page.setAge(threeDays + 1); + page.interval(); + assert.equal(page.notice.hidden, false); + page.setAge(0); + page.visibility(); + assert.equal(page.notice.hidden, true); +}); + +test("unavailable state needs no timer or refresh date", () => { + assert.equal(run(0, false).interval, undefined); +}); diff --git a/tests/browser_analytics.py b/tests/browser_analytics.py new file mode 100644 index 00000000..e0f5c78f --- /dev/null +++ b/tests/browser_analytics.py @@ -0,0 +1,83 @@ +"""Optional real-browser checks and local preview screenshots.""" + +import argparse +import json + +from pathlib import Path +from urllib.parse import urlsplit + +REQUIRED_MOBILE_WIDTH = 390 + + +def check(url: str, output: Path) -> None: + """Inspect responsive color modes without sending synthetic GA traffic.""" + from playwright.sync_api import sync_playwright + + if urlsplit(url).hostname not in {"localhost", "127.0.0.1"}: + raise ValueError("Use a local preview URL, not the production website") + output.mkdir(parents=True, exist_ok=True) + with sync_playwright() as engine: + browser = engine.chromium.launch() + for width in (1440, REQUIRED_MOBILE_WIDTH, 320): + for mode, color in (("lit", "light"), ("dim", "dark")): + context = browser.new_context( + viewport={"width": width, "height": 1000}, + color_scheme=color, + is_mobile=width <= REQUIRED_MOBILE_WIDTH, + has_touch=width <= REQUIRED_MOBILE_WIDTH, + ) + context.route( + "**/*googletagmanager.com/**", lambda r: r.abort() + ) + context.route( + "**/*google-analytics.com/**", lambda r: r.abort() + ) + context.add_init_script( + "localStorage.setItem('osl-color-mode', " + f"{json.dumps(mode)})" + ) + page = context.new_page() + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto(url, wait_until="networkidle") + if page.locator("html").get_attribute("data-mode") != mode: + raise AssertionError("Color mode not applied") + if not page.locator("[data-analytics-report]").is_visible(): + raise AssertionError("Analytics report missing") + overflow = page.evaluate( + "document.documentElement.scrollWidth > innerWidth + 1" + ) + if overflow: + raise AssertionError(f"Horizontal overflow at {width}px") + page.locator("a[download]").focus() + if not page.locator("a[download]").evaluate( + "el => el === document.activeElement" + ): + raise AssertionError("Download link is not focusable") + page.screenshot( + path=str(output / f"analytics-{width}-{mode}.png"), + full_page=True, + ) + if errors: + raise AssertionError(errors) + context.close() + context = browser.new_context(java_script_enabled=False) + page = context.new_page() + page.goto(url) + if not page.locator("[data-analytics-report]").is_visible(): + raise AssertionError("Report requires JavaScript") + context.close() + browser.close() + print(f"Browser smoke checks passed; inspect screenshots in {output}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "url", nargs="?", default="http://localhost:8000/analytics/" + ) + parser.add_argument( + "--output", type=Path, default=Path(".cache/analytics-screenshots") + ) + args = parser.parse_args() + check(args.url, args.output) diff --git a/tests/fixtures/analytics.json b/tests/fixtures/analytics.json new file mode 100644 index 00000000..4096037e --- /dev/null +++ b/tests/fixtures/analytics.json @@ -0,0 +1,101 @@ +{ + "schema_version": 1, + "data_kind": "fixture", + "status": "available", + "source": { + "publisher": "Open Science Labs", + "system": "Google Analytics 4", + "api": "Google Analytics Data API v1beta" + }, + "site": "https://opensciencelabs.org", + "hostnames": ["opensciencelabs.org"], + "timezone": "America/New_York", + "generated_at": "2026-09-16T06:23:00Z", + "reporting_period": { + "start": "2026-08-17", + "end": "2026-09-15" + }, + "history_period": { + "start": "2025-09-01", + "end": "2026-08-31" + }, + "summary": { + "pageviews": 1234, + "active_users": 345, + "sessions": 678 + }, + "monthly_history": [ + { + "month": "2025-09", + "start": "2025-09-01", + "end": "2025-09-30", + "pageviews": 210 + }, + { + "month": "2025-10", + "start": "2025-10-01", + "end": "2025-10-31", + "pageviews": 280 + }, + { + "month": "2025-11", + "start": "2025-11-01", + "end": "2025-11-30", + "pageviews": 0 + }, + { + "month": "2025-12", + "start": "2025-12-01", + "end": "2025-12-31", + "pageviews": 345 + }, + { + "month": "2026-01", + "start": "2026-01-01", + "end": "2026-01-31", + "pageviews": 410 + }, + { + "month": "2026-02", + "start": "2026-02-01", + "end": "2026-02-28", + "pageviews": 490 + }, + { + "month": "2026-03", + "start": "2026-03-01", + "end": "2026-03-31", + "pageviews": 450 + }, + { + "month": "2026-04", + "start": "2026-04-01", + "end": "2026-04-30", + "pageviews": 520 + }, + { + "month": "2026-05", + "start": "2026-05-01", + "end": "2026-05-31", + "pageviews": 640 + }, + { + "month": "2026-06", + "start": "2026-06-01", + "end": "2026-06-30", + "pageviews": 810 + }, + { + "month": "2026-07", + "start": "2026-07-01", + "end": "2026-07-31", + "pageviews": 920 + }, + { + "month": "2026-08", + "start": "2026-08-01", + "end": "2026-08-31", + "pageviews": 1100 + } + ] +} diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 00000000..bf34df65 --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,490 @@ +"""Offline synthetic tests; none of these numbers are production statistics.""" + +from __future__ import annotations + +import copy +import json +import os +import tempfile +import unittest + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace as Obj +from unittest.mock import patch + +from jsonschema import Draft202012Validator, ValidationError + +from scripts.analytics import export, hook, report, restore +from scripts.analytics.audit import audit + +NOW = datetime(2026, 9, 16, 6, 23, tzinfo=timezone.utc) +SETTINGS = export.Settings("123456789", ["opensciencelabs.org"]) +FIXTURE = Path(__file__).parent / "fixtures/analytics.json" + + +def response(metrics, rows, dimensions=(), zone="America/New_York", **meta): + """Build an SDK-shaped synthetic response, not an API recording.""" + metadata = dict( + time_zone=zone, + empty_reason="", + subject_to_thresholding=False, + data_loss_from_other_row=False, + sampling_metadatas=[], + data_truncation_reasons=[], + schema_restriction_response=Obj(active_metric_restrictions=[]), + ) + metadata.update(meta) + return Obj( + metadata=Obj(**metadata), + row_count=len(rows), + metric_headers=[Obj(name=name) for name in metrics], + dimension_headers=[Obj(name=name) for name in dimensions], + rows=[ + Obj( + dimension_values=[Obj(value=value) for value in dims], + metric_values=[Obj(value=str(value)) for value in counts], + ) + for dims, counts in rows + ], + ) + + +class FakeClient: + """Return deterministic fixtures while recording every query.""" + + def __init__(self, results=None): + """Deliberately reorder metrics to test name-based mapping.""" + self.requests = [] + self.results = ( + results + if results is not None + else [ + response(["screenPageViews"], [([], [10])]), + response( + ["sessions", "activeUsers", "screenPageViews"], + [([], [70, 31, 120])], + ), + response( + ["screenPageViews"], + [(["202608"], [100]), (["202607"], [0])], + ["yearMonth"], + ), + ] + ) + + def run_report(self, *, request): + """Never contact any remote service.""" + self.requests.append(request) + result = self.results[len(self.requests) - 1] + if isinstance(result, Exception): + raise result + return result + + +class DateTests(unittest.TestCase): + """Calendar boundaries across timezones, leap days and DST.""" + + def test_year_boundary_and_local_yesterday(self): + """At UTC new year, western properties may still be in December.""" + now = datetime(2026, 1, 1, 0, 30, tzinfo=timezone.utc) + rolling, history = report.periods(now, "America/Los_Angeles") + self.assertEqual(rolling, {"start": "2025-12-01", "end": "2025-12-30"}) + self.assertEqual(history, {"start": "2024-12-01", "end": "2025-11-30"}) + east, _ = report.periods(now, "Pacific/Kiritimati") + self.assertEqual(east["end"], "2025-12-31") + + def test_leap_month(self): + """Use February 29 rather than a fixed month length.""" + rolling, history = report.periods( + datetime(2024, 3, 1, 12, tzinfo=timezone.utc), "UTC" + ) + self.assertEqual(rolling, {"start": "2024-01-31", "end": "2024-02-29"}) + self.assertEqual(history["end"], "2024-02-29") + self.assertEqual(report.month_period("2024-02")["end"], "2024-02-29") + + def test_dst_calendar_days(self): + """Both daylight-saving transitions preserve exactly 30 dates.""" + for now in [ + datetime(2026, 3, 9, 4, 30, tzinfo=timezone.utc), + datetime(2026, 11, 2, 5, 30, tzinfo=timezone.utc), + ]: + with self.subTest(now=now): + rolling, _ = report.periods(now, "America/New_York") + delta = datetime.fromisoformat( + rolling["end"] + ) - datetime.fromisoformat(rolling["start"]) + self.assertEqual(delta.days, 29) + self.assertEqual( + rolling["end"], + (now.date() - timedelta(days=1)).isoformat(), + ) + + def test_aware_time_and_valid_timezone_required(self): + """Never silently use the runner's local timezone.""" + with self.assertRaises(ValueError): + report.periods(datetime(2026, 1, 1), "UTC") + with self.assertRaises(KeyError): + report.periods(NOW, "Not/A_Timezone") + + def test_staleness_strictly_more_than_three_days(self): + """A failed build cannot advance the successful refresh timestamp.""" + fixture = json.loads(FIXTURE.read_text()) + self.assertFalse(report.is_stale(fixture, NOW + timedelta(days=3))) + self.assertTrue( + report.is_stale(fixture, NOW + timedelta(days=3, seconds=1)) + ) + self.assertFalse(report.is_stale(report.unavailable(), NOW)) + + +class ExportTests(unittest.TestCase): + """Query contracts and failure semantics using synthetic SDK responses.""" + + def test_configuration(self): + """Reject missing property IDs, measurement IDs and unsafe scopes.""" + for env in [ + {}, + {"GA4_PROPERTY_ID": "G-ABC"}, + {"GA4_PROPERTY_ID": "123"}, + {"GA4_PROPERTY_ID": "0", "GA4_HOSTNAMES": "example.org"}, + ]: + with self.subTest(env=env), self.assertRaises(ValueError): + export.Settings.from_env(env) + self.assertEqual( + report.hostnames("WWW.Example.org, example.org,example.org"), + ["example.org", "www.example.org"], + ) + for value in [ + "", + "localhost", + "127.0.0.1", + "*.example.org", + "https://example.org", + "example.org/path", + "example.org:443", + "preview.localhost", + "example.org,", + "foo.test", + "-a.org", + ]: + with self.subTest(value=value), self.assertRaises(ValueError): + report.hostnames(value) + + def test_hostname_and_web_filter_on_every_query(self): + """No substrings, previews, apps or implicit www inclusion.""" + client = FakeClient() + export.collect(client, SETTINGS, NOW) + for request in client.requests: + filters = request["dimension_filter"]["and_group"]["expressions"] + self.assertEqual(filters[0]["filter"]["field_name"], "hostName") + host_filter = filters[0]["filter"]["in_list_filter"] + platform_filter = filters[1]["filter"]["string_filter"] + self.assertFalse(host_filter["case_sensitive"]) + self.assertEqual(platform_filter["match_type"], "EXACT") + for host, platform, expected in [ + ("opensciencelabs.org", "web", True), + ("OPENSCIENCELABS.ORG", "Web", True), + ("preview.opensciencelabs.org", "web", False), + ("opensciencelabs.org.evil.org", "web", False), + ("www.opensciencelabs.org", "web", False), + ("localhost", "web", False), + ("other.org", "web", False), + ("opensciencelabs.org", "Android", False), + ]: + self.assertEqual( + host.lower() in host_filter["values"] + and platform.lower() == platform_filter["value"], + expected, + ) + + def test_mapping_and_period_level_users(self): + """Distinct active users come from a dimensionless period query.""" + client = FakeClient() + result = export.collect(client, SETTINGS, NOW) + self.assertEqual( + result["summary"], + {"pageviews": 120, "active_users": 31, "sessions": 70}, + ) + self.assertEqual(client.requests[1]["dimensions"], []) + self.assertEqual( + client.requests[1]["date_ranges"], + [{"start_date": "2026-08-17", "end_date": "2026-09-15"}], + ) + self.assertEqual( + client.requests[2]["metrics"], [{"name": "screenPageViews"}] + ) + self.assertEqual( + client.requests[2]["date_ranges"], + [{"start_date": "2025-09-01", "end_date": "2026-08-31"}], + ) + self.assertEqual( + [item["month"] for item in result["monthly_history"]], + ["2026-07", "2026-08"], + ) + self.assertEqual(result["monthly_history"][0]["pageviews"], 0) + self.assertNotIn("property_id", json.dumps(result)) + + def test_successful_empty_is_zero_but_history_not_invented(self): + """An unrestricted empty summary is not an authentication failure.""" + client = FakeClient() + client.results[1] = response(list(report.METRICS), []) + client.results[2] = response(["screenPageViews"], [], ["yearMonth"]) + result = export.collect(client, SETTINGS, NOW) + self.assertEqual( + result["summary"], dict.fromkeys(report.METRICS.values(), 0) + ) + self.assertEqual(result["monthly_history"], []) + self.assertEqual(result["status"], "available") + + def test_failures_preserve_original_bytes_and_timestamp(self): + """No partial update when any API request or validation fails.""" + failures = [ + RuntimeError("Synthetic API failure"), + response(list(report.METRICS), [], empty_reason="Unavailable"), + response(list(report.METRICS), [], subject_to_thresholding=True), + response(list(report.METRICS), [], data_loss_from_other_row=True), + response(list(report.METRICS), [], sampling_metadatas=[Obj()]), + response( + list(report.METRICS), [], data_truncation_reasons=[Obj()] + ), + response(list(report.METRICS), [], zone="UTC"), + response(["unexpected"], [([], [1])]), + response(list(report.METRICS), [([], [1, 2, -1])]), + ] + original = export.collect(FakeClient(), SETTINGS, NOW) + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "snapshot.json" + report.write_snapshot(path, original) + before = path.read_bytes() + for failure in failures: + with self.subTest(failure=failure): + client = FakeClient() + client.results[1] = failure + with self.assertRaises(Exception): + export.refresh(path, client, SETTINGS, NOW) + self.assertEqual(path.read_bytes(), before) + client = FakeClient() + client.results[2] = RuntimeError( + "History failed after summary succeeded" + ) + with self.assertRaises(RuntimeError): + export.refresh(path, client, SETTINGS, NOW) + self.assertEqual(path.read_bytes(), before) + + def test_new_success_revises_recent_months(self): + """Do not freeze prior months while GA4 is still processing.""" + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "snapshot.json" + export.refresh(path, FakeClient(), SETTINGS, NOW) + client = FakeClient() + client.results[2].rows[0].metric_values[0].value = "105" + export.refresh(path, client, SETTINGS, NOW + timedelta(days=1)) + result = report.read_snapshot(path) + self.assertEqual(result["monthly_history"][-1]["pageviews"], 105) + self.assertNotEqual(result["generated_at"], NOW.isoformat()) + + def test_live_cli_restricted_to_ci_and_missing_configuration(self): + """Local execution and missing CI configuration never touch a file.""" + trusted = dict( + GITHUB_ACTIONS="true", + GITHUB_REPOSITORY=export.REPOSITORY, + GITHUB_REF="refs/heads/main", + GITHUB_EVENT_NAME="schedule", + ) + for env in [ + {}, + trusted, + {**trusted, "GITHUB_EVENT_NAME": "pull_request"}, + ]: + with ( + self.subTest(env=env), + patch.dict("os.environ", env, clear=True), + patch.object(export, "refresh") as refresh, + ): + self.assertEqual(export.main(), 1) + refresh.assert_not_called() + + def test_sdk_request_compatibility_if_installed(self): + """Check real protobuf request construction in credential-free CI.""" + try: + from google.analytics.data_v1beta.types import RunReportRequest + except ImportError: + if os.environ.get("CI") == "true": + raise + self.skipTest("Google SDK unavailable locally; installed in CI.") + client = FakeClient() + export.collect(client, SETTINGS, NOW) + for request in client.requests: + parsed = RunReportRequest(request) + self.assertEqual(parsed.property, "properties/123456789") + + +class ContractTests(unittest.TestCase): + """Closed schema and accidental fixture/credential publication defenses.""" + + def setUp(self): + """Start with an explicitly synthetic, versioned report.""" + self.fixture = json.loads(FIXTURE.read_text()) + + def test_schema_and_fixture_contract(self): + """Both a real empty state and labeled fixtures validate.""" + Draft202012Validator.check_schema( + json.loads(report.SCHEMA.read_text()) + ) + report.validate(report.unavailable()) + report.validate(self.fixture, allow_fixture=True) + with self.assertRaises(ValueError): + report.validate(self.fixture) + + def test_extra_fields_and_invalid_values_rejected(self): + """Only explicitly allowed aggregate fields can reach publication.""" + mutations = [ + lambda data: data.update(access_token="SYNTHETIC"), + lambda data: data["summary"].update(visitor_id="SYNTHETIC"), + lambda data: data["summary"].update(pageviews=-1), + lambda data: data["summary"].update(pageviews=True), + lambda data: data.update(schema_version=2), + lambda data: data.update(generated_at="not-a-date"), + lambda data: data.update(timezone="Invalid/Zone"), + lambda data: data["reporting_period"].update(end="2026-09-16"), + lambda data: data["monthly_history"][0].update(start="2025-09-02"), + lambda data: data["monthly_history"].append( + data["monthly_history"][0] + ), + ] + for mutate in mutations: + with self.subTest(mutate=mutate): + data = copy.deepcopy(self.fixture) + mutate(data) + with self.assertRaises( + (ValueError, ValidationError, KeyError) + ): + report.validate(data, allow_fixture=True) + + def test_absent_and_corrupt_snapshot(self): + """Corruption must block deployment, not erase previous statistics.""" + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "data.json" + self.assertEqual( + report.read_snapshot(path)["status"], "unavailable" + ) + path.write_text("{broken") + with self.assertRaises(ValueError): + report.read_snapshot(path) + + def test_hook_emits_same_report_and_blocks_ci_fixtures(self): + """HTML context and JSON use the identical validated snapshot.""" + with tempfile.TemporaryDirectory() as folder: + config = Obj(extra={}, site_dir=folder) + with patch.dict( + "os.environ", + {"ANALYTICS_PREVIEW_FIXTURE": str(FIXTURE)}, + clear=True, + ): + hook.on_config(config) + hook.on_post_build(config) + self.assertEqual( + json.loads((Path(folder) / "analytics/data.json").read_text()), + config.extra["analytics_report"], + ) + with ( + patch.dict( + "os.environ", + {"CI": "true", "ANALYTICS_PREVIEW_FIXTURE": str(FIXTURE)}, + clear=True, + ), + self.assertRaises(ValueError), + ): + hook.on_config(config) + + def test_credential_audit(self): + """Detect accidentally copied credential files before publishing.""" + with tempfile.TemporaryDirectory() as folder: + directory = Path(folder) + report.write_snapshot( + directory / "analytics/data.json", report.unavailable() + ) + audit(directory) + for name, content in [ + ("gha-creds-test.json", "{}"), + ("secret.json", '{"type":"external_account"}'), + ("secret.txt", '{"access_token":"SYNTHETIC"}'), + ]: + path = directory / name + path.write_text(content) + with self.assertRaises(ValueError): + audit(directory) + path.unlink() + report.write_snapshot( + directory / "analytics/data.json", + self.fixture, + allow_fixture=True, + ) + with self.assertRaises(ValueError): + audit(directory) + + +class RestoreTests(unittest.TestCase): + """Durable snapshot retrieval must distinguish missing from failed.""" + + def test_restore_and_transport_failures(self): + """Keep timestamps; abort on unreadable remote state.""" + original = export.collect(FakeClient(), SETTINGS, NOW) + ok = Obj(returncode=0, stdout="") + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "data.json" + for results in [ + [Obj(returncode=128, stdout="")], + [ok, Obj(returncode=128, stdout="")], + [ok, ok, Obj(returncode=128, stdout="")], + [ + ok, + ok, + Obj(returncode=0, stdout="analytics/data.json"), + Obj(returncode=128, stdout=""), + ], + ]: + with ( + self.subTest(results=results), + self.assertRaises(RuntimeError), + ): + restore.restore( + path, run=unittest.mock.Mock(side_effect=results) + ) + self.assertFalse(path.exists()) + self.assertFalse( + restore.restore( + path, + run=unittest.mock.Mock( + return_value=Obj(returncode=2, stdout="") + ), + ) + ) + self.assertFalse( + restore.restore( + path, run=unittest.mock.Mock(side_effect=[ok, ok, ok]) + ) + ) + steps = [ + ok, + ok, + Obj(returncode=0, stdout="analytics/data.json"), + Obj(returncode=0, stdout=json.dumps(original)), + ] + self.assertTrue( + restore.restore( + path, run=unittest.mock.Mock(side_effect=steps) + ) + ) + self.assertEqual(report.read_snapshot(path), original) + before = path.read_bytes() + steps[-1] = Obj(returncode=0, stdout="{broken") + with self.assertRaises(ValueError): + restore.restore( + path, run=unittest.mock.Mock(side_effect=steps) + ) + self.assertEqual(path.read_bytes(), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_analytics_presentation.py b/tests/test_analytics_presentation.py new file mode 100644 index 00000000..d119bf43 --- /dev/null +++ b/tests/test_analytics_presentation.py @@ -0,0 +1,177 @@ +"""Render real analytics templates offline with synthetic reports.""" + +import json +import unittest + +from pathlib import Path +from types import SimpleNamespace as Obj + +import yaml + +from bs4 import BeautifulSoup +from jinja2 import ChoiceLoader, DictLoader, Environment, FileSystemLoader + +from scripts.analytics.export import Settings +from scripts.analytics.report import unavailable + + +class PresentationTests(unittest.TestCase): + """Static accessibility and data consistency, not browser visual tests.""" + + def render(self, report, stale=False): + """Use the real analytics template while isolating the shared shell.""" + env = Environment( + loader=ChoiceLoader( + [ + DictLoader( + { + "main.html": ( + "{% block content_inner %}" "{% endblock %}" + ) + } + ), + FileSystemLoader("theme"), + ] + ) + ) + env.filters["url"] = lambda value: "/" + value + html = env.get_template("analytics.html").render( + config=Obj( + extra=Obj(analytics_report=report, analytics_stale=stale) + ), + page=Obj(content=""), + ) + return BeautifulSoup(html, "html.parser") + + def test_unavailable_has_no_fake_metrics(self): + """First deployments expose absence, not synthetic numbers.""" + page = self.render(unavailable()) + self.assertIn("Analytics data is not available yet", page.get_text()) + self.assertFalse(page.select(".analytics-metrics")) + self.assertFalse(page.select("time")) + self.assertEqual( + page.select_one("a[download]")["href"], "/analytics/data.json" + ) + + def test_report_table_and_cards_match_json(self): + """All data remains available without JavaScript or a chart library.""" + fixture = json.loads(Path("tests/fixtures/analytics.json").read_text()) + page = self.render(fixture) + self.assertIn("TEST FIXTURE", page.get_text()) + self.assertEqual( + [node.get_text() for node in page.select(".analytics-metrics dd")], + [f"{value:,}" for value in fixture["summary"].values()], + ) + rows = page.select("tbody tr") + self.assertEqual(len(rows), len(fixture["monthly_history"])) + for node, item in zip(rows, fixture["monthly_history"], strict=True): + self.assertEqual( + node.select_one('th[scope="row"]').get_text(), item["month"] + ) + self.assertIn(f'{item["pageviews"]:,}', node.get_text()) + self.assertEqual( + [time["datetime"] for time in node.select("td time")], + [item["start"], item["end"]], + ) + self.assertIsNotNone(page.select_one("table caption")) + self.assertEqual(len(page.select('thead th[scope="col"]')), 3) + self.assertEqual( + page.select_one("#analytics-refreshed")["datetime"], + fixture["generated_at"], + ) + self.assertTrue(page.select_one("#analytics-stale").has_attr("hidden")) + + def test_zero_chart_and_stale_state(self): + """Avoid zero division; indicate staleness in text.""" + fixture = json.loads(Path("tests/fixtures/analytics.json").read_text()) + for month in fixture["monthly_history"]: + month["pageviews"] = 0 + page = self.render(fixture, stale=True) + self.assertTrue( + all( + bar["style"] == "width: 0%" + for bar in page.select(".analytics-chart-bar") + ) + ) + notice = page.select_one("#analytics-stale") + self.assertFalse(notice.has_attr("hidden")) + self.assertIn("Stale data", notice.get_text()) + + +class WorkflowTests(unittest.TestCase): + """Guard the credential-free PR path and scheduled publication wiring.""" + + def test_shared_analytics_configuration(self): + """Use workflow identifiers without repository-variable overrides.""" + workflow_text = Path(".github/workflows/main.yaml").read_text() + workflow = yaml.load( + workflow_text, + Loader=yaml.BaseLoader, + ) + build = workflow["jobs"]["build"] + settings = Settings.from_env(build["env"]) + self.assertEqual(settings.property_id, "365530978") + self.assertEqual(settings.hosts, ["opensciencelabs.org"]) + self.assertEqual( + build["env"]["GA4_SERVICE_ACCOUNT"], + "osl-analytics-exporter@osl-general.iam.gserviceaccount.com", + ) + self.assertEqual( + build["env"]["GA4_WIF_PROVIDER"], + "projects/11701823742/locations/global/" + "workloadIdentityPools/osl-analytics/providers/github", + ) + self.assertNotIn("GA4_ACCESS_TOKEN", build["env"]) + self.assertNotIn("vars.GA4_", workflow_text) + self.assertNotIn("secrets.GA4_", workflow_text) + for step in build["steps"]: + with self.subTest(step=step.get("name", step.get("uses"))): + self.assertTrue( + build["env"].keys().isdisjoint(step.get("env", {})) + ) + + def test_workflow_safety_contract(self): + """Cover scheduled and content runs with one deployment lock.""" + workflow = yaml.load( + Path(".github/workflows/main.yaml").read_text(), + Loader=yaml.BaseLoader, + ) + self.assertEqual(workflow["on"]["schedule"][0]["cron"], "23 6 * * *") + self.assertIn("workflow_dispatch", workflow["on"]) + self.assertIn("osl-production-pages", workflow["concurrency"]["group"]) + self.assertIn( + "pull_request", workflow["concurrency"]["cancel-in-progress"] + ) + steps = workflow["jobs"]["build"]["steps"] + auth = next(step for step in steps if step.get("id") == "google_auth") + self.assertEqual( + auth["with"]["service_account"], "${{ env.GA4_SERVICE_ACCOUNT }}" + ) + self.assertEqual( + auth["with"]["workload_identity_provider"], + "${{ env.GA4_WIF_PROVIDER }}", + ) + self.assertEqual(auth["with"]["create_credentials_file"], "false") + self.assertEqual(auth["with"]["export_environment_variables"], "false") + self.assertEqual( + auth["with"]["access_token_scopes"], + "https://www.googleapis.com/auth/analytics.readonly", + ) + restore = next( + step + for step in steps + if step.get("run") == "python -m scripts.analytics.restore" + ) + self.assertIn("!= 'pull_request'", restore["if"]) + deploy = workflow["jobs"]["deploy"] + self.assertIn("'schedule'", deploy["if"]) + self.assertIn("refs/heads/main", deploy["if"]) + self.assertIn( + "actions/deploy-pages@v4", + [step.get("uses") for step in deploy["steps"]], + ) + self.assertEqual(deploy["needs"], "build") + + +if __name__ == "__main__": + unittest.main() diff --git a/theme/analytics.html b/theme/analytics.html new file mode 100644 index 00000000..9b7846b3 --- /dev/null +++ b/theme/analytics.html @@ -0,0 +1,87 @@ +{% extends "main.html" %} + +{% block header_extra %} + + +{% endblock header_extra %} + +{% block content_inner %} +{% set report = config.extra.analytics_report %} +
+ {% if report.data_kind == 'fixture' %} +

TEST FIXTURE — LOCAL PREVIEW ONLY. These are synthetic values, not OSL traffic.

+ {% endif %} +

OSL-published data · Sourced from Google Analytics

+ {% if report.status == 'available' %} +
+

Last 30 completed days

+

+ + through , inclusive. +

+
+
Pageviews
{{ '{:,}'.format(report.summary.pageviews) }}
+
Active users
{{ '{:,}'.format(report.summary.active_users) }}
+
Sessions
{{ '{:,}'.format(report.summary.sessions) }}
+
+
+
Reporting timezone
{{ report.timezone | e }}
+
Hostname scope · Web only
{{ report.hostnames | join(', ') | e }}
+
Last successful refresh
(UTC)
+
+

+ Stale data. The last successful refresh was more than three days ago. These are the last available figures, not a current report. +

+ +
+ {% else %} +
+

Analytics data is not available yet

+

We have not published a successful GA4 report. Reporting dates, timezone, hostname scope, and audience figures will appear after the first refresh. Missing data does not mean zero visits.

+
+ {% endif %} +

+ Download JSON report + Sponsor Open Science Labs +

+ {% if report.status == 'available' %} +
+

Monthly pageviews

+

Up to 12 completed months. Requested range: {{ report.history_period.start }} through {{ report.history_period.end }} ({{ report.timezone | e }}).

+ {% if report.monthly_history %} + {% set max_views = report.monthly_history | map(attribute='pageviews') | max %} +
+
Pageviews by month · exact values and dates in the table below.
+ +
+
+ + + + + {% for item in report.monthly_history %} + + + + + + {% endfor %} + +
Monthly pageviews. Dates are inclusive in {{ report.timezone | e }}.
MonthReporting datesPageviews
{{ '{:,}'.format(item.pageviews) }}
+
+ {% else %} +

No completed-month history is available for this scope. No monthly values have been inferred.

+ {% endif %} +
+ {% endif %} +
+{{ page.content }} +{% endblock content_inner %} diff --git a/theme/base.html b/theme/base.html index c70aaf3a..984228e7 100644 --- a/theme/base.html +++ b/theme/base.html @@ -5,7 +5,7 @@ - + {% set page_title = page.title %} {% if page_title %}{{ page_title }} · {% endif %}{{ config.site_name }} diff --git a/theme/css/analytics.css b/theme/css/analytics.css new file mode 100644 index 00000000..c32c0174 --- /dev/null +++ b/theme/css/analytics.css @@ -0,0 +1,87 @@ +/* Uses the shared lit/dim theme tokens; no chart library or external assets. */ +.osl-page--section-analytics { + /* The about palette is too pale for small links on light surfaces. */ + --osl-accent: color-mix(in srgb, var(--brand-strong) 85%, var(--fg)); +} +.analytics-report .osl-inline-button { + background: var(--surface-solid); + border-color: var(--osl-accent); + color: var(--osl-accent) !important; +} +.analytics-report { min-width: 0; } +.analytics-source { color: var(--muted); font-weight: 600; } +.analytics-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} +.analytics-metrics > div { + padding: 1.3rem; + background: var(--surface-solid); + border: 1px solid var(--border-strong); + border-top: 4px solid var(--brand-strong); + border-radius: 12px; +} +.analytics-metrics dt { color: var(--muted); font-weight: 600; } +.analytics-metrics dd { + margin: .4rem 0 0; + color: var(--heading); + font-size: clamp(1.6rem, 3vw, 2.5rem); + font-weight: 700; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.analytics-details > div { margin-bottom: .8rem; } +.analytics-details dt { font-weight: 600; } +.analytics-details dd { margin: .2rem 0 0; overflow-wrap: anywhere; } +.analytics-notice, .analytics-empty { + padding: 1rem 1.25rem; + border: 1px solid var(--border-strong); + border-left: 4px solid var(--accent); + border-radius: 8px; + background: var(--surface-warm); + color: var(--fg); +} +.analytics-report [hidden] { display: none !important; } +.analytics-actions { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; } +.analytics-chart { + padding: 1.25rem; + margin: 1.5rem 0; + border: 1px solid var(--border-strong); + border-radius: 12px; + background: var(--surface-solid); +} +.analytics-chart figcaption { color: var(--muted); margin-bottom: 1rem; } +.analytics-chart-row { + display: grid; + grid-template-columns: 5rem minmax(0, 1fr) 5rem; + gap: .75rem; + align-items: center; + margin: .55rem 0; + font-variant-numeric: tabular-nums; +} +.analytics-chart-row > :last-child { text-align: right; overflow-wrap: anywhere; } +.analytics-chart-track { background: var(--surface-muted); border-radius: 3px; } +.analytics-chart-bar { + display: block; + height: 1rem; + border-radius: 3px; + background: var(--brand-strong); +} +.analytics-table-wrap { max-width: 100%; overflow-x: auto; margin-bottom: 2rem; } +.analytics-table { font-variant-numeric: tabular-nums; } +.analytics-table caption { caption-side: top; color: var(--muted); } +.analytics-table th, .analytics-table td { color: var(--fg); padding: .65rem; } +.analytics-table td:last-child, .analytics-table th:last-child { text-align: right; } +.analytics-report a:focus-visible, .analytics-table-wrap:focus-visible { + outline: 3px solid var(--brand-strong); + outline-offset: 4px; +} +@media (max-width: 600px) { + .analytics-metrics { grid-template-columns: 1fr; gap: .6rem; } + .analytics-metrics > div { padding: 1rem; } + .analytics-chart { padding: .85rem; } + .analytics-chart-row { grid-template-columns: 4.5rem minmax(0, 1fr) 4rem; gap: .4rem; font-size: .85rem; } + .analytics-table { font-size: .85rem; } +} diff --git a/theme/js/analytics.js b/theme/js/analytics.js new file mode 100644 index 00000000..962e57a5 --- /dev/null +++ b/theme/js/analytics.js @@ -0,0 +1,15 @@ +/* Progressive enhancement only: figures, dates and table are static HTML. */ +(() => { + const refreshed = document.getElementById("analytics-refreshed"); + const notice = document.getElementById("analytics-stale"); + if (!refreshed || !notice) return; + const generatedAt = Date.parse(refreshed.dateTime); + if (!Number.isFinite(generatedAt)) return; + const update = () => { + notice.hidden = Date.now() - generatedAt <= 3 * 24 * 60 * 60 * 1000; + }; + update(); + // Also age a page that stays open across the three-day boundary. + window.setInterval(update, 60 * 1000); + document.addEventListener("visibilitychange", update); +})();