diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index e885b3e..7bf50ae 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -10,6 +10,7 @@ import { registerMemoryCommand } from '@mosaicstack/memory'; import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; import { registerStorageCommand } from '@mosaicstack/storage'; +import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerConfigCommand } from './commands/config.js'; import { registerMissionCommand } from './commands/mission.js'; @@ -382,6 +383,10 @@ registerQueueCommand(program); registerStorageCommand(program); +// ─── telemetry ─────────────────────────────────────────────────────────────── + +registerTelemetryCommand(program); + // ─── update ───────────────────────────────────────────────────────────── program diff --git a/packages/mosaic/src/commands/telemetry.spec.ts b/packages/mosaic/src/commands/telemetry.spec.ts new file mode 100644 index 0000000..9e5b2da --- /dev/null +++ b/packages/mosaic/src/commands/telemetry.spec.ts @@ -0,0 +1,426 @@ +/** + * CU-06-05 — Vitest tests for mosaic telemetry command + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; +import { registerTelemetryCommand } from './telemetry.js'; +import type { TelemetryConsent } from '../telemetry/consent-store.js'; + +// ─── module mocks ───────────────────────────────────────────────────────────── + +// Mock consent-store so tests don't touch the filesystem. +const mockConsent: TelemetryConsent = { + remoteEnabled: false, + optedInAt: null, + optedOutAt: null, + lastUploadAt: null, +}; + +vi.mock('../telemetry/consent-store.js', () => ({ + readConsent: vi.fn(() => ({ ...mockConsent })), + writeConsent: vi.fn(), + optIn: vi.fn(() => ({ + ...mockConsent, + remoteEnabled: true, + optedInAt: '2026-01-01T00:00:00.000Z', + })), + optOut: vi.fn(() => ({ + ...mockConsent, + remoteEnabled: false, + optedOutAt: '2026-01-01T00:00:00.000Z', + })), + recordUpload: vi.fn(), +})); + +// Mock the telemetry client shim. +const mockClientInstance = { + init: vi.fn(), + captureEvent: vi.fn(), + upload: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), +}; + +vi.mock('../telemetry/client-shim.js', () => ({ + getTelemetryClient: vi.fn(() => mockClientInstance), + setTelemetryClient: vi.fn(), + resetTelemetryClient: vi.fn(), +})); + +// Mock @clack/prompts so tests don't require stdin. +vi.mock('@clack/prompts', () => ({ + confirm: vi.fn().mockResolvedValue(true), + intro: vi.fn(), + outro: vi.fn(), + isCancel: vi.fn().mockReturnValue(false), + cancel: vi.fn(), +})); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); + registerTelemetryCommand(program); + return program; +} + +function getTelemetryCmd(program: Command): Command { + const found = program.commands.find((c) => c.name() === 'telemetry'); + if (!found) throw new Error('telemetry command not found'); + return found; +} + +function getLocalCmd(telemetryCmd: Command): Command { + const found = telemetryCmd.commands.find((c) => c.name() === 'local'); + if (!found) throw new Error('local subcommand not found'); + return found; +} + +// ─── CU-06-05 a: command structure smoke test ───────────────────────────────── + +describe('registerTelemetryCommand — structure', () => { + it('registers a "telemetry" command on the program', () => { + const program = buildProgram(); + const names = program.commands.map((c) => c.name()); + expect(names).toContain('telemetry'); + }); + + it('registers the expected top-level subcommands', () => { + const program = buildProgram(); + const tel = getTelemetryCmd(program); + const subs = tel.commands.map((c) => c.name()).sort(); + expect(subs).toEqual(['local', 'opt-in', 'opt-out', 'status', 'test', 'upload']); + }); + + it('registers all three local subcommands', () => { + const program = buildProgram(); + const local = getLocalCmd(getTelemetryCmd(program)); + const subs = local.commands.map((c) => c.name()).sort(); + expect(subs).toEqual(['jaeger', 'status', 'tail']); + }); +}); + +// ─── CU-06-05 b: opt-in / opt-out ──────────────────────────────────────────── + +describe('telemetry opt-in', () => { + let consoleSpy: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // Provide disabled consent so opt-in path is taken. + const store = await import('../telemetry/consent-store.js'); + vi.mocked(store.readConsent).mockReturnValue({ + remoteEnabled: false, + optedInAt: null, + optedOutAt: null, + lastUploadAt: null, + }); + vi.mocked(store.optIn).mockReturnValue({ + remoteEnabled: true, + optedInAt: '2026-01-01T00:00:00.000Z', + optedOutAt: null, + lastUploadAt: null, + }); + + const clack = await import('@clack/prompts'); + vi.mocked(clack.confirm).mockResolvedValue(true); + vi.mocked(clack.isCancel).mockReturnValue(false); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('calls optIn() when user confirms', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-in']); + + const store = await import('../telemetry/consent-store.js'); + expect(vi.mocked(store.optIn)).toHaveBeenCalled(); + }); + + it('does not call optIn() when user cancels', async () => { + const clack = await import('@clack/prompts'); + vi.mocked(clack.confirm).mockResolvedValue(false); + + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-in']); + + const store = await import('../telemetry/consent-store.js'); + expect(vi.mocked(store.optIn)).not.toHaveBeenCalled(); + }); +}); + +describe('telemetry opt-out', () => { + let consoleSpy: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const store = await import('../telemetry/consent-store.js'); + vi.mocked(store.readConsent).mockReturnValue({ + remoteEnabled: true, + optedInAt: '2026-01-01T00:00:00.000Z', + optedOutAt: null, + lastUploadAt: null, + }); + vi.mocked(store.optOut).mockReturnValue({ + remoteEnabled: false, + optedInAt: '2026-01-01T00:00:00.000Z', + optedOutAt: '2026-02-01T00:00:00.000Z', + lastUploadAt: null, + }); + + const clack = await import('@clack/prompts'); + vi.mocked(clack.confirm).mockResolvedValue(true); + vi.mocked(clack.isCancel).mockReturnValue(false); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('calls optOut() when user confirms', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-out']); + + const store = await import('../telemetry/consent-store.js'); + expect(vi.mocked(store.optOut)).toHaveBeenCalled(); + }); + + it('does not call optOut() when already disabled', async () => { + const store = await import('../telemetry/consent-store.js'); + vi.mocked(store.readConsent).mockReturnValue({ + remoteEnabled: false, + optedInAt: null, + optedOutAt: null, + lastUploadAt: null, + }); + + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'opt-out']); + expect(vi.mocked(store.optOut)).not.toHaveBeenCalled(); + }); +}); + +// ─── CU-06-05 c: status ────────────────────────────────────────────────────── + +describe('telemetry status', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('shows disabled state when remote upload is off', async () => { + const store = await import('../telemetry/consent-store.js'); + vi.mocked(store.readConsent).mockReturnValue({ + remoteEnabled: false, + optedInAt: null, + optedOutAt: null, + lastUploadAt: null, + }); + + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']); + + const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('false'); + expect(output).toContain('(never)'); + }); + + it('shows enabled state and timestamps when opted in', async () => { + const store = await import('../telemetry/consent-store.js'); + vi.mocked(store.readConsent).mockReturnValue({ + remoteEnabled: true, + optedInAt: '2026-01-01T00:00:00.000Z', + optedOutAt: null, + lastUploadAt: '2026-03-01T00:00:00.000Z', + }); + + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']); + + const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('true'); + expect(output).toContain('2026-01-01'); + expect(output).toContain('2026-03-01'); + }); + + it('shows dry-run banner when MOSAIC_TELEMETRY_DRY_RUN=1', async () => { + process.env['MOSAIC_TELEMETRY_DRY_RUN'] = '1'; + + const store = await import('../telemetry/consent-store.js'); + vi.mocked(store.readConsent).mockReturnValue({ + remoteEnabled: false, + optedInAt: null, + optedOutAt: null, + lastUploadAt: null, + }); + + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'status']); + + const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('[dry-run]'); + + delete process.env['MOSAIC_TELEMETRY_DRY_RUN']; + }); +}); + +// ─── CU-06-05 d: test / upload — dry-run assertions ────────────────────────── + +describe('telemetry test (dry-run)', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('prints dry-run banner and does not call upload()', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']); + + const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('[dry-run]'); + expect(mockClientInstance.upload).not.toHaveBeenCalled(); + }); + + it('calls captureEvent() with a mosaic.cli.test event', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']); + + expect(mockClientInstance.captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ name: 'mosaic.cli.test' }), + ); + }); + + it('does not make network calls in dry-run mode', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response()); + + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'test']); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + +describe('telemetry upload (dry-run default)', () => { + let consoleSpy: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // Remote disabled by default. + const store = await import('../telemetry/consent-store.js'); + vi.mocked(store.readConsent).mockReturnValue({ + remoteEnabled: false, + optedInAt: null, + optedOutAt: null, + lastUploadAt: null, + }); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + delete process.env['MOSAIC_TELEMETRY_DRY_RUN']; + delete process.env['MOSAIC_TELEMETRY_ENDPOINT']; + }); + + it('prints dry-run banner when remote upload is disabled', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'upload']); + + const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('[dry-run]'); + expect(mockClientInstance.upload).not.toHaveBeenCalled(); + }); + + it('prints dry-run banner when MOSAIC_TELEMETRY_DRY_RUN=1 even if opted in', async () => { + process.env['MOSAIC_TELEMETRY_DRY_RUN'] = '1'; + process.env['MOSAIC_TELEMETRY_ENDPOINT'] = 'http://example.com/telemetry'; + + const store = await import('../telemetry/consent-store.js'); + vi.mocked(store.readConsent).mockReturnValue({ + remoteEnabled: true, + optedInAt: '2026-01-01T00:00:00.000Z', + optedOutAt: null, + lastUploadAt: null, + }); + + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'upload']); + + const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('[dry-run]'); + expect(mockClientInstance.upload).not.toHaveBeenCalled(); + }); +}); + +// ─── local subcommand smoke tests ───────────────────────────────────────────── + +describe('telemetry local tail', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('prints Jaeger UI URL and docker compose hint', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'tail']); + + const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('Jaeger'); + expect(output).toContain('docker compose'); + }); +}); + +describe('telemetry local jaeger', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + delete process.env['JAEGER_UI_URL']; + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('prints the default Jaeger URL', async () => { + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'jaeger']); + expect(consoleSpy).toHaveBeenCalledWith('http://localhost:16686'); + }); + + it('respects JAEGER_UI_URL env var', async () => { + process.env['JAEGER_UI_URL'] = 'http://jaeger.example.com:16686'; + const program = buildProgram(); + await program.parseAsync(['node', 'mosaic', 'telemetry', 'local', 'jaeger']); + expect(consoleSpy).toHaveBeenCalledWith('http://jaeger.example.com:16686'); + delete process.env['JAEGER_UI_URL']; + }); +}); diff --git a/packages/mosaic/src/commands/telemetry.ts b/packages/mosaic/src/commands/telemetry.ts new file mode 100644 index 0000000..3a03504 --- /dev/null +++ b/packages/mosaic/src/commands/telemetry.ts @@ -0,0 +1,355 @@ +/** + * 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); +} diff --git a/packages/mosaic/src/telemetry/client-shim.ts b/packages/mosaic/src/telemetry/client-shim.ts new file mode 100644 index 0000000..de9e60f --- /dev/null +++ b/packages/mosaic/src/telemetry/client-shim.ts @@ -0,0 +1,132 @@ +/** + * Forward-compat shim for @mosaicstack/telemetry-client-js. + * + * @mosaicstack/telemetry-client-js is not yet published to the Gitea npm + * registry (returns 404 as of 2026-04-04). This shim mirrors the minimal + * interface that the real client will expose so that all telemetry wiring + * can be implemented now and swapped for the real package when it lands. + * + * TODO: replace this shim with `import { ... } from '@mosaicstack/telemetry-client-js'` + * once the package is published. + */ + +export interface TelemetryEvent { + /** Event name / category */ + name: string; + /** Arbitrary key-value payload */ + properties?: Record; + /** ISO timestamp — defaults to now if omitted */ + timestamp?: string; +} + +/** + * Minimal interface mirroring what @mosaicstack/telemetry-client-js exposes. + */ +export interface TelemetryClient { + /** Initialise the client (must be called before captureEvent / upload). */ + init(options: TelemetryClientOptions): void; + /** Queue a telemetry event for eventual upload. */ + captureEvent(event: TelemetryEvent): void; + /** + * Flush all queued events to the remote endpoint. + * In dry-run mode the client must print instead of POST. + */ + upload(): Promise; + /** Flush and release resources. */ + shutdown(): Promise; +} + +export interface TelemetryClientOptions { + /** Remote OTLP / telemetry endpoint URL */ + endpoint?: string; + /** Dry-run: print payloads instead of posting */ + dryRun?: boolean; + /** Extra labels attached to every event */ + labels?: Record; +} + +// ─── Shim implementation ───────────────────────────────────────────────────── + +/** + * A no-network shim that buffers events and pretty-prints them in dry-run mode. + * This is the ONLY implementation used until the real package is published. + */ +class TelemetryClientShim implements TelemetryClient { + private options: TelemetryClientOptions = {}; + private queue: TelemetryEvent[] = []; + + init(options: TelemetryClientOptions): void { + // Merge options without clearing the queue — buffered events must survive + // re-initialisation so that `telemetry upload` can flush them. + this.options = options; + } + + captureEvent(event: TelemetryEvent): void { + this.queue.push({ + ...event, + timestamp: event.timestamp ?? new Date().toISOString(), + }); + } + + async upload(): Promise { + const isDryRun = this.options.dryRun !== false; // dry-run is default + + if (isDryRun) { + console.log('[dry-run] telemetry upload — no network call made'); + for (const evt of this.queue) { + console.log(JSON.stringify({ ...evt, labels: this.options.labels }, null, 2)); + } + this.queue = []; + return; + } + + // Real upload path — placeholder until real client replaces this shim. + const endpoint = this.options.endpoint; + if (!endpoint) { + console.log('[dry-run] telemetry upload — no endpoint configured, no network call made'); + for (const evt of this.queue) { + console.log(JSON.stringify(evt, null, 2)); + } + this.queue = []; + return; + } + + // The real client is not yet published — throw so callers know no data + // was actually sent. This prevents the CLI from marking an upload as + // successful when only the shim is present. + // TODO: remove once @mosaicstack/telemetry-client-js replaces this shim. + throw new Error( + `[shim] telemetry-client-js is not yet available — cannot POST to ${endpoint}. ` + + 'Remote upload is supported only after the mosaicstack.dev endpoint is live.', + ); + } + + async shutdown(): Promise { + await this.upload(); + } +} + +/** Singleton client instance. */ +let _client: TelemetryClient | null = null; + +/** Return (or lazily create) the singleton telemetry client. */ +export function getTelemetryClient(): TelemetryClient { + if (!_client) { + _client = new TelemetryClientShim(); + } + return _client; +} + +/** + * Replace the singleton — used in tests to inject a mock. + */ +export function setTelemetryClient(client: TelemetryClient): void { + _client = client; +} + +/** + * Reset the singleton to null (useful in tests). + */ +export function resetTelemetryClient(): void { + _client = null; +} diff --git a/packages/mosaic/src/telemetry/consent-store.ts b/packages/mosaic/src/telemetry/consent-store.ts new file mode 100644 index 0000000..b2bc049 --- /dev/null +++ b/packages/mosaic/src/telemetry/consent-store.ts @@ -0,0 +1,112 @@ +/** + * Persistent consent store for remote telemetry upload. + * + * State is stored in $MOSAIC_HOME/telemetry.json (not inside the markdown + * config files — those are template-rendered and would lose structured data). + * + * Schema: + * { + * remoteEnabled: boolean, + * optedInAt: string | null, // ISO timestamp + * optedOutAt: string | null, // ISO timestamp + * lastUploadAt: string | null // ISO timestamp + * } + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { atomicWrite } from '../platform/file-ops.js'; +import { DEFAULT_MOSAIC_HOME } from '../constants.js'; + +export interface TelemetryConsent { + remoteEnabled: boolean; + optedInAt: string | null; + optedOutAt: string | null; + lastUploadAt: string | null; +} + +const TELEMETRY_FILE = 'telemetry.json'; + +const DEFAULT_CONSENT: TelemetryConsent = { + remoteEnabled: false, + optedInAt: null, + optedOutAt: null, + lastUploadAt: null, +}; + +function consentFilePath(mosaicHome?: string): string { + return join(mosaicHome ?? getMosaicHome(), TELEMETRY_FILE); +} + +function getMosaicHome(): string { + return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME; +} + +/** + * Read the current consent state. Returns defaults if file doesn't exist. + */ +export function readConsent(mosaicHome?: string): TelemetryConsent { + const filePath = consentFilePath(mosaicHome); + if (!existsSync(filePath)) { + return { ...DEFAULT_CONSENT }; + } + try { + const raw = readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + return { + remoteEnabled: parsed.remoteEnabled ?? false, + optedInAt: parsed.optedInAt ?? null, + optedOutAt: parsed.optedOutAt ?? null, + lastUploadAt: parsed.lastUploadAt ?? null, + }; + } catch { + return { ...DEFAULT_CONSENT }; + } +} + +/** + * Persist a full or partial consent update. + */ +export function writeConsent(update: Partial, mosaicHome?: string): void { + const current = readConsent(mosaicHome); + const next: TelemetryConsent = { ...current, ...update }; + atomicWrite(consentFilePath(mosaicHome), JSON.stringify(next, null, 2) + '\n'); +} + +/** + * Mark opt-in: enable remote upload and record timestamp. + */ +export function optIn(mosaicHome?: string): TelemetryConsent { + const now = new Date().toISOString(); + const next: TelemetryConsent = { + remoteEnabled: true, + optedInAt: now, + optedOutAt: null, + lastUploadAt: readConsent(mosaicHome).lastUploadAt, + }; + writeConsent(next, mosaicHome); + return next; +} + +/** + * Mark opt-out: disable remote upload and record timestamp. + */ +export function optOut(mosaicHome?: string): TelemetryConsent { + const now = new Date().toISOString(); + const current = readConsent(mosaicHome); + const next: TelemetryConsent = { + remoteEnabled: false, + optedInAt: current.optedInAt, + optedOutAt: now, + lastUploadAt: current.lastUploadAt, + }; + writeConsent(next, mosaicHome); + return next; +} + +/** + * Record a successful upload timestamp. + */ +export function recordUpload(mosaicHome?: string): void { + writeConsent({ lastUploadAt: new Date().toISOString() }, mosaicHome); +}