/** * mosaic telemetry — CU-06-02 (local) + CU-06-03 (remote) * * Local half: mosaic telemetry local {status, tail, jaeger} * Remote half: mosaic telemetry {status, opt-in, opt-out, test, upload} * * Remote upload is DISABLED by default (dry-run mode). * Per session-1 decision: ship upload/test in dry-run-only mode until * the mosaicstack.dev server endpoint is live. * * Telemetry client: uses a forward-compat shim (see telemetry/client-shim.ts) * because @mosaicstack/telemetry-client-js is not yet published. */ import type { Command } from 'commander'; import { confirm, intro, outro, isCancel, cancel } from '@clack/prompts'; import { DEFAULT_MOSAIC_HOME } from '../constants.js'; import { getTelemetryClient } from '../telemetry/client-shim.js'; import { readConsent, optIn, optOut, recordUpload } from '../telemetry/consent-store.js'; // ─── helpers ───────────────────────────────────────────────────────────────── function getMosaicHome(): string { return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME; } function isDryRun(): boolean { return process.env['MOSAIC_TELEMETRY_DRY_RUN'] === '1'; } /** Try to open a URL — best-effort, does not fail if unsupported. */ async function tryOpenUrl(url: string): Promise { try { const { spawn } = await import('node:child_process'); // `start` is a Windows shell builtin — must be invoked via cmd /c. const [bin, args] = process.platform === 'darwin' ? (['open', [url]] as [string, string[]]) : process.platform === 'win32' ? (['cmd', ['/c', 'start', '', url]] as [string, string[]]) : (['xdg-open', [url]] as [string, string[]]); spawn(bin, args, { detached: true, stdio: 'ignore' }).unref(); } catch { // Best-effort — silently skip if unavailable. console.log(`Open this URL in your browser: ${url}`); } } // ─── local subcommands ─────────────────────────────────────────────────────── function registerLocalCommand(parent: Command): void { const local = parent .command('local') .description('Inspect the local OpenTelemetry stack') .configureHelp({ sortSubcommands: true }); // ── telemetry local status ────────────────────────────────────────────── local .command('status') .description('Report reachability of the local OTEL collector endpoint') .action(async () => { const endpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? 'http://localhost:4318'; const serviceName = process.env['OTEL_SERVICE_NAME'] ?? 'mosaic-gateway'; const exportInterval = '15000ms'; // matches tracing.ts PeriodicExportingMetricReader console.log(`OTEL endpoint: ${endpoint}`); console.log(`Service name: ${serviceName}`); console.log(`Export interval: ${exportInterval}`); console.log(''); try { const response = await fetch(endpoint, { method: 'GET', signal: AbortSignal.timeout(3000), }); // OTLP collector typically returns 404 for GET on the root path — // but a response means it's listening. console.log(`Status: reachable (HTTP ${String(response.status)})`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.log(`Status: unreachable — ${msg}`); console.log(''); console.log('Hint: start the local stack with `docker compose up -d`'); } }); // ── telemetry local tail ──────────────────────────────────────────────── local .command('tail') .description('Explain how to view live traces from the local OTEL stack') .action(() => { const jaegerUrl = process.env['JAEGER_UI_URL'] ?? 'http://localhost:16686'; console.log('OTLP is a push protocol — there is no log tail.'); console.log(''); console.log('Traces flow: your service → OTEL Collector → Jaeger'); console.log(''); console.log(`Jaeger UI: ${jaegerUrl}`); console.log('Run `mosaic telemetry local jaeger` to print the URL (or open it).'); console.log(''); console.log('For raw collector output:'); console.log(' docker compose logs -f otel-collector'); }); // ── telemetry local jaeger ────────────────────────────────────────────── local .command('jaeger') .description('Print the Jaeger UI URL (use --open to launch in browser)') .option('--open', 'Open the Jaeger UI in the default browser') .action(async (opts: { open?: boolean }) => { const jaegerUrl = process.env['JAEGER_UI_URL'] ?? 'http://localhost:16686'; console.log(jaegerUrl); if (opts.open) { await tryOpenUrl(jaegerUrl); } }); } // ─── remote subcommands ────────────────────────────────────────────────────── function registerRemoteStatusCommand(cmd: Command): void { cmd .command('status') .description('Print the remote telemetry upload status and consent state') .action(() => { const mosaicHome = getMosaicHome(); const consent = readConsent(mosaicHome); const remoteEndpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT'] ?? '(not configured)'; const dryRunActive = isDryRun(); console.log('Remote telemetry status'); console.log('─────────────────────────────────────────────'); console.log(` Remote upload enabled: ${String(consent.remoteEnabled)}`); console.log(` Remote endpoint: ${remoteEndpoint}`); if (consent.optedInAt) { console.log(` Opted in: ${consent.optedInAt}`); } if (consent.optedOutAt) { console.log(` Opted out: ${consent.optedOutAt}`); } if (consent.lastUploadAt) { console.log(` Last upload: ${consent.lastUploadAt}`); } else { console.log(' Last upload: (never)'); } if (dryRunActive) { console.log(''); console.log(' [dry-run] MOSAIC_TELEMETRY_DRY_RUN=1 is set — uploads are suppressed'); } console.log(''); console.log('Local OTEL stack always active (see `mosaic telemetry local status`).'); }); } function registerOptInCommand(cmd: Command): void { cmd .command('opt-in') .description('Enable remote telemetry upload (requires explicit consent)') .action(async () => { const mosaicHome = getMosaicHome(); const current = readConsent(mosaicHome); if (current.remoteEnabled) { console.log('Remote telemetry upload is already enabled.'); console.log(`Opted in: ${current.optedInAt ?? '(unknown)'}`); return; } intro('Mosaic remote telemetry opt-in'); console.log(''); console.log('What gets uploaded:'); console.log(' - CLI command names and completion status (no arguments / values)'); console.log(' - Error types (no stack traces or user data)'); console.log(' - Mosaic version and platform metadata'); console.log(''); console.log('What is NEVER uploaded:'); console.log(' - File contents, code, or credentials'); console.log(' - Personal information or agent conversation data'); console.log(''); console.log('Note: remote upload is currently in dry-run mode until'); console.log(' the mosaicstack.dev telemetry endpoint is live.'); console.log(''); const confirmed = await confirm({ message: 'Enable remote telemetry upload?', }); if (isCancel(confirmed) || !confirmed) { cancel('Opt-in cancelled — no changes made.'); return; } const consent = optIn(mosaicHome); outro(`Remote telemetry enabled. Opted in at ${consent.optedInAt ?? ''}`); }); } function registerOptOutCommand(cmd: Command): void { cmd .command('opt-out') .description('Disable remote telemetry upload') .action(async () => { const mosaicHome = getMosaicHome(); const current = readConsent(mosaicHome); if (!current.remoteEnabled) { console.log('Remote telemetry upload is already disabled.'); return; } intro('Mosaic remote telemetry opt-out'); console.log(''); console.log('This will disable remote upload of anonymised usage data.'); console.log('Local OTEL tracing (to Jaeger) will remain active — it is'); console.log('independent of this consent state.'); console.log(''); const confirmed = await confirm({ message: 'Disable remote telemetry upload?', }); if (isCancel(confirmed) || !confirmed) { cancel('Opt-out cancelled — no changes made.'); return; } const consent = optOut(mosaicHome); outro(`Remote telemetry disabled. Opted out at ${consent.optedOutAt ?? ''}`); console.log('Local OTEL stack (Jaeger) remains active.'); }); } function registerTestCommand(cmd: Command): void { cmd .command('test') .description('Synthesise a fake event and print the payload that would be sent (dry-run)') .option('--upload', 'Actually upload (requires consent + MOSAIC_TELEMETRY_ENDPOINT)') .action(async (opts: { upload?: boolean }) => { const mosaicHome = getMosaicHome(); const consent = readConsent(mosaicHome); const dryRunActive = isDryRun() || !opts.upload; if (!dryRunActive && !consent.remoteEnabled) { console.error('Remote upload is not enabled. Run `mosaic telemetry opt-in` first.'); process.exit(1); } const fakeEvent = { name: 'mosaic.cli.test', properties: { command: 'telemetry test', version: process.env['npm_package_version'] ?? 'unknown', platform: process.platform, }, timestamp: new Date().toISOString(), }; const endpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT']; const client = getTelemetryClient(); client.init({ endpoint, dryRun: dryRunActive, labels: { source: 'mosaic-cli' }, }); client.captureEvent(fakeEvent); if (dryRunActive) { console.log('[dry-run] telemetry test — payload that would be sent:'); console.log(JSON.stringify(fakeEvent, null, 2)); console.log(''); console.log('No network call made. Pass --upload to attempt real delivery.'); } else { try { await client.upload(); recordUpload(mosaicHome); console.log('Event delivered.'); } catch (err) { // The shim throws when a real POST is attempted — make it clear nothing was sent. console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } } }); } function registerUploadCommand(cmd: Command): void { cmd .command('upload') .description('Send pending telemetry events to the remote endpoint') .action(async () => { const mosaicHome = getMosaicHome(); const consent = readConsent(mosaicHome); const dryRunActive = isDryRun(); if (!consent.remoteEnabled) { console.log('[dry-run] telemetry upload — no network call made'); console.log('Remote upload is disabled. Run `mosaic telemetry opt-in` to enable.'); return; } const endpoint = process.env['MOSAIC_TELEMETRY_ENDPOINT']; if (dryRunActive || !endpoint) { console.log('[dry-run] telemetry upload — no network call made'); if (!endpoint) { console.log('MOSAIC_TELEMETRY_ENDPOINT is not set — running in dry-run mode.'); } if (dryRunActive) { console.log('MOSAIC_TELEMETRY_DRY_RUN=1 — uploads suppressed.'); } console.log(''); console.log('Dry-run is the default until the mosaicstack.dev telemetry endpoint is live.'); return; } const client = getTelemetryClient(); client.init({ endpoint, dryRun: false, labels: { source: 'mosaic-cli' } }); try { await client.upload(); recordUpload(mosaicHome); console.log('Upload complete.'); } catch (err) { // The shim throws when a real POST is attempted — make it clear nothing was sent. console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); } // ─── public registration ────────────────────────────────────────────────────── export function registerTelemetryCommand(program: Command): void { const cmd = program .command('telemetry') .description('Inspect and manage telemetry (local OTEL stack + remote upload)') .configureHelp({ sortSubcommands: true }); // ── local subgroup ────────────────────────────────────────────────────── registerLocalCommand(cmd); // ── remote subcommands ────────────────────────────────────────────────── registerRemoteStatusCommand(cmd); registerOptInCommand(cmd); registerOptOutCommand(cmd); registerTestCommand(cmd); registerUploadCommand(cmd); }