Skip to content
Draft
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
11 changes: 10 additions & 1 deletion packages/openui-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,19 @@ node dist/index.js generate --help

## Telemetry

The CLI sends usage analytics; OAuth sign-ins may link usage to your OIDC account ID. It does not send code, prompts, API keys, email, or name. Disable telemetry with `--no-telemetry` or `DO_NOT_TRACK=1`.
The CLI sends usage analytics; OAuth sign-ins may link CLI usage to your OIDC account ID. When
telemetry is enabled, newly scaffolded OpenUI Cloud projects also receive a random pseudonymous
project ID and report when Next.js production compilation completes. This compiler event occurs
before type checking and static generation, so it is not reported as a successful build.

Telemetry does not send code, prompts, API keys, email, name, project paths, repository metadata,
or build output. Disable CLI and generated-project telemetry with `--no-telemetry` or
`DO_NOT_TRACK=1`. Existing generated projects also honor `OPENUI_TELEMETRY_DISABLED=1` during
builds.

```bash
openui create --no-telemetry
OPENUI_TELEMETRY_DISABLED=1 npm run build
```

## Notes
Expand Down
3 changes: 2 additions & 1 deletion packages/openui-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
"build:templates": "node scripts/build-templates.js",
"build": "pnpm run build:cli && pnpm run build:templates",
"build:exec": "node dist/index.js",
"test": "pnpm run build && node --test tests/*.test.mjs",
"lint:check": "eslint ./src --ignore-pattern 'src/templates/**'",
"lint:fix": "eslint ./src --fix --ignore-pattern 'src/templates/**'",
"format:fix": "prettier --write './src/**' '!./src/templates/**'",
"format:check": "prettier --check './src/**' '!./src/templates/**'",
"prepare": "pnpm run build",
"ci": "pnpm run lint:check && pnpm run format:check"
"ci": "pnpm run lint:check && pnpm run format:check && pnpm run test"
},
"devDependencies": {
"@types/node": "catalog:",
Expand Down
9 changes: 9 additions & 0 deletions packages/openui-cli/src/commands/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { aiSetupFromTemplate, createFunnelProps } from "../lib/create-telemetry"
import type { CreateAppOptions, EnvResult, TemplateName } from "../lib/create-types";
import { resolveInstallPackageManager } from "../lib/detect-package-manager";
import { runSkillInstall, shouldInstallSkill } from "../lib/install-skill";
import { createProjectTelemetryState, writeProjectTelemetryState } from "../lib/project-telemetry";
import { resolveArgs } from "../lib/resolve-args";
import { CreateError, telemetry } from "../lib/telemetry";

Expand Down Expand Up @@ -111,6 +112,11 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
throw new CreateError("dir_exists", `Directory "${name}" already exists.`);
}

const projectTelemetryState = createProjectTelemetryState(template, telemetry.isEnabled());
if (projectTelemetryState) {
telemetry.register({ project_id: projectTelemetryState.projectId });
}

