diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f5db5..1bc484c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,8 @@ follows [Semantic Versioning](https://semver.org/). `@ahtmljs/agent` (conditional polling, unref'd timer; new optional `FetchOptions.revalidate`). `ahtml diff [--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 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: diff --git a/README.md b/README.md index cebe137..85bfffa 100644 --- a/README.md +++ b/README.md @@ -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.` 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 | |---|---|---| @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 1cd583b..dd9389a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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.` 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 diff --git a/examples/landing/app/privacy/page.tsx b/examples/landing/app/privacy/page.tsx index 46e1607..2e6a96e 100644 --- a/examples/landing/app/privacy/page.tsx +++ b/examples/landing/app/privacy/page.tsx @@ -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' }, }; @@ -15,38 +15,65 @@ export default function PrivacyPage() {
-
Privacy · effective 2026-05-15
+
Privacy · effective 2026-10-08

Privacy policy.

AHTML is an open-source library that runs on your servers. - We don't collect data from your site or your users through it. This - page covers how the marketing site ahtml.dev handles - the small set of data it does see. + We don't collect data from your site or your users through it. + This page lists exactly what the packages and the marketing site{' '} + ahtml.dev do send.

-

The library

+

The npm packages

The npm packages (@ahtmljs/next, @ahtmljs/vite,{' '} @ahtmljs/schema, @ahtmljs/agent,{' '} @ahtmljs/langchain) execute entirely inside your infrastructure and do not transmit your users' 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, eu.i.posthog.com) 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{' '} + eu.i.posthog.com at egress and the packages keep + working. The complete field list and the source file are in{' '} the README .

+

Each event carries only these fields:

+
    +
  • + What ran — the event name (a fixed feature name from our source, such as snapshot.build), pkg, pkg_version and count. +
  • +
  • + Runtime and platform — runtime, runtime_version, os and arch. +
  • +
  • + Coarse environment labels — ci, ci_provider, env_class, hosting, is_tty, is_container, node_env and package_manager. These are flags and category labels, not values read from your machine. +
  • +
  • + Session — a random per-process session id, so events from one run can be grouped. +
  • +
  • + Anonymous install profile — 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 env_class, package, version, runtime and OS seen for that install. +
  • +
  • + Errors — error events carry only an error code, never a message or stack trace. +
  • +
+

+ PostHog derives approximate location (country/city) from the + request IP at ingestion; the project discards client IP addresses, + so IPs are not stored. Never sent: URLs, page + content, hostnames, file paths, CLI arguments, or environment + variable values. +

Separately, at install time the packages depend on{' '} @scarf/scarf, which sends anonymous install analytics (OS info, package and version, a hashed dependency tree) to Scarf. - See{' '} + Scarf does not store IP addresses. See{' '} the README {' '} @@ -60,7 +87,10 @@ export default function PrivacyPage() { Server logs — IP, user-agent, requested URL, timestamp. Retained 30 days for security and abuse prevention.

  • - Privacy-respecting analytics — page-view counts and aggregate Core Web Vitals via Google Analytics 4 + Google Search Console. No cookies that identify individuals; IP anonymization on. + Cookieless analytics (PostHog, EU Cloud) — 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's Do Not Track setting. Requests go through ahtml.dev/ingest, a proxy on our own domain. +
  • +
  • + Vercel Web Analytics and Speed Insights — aggregate page-view counts and Core Web Vitals from our host, Vercel. Both are cookieless and do not track you across sites.
  • Waitlist form — if you submit your email at /api/waitlist, we store the email address only, to notify you when v1.0 ships. You can request deletion at any time by emailing{' '} diff --git a/examples/landing/instrumentation-client.ts b/examples/landing/instrumentation-client.ts new file mode 100644 index 0000000..cae8bb0 --- /dev/null +++ b/examples/landing/instrumentation-client.ts @@ -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' }), + }); +} diff --git a/examples/landing/next.config.mjs b/examples/landing/next.config.mjs index 8cadfc3..ea0864a 100644 --- a/examples/landing/next.config.mjs +++ b/examples/landing/next.config.mjs @@ -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 diff --git a/examples/landing/package.json b/examples/landing/package.json index 968382b..3f31a95 100644 --- a/examples/landing/package.json +++ b/examples/landing/package.json @@ -18,6 +18,7 @@ "@vercel/speed-insights": "^2.0.0", "gpt-tokenizer": "^3.4.0", "next": "^15.1.0", + "posthog-js": "^1.438.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/package-lock.json b/package-lock.json index 5a65588..6af7018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,6 +118,7 @@ "@vercel/speed-insights": "^2.0.0", "gpt-tokenizer": "^3.4.0", "next": "^15.1.0", + "posthog-js": "^1.438.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -2055,6 +2056,31 @@ "node": ">=14" } }, + "node_modules/@posthog/browser-common": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.2.tgz", + "integrity": "sha512-kD0ayI59d+DVcqEFLFA1TU1B03PFNV/Hy18Yv1EOB24Px0CDDowkTR3pBE2j8MYeqtp+4XOoVTXrcJaJUp6zrg==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.57.0", + "@posthog/types": "^1.415.1" + } + }, + "node_modules/@posthog/core": { + "version": "1.57.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.57.2.tgz", + "integrity": "sha512-dfxeSjUvR+j4M7odvyf/KVdcKTZZ87u/l9swJtDt9vFkI2km3RhEveOkIHtFcZEQ1cllx7YaKJfdw6c8zfIEvg==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.415.1" + } + }, + "node_modules/@posthog/types": { + "version": "1.415.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.415.1.tgz", + "integrity": "sha512-7Qs2wcqipl8KcFpIkIKE1Wcj1djX4dEB3cfq907HrNpKK7/xKh3U4N4bkOD6h9+8j3SxWWrj9JNpE8CZXsE35w==", + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2141,7 +2167,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2163,6 +2189,13 @@ "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vercel/analytics": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", @@ -2478,11 +2511,25 @@ "node": ">= 0.6" } }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-uri-to-buffer": { @@ -2526,6 +2573,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2632,6 +2688,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2985,6 +3047,54 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-js": { + "version": "1.438.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.2.tgz", + "integrity": "sha512-4AbEQ0lJp4WupgA+y5fiBnepSril+qA1rAYuFy9oKQ0xd8yg19p5XNKDgZW/nGwJF2KVx/joy4vKWhyuJNoCVw==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.9.2", + "@posthog/core": "^1.57.1", + "@posthog/types": "^1.415.1", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^6.2.1", + "web-vitals-soft-navs": "npm:web-vitals@6.2.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", @@ -3015,6 +3125,12 @@ "node": ">=12.0.0" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -3389,6 +3505,19 @@ "ufo": "^1.5.4" } }, + "node_modules/web-vitals": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz", + "integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", + "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", + "license": "Apache-2.0" + }, "node_modules/workerd": { "version": "1.20250718.0", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", diff --git a/packages/schema/src/__tests__/telemetry.test.ts b/packages/schema/src/__tests__/telemetry.test.ts index 06545dd..e995f38 100644 --- a/packages/schema/src/__tests__/telemetry.test.ts +++ b/packages/schema/src/__tests__/telemetry.test.ts @@ -4,9 +4,13 @@ import { hostname } from 'node:os'; import { track, flushTelemetry, + classifyEnv, _setTelemetryTransport, + type EnvFlags, type TelemetryBatch, } from '../telemetry.js'; +import { AHTMLError, makeError } from '../errors.js'; +import { InvalidDiffError } from '../diff.js'; const PKG = '@ahtmljs/schema'; @@ -75,12 +79,17 @@ test('payload matches the PostHog /batch/ contract and is anonymous', async () = assert.equal(p.os, process.platform); assert.equal(p.arch, process.arch); assert.equal(typeof p.ci, 'boolean'); - assert.equal(p.$process_person_profile, false); - assert.equal(p.$geoip_disable, true); + assert.equal(p.$process_person_profile, true); + assert.ok(!('$geoip_disable' in p), '$geoip_disable must not be sent'); + assert.ok(!('$ip' in p), '$ip must not be sent'); assert.equal(p.$lib, 'ahtml'); assert.deepEqual( Object.keys(p).sort(), - ['$geoip_disable', '$lib', '$process_person_profile', 'arch', 'ci', 'count', 'os', 'pkg', 'pkg_version', 'runtime', 'runtime_version'].sort(), + [ + '$lib', '$process_person_profile', '$session_id', '$set', '$set_once', + 'arch', 'ci', 'ci_provider', 'count', 'env_class', 'hosting', 'is_container', 'is_tty', + 'node_env', 'os', 'package_manager', 'pkg', 'pkg_version', 'runtime', 'runtime_version', + ].sort(), ); // No raw hostname / cwd anywhere in the wire payload. @@ -224,3 +233,238 @@ test('default transport swallows network failures and timeouts', async () => { process.env.NODE_TEST_CONTEXT = savedCtx; } }); + +// ---- environment classification ------------------------------------------------- + +const NODE: EnvFlags = { runtime: 'node', cli: false, tty: false, container: false }; +const cls = (env: Record, f: Partial = {}) => classifyEnv(env, { ...NODE, ...f }); + +test('env_class precedence: test > ci > build > serverless > browser > cli_interactive > dev > server > unknown', () => { + // Everything set at once resolves to the top of the ladder, then peel layers off. + const all: Record = { + VITEST: '1', CI: 'true', npm_lifecycle_event: 'build', AWS_LAMBDA_FUNCTION_NAME: 'fn', + NODE_ENV: 'development', + }; + const flags = { cli: true, tty: true }; + assert.equal(cls(all, flags).env_class, 'test'); + for (const k of ['JEST_WORKER_ID', 'MOCHA', 'AVA']) assert.equal(cls({ [k]: '1' }).env_class, 'test'); + delete all.VITEST; + assert.equal(cls(all, flags).env_class, 'ci'); + delete all.CI; + assert.equal(cls(all, flags).env_class, 'build'); + assert.equal(cls({ NEXT_PHASE: 'phase-production-build' }).env_class, 'build'); + for (const ev of ['build', 'prebuild', 'postbuild', 'generate', 'export', 'prepare', 'prepack', 'prepublishOnly']) { + assert.equal(cls({ npm_lifecycle_event: ev }).env_class, 'build', ev); + } + delete all.npm_lifecycle_event; + assert.equal(cls(all, flags).env_class, 'serverless'); + delete all.AWS_LAMBDA_FUNCTION_NAME; + assert.equal(cls(all, { cli: true, tty: true }).env_class, 'cli_interactive'); + assert.equal(cls(all, { runtime: 'browser', cli: true, tty: true }).env_class, 'browser'); + assert.equal(cls(all).env_class, 'dev'); // NODE_ENV=development, not a CLI tty + assert.equal(cls({ NODE_ENV: 'production' }, flags).env_class, 'cli_interactive'); + assert.equal(cls({ NODE_ENV: 'production' }).env_class, 'server'); + assert.equal(cls({}).env_class, 'unknown'); + assert.equal(cls({ NODE_ENV: 'staging' }).env_class, 'unknown'); + for (const ev of ['dev', 'start:dev', 'serve', 'watch']) { + assert.equal(cls({ npm_lifecycle_event: ev }).env_class, 'dev', ev); + } + assert.equal(cls({ npm_lifecycle_event: 'start' }).env_class, 'unknown'); +}); + +test('env_class serverless triggers', () => { + assert.equal(cls({ AWS_LAMBDA_FUNCTION_NAME: 'f' }).env_class, 'serverless'); + assert.equal(cls({ K_SERVICE: 'svc' }).env_class, 'serverless'); + assert.equal(cls({ FUNCTIONS_WORKER_RUNTIME: 'node' }).env_class, 'serverless'); + assert.equal(cls({}, { runtime: 'workerd' }).env_class, 'serverless'); + assert.equal(cls({ VERCEL: '1', VERCEL_ENV: 'production' }).env_class, 'serverless'); + assert.equal(cls({ VERCEL: '1', VERCEL_ENV: 'development' }).env_class, 'unknown'); // `vercel dev` + assert.equal(cls({ VERCEL: '1' }).env_class, 'unknown'); // no runtime VERCEL_ENV + // The Vercel build container sets CI=1: that is ci, not serverless. + assert.equal(cls({ VERCEL: '1', VERCEL_ENV: 'production', CI: '1' }).env_class, 'ci'); + assert.equal(cls({ NETLIFY: 'true' }).env_class, 'serverless'); + assert.equal(cls({ NETLIFY: 'true', NETLIFY_DEV: 'true' }).env_class, 'unknown'); +}); + +test('ci / ci_provider detection', () => { + const cases: [Record, string][] = [ + [{ GITHUB_ACTIONS: 'true', CI: 'true' }, 'github_actions'], + [{ GITLAB_CI: 'true' }, 'gitlab'], + [{ CIRCLECI: 'true' }, 'circleci'], + [{ BUILDKITE: 'true' }, 'buildkite'], + [{ JENKINS_URL: 'http://j' }, 'jenkins'], + [{ TRAVIS: 'true' }, 'travis'], + [{ TF_BUILD: 'True' }, 'azure_pipelines'], + [{ BITBUCKET_BUILD_NUMBER: '7' }, 'bitbucket'], + [{ CODEBUILD_BUILD_ID: 'x' }, 'aws_codebuild'], + [{ VERCEL: '1', CI: '1' }, 'vercel'], + [{ NETLIFY: 'true', CI: 'true' }, 'netlify'], + [{ CF_PAGES: '1', CI: 'true' }, 'cloudflare_pages'], + [{ RENDER: 'true', CI: 'true' }, 'render'], + [{ CI: 'true' }, 'other_ci'], + [{}, 'none'], + [{ CI: 'false' }, 'none'], + // Platform vars alone (runtime, no CI) are hosting, not CI. + [{ VERCEL: '1' }, 'none'], + [{ RENDER: 'true' }, 'none'], + ]; + for (const [env, want] of cases) { + const r = cls(env); + assert.equal(r.ci_provider, want, JSON.stringify(env)); + assert.equal(r.ci, want !== 'none', JSON.stringify(env)); + } +}); + +test('hosting detection', () => { + const cases: [Record, string, Partial?][] = [ + [{ VERCEL: '1' }, 'vercel'], + [{ NETLIFY: 'true' }, 'netlify'], + [{ CF_PAGES: '1' }, 'cloudflare'], + [{}, 'cloudflare', { runtime: 'workerd' }], + [{ AWS_LAMBDA_FUNCTION_NAME: 'f' }, 'aws_lambda'], + [{ K_SERVICE: 's' }, 'cloud_run'], + [{ FLY_APP_NAME: 'a' }, 'fly'], + [{ RAILWAY_ENVIRONMENT: 'production' }, 'railway'], + [{ RENDER: 'true' }, 'render'], + [{ DYNO: 'web.1' }, 'heroku'], + [{}, 'none'], + ]; + for (const [env, want, f] of cases) assert.equal(cls(env, f).hosting, want, JSON.stringify(env)); +}); + +test('package_manager is the allow-listed prefix of npm_config_user_agent, no version', () => { + const ua = (s: string) => cls({ npm_config_user_agent: s }).package_manager; + assert.equal(ua('npm/10.2.0 node/v20.9.0 linux x64 workspaces/false'), 'npm'); + assert.equal(ua('pnpm/8.6.0 npm/? node/v18.0.0 linux x64'), 'pnpm'); + assert.equal(ua('yarn/1.22.19 npm/? node/v18.0.0 darwin arm64'), 'yarn'); + assert.equal(ua('bun/1.1.0 npm/? node/v22.0.0 linux x64'), 'bun'); + assert.equal(ua('evil/1.0 secret'), 'unknown'); + assert.equal(ua(''), 'unknown'); + assert.equal(cls({}).package_manager, 'unknown'); +}); + +test('node_env, is_tty and is_container', () => { + for (const v of ['development', 'production', 'test']) assert.equal(cls({ NODE_ENV: v }).node_env, v); + assert.equal(cls({ NODE_ENV: 'staging' }).node_env, 'other'); + assert.equal(cls({}).node_env, 'unset'); + const r = cls({}, { tty: true, container: true }); + assert.equal(r.is_tty, true); + assert.equal(r.is_container, true); + assert.equal(cls({}).is_tty, false); + assert.equal(cls({}).is_container, false); +}); + +test('classification emits only enum values: no env var VALUE can leak', () => { + const env = { + GITHUB_ACTIONS: 'secretvalue', CI: 'secretvalue', NODE_ENV: 'secretvalue', VERCEL: 'secretvalue', + VERCEL_ENV: 'secretvalue', K_SERVICE: 'secretvalue', npm_config_user_agent: 'secretvalue/1 node/v1', + npm_lifecycle_event: 'secretvalue', AWS_LAMBDA_FUNCTION_NAME: 'secretvalue', FLY_APP_NAME: 'secretvalue', + }; + assert.ok(!JSON.stringify(cls(env, { cli: true, tty: true })).includes('secretvalue')); +}); + +// ---- wire payload: session, person profile, no leaks --------------------------- + +const UUIDV7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +test('$session_id is a UUIDv7 minted before the first event and stable per process', async () => { + await drain(); + const posts = capture(); + track(PKG, '1.1.0', 'sess.one'); + await flushTelemetry(); + track(PKG, '1.1.0', 'sess.two'); + await flushTelemetry(); + + const a = posts[0]!.body.batch[0]!; + const b = posts[1]!.body.batch[0]!; + const sid = a.properties.$session_id as string; + assert.match(sid, UUIDV7); + assert.equal(b.properties.$session_id, sid); + + // PostHog: the UUIDv7 time must be <= the first event's time (and within 24h of the last). + const idMs = parseInt(sid.replace(/-/g, '').slice(0, 12), 16); + assert.ok(idMs <= Date.parse(a.timestamp), 'session id time must not be after the first event'); + assert.ok(Date.parse(b.timestamp) - idMs < 24 * 3600 * 1000); +}); + +test('person profile: $process_person_profile true with $set / $set_once and nothing else personal', async () => { + await drain(); + const posts = capture(); + track(PKG, '1.1.0', 'person.check'); + await flushTelemetry(); + + const p = posts[0]!.body.batch[0]!.properties as Record>; + assert.equal((p as unknown as { $process_person_profile: boolean }).$process_person_profile, true); + assert.deepEqual(Object.keys(p.$set!).sort(), ['last_env_class', 'last_os', 'last_pkg_version', 'last_runtime']); + assert.deepEqual( + Object.keys(p.$set_once!).sort(), + ['first_env_class', 'first_pkg', 'first_pkg_version', 'first_runtime', 'first_seen_os'], + ); + const flat = p as unknown as Record; + assert.equal(p.$set!.last_env_class, flat.env_class); + assert.equal(p.$set!.last_runtime, flat.runtime); + assert.equal(p.$set!.last_pkg_version, '1.1.0'); + assert.equal(p.$set!.last_os, flat.os); + assert.equal(p.$set_once!.first_env_class, flat.env_class); + assert.equal(p.$set_once!.first_pkg, PKG); + assert.equal(p.$set_once!.first_pkg_version, '1.1.0'); + assert.equal(p.$set_once!.first_runtime, flat.runtime); + assert.equal(p.$set_once!.first_seen_os, flat.os); + assert.match(String(flat.env_class), /^(test|ci|build|serverless|dev|server|cli_interactive|browser|unknown)$/); + assert.match(String(flat.ci_provider), /^[a-z_]+$/); + assert.equal(typeof flat.is_tty, 'boolean'); + assert.equal(typeof flat.is_container, 'boolean'); +}); + +test('env var values never appear in the serialized payload', async () => { + await drain(); + const keys = ['GITHUB_ACTIONS', 'VERCEL', 'K_SERVICE', 'npm_config_user_agent'] as const; + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])); + try { + for (const k of keys) process.env[k] = 'secretvalue'; + const posts = capture(); + track(PKG, '1.1.0', 'leak.check'); + await flushTelemetry(); + const wire = JSON.stringify(posts[0]!.body); + assert.ok(!wire.includes('secretvalue'), 'an env var value leaked into the payload'); + const p = posts[0]!.body.batch[0]!.properties; + assert.equal(p.ci_provider, 'github_actions'); // presence was read... + assert.equal(p.package_manager, 'unknown'); // ...but the unlisted value was not echoed + } finally { + for (const k of keys) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +}); + +// ---- error events --------------------------------------------------------------- + +test('AHTMLError construction emits error. with the code only', async () => { + await drain(); + const posts = capture(); + const secret = 'https://user:pw@internal.example/very/secret/path'; + new AHTMLError({ code: 'RATE_LIMITED', message: secret, context: secret, path: '/x', cause: new Error(secret) }); + makeError({ code: 'SCHEMA_INVALID', message: 'bad ' + secret }); + new InvalidDiffError('add', ['nope ' + secret]); // subclass inherits -> error.diff_invalid + await flushTelemetry(); + + const events = posts.flatMap((p) => p.body.batch); + assert.deepEqual(events.map((e) => e.event).sort(), ['error.diff_invalid', 'error.rate_limited', 'error.schema_invalid']); + for (const e of events) assert.equal(e.properties.pkg, PKG); + const wire = JSON.stringify(posts.map((p) => p.body)); + assert.ok(!wire.includes('secret'), 'error message / context / cause must not be sent'); + assert.ok(!wire.includes('internal.example')); +}); + +test('re-wrapping an AHTMLError is not counted twice, and bad codes never throw', async () => { + await drain(); + const posts = capture(); + const inner = new AHTMLError({ code: 'NETWORK', message: 'x' }); + new AHTMLError({ code: 'TIMEOUT', message: 'y', cause: inner }); // wrapper: not counted + assert.doesNotThrow(() => new AHTMLError({ code: undefined as never, message: 'z' })); + assert.doesNotThrow(() => new AHTMLError({ code: 'Weird Code!' as never, message: 'z' })); + await flushTelemetry(); + const events = posts.flatMap((p) => p.body.batch); + assert.deepEqual(events.map((e) => [e.event, e.properties.count]), [['error.network', 1]]); +}); diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index 5ca5f3c..3be837b 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -13,6 +13,9 @@ * those into one class. */ +import { track } from './telemetry.js'; +import { VERSION } from './version.js'; + /** * Stable, machine-readable discriminator for `AHTMLError`. Adopters should * `switch` on this in `catch` blocks — it never changes shape across @@ -94,6 +97,14 @@ export class AHTMLError extends Error { if (init.retryAfterMs !== undefined) this.retryAfterMs = init.retryAfterMs; if (init.context !== undefined) this.context = init.context; if (init.cause !== undefined) this.cause = init.cause; + // Anonymous usage count: the code only, never message/context/cause. Counted at + // construction; wrapping an existing AHTMLError is not counted again. track() is + // synchronous, never throws and never constructs an AHTMLError, so no recursion. + try { + if (!(init.cause instanceof AHTMLError)) track('@ahtmljs/schema', VERSION, 'error.' + init.code.toLowerCase()); + } catch { + /* telemetry must never change what gets thrown */ + } } /** diff --git a/packages/schema/src/telemetry.ts b/packages/schema/src/telemetry.ts index 3458a66..214d743 100644 --- a/packages/schema/src/telemetry.ts +++ b/packages/schema/src/telemetry.ts @@ -5,8 +5,10 @@ * track('@ahtmljs/schema', VERSION, 'snapshot.build'); // counter bump, cheap * * Events are aggregated in memory per pkg+event and flushed in batches. Only the - * feature name, a count, package/runtime/OS/arch/CI facts and an anonymous install - * id leave the process: no hostnames, paths, URLs, arguments or page content. + * feature name, a count, package/runtime/OS/arch facts, coarse environment class + * (CI/build/serverless/...; derived from env-var PRESENCE, never their values) and + * an anonymous install id leave the process: no hostnames, paths, URLs, arguments + * or page content. */ // PostHog EU Cloud ingestion host + project key. The key is a write-only ingestion @@ -47,7 +49,9 @@ const agg = new Map(); let transport: TelemetryTransport | null = null; let started = false; let chain: Promise = Promise.resolve(); -let envP: Promise<{ id: string; props: Record }> | null = null; +let envP: Promise | null = null; +let sid = ''; // $session_id: one UUIDv7 per process +let cliSeen = false; // this process runs the ahtml CLI (decides env_class cli_interactive) // eslint-disable-next-line @typescript-eslint/no-explicit-any const g = globalThis as any; @@ -59,6 +63,11 @@ export function track(pkg: string, version: string, event: string, n = 1): void const count = Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1; const key = pkg + '|' + event; const cur = agg.get(key); + if (pkg === '@ahtmljs/cli') cliSeen = true; + // PostHog needs the UUIDv7 time <= the session's first event time, so mint it before `now`. + // ponytail: one id per process; events >24h after start drop out of PostHog session + // aggregations (their own 24h rule). Rotate here if long-lived servers matter. + sid ||= uuidv7(); const now = Date.now(); if (cur) { cur.n += count; @@ -108,7 +117,9 @@ async function doFlush(): Promise { if (!agg.size) return; const items = [...agg.values()]; agg.clear(); - const { id, props } = await getEnv(); + const { id, base, env, flags } = await getEnv(); + // Classified at flush from live env-var presence (cheap); values are never copied out. + const cls = classifyEnv(env, { ...flags, cli: cliSeen }); const events: TelemetryEvent[] = items.map((a) => ({ event: a.event, distinct_id: id, @@ -116,9 +127,24 @@ async function doFlush(): Promise { pkg: a.pkg, pkg_version: a.ver, count: a.n, - ...props, - $process_person_profile: false, - $geoip_disable: true, + ...base, + ...cls, + $session_id: sid, + // Anonymous person profile keyed by the anonymous install id (cohorts, retention). + $process_person_profile: true, + $set: { + last_env_class: cls.env_class, + last_runtime: base.runtime, + last_pkg_version: a.ver, + last_os: base.os, + }, + $set_once: { + first_env_class: cls.env_class, + first_pkg: a.pkg, + first_pkg_version: a.ver, + first_runtime: base.runtime, + first_seen_os: base.os, + }, $lib: 'ahtml', }, timestamp: new Date(a.last).toISOString(), @@ -159,14 +185,112 @@ async function defaultTransport(url: string, body: TelemetryBatch): Promise }> { +type EnvLike = Record; + +interface EnvInfo { + id: string; + /** Static facts: runtime, runtime_version, os, arch. */ + base: Record; + /** Live reference to process.env (or {}); only key PRESENCE is ever read out of it. */ + env: EnvLike; + flags: Omit; +} + +export interface EnvFlags { + runtime: string; + /** The process runs the ahtml CLI. */ + cli: boolean; + tty: boolean; + container: boolean; +} + +export interface EnvClassification { + env_class: string; + ci: boolean; + ci_provider: string; + hosting: string; + is_tty: boolean; + is_container: boolean; + node_env: string; + package_manager: string; +} + +const BUILD_EVENTS = new Set(['build', 'prebuild', 'postbuild', 'generate', 'export', 'prepare', 'prepack', 'prepublishOnly']); +const DEV_EVENTS = new Set(['dev', 'start:dev', 'serve', 'watch']); +const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']); + +/** + * Pure classification from env-var PRESENCE (never values) plus a few flags. Exported + * for tests; every output is a value from a fixed enum, so no env value can leak. + */ +export function classifyEnv(e: EnvLike, f: EnvFlags): EnvClassification { + const has = (...ks: string[]): boolean => ks.some((k) => e[k] !== undefined && e[k] !== ''); + const genericCi = has('CI') && e.CI !== 'false' && e.CI !== '0'; + // Platform CIs (Vercel/Netlify/Cloudflare Pages/Render) set the platform var at runtime + // too, but CI only at build time, so they count as CI only together with CI. + const ci_provider = has('GITHUB_ACTIONS') ? 'github_actions' + : has('GITLAB_CI') ? 'gitlab' + : has('CIRCLECI') ? 'circleci' + : has('BUILDKITE') ? 'buildkite' + : has('JENKINS_URL') ? 'jenkins' + : has('TRAVIS') ? 'travis' + : has('TF_BUILD') ? 'azure_pipelines' + : has('BITBUCKET_BUILD_NUMBER') ? 'bitbucket' + : has('CODEBUILD_BUILD_ID') ? 'aws_codebuild' + : genericCi && has('VERCEL') ? 'vercel' + : genericCi && has('NETLIFY') ? 'netlify' + : genericCi && has('CF_PAGES') ? 'cloudflare_pages' + : genericCi && has('RENDER') ? 'render' + : genericCi ? 'other_ci' + : 'none'; + const ci = ci_provider !== 'none'; + + const hosting = has('VERCEL') ? 'vercel' + : has('NETLIFY') ? 'netlify' + : f.runtime === 'workerd' || has('CF_PAGES') ? 'cloudflare' + : has('AWS_LAMBDA_FUNCTION_NAME') ? 'aws_lambda' + : has('K_SERVICE') ? 'cloud_run' + : has('FLY_APP_NAME') ? 'fly' + : has('RAILWAY_ENVIRONMENT', 'RAILWAY_PROJECT_ID') ? 'railway' + : has('RENDER') ? 'render' + : has('DYNO') ? 'heroku' + : 'none'; + + const life = e.npm_lifecycle_event ?? ''; + // Order matters (first match wins); build/CI are checked before serverless so a + // platform's build container is not mistaken for its runtime. + const env_class = has('VITEST', 'JEST_WORKER_ID', 'MOCHA', 'AVA') ? 'test' + : ci ? 'ci' + : BUILD_EVENTS.has(life) || e.NEXT_PHASE === 'phase-production-build' ? 'build' + : has('AWS_LAMBDA_FUNCTION_NAME', 'K_SERVICE', 'FUNCTIONS_WORKER_RUNTIME') || + (has('VERCEL') && has('VERCEL_ENV') && e.VERCEL_ENV !== 'development') || + (has('NETLIFY') && !has('NETLIFY_DEV')) || + f.runtime === 'workerd' ? 'serverless' + : f.runtime === 'browser' ? 'browser' + : f.cli && f.tty ? 'cli_interactive' + : e.NODE_ENV === 'development' || DEV_EVENTS.has(life) ? 'dev' + : e.NODE_ENV === 'production' ? 'server' + : 'unknown'; + + const ne = e.NODE_ENV; + const node_env = ne === 'development' || ne === 'production' || ne === 'test' ? ne : ne ? 'other' : 'unset'; + // npm_config_user_agent is "/ node/... " - keep the allow-listed name only. + const pm = String(e.npm_config_user_agent ?? '').trim().split(/[/\s]/)[0] ?? ''; + const package_manager = PACKAGE_MANAGERS.has(pm) ? pm : 'unknown'; + + return { env_class, ci, ci_provider, hosting, is_tty: f.tty, is_container: f.container, node_env, package_manager }; +} + +function getEnv(): Promise { return (envP ??= detectEnv().catch(() => ({ id: randomHex(16), - props: { runtime: 'unknown', runtime_version: 'unknown', os: 'unknown', arch: 'unknown', ci: false }, + base: { runtime: 'unknown', runtime_version: 'unknown', os: 'unknown', arch: 'unknown' }, + env: {}, + flags: { runtime: 'unknown', tty: false, container: false }, }))); } -async function detectEnv(): Promise<{ id: string; props: Record }> { +async function detectEnv(): Promise { const p = g.process; const nodeV = p?.versions?.node; let runtime = 'unknown'; @@ -185,13 +309,29 @@ async function detectEnv(): Promise<{ id: string; props: Record } else if (typeof g.window !== 'undefined' || typeof g.document !== 'undefined') { runtime = 'browser'; } - const e = p?.env ?? {}; - const props = { + const env: EnvLike = p?.env ?? {}; + let tty = false; + try { + if (runtime === 'node' || runtime === 'bun') tty = !!p?.stdout?.isTTY; + else if (runtime === 'deno') tty = !!g.Deno?.stdout?.isTerminal?.(); + } catch { + /* not a TTY we can see */ + } + let container = !!env.KUBERNETES_SERVICE_HOST; + if (!container && runtime === 'node') { + try { + const fsSpec = 'node:fs'; + const fs = await import(/* webpackIgnore: true */ /* @vite-ignore */ fsSpec); + container = !!fs.existsSync('/.dockerenv'); + } catch { + /* no fs */ + } + } + const base = { runtime, runtime_version: String(rv).slice(0, 32), os: String(p?.platform ?? 'unknown').slice(0, 16), arch: String(p?.arch ?? 'unknown').slice(0, 16), - ci: !!(e.CI || e.GITHUB_ACTIONS || e.GITLAB_CI || e.BUILDKITE || e.CIRCLECI), }; // Anonymous install id: a one-way hash of host+cwd, so the same checkout reports // as one install without the raw hostname/path ever being sent. Random where there @@ -208,7 +348,7 @@ async function detectEnv(): Promise<{ id: string; props: Record /* fall through to random */ } } - return { id: id || randomHex(16), props }; + return { id: id || randomHex(16), base, env, flags: { runtime, tty, container } }; } async function sha256Hex(s: string): Promise { @@ -234,3 +374,11 @@ function randomHex(len: number): string { while (out.length < len) out += Math.floor(Math.random() * 16).toString(16); return out; } + +/** RFC 9562 UUIDv7: 48-bit unix-ms timestamp + version 7 + variant 10 + random. */ +function uuidv7(): string { + const t = Date.now().toString(16).padStart(12, '0'); + const r = randomHex(20); + const v = '89ab'.charAt(parseInt(r.charAt(3), 16) & 3); + return `${t.slice(0, 8)}-${t.slice(8)}-7${r.slice(0, 3)}-${v}${r.slice(4, 7)}-${r.slice(7, 19)}`; +}