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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,13 +369,15 @@ install ID stored locally in `~/.openwiki/install-id`:
no-op), plus a coarse error category on failure (never the error message).
Interactive chat, `auth`, and `ingest` are not recorded.
- At setup (on init only): which brain mode (code / personal), the model
provider, and which connectors you configured (connector names only, never
their contents).
provider, the configured base API URL when using the OpenAI-compatible provider
(with credentials, query strings, and fragments stripped), and which connectors
you configured (connector names only, never their contents).

**What is never collected:** file contents, repository data or names,
credentials, prompts, model output, connector payloads, error messages, file
paths, URLs, model IDs, run duration, your IP address, or any personal
information. Geoip enrichment is disabled and your IP is never stored. Events
paths, URLs other than the OpenAI-compatible base API URL, model IDs, run
duration, your IP address, or any personal information. Geoip enrichment is
disabled and your IP is never stored. Events
are grouped by your random install ID so we can measure repeat usage, but that
ID contains no personal data.

Expand Down
5 changes: 4 additions & 1 deletion src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,12 @@ export async function runOpenWikiAgent(
// invalid model, missing base URL) is still recorded. They may be undefined
// in the catch if resolution threw before assigning them.
let provider: OpenWikiProvider | undefined;
let providerBaseUrl: string | undefined;
let modelId: string | undefined;

try {
provider = resolveConfiguredProvider();
const providerBaseUrl = resolveProviderBaseUrl(provider);
providerBaseUrl = resolveProviderBaseUrl(provider);
emitDebug(options, `provider=${provider}`);
if (providerBaseUrl) {
emitDebug(options, `provider.baseUrl=${JSON.stringify(providerBaseUrl)}`);
Expand Down Expand Up @@ -184,6 +185,7 @@ export async function runOpenWikiAgent(
await recordRunSafe(command, options, {
provider,
outcome: "success",
openAiCompatibleBaseUrl: providerBaseUrl,
});

return result;
Expand All @@ -194,6 +196,7 @@ export async function runOpenWikiAgent(
provider,
outcome: "failure",
errorClass: classifyError(error),
openAiCompatibleBaseUrl: providerBaseUrl,
});

throw error;
Expand Down
2 changes: 1 addition & 1 deletion src/telemetry/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const TELEMETRY_RUN_EVENT = "openwiki_run";
* box, and the print/non-TTY path frames and wraps them (see cli.tsx).
*/
export const FIRST_RUN_NOTICE_BODY =
"OpenWiki collects anonymous, aggregate usage data: which command you run (init or update), the brain mode and model provider you set up, whether runs succeed or fail (and a general error category), and which connectors you configured. No file contents, repository data, credentials, prompts, model output, IP address, or personal information are ever collected.";
"OpenWiki collects anonymous, aggregate usage data: which command you run (init or update), the brain mode and model provider you set up, the configured base API URL when using the OpenAI-compatible provider (without credentials, query strings, or fragments), whether runs succeed or fail (and a general error category), and which connectors you configured. No file contents, repository data, credentials, prompts, model output, IP address, or personal information are ever collected.";
export const FIRST_RUN_NOTICE_OPT_OUT =
"Opt out anytime: set OPENWIKI_TELEMETRY_DISABLED=1 (or DO_NOT_TRACK=1). Add it to ~/.openwiki/.env to make it permanent.";
export const FIRST_RUN_NOTICE_VERIFY =
Expand Down
4 changes: 4 additions & 0 deletions src/telemetry/record-run-safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export async function recordRunSafe(
provider?: OpenWikiProvider;
outcome: "success" | "failure" | "noop";
errorClass?: TelemetryErrorClass;
openAiCompatibleBaseUrl?: string;
},
): Promise<void> {
// Chat is deliberately not recorded: it is interactive and would emit one
Expand All @@ -55,6 +56,9 @@ export async function recordRunSafe(
? {
mode: outputMode === "repository" ? "code" : "personal",
provider: facts.provider ?? "unknown",
...(facts.provider === "openai-compatible"
? { openAiCompatibleBaseUrl: facts.openAiCompatibleBaseUrl }
: {}),
configuredConnectors: getConfiguredConnectorIds(),
}
: {}),
Expand Down
25 changes: 25 additions & 0 deletions src/telemetry/senders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export function buildRunEvent(
details: RunTelemetry,
context: RunEventContext,
): TelemetryEvent {
const openAiCompatibleBaseUrl = sanitizeTelemetryUrl(
details.openAiCompatibleBaseUrl,
);

return {
distinctId: context.distinctId,
event: TELEMETRY_RUN_EVENT,
Expand All @@ -61,6 +65,9 @@ export function buildRunEvent(
...(details.errorClass ? { error_class: details.errorClass } : {}),
...(details.mode ? { mode: details.mode } : {}),
...(details.provider ? { provider: details.provider } : {}),
...(openAiCompatibleBaseUrl
? { openai_compatible_base_url: openAiCompatibleBaseUrl }
: {}),
...connectorProperties(details.configuredConnectors ?? []),
// True for the published build, false for dev/source/seed runs; lets real
// usage be separated from local testing and pre-launch seed data.
Expand Down Expand Up @@ -127,6 +134,24 @@ function connectorProperties(configured: string[]): Record<string, true> {
);
}

function sanitizeTelemetryUrl(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}

try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";

return url.toString();
} catch {
return undefined;
}
}

async function writeTelemetryFile(
filePath: string | undefined,
record: Record<string, unknown>,
Expand Down
5 changes: 5 additions & 0 deletions src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export interface RunTelemetry {
*/
configuredConnectors?: string[];

/**
* Base URL configured for the OpenAI-compatible provider. Init only.
*/
openAiCompatibleBaseUrl?: string;

/**
* Optional tee target from --telemetry-file.
*/
Expand Down
66 changes: 66 additions & 0 deletions test/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
isTelemetryDisabled,
noticeSuppressed,
} from "../src/telemetry/gates.ts";
import { recordRunSafe } from "../src/telemetry/record-run-safe.ts";
import { recordRun } from "../src/telemetry/senders.ts";
import type { RunTelemetry } from "../src/telemetry/types.ts";

Expand Down Expand Up @@ -306,6 +307,71 @@ describe("recordRun connector properties", () => {
);
});

test("init records the sanitized OpenAI-compatible base URL", async () => {
await recordRunSafe(
"init",
{},
{
provider: "openai-compatible",
outcome: "success",
openAiCompatibleBaseUrl:
"https://user:password@gateway.example.com/v1?token=secret#fragment",
},
);

expect(runEvent().properties.openai_compatible_base_url).toBe(
"https://gateway.example.com/v1",
);
});

test("other providers omit OpenAI-compatible base URL telemetry", async () => {
await recordRunSafe(
"init",
{},
{
provider: "anthropic",
outcome: "success",
openAiCompatibleBaseUrl: "https://gateway.example.com/v1",
},
);

expect(runEvent().properties).not.toHaveProperty(
"openai_compatible_base_url",
);
});

test("updates omit OpenAI-compatible base URL telemetry", async () => {
await recordRunSafe(
"update",
{},
{
provider: "openai-compatible",
outcome: "success",
openAiCompatibleBaseUrl: "https://gateway.example.com/v1",
},
);

expect(runEvent().properties).not.toHaveProperty(
"openai_compatible_base_url",
);
});

test("invalid OpenAI-compatible base URLs are not sent", async () => {
await recordRunSafe(
"init",
{},
{
provider: "openai-compatible",
outcome: "failure",
openAiCompatibleBaseUrl: "not a url with secret-token",
},
);

expect(runEvent().properties).not.toHaveProperty(
"openai_compatible_base_url",
);
});

test("stamps production=false when running from source (dev/test)", async () => {
// Tests import from src/, so isProductionBuild() (dist/ check) is false;
// the published build runs from dist/ and would send production=true.
Expand Down