const packageManager = resolveInstallPackageManager();
const templateDir = path.join(__dirname, "..", "templates", template);
if (!fs.existsSync(templateDir)) {
Expand Down Expand Up @@ -150,6 +156,9 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
filter: (src) => shouldCopyTemplatePath(templateDir, src),
});
rewritePackageJson(targetDir, name);
if (projectTelemetryState) {
writeProjectTelemetryState(targetDir, projectTelemetryState);
}
} catch (err) {
captureScaffoldFailed();
throw err;
Expand Down
39 changes: 39 additions & 0 deletions packages/openui-cli/src/lib/project-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as fs from "node:fs";
import * as path from "node:path";

import type { TemplateName } from "./create-types";

export const PROJECT_TELEMETRY_SCHEMA_VERSION = 1;
export const PROJECT_TELEMETRY_RELATIVE_PATH = path.join(".openui", "telemetry.json");

export type ProjectTelemetryState = {
schemaVersion: typeof PROJECT_TELEMETRY_SCHEMA_VERSION;
projectId: string;
};

export function createProjectTelemetryState(
template: TemplateName,
telemetryEnabled: boolean,
): ProjectTelemetryState | undefined {
if (!telemetryEnabled || template !== "openui-cloud") return undefined;

return {
schemaVersion: PROJECT_TELEMETRY_SCHEMA_VERSION,
projectId: crypto.randomUUID(),
};
}

/** Best-effort metadata write: telemetry must never make scaffolding fail. */
export function writeProjectTelemetryState(
projectDir: string,
state: ProjectTelemetryState,
): boolean {
try {
const file = path.join(projectDir, PROJECT_TELEMETRY_RELATIVE_PATH);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
return true;
} catch {
return false;
}
}
14 changes: 11 additions & 3 deletions packages/openui-cli/src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ export class Telemetry {
isTruthyEnv(process.env["DO_NOT_TRACK"]) ||
isTruthyEnv(process.env["OPENUI_TELEMETRY_DISABLED"]) ||
opts.flagEnabled === false;
if (optedOut) return; // enabled stays false → all capture() are no-ops
if (optedOut) {
this.enabled = false;
return;
}
const state = loadOrCreateState();
this.distinctId = state.distinctId;
const interactiveTerminal = isInteractiveTerminal();
Expand Down Expand Up @@ -116,8 +119,9 @@ export class Telemetry {
if (isTelemetryDebug()) this.client.debug();
if (state.isFirstRun) {
process.stderr.write(
"\n◆ OpenUI CLI collects usage analytics; OAuth sign-ins may link usage to your OIDC account ID.\n" +
" No code, prompts, API keys, email, or name are collected. Opt out: set DO_NOT_TRACK=1 or pass --no-telemetry.\n\n",
"\n◆ OpenUI collects anonymous CLI analytics; Cloud templates also report pseudonymous production compilation.\n" +
" OAuth may link CLI usage to your OIDC account ID. No code, prompts, API keys, email, or name are collected.\n" +
" Opt out: set DO_NOT_TRACK=1 or pass --no-telemetry. Later builds also honor OPENUI_TELEMETRY_DISABLED=1.\n\n",
);
state.persist();
}
Expand All @@ -127,6 +131,10 @@ export class Telemetry {
if (this.enabled) Object.assign(this.superProps, props);
}

isEnabled() {
return this.enabled;
}

capture(event: string, properties: Record<string, unknown> = {}) {
if (!this.enabled || !this.client) return;
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";

const DEFAULT_POSTHOG_KEY = "phc_3OLW53x09ZTVZSV6BEpj5uycj3ooqR6KOemOjx04e3D";
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
const PROJECT_STATE_PATH = path.join(".openui", "telemetry.json");
const TELEMETRY_SCHEMA_VERSION = 1;
const SEND_TIMEOUT_MS = 1000;
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

type ProductionCompileContext = {
distDir: string;
projectDir: string;
};

type ProjectTelemetryState = {
schemaVersion?: unknown;
projectId?: unknown;
};

const isTruthyEnv = (value?: string) => value === "1" || value?.toLowerCase() === "true";

function isCi(): boolean {
const env = process.env;
return [env.CI, env.GITHUB_ACTIONS, env.GITLAB_CI, env.BUILDKITE].some(isTruthyEnv);
}

function readProjectId(projectDir: string): string | undefined {
try {
const state = JSON.parse(
fs.readFileSync(path.join(projectDir, PROJECT_STATE_PATH), "utf8"),
) as ProjectTelemetryState;

if (
state.schemaVersion !== TELEMETRY_SCHEMA_VERSION ||
typeof state.projectId !== "string" ||
!UUID_V4_PATTERN.test(state.projectId)
) {
return undefined;
}

return state.projectId;
} catch {
return undefined;
}
}

function readNextVersion(projectDir: string): string {
try {
const requireFromProject = createRequire(path.join(projectDir, "package.json"));
const manifestPath = requireFromProject.resolve("next/package.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { version?: unknown };
return typeof manifest.version === "string" ? manifest.version : "unknown";
} catch {
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(projectDir, "package.json"), "utf8"),
) as {
dependencies?: Record<string, unknown>;
devDependencies?: Record<string, unknown>;
};
const declaredVersion = manifest.dependencies?.next ?? manifest.devDependencies?.next;
return typeof declaredVersion === "string" ? declaredVersion : "unknown";
} catch {
return "unknown";
}
}
}

/**
* Reports completion of Next.js production compilation. This is intentionally
* not named "build succeeded": type checking and static generation run later.
*/
export async function reportOpenUIProductionCompile({
projectDir,
}: ProductionCompileContext): Promise<void> {
if (isTruthyEnv(process.env.DO_NOT_TRACK) || isTruthyEnv(process.env.OPENUI_TELEMETRY_DISABLED)) {
return;
}

const projectId = readProjectId(projectDir);
if (!projectId) return;

const payload = {
api_key: process.env.OPENUI_POSTHOG_KEY ?? DEFAULT_POSTHOG_KEY,
event: "openui_template_production_compile_completed",
distinct_id: projectId,
properties: {
project_id: projectId,
template: "openui-cloud",
framework: "nextjs",
next_version: readNextVersion(projectDir),
node_major_version: Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10),
platform: process.platform,
architecture: process.arch,
ci: isCi(),
cloud_key_configured: Boolean(process.env.THESYS_API_KEY?.trim()),
telemetry_schema_version: TELEMETRY_SCHEMA_VERSION,
$process_person_profile: false,
$geoip_disable: true,
},
};

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
try {
await fetch(new URL("/i/v0/e/", process.env.OPENUI_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch {
// Telemetry is best-effort and must never affect or add noise to a build.
} finally {
clearTimeout(timeout);
}
}
24 changes: 24 additions & 0 deletions packages/openui-cli/src/templates/openui-cloud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,30 @@ OPENUI_MODEL=google/gemini-3.1-pro-free
- `@openuidev/react-headless` / `@openuidev/react-ui` — the chat UI runtime
(`AgentInterface`, storage/stream contracts, `defineArtifactRenderer`).

## Pseudonymous build telemetry

Projects scaffolded with CLI telemetry enabled contain a random project ID in
`.openui/telemetry.json`. After Next.js production compilation completes, the hook in
`next.config.ts` sends one best-effort event to OpenUI's PostHog project. This hook runs before type
checking and static generation, so the event means compilation completed, not that the full build
succeeded.

The event contains only the random project ID, template and framework identifiers, Next.js and Node
versions, platform and architecture, whether the build is running in CI, whether a Cloud key is
configured, and the telemetry schema version. It does not contain code, prompts, API keys, project
names or paths, repository metadata, environment-variable values, or build output.
Geo-IP enrichment and PostHog person-profile creation are disabled for this event.

Disable the event with either environment variable:

```bash
DO_NOT_TRACK=1 npm run build
OPENUI_TELEMETRY_DISABLED=1 npm run build
```

Projects originally created with `openui create --no-telemetry` do not receive a project ID, so the
build hook is a no-op.

## Learn More

To learn more about OpenUI, take a look at the following resources:
Expand Down
5 changes: 5 additions & 0 deletions packages/openui-cli/src/templates/openui-cloud/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { NextConfig } from "next";

import { reportOpenUIProductionCompile } from "./.openui/build-telemetry";

const nextConfig: NextConfig = {
output: "standalone",
compiler: {
runAfterProductionCompile: reportOpenUIProductionCompile,
},
};

export default nextConfig;
Loading