mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: phase 3 observability — IMS telemetry merge
Port IMS telemetry into Primora conventions: - core.components/core.events (migration 00004 + sqlc) scoped to projects - POST /api/v1/ingest — single or <=500-event batches via project prm_ keys (X-Primora-Key alias), auto-registering components, permissive CORS - Issues: fingerprint-grouped errors with count/severity/first+last seen - Reads: events w/ filters + before-cursor, components, stats (1h/24h/7d/30d bucketed series + component health + totals + metric names), metric series (avg/p50/p95/max) - GET .../telemetry/stream — project-scoped SSE through an in-process hub (buffered subs, drop-on-slow, 25s keepalives); ?api_key=/?token= auth for EventSource - Telemetry page: health/issues/events/metrics/integrate tabs, live badge, 390px-clean; demo stubs - @primora/client SDK (createPrimoraClient: capture*/heartbeat/installAuto, queued keepalive flushes) - MCP: primora_list_issues/list_events/telemetry_stats/metric_series - IMS README deprecation note pointing here Also fixes workspace pins to 0.3.0 — npm ci in the frontend image was resolving the unpublished 0.2.0 spec from the registry. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6507eee606
commit
8cfddf9184
60 files changed
+3694
-30
No files matched your search
@@ -0,0 +1,109 @@
|
||||
export type PrimoraEventType = "error" | "metric" | "log" | "heartbeat" | "event";
|
||||
export type PrimoraComponentKind =
|
||||
| "frontend" | "backend" | "database" | "android" | "desktop" | "web" | "other";
|
||||
|
||||
export interface PrimoraClientOptions {
|
||||
/** Primora deployment origin, e.g. http://localhost — /api/v1/ingest is appended */
|
||||
endpoint: string;
|
||||
/** project API key (prm_…) sent as X-Primora-Key */
|
||||
key: string;
|
||||
/** component name reported with every event */
|
||||
component: string;
|
||||
/** component kind used when auto-registering */
|
||||
kind?: PrimoraComponentKind;
|
||||
/** extra fields merged into every event payload (release, env, user opt-in flags…) */
|
||||
base?: Record<string, unknown>;
|
||||
/** batch flush interval ms (default 5000) */
|
||||
flushInterval?: number;
|
||||
}
|
||||
|
||||
interface QueuedEvent {
|
||||
type: PrimoraEventType;
|
||||
severity?: string;
|
||||
message?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface PrimoraClient {
|
||||
captureError(err: unknown, extra?: Record<string, unknown>): void;
|
||||
captureMetric(m: { name: string; value: number; unit?: string } & Record<string, unknown>): void;
|
||||
captureLog(level: string, message: string, attrs?: Record<string, unknown>): void;
|
||||
captureEvent(message: string, payload?: Record<string, unknown>): void;
|
||||
heartbeat(status?: "up" | "degraded" | "down", meta?: Record<string, unknown>): void;
|
||||
/** attach window.onerror + unhandledrejection (browser only) */
|
||||
installAuto(): void;
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
export function createPrimoraClient(opts: PrimoraClientOptions): PrimoraClient {
|
||||
const endpoint = opts.endpoint.replace(/\/$/, "") + "/api/v1/ingest";
|
||||
const queue: QueuedEvent[] = [];
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const push = (e: QueuedEvent) => {
|
||||
queue.push(e);
|
||||
if (!timer) {
|
||||
timer = setTimeout(flush, opts.flushInterval ?? 5000);
|
||||
}
|
||||
};
|
||||
|
||||
function flush() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (!queue.length) return;
|
||||
const events = queue.splice(0, queue.length).map((e) => ({
|
||||
...e,
|
||||
component: opts.component,
|
||||
kind: opts.kind,
|
||||
payload: { ...opts.base, ...e.payload },
|
||||
}));
|
||||
const body = JSON.stringify({ events });
|
||||
// fetch + keepalive survives page unload; sendBeacon can't set X-Primora-Key so it isn't used.
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Primora-Key": opts.key },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const client: PrimoraClient = {
|
||||
captureError(err, extra) {
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
push({
|
||||
type: "error",
|
||||
severity: "error",
|
||||
message: e.message,
|
||||
payload: { stack: e.stack, name: e.name, ...extra },
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
captureMetric(m) {
|
||||
const { name, value, unit, ...rest } = m;
|
||||
push({ type: "metric", payload: { name, value, unit, ...rest }, ts: new Date().toISOString() });
|
||||
},
|
||||
captureLog(level, message, attrs) {
|
||||
push({ type: "log", severity: level, message, payload: attrs, ts: new Date().toISOString() });
|
||||
},
|
||||
captureEvent(message, payload) {
|
||||
push({ type: "event", message, payload, ts: new Date().toISOString() });
|
||||
},
|
||||
heartbeat(status = "up", meta) {
|
||||
push({ type: "heartbeat", payload: { status, ...meta }, ts: new Date().toISOString() });
|
||||
flush();
|
||||
},
|
||||
installAuto() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener("error", (ev) => client.captureError(ev.error ?? ev.message));
|
||||
window.addEventListener("unhandledrejection", (ev) => client.captureError(ev.reason));
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flush();
|
||||
});
|
||||
},
|
||||
flush,
|
||||
};
|
||||
return client;
|
||||
}
|
||||
Reference in new issue
Block a user