diff --git a/README.md b/README.md index 2dfd6fa36..fb5d2ac28 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The platform ships the backend surface (auth, wallet, player management, complia - [Community](#community) - [Contributing](#contributing) - [Security](#security) +- [Telemetry](#telemetry) - [License](#license) ## Requirements @@ -152,7 +153,7 @@ Planned work and progress live on the public board: **[openora roadmap](https:// `docs/` holds one folder per kind, plus the generated `catalog.json`. -- **[docs/platform/](./docs/platform/)** - what the platform is: [introduction](./docs/platform/introduction.md), [core concepts](./docs/platform/core-concepts.md), [architecture](./docs/platform/architecture.md), [system design](./docs/platform/system-design.md), [glossary](./docs/platform/glossary.md). +- **[docs/platform/](./docs/platform/)** - what the platform is: [introduction](./docs/platform/introduction.md), [core concepts](./docs/platform/core-concepts.md), [architecture](./docs/platform/architecture.md), [system design](./docs/platform/system-design.md), [usage telemetry](./docs/platform/telemetry.md), [glossary](./docs/platform/glossary.md). - **[docs/guides/](./docs/guides/)** - how to do a job: [quickstart](./docs/guides/quickstart.md), [agent quickstart](./docs/guides/agent-quickstart.md), [agentic workflow](./docs/guides/agentic-workflow.md), [consuming downstream](./docs/guides/downstream-consumer.md), [MCP setup](./docs/guides/mcp-setup.md). - **[docs/standards/](./docs/standards/)** - the rules a change must follow (money, custody, database, types, testing...). - **[docs/adapters/](./docs/adapters/)** - binding a vendor behind a port (payment, custody, KYC, notification, error tracking). @@ -180,6 +181,10 @@ Contributions are welcome. Read [CONTRIBUTING.md](./CONTRIBUTING.md) for the wor Please do not file public issues for vulnerabilities. See [SECURITY.md](./SECURITY.md) for private reporting. +## Telemetry + +A running server and the scaffolder send an anonymous usage report: a random instance id, the version, the Node.js runtime and two active-player counts. No personal data. [Usage telemetry](./docs/platform/telemetry.md) lists every field. Turn it off with `OPENORA_TELEMETRY_DISABLED=1` (or `DO_NOT_TRACK=1`). + ## License Dual-licensed: **AGPL-3.0-only** OR a **commercial license**. diff --git a/docs/adr/0044-opt-out-anonymous-usage-telemetry.md b/docs/adr/0044-opt-out-anonymous-usage-telemetry.md new file mode 100644 index 000000000..6e15849c2 --- /dev/null +++ b/docs/adr/0044-opt-out-anonymous-usage-telemetry.md @@ -0,0 +1,41 @@ +# ADR-0044: Opt-out anonymous usage telemetry + +**Date**: 2026-10-12 +**Status**: Accepted + +## Context + +We have no idea how many deployments run Openora, which versions they're on, or whether they +have players. npm download counts mix CI installs, mirrors and bots, and say nothing about a +server that keeps running. We want a count of active installs that respects operators: no +personal data, visible, and easy to switch off. + +## Decision + +The server sends a small anonymous report to an Umami collector at `telemetry.openora.ai`, on +start (at most hourly per deployment, guarded by a Redis key) and daily through a `JOB_QUEUE` +scheduler. The scaffolder sends one report per project created. What each report holds, and +every way to turn it off, is in [usage telemetry](../platform/telemetry.md). + +- **On by default, opt-out.** An opt-in count only measures the people who read the docs. The + switch is an env var, the `DO_NOT_TRACK` standard, or a `createApp` option, and the server logs + how to use it at boot. +- **The instance id lives in Postgres**, in a single-row engine table next to the outbox. + Replicas racing on first boot converge on one id. Redis was rejected: a flush would mint a new + id and inflate the count. +- **Player counts go through the activity tracker port**, so the runtime never reads the player + table, and a deployment without the player module reports empty counts. +- **The Umami event format, not a custom collector.** The maintainers already run Umami, and its + distinct id makes "unique visitors" equal active installs. The hostname is dedicated so a + released version keeps a working URL whatever runs behind it. +- **No install hook.** Scanners flag postinstall scripts, pnpm 10 blocks them, and CI installs + would swamp the count. + +## Consequences + +- A public client can't authenticate, so anyone can post fake reports. Rate limits and a body cap + sit at the edge in front of the collector, and the counts are treated as approximate. +- A failed report is dropped with a warning; nothing retries, and boot never waits on it. +- Changing what a report carries means updating the usage telemetry page in the same change. +- The scaffolder copies the payload and opt-out rules instead of importing the core runtime; the + two are kept in step by hand. diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index 93cd337da..2448b763a 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -70,6 +70,8 @@ await listen(); `createApp` refuses to boot without CORS origins: it takes `cors.origins`, else the comma-separated `CORS_ORIGINS` env var. Pass `cors: false` for an API served same-origin only. Request bodies are capped at 4 MiB (413 above it); raise or lower the cap with `bodyLimit: { maxBytes }`, or turn it off with `bodyLimit: false` when a proxy in front already enforces one. +`createApp` sends anonymous usage telemetry unless `telemetry: false` is passed or `OPENORA_TELEMETRY_DISABLED=1` is set. [Usage telemetry](../platform/telemetry.md) lists what a report carries. + Downstream consumers create their own thin entrypoint that calls `createApp` and bring their own `extensions.config.ts`. See `tools/templates/consumer/apps/api/src/main.ts.tpl` for the reference. diff --git a/docs/platform/telemetry.md b/docs/platform/telemetry.md new file mode 100644 index 000000000..6ee40abe2 --- /dev/null +++ b/docs/platform/telemetry.md @@ -0,0 +1,57 @@ +# Usage telemetry + +Openora reports anonymous usage so we know how many installs are running and which versions +and runtimes they're on. It's on by default and takes one environment variable to turn off. +The decision is in [ADR-0044](../adr/0044-opt-out-anonymous-usage-telemetry.md). + +## What is sent + +Every report carries the same fields and nothing else: + +- a random instance id, generated on first boot and stored in the database, so one deployment + counts once however many replicas it runs +- the `@openora/core` version +- the Node.js version, the operating system and the CPU architecture +- how many players were active in the last day and the last 30 days, as two plain counts (empty + when the player module isn't loaded) + +No hostname, IP-derived data, player id, operator name, config value or request data is sent. +The collector sees the sending IP like any HTTP server does. Umami turns it into a country and +doesn't keep the raw address. + +## When + +- On server start, at most once an hour per deployment, so a crash loop or a rolling deploy + doesn't flood the collector. +- Once a day from the job queue, so a server that runs for months still counts as active. The + queue fires it once per day across all replicas. +- Once when `npm create @openora` scaffolds a project. The scaffolder sends a fresh random id + each run, so every run counts as one project, whoever ran it. + +A report that fails is dropped with one warning in the log. Nothing retries, and a failure +never delays or breaks boot. + +## Where + +Reports go to `https://telemetry.openora.ai`, an [Umami](https://umami.is) instance run by the +Openora maintainers. `OPENORA_TELEMETRY_URL` points them somewhere else, for example a collector +of your own. + +## Turning it off + +Any one of these switches it off. Nothing is sent and the daily job is removed from the queue: + +```bash +OPENORA_TELEMETRY_DISABLED=1 # or the cross-tool standard DO_NOT_TRACK=1 +``` + +```ts +createApp({ ...config, telemetry: false }); +``` + +```bash +npm create @openora my-app -- --no-telemetry +``` + +It's always off under `NODE_ENV=test` or when `CI` is set, so pipeline and e2e runs don't count as +installs. The server logs one line at boot when telemetry is on, with the switch to turn it off. diff --git a/packages/core/.env.example b/packages/core/.env.example index b9c8504be..e7d35665c 100644 --- a/packages/core/.env.example +++ b/packages/core/.env.example @@ -12,3 +12,5 @@ PORT_API=3001 PORT_WS=3002 PORT_WORKER=3003 PORT_MCP_DEV=3004 +# Anonymous usage telemetry is on by default: https://openora.ai/documentation/telemetry +# OPENORA_TELEMETRY_DISABLED=1 diff --git a/packages/core/README.md b/packages/core/README.md index 4f002b04e..9d5add1bb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -44,6 +44,10 @@ Everything enters through typed plugin objects - `{ id, dependsOn, register } sa The fastest path is the consumer scaffold and docs in the [main repository](https://github.com/blurifycom/openora) - it walks through booting an API, enabling modules, and overriding adapters. +## Telemetry + +The server sends an anonymous usage report on start and once a day: a random instance id, the version, the Node.js runtime and two active-player counts. No personal data. Turn it off with `OPENORA_TELEMETRY_DISABLED=1`, `DO_NOT_TRACK=1` or `createApp({ telemetry: false })`. Every field is listed in [usage telemetry](https://openora.ai/documentation/telemetry). + ## License [AGPL-3.0-only](./LICENSE). A commercial license is available - see [LICENSE-COMMERCIAL.md](./LICENSE-COMMERCIAL.md). diff --git a/packages/core/src/contracts/adapters/player-activity.ts b/packages/core/src/contracts/adapters/player-activity.ts index 7d1790a8b..534912769 100644 --- a/packages/core/src/contracts/adapters/player-activity.ts +++ b/packages/core/src/contracts/adapters/player-activity.ts @@ -2,6 +2,8 @@ import { createToken, type Token } from './token.js'; export type PlayerActivityTracker = { touchLastSeen(userId: string): Promise; + /** Players whose last-seen stamp is at or after `since`. Feeds the anonymous usage telemetry; telemetry reports `null` without it. */ + countActiveSince?(since: Date): Promise; }; export const PLAYER_ACTIVITY_TRACKER: Token = diff --git a/packages/core/src/pam/player-management/__tests__/player.service.int.test.ts b/packages/core/src/pam/player-management/__tests__/player.service.int.test.ts index 6f87e8d13..c7da8a09f 100644 --- a/packages/core/src/pam/player-management/__tests__/player.service.int.test.ts +++ b/packages/core/src/pam/player-management/__tests__/player.service.int.test.ts @@ -242,6 +242,19 @@ describe('PlayerService.touchLastSeen (real PG)', () => { }); }); +describe('PlayerService.countActiveSince (real PG)', () => { + it('counts only players seen inside the window', async () => { + const { svc } = makeService(); + const windowStart = new Date(Date.UTC(2999, 0, 1)); + const before = await svc.countActiveSince(windowStart); + await seedPlayer(randomUUID(), { lastSeenAt: new Date(windowStart.getTime() + 1000) }); + await seedPlayer(randomUUID(), { lastSeenAt: windowStart }); + await seedPlayer(randomUUID(), { lastSeenAt: new Date(windowStart.getTime() - 1000) }); + + expect(await svc.countActiveSince(windowStart)).toBe(before + 2); + }); +}); + describe('PlayerService.getByUserId (real PG)', () => { it('returns the player enriched with the identity email', async () => { const { svc } = makeService(); diff --git a/packages/core/src/pam/player-management/service/player.service.ts b/packages/core/src/pam/player-management/service/player.service.ts index 76b5622f9..dc5606804 100644 --- a/packages/core/src/pam/player-management/service/player.service.ts +++ b/packages/core/src/pam/player-management/service/player.service.ts @@ -361,6 +361,14 @@ export class PlayerService implements PlayerActivityTracker { ); } + async countActiveSince(since: Date): Promise { + const [row] = await this.drizzle.db + .select({ value: count() }) + .from(player) + .where(gte(player.lastSeenAt, since)); + return row?.value ?? 0; + } + async remove(playerId: Player['id'], actorId: User['id']) { const before = await this.drizzle.db.transaction(async (trx) => { const locked = findOneOrThrow( diff --git a/packages/core/src/pam/profile/drizzle/migrations/0010_fine_mastermind.sql b/packages/core/src/pam/profile/drizzle/migrations/0010_fine_mastermind.sql new file mode 100644 index 000000000..5fe515e0b --- /dev/null +++ b/packages/core/src/pam/profile/drizzle/migrations/0010_fine_mastermind.sql @@ -0,0 +1 @@ +CREATE INDEX "player_last_seen_at_idx" ON "player" USING btree ("last_seen_at"); \ No newline at end of file diff --git a/packages/core/src/pam/profile/drizzle/migrations/meta/0010_snapshot.json b/packages/core/src/pam/profile/drizzle/migrations/meta/0010_snapshot.json new file mode 100644 index 000000000..6ed628a1b --- /dev/null +++ b/packages/core/src/pam/profile/drizzle/migrations/meta/0010_snapshot.json @@ -0,0 +1,295 @@ +{ + "id": "d29fe016-60f2-4b97-8c46-7df8c55c3c46", + "prevId": "4c10b5ea-deec-4afc-949f-2411587be56e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.player": { + "name": "player", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "display_currency": { + "name": "display_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_decimal_places": { + "name": "display_decimal_places", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "player_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "kyc_status": { + "name": "kyc_status", + "type": "kyc_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "total_wagered": { + "name": "total_wagered", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_deposits": { + "name": "total_deposits", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone_updated_at": { + "name": "timezone_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terms_accepted_at": { + "name": "terms_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "age_accepted_at": { + "name": "age_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "registration_ip": { + "name": "registration_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_user_agent": { + "name": "registration_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hide_username_on_leaderboards": { + "name": "hide_username_on_leaderboards", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_friend_requests": { + "name": "allow_friend_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_online_status_to_friends": { + "name": "show_online_status_to_friends", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "player_status_idx": { + "name": "player_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_created_at_idx": { + "name": "player_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_last_seen_at_idx": { + "name": "player_last_seen_at_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "player_user_id_unique": { + "name": "player_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "player_display_decimal_places_range": { + "name": "player_display_decimal_places_range", + "value": "\"player\".\"display_decimal_places\" BETWEEN 0 AND 18" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.kyc_status": { + "name": "kyc_status", + "schema": "public", + "values": [ + "not_started", + "pending", + "approved", + "verified", + "rejected", + "resubmission_requested", + "manually_overridden" + ] + }, + "public.player_status": { + "name": "player_status", + "schema": "public", + "values": ["active", "dormant", "self_excluded", "suspended", "closed"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/pam/profile/drizzle/migrations/meta/_journal.json b/packages/core/src/pam/profile/drizzle/migrations/meta/_journal.json index dffbe9d24..0fa1f588d 100644 --- a/packages/core/src/pam/profile/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/pam/profile/drizzle/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1790760242111, "tag": "0009_low_nemesis", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1792168130277, + "tag": "0010_fine_mastermind", + "breakpoints": true } ] } diff --git a/packages/core/src/pam/profile/schema/index.ts b/packages/core/src/pam/profile/schema/index.ts index 5135c7b65..5f4066d54 100644 --- a/packages/core/src/pam/profile/schema/index.ts +++ b/packages/core/src/pam/profile/schema/index.ts @@ -69,6 +69,7 @@ export const player = pgTable( (t) => [ index('player_status_idx').on(t.status), index('player_created_at_idx').on(t.createdAt), + index('player_last_seen_at_idx').on(t.lastSeenAt), check( 'player_display_decimal_places_range', sql`${t.displayDecimalPlaces} BETWEEN 0 AND ${sql.raw(String(MAX_DISPLAY_DECIMAL_PLACES))}`, diff --git a/packages/core/src/server/db/index.ts b/packages/core/src/server/db/index.ts index 0789de3ee..0904685dc 100644 --- a/packages/core/src/server/db/index.ts +++ b/packages/core/src/server/db/index.ts @@ -26,7 +26,7 @@ export { } from './query-helpers.js'; // Transactional outbox - writer binds atomically with the state change; relay publishes to MESSAGE_BROKER. See ADR-0016. -export { eventOutbox, type EventOutboxRow } from './outbox/schema.js'; +export { eventOutbox, openoraInstance, type EventOutboxRow } from './outbox/schema.js'; export { DrizzleOutboxWriter } from './outbox/writer.js'; export { OutboxRelay, type OutboxRelayOptions } from './outbox/relay.js'; diff --git a/packages/core/src/server/db/migrate.ts b/packages/core/src/server/db/migrate.ts index 5c44bdb3b..6b03bd74b 100644 --- a/packages/core/src/server/db/migrate.ts +++ b/packages/core/src/server/db/migrate.ts @@ -174,7 +174,7 @@ export async function runMigrations(opts: RunMigrationsOptions) { } } -/** Apply the engine `outbox` migration set (the only engine-owned table); domain modules ship their own `migrate`. */ +/** Apply the engine `outbox` migration set (the outbox and instance tables); domain modules ship their own `migrate`. */ export function migrate(databaseUrl?: string) { return runMigrations({ migrationsFolder: fileURLToPath(new URL('./outbox/drizzle/migrations', import.meta.url)), diff --git a/packages/core/src/server/db/outbox/drizzle/migrations/0001_watery_turbo.sql b/packages/core/src/server/db/outbox/drizzle/migrations/0001_watery_turbo.sql new file mode 100644 index 000000000..40a9f9d35 --- /dev/null +++ b/packages/core/src/server/db/outbox/drizzle/migrations/0001_watery_turbo.sql @@ -0,0 +1,6 @@ +CREATE TABLE "openora_instance" ( + "singleton" boolean PRIMARY KEY DEFAULT true NOT NULL, + "instance_id" uuid DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "openora_instance_singleton" CHECK ("openora_instance"."singleton") +); diff --git a/packages/core/src/server/db/outbox/drizzle/migrations/meta/0001_snapshot.json b/packages/core/src/server/db/outbox/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..8e9de49a4 --- /dev/null +++ b/packages/core/src/server/db/outbox/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,156 @@ +{ + "id": "13658414-cdd2-447d-80a6-ed1ca7e9f4fe", + "prevId": "8e20a713-5cd3-423e-8eea-8d2bd3d9832c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.event_outbox": { + "name": "event_outbox", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ordering_key": { + "name": "ordering_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "event_outbox_pending_idx": { + "name": "event_outbox_pending_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openora_instance": { + "name": "openora_instance", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "openora_instance_singleton": { + "name": "openora_instance_singleton", + "value": "\"openora_instance\".\"singleton\"" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/server/db/outbox/drizzle/migrations/meta/_journal.json b/packages/core/src/server/db/outbox/drizzle/migrations/meta/_journal.json index f00968884..c2d0f35f2 100644 --- a/packages/core/src/server/db/outbox/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/server/db/outbox/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1783555226979, "tag": "0000_living_snowbird", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791813555401, + "tag": "0001_watery_turbo", + "breakpoints": true } ] } diff --git a/packages/core/src/server/db/outbox/schema.ts b/packages/core/src/server/db/outbox/schema.ts index 7a4df056f..4d84b5126 100644 --- a/packages/core/src/server/db/outbox/schema.ts +++ b/packages/core/src/server/db/outbox/schema.ts @@ -1,4 +1,15 @@ -import { pgTable, text, jsonb, integer, timestamp, index } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { + pgTable, + text, + jsonb, + integer, + timestamp, + index, + boolean, + uuid, + check, +} from 'drizzle-orm/pg-core'; // Transactional outbox table. A money/critical service writes the event envelope // here INSIDE the same db.transaction as its state change (via the OUTBOX port), @@ -24,3 +35,15 @@ export const eventOutbox = pgTable( ); export type EventOutboxRow = typeof eventOutbox.$inferSelect; + +// One row per deployment: the anonymous id the usage telemetry reports under. The check pins the +// table to a single row, so replicas racing on first boot converge on one id. +export const openoraInstance = pgTable( + 'openora_instance', + { + singleton: boolean().primaryKey().default(true), + instanceId: uuid().notNull().defaultRandom(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [check('openora_instance_singleton', sql`${t.singleton}`)], +); diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index 1f6a0efa0..e3d1427dc 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -401,6 +401,7 @@ describe('createApp - the player-activity stamp', () => { await writing; stamped = true; }, + countActiveSince: async () => 0, })); }, ); diff --git a/packages/core/src/server/runtime/__tests__/telemetry.int.test.ts b/packages/core/src/server/runtime/__tests__/telemetry.int.test.ts new file mode 100644 index 000000000..b0eb2b9af --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/telemetry.int.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { Queue } from 'bullmq'; +import { CACHE, JOB_QUEUE, PLAYER_ACTIVITY_TRACKER } from '@openora/core/contracts'; +import { + createTestDb, + createTestRedis, + redisUrlForWorker, + type TestDb, + type TestRedis, +} from '@openora/core/testing'; +import { migrate } from '@openora/core/server/migrate'; +import { DRIZZLE, openoraInstance } from '../../db/index.js'; +import { BullMqJobQueue, Container, RedisCache } from '../../kernel/index.js'; +import type { CoreTokenCatalog } from '../core-token-catalog.js'; +import { resolveInstanceId, startTelemetry, type TelemetryBody } from '../telemetry.js'; + +const QUIET_PERIOD_MS = 500; + +let db: TestDb; +let redis: TestRedis; +let server: Server; +let collectorUrl: string; +const received: TelemetryBody[] = []; + +function makeContainer({ withPlayers = true } = {}) { + const container = new Container(); + if (withPlayers) { + container.register(PLAYER_ACTIVITY_TRACKER, () => ({ touchLastSeen: async () => {} })); + } + container.register(DRIZZLE, () => db.drizzle); + container.register(CACHE, () => new RedisCache(redis.client)); + container.register(JOB_QUEUE, () => { + const q = new BullMqJobQueue(redisUrlForWorker()); + container.onDispose(() => q.close()); + return q; + }); + return container; +} + +function eventsNamed(name: string) { + return received.filter((b) => b.payload.name === name); +} + +beforeAll(async () => { + db = await createTestDb([migrate]); + redis = await createTestRedis(); + server = createServer((req, res) => { + let raw = ''; + req.on('data', (chunk: Buffer) => { + raw += chunk.toString(); + }); + req.on('end', () => { + received.push(JSON.parse(raw)); + res.writeHead(200).end(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + collectorUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/send`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + await redis.quit(); + await db.drop(); +}); + +beforeEach(async () => { + received.length = 0; + await redis.flush(); +}); + +describe('resolveInstanceId', () => { + it('returns one stable id however many replicas race for it', async () => { + const ids = await Promise.all(Array.from({ length: 5 }, () => resolveInstanceId(db.drizzle))); + + expect(new Set(ids).size).toBe(1); + expect(await resolveInstanceId(db.drizzle)).toBe(ids[0]); + expect(await db.drizzle.db.select().from(openoraInstance)).toHaveLength(1); + }); +}); + +describe('startTelemetry', () => { + let containers: Container[] = []; + + afterEach(async () => { + await Promise.all(containers.map((c) => c.dispose())); + containers = []; + }); + + function boot( + option: false | undefined, + overrides: Record = {}, + { withPlayers = true } = {}, + ) { + const container = makeContainer({ withPlayers }); + containers.push(container); + startTelemetry(container, option, { + NODE_ENV: 'production', + OPENORA_TELEMETRY_URL: collectorUrl, + ...overrides, + }); + } + + it('sends one boot event per hour however many times the server starts', async () => { + boot(undefined); + boot(undefined); + + await vi.waitFor(() => expect(eventsNamed('boot')).toHaveLength(1)); + await new Promise((resolve) => setTimeout(resolve, QUIET_PERIOD_MS)); + expect(eventsNamed('boot')).toHaveLength(1); + expect(eventsNamed('boot')[0]?.payload.id).toBe(await resolveInstanceId(db.drizzle)); + }); + + it('runs the daily heartbeat from the job scheduler', async () => { + boot(undefined); + + await vi.waitFor(() => expect(eventsNamed('heartbeat')).toHaveLength(1), { timeout: 5000 }); + expect(eventsNamed('heartbeat')[0]?.payload.data.activePlayers1d).toBeNull(); + }); + + it('sends nothing when opted out', async () => { + boot(false); + boot(undefined, { OPENORA_TELEMETRY_DISABLED: '1' }); + boot(undefined, { DO_NOT_TRACK: '1' }); + + await new Promise((resolve) => setTimeout(resolve, QUIET_PERIOD_MS)); + expect(received).toHaveLength(0); + }); + + it('removes the daily heartbeat when a later boot opts out', async () => { + const raw = new Queue('telemetry', { + connection: { url: redisUrlForWorker(), maxRetriesPerRequest: null }, + }); + try { + boot(undefined); + await vi.waitFor(async () => expect(await raw.getJobSchedulers()).toHaveLength(1)); + + boot(undefined, { OPENORA_TELEMETRY_DISABLED: '1' }); + await vi.waitFor(async () => expect(await raw.getJobSchedulers()).toHaveLength(0)); + } finally { + await raw.close(); + } + }); + + it('leaves reporting to the service that owns players', async () => { + boot(undefined, {}, { withPlayers: false }); + + await new Promise((resolve) => setTimeout(resolve, QUIET_PERIOD_MS)); + expect(received).toHaveLength(0); + }); +}); diff --git a/packages/core/src/server/runtime/__tests__/telemetry.test.ts b/packages/core/src/server/runtime/__tests__/telemetry.test.ts new file mode 100644 index 000000000..24b97321d --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/telemetry.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { isTelemetryEnabled, sendTelemetry, type TelemetryBody } from '../telemetry.js'; + +const PRODUCTION = { NODE_ENV: 'production' }; + +const body: TelemetryBody = { + type: 'event', + payload: { + website: 'site', + hostname: 'instance', + url: '/heartbeat', + name: 'heartbeat', + id: 'instance-1', + data: { + version: '1.0.0', + node: 'v22.0.0', + os: 'linux', + arch: 'x64', + activePlayers1d: 1, + activePlayers30d: 2, + }, + }, +}; + +describe('isTelemetryEnabled', () => { + it('is on by default outside tests', () => { + expect(isTelemetryEnabled(undefined, PRODUCTION)).toBe(true); + expect(isTelemetryEnabled(undefined, {})).toBe(true); + }); + + it('is off when createApp is given telemetry: false', () => { + expect(isTelemetryEnabled(false, PRODUCTION)).toBe(false); + }); + + it.each(['1', 'true', 'TRUE', ' yes '])('is off when OPENORA_TELEMETRY_DISABLED=%s', (flag) => { + expect(isTelemetryEnabled(undefined, { ...PRODUCTION, OPENORA_TELEMETRY_DISABLED: flag })).toBe( + false, + ); + }); + + it.each(['0', 'false', ''])('stays on when OPENORA_TELEMETRY_DISABLED=%s', (flag) => { + expect(isTelemetryEnabled(undefined, { ...PRODUCTION, OPENORA_TELEMETRY_DISABLED: flag })).toBe( + true, + ); + }); + + it('is off when DO_NOT_TRACK=1', () => { + expect(isTelemetryEnabled(undefined, { ...PRODUCTION, DO_NOT_TRACK: '1' })).toBe(false); + }); + + it('is off under NODE_ENV=test', () => { + expect(isTelemetryEnabled(undefined, { NODE_ENV: 'test' })).toBe(false); + }); + + it('is off when CI is set', () => { + expect(isTelemetryEnabled(undefined, { ...PRODUCTION, CI: 'true' })).toBe(false); + }); +}); + +describe('sendTelemetry', () => { + let server: Server; + let url: string; + let status = 200; + const received: Array<{ headers: Record; body: unknown }> = []; + + beforeAll(async () => { + server = createServer((req, res) => { + let raw = ''; + req.on('data', (chunk: Buffer) => { + raw += chunk.toString(); + }); + req.on('end', () => { + received.push({ headers: req.headers, body: JSON.parse(raw) }); + res.writeHead(status).end(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/send`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + beforeEach(() => { + received.length = 0; + status = 200; + }); + + it('posts the event as JSON under a user agent Umami does not drop as a bot', async () => { + await sendTelemetry(url, body); + + expect(received).toHaveLength(1); + expect(received[0]?.body).toEqual(body); + expect(received[0]?.headers['user-agent']).toMatch( + /^Mozilla\/5\.0 \(compatible; openora\/\d+\.\d+\.\d+/, + ); + expect(received[0]?.headers['content-type']).toBe('application/json'); + }); + + it('rejects when the collector answers with an error status', async () => { + status = 503; + + await expect(sendTelemetry(url, body)).rejects.toThrow('503'); + }); +}); diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index ed56705c0..ef8ff027f 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -57,6 +57,7 @@ import { authorizeWithAdminGuard } from '../mcp/authorize.js'; import type { McpHttpTransport } from '../mcp/http-transport.js'; import { payloadTooLarge } from '../mcp/transport-gate.js'; import { assertDurableSeamsBound } from './assert-durable-seams.js'; +import { startTelemetry } from './telemetry.js'; import { applyClientAddress, resolveTrustedProxies, @@ -159,6 +160,10 @@ export type CreateAppConfig = { disableHealthModule?: boolean; + // `false` turns off the anonymous usage telemetry, as OPENORA_TELEMETRY_DISABLED=1 or + // DO_NOT_TRACK=1 do. See docs/platform/telemetry.md for what it sends. + telemetry?: false; + // Peers (IP or CIDR) allowed to set `X-Real-IP` / `X-Forwarded-For` - the reverse proxy in // front of the API. Any other peer's forwarding headers are replaced with its socket // address, so a direct caller cannot pick the IP that per-IP throttles key on. Falls back @@ -531,6 +536,8 @@ export async function createApp( container.onDispose(() => relay.stop()); } + startTelemetry(container, config.telemetry); + if (!config.disableHealthModule) { const os = implement(healthContract).$context(); router['health'] = os.router({ diff --git a/packages/core/src/server/runtime/telemetry.ts b/packages/core/src/server/runtime/telemetry.ts new file mode 100644 index 000000000..84c86d971 --- /dev/null +++ b/packages/core/src/server/runtime/telemetry.ts @@ -0,0 +1,186 @@ +import { createRequire } from 'node:module'; +import { arch, platform, version as nodeVersion } from 'node:process'; +import { + CACHE, + JOB_QUEUE, + PLAYER_ACTIVITY_TRACKER, + queue, + type PayloadSchema, +} from '@openora/core/contracts'; +import { DRIZZLE, openoraInstance, type DrizzleService } from '../db/index.js'; +import { createLogger, type Container } from '../kernel/index.js'; +import type { CoreTokenCatalog } from './core-token-catalog.js'; + +export const TELEMETRY_DEFAULT_URL = 'https://telemetry.openora.ai/api/send'; +export const TELEMETRY_DOCS_URL = 'https://openora.ai/documentation/telemetry'; +// Public by design: an Umami website id ships in every browser tracking snippet. +const UMAMI_WEBSITE_ID = '6c0c7f10-66e1-4100-a9b4-b5640982674d'; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +const SEND_TIMEOUT_MS = 5000; +const TELEMETRY_QUEUE = queue('telemetry'); +const HEARTBEAT_SCHEDULE_ID = 'telemetry.heartbeat'; +const BOOT_GUARD_KEY = 'telemetry:boot'; + +const logger = createLogger('telemetry'); +const corePackage: { version: string } = createRequire(import.meta.url)( + '@openora/core/package.json', +); +const coreVersion = corePackage.version; + +export type TelemetryEvent = 'boot' | 'heartbeat'; +type Env = Record; + +export type TelemetryBody = { + type: 'event'; + payload: { + website: string; + hostname: 'instance'; + url: `/${TelemetryEvent}`; + name: TelemetryEvent; + id: string; + data: { + version: string; + node: string; + os: string; + arch: string; + activePlayers1d: number | null; + activePlayers30d: number | null; + }; + }; +}; + +const emptyPayload: PayloadSchema> = { parse: () => ({}) }; + +function isSet(flag: string | undefined): boolean { + return flag !== undefined && ['1', 'true', 'yes'].includes(flag.trim().toLowerCase()); +} + +export function isTelemetryEnabled(option: false | undefined, env: Env = process.env): boolean { + return ( + option !== false && + env['NODE_ENV'] !== 'test' && + env['CI'] === undefined && + !isSet(env['OPENORA_TELEMETRY_DISABLED']) && + !isSet(env['DO_NOT_TRACK']) + ); +} + +export async function resolveInstanceId(drizzle: DrizzleService): Promise { + await drizzle.db.insert(openoraInstance).values({ singleton: true }).onConflictDoNothing(); + const [row] = await drizzle.db + .select({ instanceId: openoraInstance.instanceId }) + .from(openoraInstance); + if (!row) { + throw new Error('openora_instance row missing after insert'); + } + return row.instanceId; +} + +export async function collectTelemetry( + container: Container, + event: TelemetryEvent, + now = new Date(), +): Promise { + const tracker = container.has(PLAYER_ACTIVITY_TRACKER) + ? container.get(PLAYER_ACTIVITY_TRACKER) + : undefined; + const countSince = (ms: number) => + tracker?.countActiveSince?.(new Date(now.getTime() - ms)).catch(() => null) ?? null; + const [instanceId, activePlayers1d, activePlayers30d] = await Promise.all([ + resolveInstanceId(container.get(DRIZZLE)), + countSince(DAY_MS), + countSince(30 * DAY_MS), + ]); + return { + type: 'event', + payload: { + website: UMAMI_WEBSITE_ID, + hostname: 'instance', + url: `/${event}`, + name: event, + id: instanceId, + data: { + version: coreVersion, + node: nodeVersion, + os: platform, + arch, + activePlayers1d, + activePlayers30d, + }, + }, + }; +} + +export async function sendTelemetry(url: string, body: TelemetryBody): Promise { + const res = await fetch(url, { + method: 'POST', + // Umami drops bot-like user agents, which a bare `name/version` or anything naming node is. + headers: { + 'content-type': 'application/json', + 'user-agent': `Mozilla/5.0 (compatible; openora/${coreVersion})`, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(SEND_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error(`telemetry endpoint answered ${res.status}`); + } +} + +async function report(container: Container, event: TelemetryEvent, env: Env) { + try { + const body = await collectTelemetry(container, event); + await sendTelemetry(env['OPENORA_TELEMETRY_URL'] ?? TELEMETRY_DEFAULT_URL, body); + logger.debug({ body }, 'telemetry sent'); + } catch (err) { + logger.warn({ err, event }, 'telemetry not sent'); + } +} + +async function reportBoot(container: Container, env: Env) { + try { + const first = await container.get(CACHE).setIfAbsent(BOOT_GUARD_KEY, true, { ttlMs: HOUR_MS }); + if (first) { + await report(container, 'boot', env); + } + } catch (err) { + logger.warn({ err }, 'telemetry boot guard failed'); + } +} + +export function startTelemetry( + container: Container, + option: false | undefined, + env: Env = process.env, +): void { + if (env['NODE_ENV'] === 'test') { + return; + } + const jobQueue = container.get(JOB_QUEUE); + if (!isTelemetryEnabled(option, env)) { + // BullMQ keeps a job scheduler in Redis after the process that created it exits. + void jobQueue + .unschedule(TELEMETRY_QUEUE, HEARTBEAT_SCHEDULE_ID) + .catch((err: unknown) => logger.warn({ err }, 'telemetry unschedule failed')); + return; + } + // Split services share the queue and the boot guard, so only the one that can count players reports. + if (!container.has(PLAYER_ACTIVITY_TRACKER)) { + return; + } + logger.info( + `Anonymous usage telemetry is on (instance id, version, active player counts). ` + + `Set OPENORA_TELEMETRY_DISABLED=1 to turn it off. Details: ${TELEMETRY_DOCS_URL}`, + ); + jobQueue.registerWorker({ + queue: TELEMETRY_QUEUE, + schema: emptyPayload, + handler: () => report(container, 'heartbeat', env), + }); + void jobQueue + .schedule(TELEMETRY_QUEUE, HEARTBEAT_SCHEDULE_ID, {}, { everyMs: DAY_MS }) + .catch((err: unknown) => logger.warn({ err }, 'telemetry schedule failed')); + void reportBoot(container, env); +} diff --git a/packages/create-openora/src/__tests__/render.test.ts b/packages/create-openora/src/__tests__/render.test.ts index 631081061..02b540a2f 100644 --- a/packages/create-openora/src/__tests__/render.test.ts +++ b/packages/create-openora/src/__tests__/render.test.ts @@ -14,6 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildVars, emitTree, + isTelemetryEnabled, outputPath, parseArgs, sanitizeName, @@ -59,15 +60,27 @@ describe('parseArgs', () => { const argv = (...args: string[]) => ['node', 'create-openora', ...args]; it('reads --name=value', () => { - expect(parseArgs(argv('dir', '--name=x'))).toEqual({ target: 'dir', name: 'x' }); + expect(parseArgs(argv('dir', '--name=x'))).toEqual({ + target: 'dir', + name: 'x', + telemetry: true, + }); }); it('reads --name value', () => { - expect(parseArgs(argv('dir', '--name', 'x'))).toEqual({ target: 'dir', name: 'x' }); + expect(parseArgs(argv('dir', '--name', 'x'))).toEqual({ + target: 'dir', + name: 'x', + telemetry: true, + }); }); it('omits name when not given', () => { - expect(parseArgs(argv('dir'))).toEqual({ target: 'dir' }); + expect(parseArgs(argv('dir'))).toEqual({ target: 'dir', telemetry: true }); + }); + + it('turns telemetry off with --no-telemetry', () => { + expect(parseArgs(argv('dir', '--no-telemetry'))).toEqual({ target: 'dir', telemetry: false }); }); it('throws without a target', () => { @@ -75,6 +88,29 @@ describe('parseArgs', () => { }); }); +describe('isTelemetryEnabled', () => { + it('is on by default', () => { + expect(isTelemetryEnabled(true, {})).toBe(true); + }); + + it('is off with --no-telemetry', () => { + expect(isTelemetryEnabled(false, {})).toBe(false); + }); + + it.each([ + { OPENORA_TELEMETRY_DISABLED: ' yes ' }, + { DO_NOT_TRACK: '1' }, + { CI: '' }, + { NODE_ENV: 'test' }, + ])('is off under %o', (env) => { + expect(isTelemetryEnabled(true, env)).toBe(false); + }); + + it('stays on when OPENORA_TELEMETRY_DISABLED=0', () => { + expect(isTelemetryEnabled(true, { OPENORA_TELEMETRY_DISABLED: '0' })).toBe(true); + }); +}); + describe('substitute', () => { it('replaces known keys and leaves unknown ones', () => { expect(substitute('{{a}}-{{unknown}}-{{a}}', { a: '1' })).toBe('1-{{unknown}}-1'); diff --git a/packages/create-openora/src/index.ts b/packages/create-openora/src/index.ts index 3e318eabc..3553e0a02 100644 --- a/packages/create-openora/src/index.ts +++ b/packages/create-openora/src/index.ts @@ -2,26 +2,64 @@ import { existsSync, readdirSync } from 'node:fs'; import { basename, dirname, join, relative, resolve } from 'node:path'; +import { arch, platform, version as nodeVersion } from 'node:process'; +import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { CORE_VERSION } from './generated/core-version.js'; -import { buildVars, emitTree, parseArgs, posix, sanitizeName } from './render.js'; +import { + buildVars, + emitTree, + isTelemetryEnabled, + parseArgs, + posix, + sanitizeName, +} from './render.js'; const here = dirname(fileURLToPath(import.meta.url)); const templateRoot = join(here, '..', 'template'); +const TELEMETRY_URL = 'https://telemetry.openora.ai/api/send'; +const TELEMETRY_DOCS_URL = 'https://openora.ai/documentation/telemetry'; +const UMAMI_WEBSITE_ID = '6c0c7f10-66e1-4100-a9b4-b5640982674d'; +const PING_TIMEOUT_MS = 2000; +const CORE_RELEASE = CORE_VERSION.replace(/^[\^~]/, ''); function die(message: string): never { console.error(`\n error: ${message}\n`); process.exit(1); } -function main(): void { +// ponytail: mirrors the payload of packages/core/src/server/runtime/telemetry.ts +// so the scaffolder stays free of the core runtime; change both together. +async function sendScaffoldPing(): Promise { + await fetch(process.env['OPENORA_TELEMETRY_URL'] ?? TELEMETRY_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'user-agent': `Mozilla/5.0 (compatible; openora/${CORE_RELEASE})`, + }, + body: JSON.stringify({ + type: 'event', + payload: { + website: UMAMI_WEBSITE_ID, + hostname: 'instance', + url: '/scaffold', + name: 'scaffold', + id: randomUUID(), + data: { version: CORE_RELEASE, node: nodeVersion, os: platform, arch }, + }, + }), + signal: AbortSignal.timeout(PING_TIMEOUT_MS), + }).catch(() => undefined); +} + +async function main(): Promise { let parsed: ReturnType; try { parsed = parseArgs(process.argv); } catch (error) { die(error instanceof Error ? error.message : String(error)); } - const { target, name: nameFlag } = parsed; + const { target, name: nameFlag, telemetry } = parsed; const targetDir = resolve(process.cwd(), target); const name = sanitizeName(nameFlag ?? basename(targetDir)); @@ -58,6 +96,15 @@ function main(): void { Code and run /start. This is a headless api - build your frontend in this repo and consume the api over HTTP with @openora/core/react. `); + + if (!isTelemetryEnabled(telemetry, process.env)) { + return; + } + console.log( + ` Openora counts this run with one anonymous ping. Opt out with --no-telemetry or\n` + + ` OPENORA_TELEMETRY_DISABLED=1. Details: ${TELEMETRY_DOCS_URL}\n`, + ); + await sendScaffoldPing(); } -main(); +await main(); diff --git a/packages/create-openora/src/render.ts b/packages/create-openora/src/render.ts index 5edb6f11d..27872cf23 100644 --- a/packages/create-openora/src/render.ts +++ b/packages/create-openora/src/render.ts @@ -8,9 +8,10 @@ const SKIPPED_BASENAMES = new Set(); export type ParsedArgs = { target: string; name?: string; + telemetry: boolean; }; -const USAGE = 'Usage: npm create @openora [--name ]'; +const USAGE = 'Usage: npm create @openora [--name ] [--no-telemetry]'; function readFlag(args: string[], index: number): { value: string | undefined; next: number } { const arg = args[index] ?? ''; @@ -25,6 +26,7 @@ export function parseArgs(argv: string[]): ParsedArgs { const args = argv.slice(2); let target: string | undefined; let name: string | undefined; + let telemetry = true; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === undefined) { @@ -34,6 +36,8 @@ export function parseArgs(argv: string[]): ParsedArgs { const flag = readFlag(args, i); name = flag.value; i = flag.next; + } else if (arg === '--no-telemetry') { + telemetry = false; } else if (!arg.startsWith('-') && !target) { target = arg; } @@ -41,7 +45,22 @@ export function parseArgs(argv: string[]): ParsedArgs { if (!target) { throw new Error(`missing target directory.\n ${USAGE}`); } - return name === undefined ? { target } : { target, name }; + return name === undefined ? { target, telemetry } : { target, name, telemetry }; +} + +function isSet(flag: string | undefined): boolean { + return flag !== undefined && ['1', 'true', 'yes'].includes(flag.trim().toLowerCase()); +} + +// Mirrors `isTelemetryEnabled` in core's server/runtime/telemetry.ts; change both together. +export function isTelemetryEnabled(telemetry: boolean, env: NodeJS.ProcessEnv): boolean { + return ( + telemetry && + !isSet(env['OPENORA_TELEMETRY_DISABLED']) && + !isSet(env['DO_NOT_TRACK']) && + env['CI'] === undefined && + env['NODE_ENV'] !== 'test' + ); } export function sanitizeName(raw: string): string { diff --git a/tools/templates/consumer/README.md.tpl b/tools/templates/consumer/README.md.tpl index 779d04b4e..9818e4315 100644 --- a/tools/templates/consumer/README.md.tpl +++ b/tools/templates/consumer/README.md.tpl @@ -38,6 +38,10 @@ The `oss` MCP server is declared in `.mcp.json`. Approve it in your editor, rest To pull the newest platform release, re-run `pnpm install` (the pin tracks the `latest` stable tag). +The API sends anonymous usage telemetry (a random instance id, the version, the Node.js runtime and +two active-player counts). Set `OPENORA_TELEMETRY_DISABLED=1` in `.env` to turn it off; every field +is listed at https://openora.ai/documentation/telemetry. + ## Extending | Need | Command | diff --git a/tools/templates/consumer/__dot__env.example b/tools/templates/consumer/__dot__env.example index 2104509c0..28b7c7d52 100644 --- a/tools/templates/consumer/__dot__env.example +++ b/tools/templates/consumer/__dot__env.example @@ -17,6 +17,9 @@ AUTH_SECRET=change-me-in-production-min-32-chars # up -d` starts this alongside Postgres. REDIS_URL=redis://localhost:6379 +# Anonymous usage telemetry is on by default: https://openora.ai/documentation/telemetry +# OPENORA_TELEMETRY_DISABLED=1 + # Ports PORT=3001 CORS_ORIGINS=http://localhost:3000,http://localhost:3002