feat(mosaic): mosaic telemetry command (M6 CU-06-01..05) (#417)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #417.
This commit is contained in:
2026-04-05 07:06:42 +00:00
parent 35ab619bd0
commit a531029c5b
5 changed files with 1030 additions and 0 deletions

View 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;
}

View 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);
}