diff --git a/README.md b/README.md index d5dbd353..3f8cb573 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/agent/index.ts b/src/agent/index.ts index 06b99b07..c09c25ba 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -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)}`); @@ -184,6 +185,7 @@ export async function runOpenWikiAgent( await recordRunSafe(command, options, { provider, outcome: "success", + openAiCompatibleBaseUrl: providerBaseUrl, }); return result; @@ -194,6 +196,7 @@ export async function runOpenWikiAgent( provider, outcome: "failure", errorClass: classifyError(error), + openAiCompatibleBaseUrl: providerBaseUrl, }); throw error; diff --git a/src/telemetry/config.ts b/src/telemetry/config.ts index b151381e..537e6bb8 100644 --- a/src/telemetry/config.ts +++ b/src/telemetry/config.ts @@ -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 = diff --git a/src/telemetry/record-run-safe.ts b/src/telemetry/record-run-safe.ts index 1bf15b32..a864d68c 100644 --- a/src/telemetry/record-run-safe.ts +++ b/src/telemetry/record-run-safe.ts @@ -35,6 +35,7 @@ export async function recordRunSafe( provider?: OpenWikiProvider; outcome: "success" | "failure" | "noop"; errorClass?: TelemetryErrorClass; + openAiCompatibleBaseUrl?: string; }, ): Promise { // Chat is deliberately not recorded: it is interactive and would emit one @@ -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(), } : {}), diff --git a/src/telemetry/senders.ts b/src/telemetry/senders.ts index 4049a654..73174b11 100644 --- a/src/telemetry/senders.ts +++ b/src/telemetry/senders.ts @@ -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, @@ -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. @@ -127,6 +134,24 @@ function connectorProperties(configured: string[]): Record { ); } +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, diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index 3e46f593..5682324d 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -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. */ diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 761c109e..35511487 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -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"; @@ -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.