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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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**.
Expand Down
41 changes: 41 additions & 0 deletions docs/adr/0044-opt-out-anonymous-usage-telemetry.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/guides/downstream-consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
57 changes: 57 additions & 0 deletions docs/platform/telemetry.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/core/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 2 additions & 0 deletions packages/core/src/contracts/adapters/player-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createToken, type Token } from './token.js';

export type PlayerActivityTracker = {
touchLastSeen(userId: string): Promise<void>;
/** Players whose last-seen stamp is at or after `since`. Feeds the anonymous usage telemetry; telemetry reports `null` without it. */
countActiveSince?(since: Date): Promise<number>;
};

export const PLAYER_ACTIVITY_TRACKER: Token<PlayerActivityTracker> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,14 @@ export class PlayerService implements PlayerActivityTracker {
);
}

async countActiveSince(since: Date): Promise<number> {
const [row] = await this.drizzle.db
.select({ value: count() })
.from(player)
.where(gte(player.lastSeenAt, since));
Comment thread
zaxovaiko marked this conversation as resolved.
Resolved
return row?.value ?? 0;
}

async remove(playerId: Player['id'], actorId: User['id']) {
const before = await this.drizzle.db.transaction(async (trx) => {
const locked = findOneOrThrow(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE INDEX "player_last_seen_at_idx" ON "player" USING btree ("last_seen_at");
Loading
Loading