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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ follows [Semantic Versioning](https://semver.org/).
`@ahtmljs/agent` (conditional polling, unref'd timer; new optional
`FetchOptions.revalidate`). `ahtml diff <a> <b> [--json] [--fail-on
breaking|notable]` in `@ahtmljs/cli` for CI gating. See `docs/diffing.md`.
- **Anonymous runtime usage analytics (PostHog, EU)** — every `@ahtmljs/*` library and the `ahtml` CLI now send aggregated feature counts (feature name, count, package/version, runtime, OS, arch, CI flag, hashed anonymous install id; no URLs, hostnames, paths, content or CLI arguments; GeoIP disabled, no person profiles) via a dependency-free client, `track()` / `flushTelemetry()` in `@ahtmljs/schema` (`packages/schema/src/telemetry.ts`). Always on, no opt-out switch; skipped inside `node --test`. Every package gained a `src/version.ts` (kept in sync by `scripts/bump-version.mjs`). See README "Usage analytics".
- **Anonymous runtime usage analytics (PostHog, EU)** — every `@ahtmljs/*` library and the `ahtml` CLI now send aggregated feature counts (feature name, count, package/version, runtime, OS, arch, CI flag, hashed anonymous install id; no URLs, hostnames, paths, content or CLI arguments; PostHog derives approximate country/city from the request IP at ingestion and the project discards client IPs, so none are stored) via a dependency-free client, `track()` / `flushTelemetry()` in `@ahtmljs/schema` (`packages/schema/src/telemetry.ts`). Always on, no opt-out switch; skipped inside `node --test`. Every package gained a `src/version.ts` (kept in sync by `scripts/bump-version.mjs`). See README "Usage analytics".
- **Richer usage analytics so real users can be told apart from CI/build/test traffic** — every PostHog event now also carries `env_class` (`test` | `ci` | `build` | `serverless` | `dev` | `server` | `cli_interactive` | `browser` | `unknown`), `ci_provider`, `hosting`, `is_tty`, `is_container`, `node_env`, `package_manager` (name only) and a per-process UUIDv7 `$session_id`, all derived from env-var presence, never values. Events now create anonymous person profiles keyed by the anonymous install id (`$process_person_profile: true`, `$set` `last_*`, `$set_once` `first_*`) to enable cohorts, retention and lifecycle; constructing an `AHTMLError` emits `error.<code>` (code only, never message/context/cause). Location is derived from the request IP at ingestion and IPs are discarded. See README "Usage analytics".
- **Install analytics via Scarf** — all 16 published packages now depend on `@scarf/scarf`, which sends anonymous install analytics on `npm install`; opt out with `SCARF_ANALYTICS=false` or `"scarfSettings": { "enabled": false }` (see README "Install analytics (Scarf)").

Planned post-1.0:
Expand Down
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,7 @@ cli, ahtml doctor, ahtml score, ahtml kv, ahtml webmcp.

The `@ahtmljs/*` libraries and the `ahtml` CLI send **anonymous, aggregated usage counts** to [PostHog](https://posthog.com) (EU Cloud, `eu.i.posthog.com`) so we can see which features are actually used. It is always on; there is no opt-out environment variable. The whole client is one dependency-free file you can audit: [`packages/schema/src/telemetry.ts`](packages/schema/src/telemetry.ts).

**Exactly what is sent.** One event per feature per flush (for example `snapshot.build`, `adapter.serve`, `cli.doctor`), with these fields and nothing else:
**Exactly what is sent.** One event per feature per flush (for example `snapshot.build`, `adapter.serve`, `cli.doctor`, or `error.<code>` such as `error.rate_limited` when an `AHTMLError` is constructed: the error code only, never its message, context or cause), with these fields and nothing else:

| Field | Example | Meaning |
|---|---|---|
Expand All @@ -821,14 +821,25 @@ The `@ahtmljs/*` libraries and the `ahtml` CLI send **anonymous, aggregated usag
| `pkg`, `pkg_version` | `@ahtmljs/next`, `1.1.0` | Which package and version. |
| `runtime`, `runtime_version` | `node`, `22.21.1` | `node`, `bun`, `deno`, `workerd`, `browser` or `unknown`, and its version. |
| `os`, `arch` | `linux`, `x64` | Operating system and CPU architecture. |
| `ci` | `false` | Whether a CI environment variable (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`) is set. |
| `ci` | `false` | `true` when `ci_provider` is anything but `none`. |
| `ci_provider` | `github_actions` | `github_actions`, `gitlab`, `circleci`, `buildkite`, `jenkins`, `travis`, `azure_pipelines`, `bitbucket`, `vercel`, `netlify`, `cloudflare_pages`, `render`, `aws_codebuild`, `other_ci` (a generic `CI` variable only) or `none`. |
| `env_class` | `server` | What kind of run this is, so real usage can be told apart from automation: `test`, `ci`, `build`, `serverless`, `dev`, `server`, `cli_interactive`, `browser` or `unknown`. First match wins: a test runner (Vitest, Jest, Mocha, AVA), then CI, then a build step (`npm run build`/`prepare`/`generate`/`export`..., or a Next.js production build), then a serverless runtime (AWS Lambda, Vercel, Netlify, Google Cloud Run/Functions, Azure Functions, Cloudflare Workers), then a browser, then the `ahtml` CLI in an interactive terminal, then `dev` (`NODE_ENV=development` or an `npm run dev`/`serve`/`watch` script), then `server` (`NODE_ENV=production`). |
| `hosting` | `vercel` | `vercel`, `netlify`, `cloudflare`, `aws_lambda`, `cloud_run`, `fly`, `railway`, `render`, `heroku` or `none`. |
| `is_tty` | `true` | Whether standard output is an interactive terminal (Node, Bun, Deno; `false` elsewhere). |
| `is_container` | `false` | Whether the process looks containerized (`/.dockerenv` exists or `KUBERNETES_SERVICE_HOST` is set; Node only for the file check). |
| `node_env` | `production` | `development`, `production`, `test`, `other` (set to anything else) or `unset`. |
| `package_manager` | `pnpm` | `npm`, `pnpm`, `yarn`, `bun` or `unknown`, from the name at the start of `npm_config_user_agent` (never the version). |
| `$session_id` | `0199...` | A random UUIDv7 generated once per process, so PostHog can group one run's events into a session. |
| `distinct_id` | `3f9a1c07b2d84e65` | Anonymous install id: the first 16 hex characters of `SHA-256(hostname \| working directory \| "ahtml-v1")`. It is a one-way hash, so the hostname and path cannot be recovered from it. In browsers and Workers, where there is no filesystem identity, it is a random per-process id. |
| `timestamp` | ISO 8601 | When the feature last ran. |
| `$process_person_profile` | `false` | Tells PostHog not to create a person profile. |
| `$geoip_disable` | `true` | Tells PostHog not to enrich the event with GeoIP location. |
| `$process_person_profile` | `true` | Lets PostHog keep an **anonymous person profile keyed by the anonymous install id** (no name, email or account), which enables cohorts, retention and lifecycle charts. |
| `$set` | `{ last_env_class, last_runtime, last_pkg_version, last_os }` | Person properties updated on each event: the latest environment class, runtime, package version and OS. |
| `$set_once` | `{ first_env_class, first_pkg, first_pkg_version, first_runtime, first_seen_os }` | Person properties recorded the first time only. |
| `$lib` | `ahtml` | Identifies the sender. |

**Never sent:** URLs, hostnames, file paths, page or snapshot content, CLI arguments (only the fixed command name, such as `cli.doctor`), environment variable values, tokens, or anything about your users. IP addresses are visible to any server you make an HTTPS request to, so we ask PostHog to discard them: events carry `$geoip_disable: true` and the PostHog project is configured to discard client IP data.
The environment fields are derived from whether certain environment variables exist (for example `GITHUB_ACTIONS` or `VERCEL`), never from their contents: only the fixed values listed above are ever sent.

**Never sent:** URLs, hostnames, file paths, page or snapshot content, CLI arguments (only the fixed command name, such as `cli.doctor`), environment variable values, error messages, tokens, or anything about your users. IP addresses are visible to any server you make an HTTPS request to. PostHog derives approximate location (country/city) from the request IP at ingestion; the project is configured to discard client IP addresses, so IPs are not stored.

**Behavior.** Counts are aggregated in memory and sent in batches of at most 50 events, about 2 seconds after the first use, then every 60 seconds, and when the process exits (the CLI flushes before it exits). Timers never keep your process alive. Requests time out after 1.5 seconds; on any failure the batch is dropped silently. The client never throws, logs, or blocks your application. Nothing is sent inside Node's built-in test runner (`node --test`). If your network policy forbids this traffic, block `eu.i.posthog.com` at egress: AHTML keeps working and discards the counts.

Expand Down
20 changes: 16 additions & 4 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,22 @@ The first form is actionable and earns developer trust. The second is generic.
AHTML collects **anonymous usage analytics**, in two places:

- **Runtime (PostHog, EU):** the libraries and the `ahtml` CLI send aggregated
feature counts (feature name, count, package and version, runtime, OS, CPU
architecture, CI flag, and an anonymous hashed install id) to PostHog. No
URLs, hostnames, file paths, page content or CLI arguments are sent; GeoIP is
disabled and no person profiles are created. It is always on, with no opt-out
feature counts to PostHog, plus `error.<code>` counts when an `AHTMLError` is
constructed (the code only, never the message, context or cause). Each event
carries: `pkg`, `pkg_version`, `count`, `runtime`, `runtime_version`, `os`,
`arch`, `ci`, `ci_provider`, `env_class` (`test`, `ci`, `build`,
`serverless`, `dev`, `server`, `cli_interactive`, `browser`, `unknown`),
`hosting`, `is_tty`, `is_container`, `node_env`, `package_manager` (name
only, no version), a per-process UUIDv7 `$session_id`, and an anonymous hashed
install id as `distinct_id`. The environment fields come from whether
environment variables exist, never from their values. Events also set an
**anonymous person profile keyed by that anonymous install id** (`$set`:
`last_env_class`, `last_runtime`, `last_pkg_version`, `last_os`; `$set_once`:
`first_env_class`, `first_pkg`, `first_pkg_version`, `first_runtime`,
`first_seen_os`), with no name, email or account. No URLs, hostnames, file
paths, page content or CLI arguments are sent. PostHog derives approximate
location (country/city) from the request IP at ingestion; the project
discards client IPs, so they are not stored. It is always on, with no opt-out
switch; see "Usage analytics" in the root `README.md` for the full field list.
- **Install time (Scarf):** the published packages depend on `@scarf/scarf`,
which sends anonymous install analytics to Scarf (OS info, package and
Expand Down
60 changes: 45 additions & 15 deletions examples/landing/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Footer from '@/components/Footer';
export const metadata: Metadata = {
title: 'Privacy',
description:
'How AHTML handles data: an open-source library that runs on your servers — we collect no personal data through it, only anonymous aggregate usage counts. Marketing-site analytics are scoped and listed.',
'How AHTML handles data: an open-source library that runs on your servers, with anonymous usage analytics listed field by field, and a cookieless marketing site. No cookies, no consent banner, no ad trackers.',
alternates: { canonical: '/privacy' },
};

Expand All @@ -15,38 +15,65 @@ export default function PrivacyPage() {
<Header />
<main className="section tall">
<div className="container" style={{ maxWidth: '64ch' }}>
<div className="eyebrow">Privacy · effective 2026-05-15</div>
<div className="eyebrow">Privacy · effective 2026-10-08</div>
<h1 style={{ fontSize: 'clamp(40px, 6vw, 72px)' }}>Privacy policy.</h1>
<p className="lede" style={{ marginTop: 32 }}>
AHTML is an open-source library that runs on <em>your</em> servers.
We don&apos;t collect data from your site or your users through it. This
page covers how the marketing site <code>ahtml.dev</code> handles
the small set of data it does see.
We don&apos;t collect data from your site or your users through it.
This page lists exactly what the packages and the marketing site{' '}
<code>ahtml.dev</code> do send.
</p>

<h2 style={{ fontSize: 32, marginTop: 64, marginBottom: 16 }}>The library</h2>
<h2 style={{ fontSize: 32, marginTop: 64, marginBottom: 16 }}>The npm packages</h2>
<p>
The npm packages (<code>@ahtmljs/next</code>, <code>@ahtmljs/vite</code>,{' '}
<code>@ahtmljs/schema</code>, <code>@ahtmljs/agent</code>,{' '}
<code>@ahtmljs/langchain</code>) execute entirely inside your
infrastructure and do not transmit your users&apos; data, page
content, URLs or hostnames anywhere. They do send anonymous,
aggregated usage analytics to PostHog (EU): which feature ran, how
many times, the package and version, runtime, OS, CPU architecture,
whether CI was detected, and a one-way-hashed anonymous install id.
GeoIP is disabled, no person profiles are created, and client IP
addresses are discarded. This is always on and has no opt-out
switch; the complete field list and the source file are in{' '}
content, URLs or hostnames anywhere. They do send anonymous usage
analytics to PostHog (EU Cloud, <code>eu.i.posthog.com</code>) so we
can see which features are used. This is always on and has no
opt-out switch; if your network policy forbids it, block{' '}
<code>eu.i.posthog.com</code> at egress and the packages keep
working. The complete field list and the source file are in{' '}
<a href="https://github.com/DibbayajyotiRoy/AHTML#usage-analytics">
the README
</a>
.
</p>
<p>Each event carries only these fields:</p>
<ul style={{ paddingLeft: 24, lineHeight: 1.7 }}>
<li>
<strong>What ran</strong> — the event name (a fixed feature name from our source, such as <code>snapshot.build</code>), <code>pkg</code>, <code>pkg_version</code> and <code>count</code>.
</li>
<li>
<strong>Runtime and platform</strong> — <code>runtime</code>, <code>runtime_version</code>, <code>os</code> and <code>arch</code>.
</li>
<li>
<strong>Coarse environment labels</strong> — <code>ci</code>, <code>ci_provider</code>, <code>env_class</code>, <code>hosting</code>, <code>is_tty</code>, <code>is_container</code>, <code>node_env</code> and <code>package_manager</code>. These are flags and category labels, not values read from your machine.
</li>
<li>
<strong>Session</strong> — a random per-process session id, so events from one run can be grouped.
</li>
<li>
<strong>Anonymous install profile</strong> — an anonymous person profile keyed by an install id, which is a one-way hash of the hostname and working directory (the raw values are never sent). The profile records the first and last <code>env_class</code>, package, version, runtime and OS seen for that install.
</li>
<li>
<strong>Errors</strong> — error events carry only an error code, never a message or stack trace.
</li>
</ul>
<p>
PostHog derives approximate location (country/city) from the
request IP at ingestion; the project discards client IP addresses,
so IPs are not stored. <strong>Never sent:</strong> URLs, page
content, hostnames, file paths, CLI arguments, or environment
variable values.
</p>
<p>
Separately, at install time the packages depend on{' '}
<code>@scarf/scarf</code>, which sends anonymous install analytics
(OS info, package and version, a hashed dependency tree) to Scarf.
See{' '}
Scarf does not store IP addresses. See{' '}
<a href="https://github.com/DibbayajyotiRoy/AHTML#install-analytics-scarf">
the README
</a>{' '}
Expand All @@ -60,7 +87,10 @@ export default function PrivacyPage() {
<strong>Server logs</strong> — IP, user-agent, requested URL, timestamp. Retained 30 days for security and abuse prevention.
</li>
<li>
<strong>Privacy-respecting analytics</strong> — page-view counts and aggregate Core Web Vitals via Google Analytics 4 + Google Search Console. No cookies that identify individuals; IP anonymization on.
<strong>Cookieless analytics (PostHog, EU Cloud)</strong> — page views, clicks, referrer, device and browser, page-load performance (Core Web Vitals) and click heatmaps. It sets no cookies and stores nothing in your browser (in-memory only, gone when you close or reload the page), so there is no consent banner. Because nothing is stored, a reload or a new visit counts as a new anonymous visitor. PostHog derives approximate location (country/city) from your IP address at ingestion; IP addresses are discarded and not stored. No session recordings, no person profiles, and we honour your browser&apos;s Do Not Track setting. Requests go through <code>ahtml.dev/ingest</code>, a proxy on our own domain.
</li>
<li>
<strong>Vercel Web Analytics and Speed Insights</strong> — aggregate page-view counts and Core Web Vitals from our host, Vercel. Both are cookieless and do not track you across sites.
</li>
<li>
<strong>Waitlist form</strong> — if you submit your email at <code>/api/waitlist</code>, we store the email address only, to notify you when v1.0 ships. You can request deletion at any time by emailing{' '}
Expand Down
21 changes: 21 additions & 0 deletions examples/landing/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import posthog from 'posthog-js';

// Client-only: Next runs this file in the browser, never during `next build` or SSR.
// The key is a public ingestion key (safe to ship). Events go through the /ingest
// rewrite in next.config.mjs (EU region) so ad blockers don't drop them.
// Docs: https://posthog.com/docs/libraries/next-js
if (process.env.NODE_ENV === 'production') {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY ?? 'phc_qyHT2aRErEuTFcwGZyjju6izT2EfEUWEApqtjigLPAGH', {
api_host: '/ingest',
ui_host: 'https://eu.posthog.com',
defaults: '2026-05-30', // pageviews on history change (App Router navigation)
persistence: 'memory', // no cookies or browser storage (so no consent banner); GeoIP still works
respect_dnt: true,
person_profiles: 'identified_only',
disable_session_recording: true,
capture_heatmaps: true,
capture_performance: { web_vitals: true },
// Runs before the first $pageview, so every web event carries `site`.
loaded: (ph) => ph.register({ site: 'ahtml-landing' }),
});
}
11 changes: 11 additions & 0 deletions examples/landing/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// PostHog reverse proxy (EU). Static/array rewrites must precede the catch-all.
// https://posthog.com/docs/advanced/proxy/nextjs
async rewrites() {
return [
{ source: '/ingest/static/:path*', destination: 'https://eu-assets.i.posthog.com/static/:path*' },
{ source: '/ingest/array/:path*', destination: 'https://eu-assets.i.posthog.com/array/:path*' },
{ source: '/ingest/:path*', destination: 'https://eu.i.posthog.com/:path*' },
];
},
// PostHog's API uses trailing slashes (e.g. /e/); don't let Next redirect them.
skipTrailingSlashRedirect: true,
// npm workspaces symlink workspace packages into node_modules but Next's
// resolver falls back to TS source for workspace packages. Pinning aliases
// to the built dist/ files makes resolution deterministic. Build the
Expand Down
Loading
Loading