feat(mosaic): mosaic telemetry command (M6 CU-06-01..05) (#417)
This commit was merged in pull request #417.
This commit is contained in:
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