356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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);
|
|
}
|