feat(mosaic): mosaic telemetry command (CU-06-01..05)
Add mosaic telemetry command tree covering CU-06-01 through CU-06-05.
Local half (mosaic telemetry local {status,tail,jaeger}):
- status: probes OTEL_EXPORTER_OTLP_ENDPOINT reachability via fetch
- tail: explains OTLP push model and points to Jaeger + docker compose logs
- jaeger: prints Jaeger UI URL (respects JAEGER_UI_URL), --open flag best-effort
Remote half (mosaic telemetry {status,opt-in,opt-out,test,upload}):
- Remote upload is DISABLED by default (dry-run is the default)
- opt-in / opt-out: @clack/prompts consent flow persisted to telemetry.json
- status: shows consent state, endpoint, last upload timestamp
- test: synthesises fake event, dry-run by default; --upload path throws if shim
- upload: dry-run unless MOSAIC_TELEMETRY_ENDPOINT + consent + no dry-run flag
CU-06-01: @mosaicstack/telemetry-client-js is not yet published (404).
Uses forward-compat shim at packages/mosaic/src/telemetry/client-shim.ts
matching the expected {init, captureEvent, upload, shutdown} interface.
CU-06-04: Consent persisted to $MOSAIC_HOME/telemetry.json via atomicWrite.
Schema: {remoteEnabled, optedInAt, optedOutAt, lastUploadAt}.
CU-06-05: 18 Vitest tests in telemetry.spec.ts covering structure,
opt-in/opt-out persistence, status output (both states + dry-run banner),
and dry-run assertions (no fetch, no upload() call, banner present).
Code-review findings addressed:
- Shim throws on real-upload attempts (blocker: no false success recorded)
- init() no longer resets queue (should-fix: pending events survive re-init)
- Windows --open uses cmd /c start (should-fix: start is a shell builtin)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { registerMemoryCommand } from '@mosaicstack/memory';
|
|||||||
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||||
|
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
@@ -382,6 +383,10 @@ registerQueueCommand(program);
|
|||||||
|
|
||||||
registerStorageCommand(program);
|
registerStorageCommand(program);
|
||||||
|
|
||||||
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerTelemetryCommand(program);
|
||||||
|
|
||||||
// ─── update ─────────────────────────────────────────────────────────────
|
// ─── update ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
426
packages/mosaic/src/commands/telemetry.spec.ts
Normal file
426
packages/mosaic/src/commands/telemetry.spec.ts
Normal file
@@ -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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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'];
|
||||||
|
});
|
||||||
|
});
|
||||||
355
packages/mosaic/src/commands/telemetry.ts
Normal file
355
packages/mosaic/src/commands/telemetry.ts
Normal file
@@ -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<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);
|
||||||
|
}
|
||||||
132
packages/mosaic/src/telemetry/client-shim.ts
Normal file
132
packages/mosaic/src/telemetry/client-shim.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
/** 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<void>;
|
||||||
|
/** Flush and release resources. */
|
||||||
|
shutdown(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
112
packages/mosaic/src/telemetry/consent-store.ts
Normal file
112
packages/mosaic/src/telemetry/consent-store.ts
Normal file
@@ -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<TelemetryConsent>;
|
||||||
|
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<TelemetryConsent>, 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user