+
+
+
+ {error()}
+
+
+
+
+ {(t) => (
+
+ )}
+
+
+
+
+
+
+
Errors
+
{stats()?.errors ?? 0}
+
+
+
Events
+
{stats()?.events ?? 0}
+
+
+
Metrics
+
{stats()?.metrics ?? 0}
+
+
+
+
+
+
+
+ {(b) => {
+ const total = () => Object.values(b.counts ?? {}).reduce((a, n) => a + n, 0);
+ return (
+ 0 ? "var(--error, #e5484d)" : "var(--accent)",
+ opacity: 0.85,
+ }}
+ title={`${new Date(b.ts).toLocaleString()} — ${total()} events`}
+ />
+ );
+ }}
+
+
+
+
+ {([t, n]) => (
+
+ {t}: {n}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ Kind |
+ Status |
+ Errors 24h |
+ Events 24h |
+ Last seen |
+
+
+
+
+
+ No components yet — send an event with a `component` field.
+ |
+
+ }
+ >
+ {(c) => (
+
+ | {c.name} |
+ {c.kind} |
+
+
+ {c.last_status || "unknown"}
+
+ |
+ {c.errors_24h} |
+ {c.events_24h} |
+ {fmtTime(c.last_seen_at)} |
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Error |
+ Component |
+ Count |
+ Severity |
+ Last seen |
+ |
+
+
+
+
+
+ No error groups in the last 30 days.
+ |
+
+ }
+ >
+ {(i) => (
+
+ |
+ {i.message}
+ |
+ {i.component_name || "—"} |
+ {i.count} |
+
+ {i.severity}
+ |
+ {fmtTime(i.last_seen)} |
+
+
+ |
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {(t) => (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ | Type |
+ Severity |
+ Message |
+ Component |
+ Time |
+
+
+
+
+
+ No events.
+ |
+
+ }
+ >
+ {(e) => (
+ setDetail(e)}>
+ |
+ {e.type}
+ |
+
+ {e.severity}
+ |
+
+ {e.message || {e.payload?.name ? `${e.payload.name} = ${e.payload.value}` : JSON.stringify(e.payload)}}
+ |
+ {e.component_name || "—"} |
+ {fmtTime(e.ts)} |
+
+ )}
+
+
+
+
+
+
+
+
+
+ No metric names seen yet.}
+ >
+ {(n) => (
+
+ )}
+
+
+
+
+
+
+
+ | Bucket |
+ Avg |
+ p50 |
+ p95 |
+ Max |
+ Count |
+
+
+
+
+
+ Select a metric name.
+ |
+
+ }
+ >
+ {(p) => (
+
+ | {fmtTime(p.ts)} |
+ {p.avg?.toFixed(1) ?? "—"} |
+ {p.p50?.toFixed(1) ?? "—"} |
+ {p.p95?.toFixed(1) ?? "—"} |
+ {p.max?.toFixed(1) ?? "—"} |
+ {p.count} |
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Create a project API key under Settings, then pass it as X-Primora-Key to POST /api/v1/ingest. Batches of up to 500 events are accepted.
+
+
+
+
setDetail(null)}
+ title={detail()?.message || detail()?.type || "Event"}
+ >
+
+ {(e) => (
+
+
+ {e().type}
+ {e().severity}
+
+ {e().component_name}
+
+
+ fp {e().fingerprint!.slice(0, 8)}
+
+
+
{fmtTime(e().ts)}
+
+ {JSON.stringify(e().payload, null, 2)}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/frontend/src/pages/index.ts b/apps/frontend/src/pages/index.ts
index f9f4fca..915a51d 100644
--- a/apps/frontend/src/pages/index.ts
+++ b/apps/frontend/src/pages/index.ts
@@ -5,6 +5,7 @@ export { MembersPage } from "./MembersPage";
export { StoragePage } from "./StoragePage";
export { CollectionsPage } from "./CollectionsPage";
export { DatabasesPage } from "./DatabasesPage";
+export { TelemetryPage } from "./TelemetryPage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";
export { AuthPage, type AuthUser } from "./AuthPage";
diff --git a/apps/mcp/package.json b/apps/mcp/package.json
index 445763e..4588373 100644
--- a/apps/mcp/package.json
+++ b/apps/mcp/package.json
@@ -22,7 +22,7 @@
"zod": "^4.1.5"
},
"devDependencies": {
- "@primora/api-client": "0.2.0",
+ "@primora/api-client": "0.3.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"
diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts
index 59b3adb..6e7c565 100644
--- a/apps/mcp/src/server.ts
+++ b/apps/mcp/src/server.ts
@@ -9,6 +9,7 @@ import {
PlatformService,
ProjectsService,
StorageService,
+ TelemetryService,
} from "@primora/api-client";
import { configureClient, loadConfig, type McpConfig } from "./config.js";
@@ -362,4 +363,94 @@ server.registerTool(
},
);
+const eventType = z.enum(["error", "metric", "log", "heartbeat", "event"]);
+
+server.registerTool(
+ "primora_list_issues",
+ {
+ description: "Error groups by fingerprint — count, severity, first/last seen",
+ inputSchema: {
+ projectId: z.string().optional(),
+ days: z.number().int().optional().describe("Lookback window (default 30)"),
+ },
+ },
+ async ({ projectId: p, days }) => {
+ try {
+ return ok(await TelemetryService.listTelemetryIssues({ projectId: projectId(p), days }));
+ } catch (e) {
+ return fail(e);
+ }
+ },
+);
+
+server.registerTool(
+ "primora_list_events",
+ {
+ description: "Telemetry events, newest first — errors, metrics, logs, heartbeats",
+ inputSchema: {
+ projectId: z.string().optional(),
+ type: eventType.optional(),
+ component: z.string().optional(),
+ fingerprint: z.string().optional().describe("Limit to one error group"),
+ limit: z.number().int().optional(),
+ before: z.number().int().optional().describe("Event id cursor for pagination"),
+ },
+ },
+ async ({ projectId: p, type, component, fingerprint, limit, before }) => {
+ try {
+ return ok(
+ await TelemetryService.listTelemetryEvents({
+ projectId: projectId(p),
+ type,
+ component,
+ fingerprint,
+ limit,
+ before,
+ }),
+ );
+ } catch (e) {
+ return fail(e);
+ }
+ },
+);
+
+server.registerTool(
+ "primora_telemetry_stats",
+ {
+ description: "Aggregated telemetry: event series, component health, totals, metric names",
+ inputSchema: {
+ projectId: z.string().optional(),
+ window: z.enum(["1h", "24h", "7d", "30d"]).optional(),
+ },
+ },
+ async ({ projectId: p, window }) => {
+ try {
+ return ok(await TelemetryService.getTelemetryStats({ projectId: projectId(p), window }));
+ } catch (e) {
+ return fail(e);
+ }
+ },
+);
+
+server.registerTool(
+ "primora_metric_series",
+ {
+ description: "Time series for one metric name — avg, p50, p95, max per bucket",
+ inputSchema: {
+ projectId: z.string().optional(),
+ name: z.string(),
+ window: z.enum(["1h", "24h", "7d", "30d"]).optional(),
+ },
+ },
+ async ({ projectId: p, name, window }) => {
+ try {
+ return ok(
+ await TelemetryService.getTelemetryMetricSeries({ projectId: projectId(p), name, window }),
+ );
+ } catch (e) {
+ return fail(e);
+ }
+ },
+);
+
await server.connect(new StdioServerTransport());
diff --git a/package-lock.json b/package-lock.json
index 5d28b2d..6d9e218 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "primora",
- "version": "0.2.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "primora",
- "version": "0.2.0",
+ "version": "0.3.0",
"workspaces": [
"apps/*",
"packages/*"
@@ -14,7 +14,7 @@
},
"apps/auth": {
"name": "@primora/auth",
- "version": "0.2.0",
+ "version": "0.3.0",
"dependencies": {
"@hono/node-server": "^1.19.4",
"@hono/zod-validator": "^0.7.2",
@@ -38,7 +38,7 @@
},
"apps/cli": {
"name": "@primora/cli",
- "version": "0.2.0",
+ "version": "0.3.0",
"dependencies": {
"@clack/prompts": "^1.8.1",
"cac": "^7.0.0",
@@ -48,7 +48,7 @@
"primora": "dist/cli.js"
},
"devDependencies": {
- "@primora/api-client": "0.2.0",
+ "@primora/api-client": "0.3.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"
@@ -67,10 +67,10 @@
},
"apps/frontend": {
"name": "@primora/frontend",
- "version": "0.2.0",
+ "version": "0.3.0",
"dependencies": {
- "@primora/api-client": "0.2.0",
- "@primora/shared-types": "0.2.0",
+ "@primora/api-client": "0.3.0",
+ "@primora/shared-types": "0.3.0",
"better-auth": "^1.5.6",
"solid-js": "^1.9.12"
},
@@ -91,7 +91,7 @@
},
"apps/mcp": {
"name": "@primora/mcp",
- "version": "0.2.0",
+ "version": "0.3.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"zod": "^4.1.5"
@@ -100,7 +100,7 @@
"primora-mcp": "dist/server.js"
},
"devDependencies": {
- "@primora/api-client": "0.2.0",
+ "@primora/api-client": "0.3.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"
@@ -1353,6 +1353,10 @@
"resolved": "apps/cli",
"link": true
},
+ "node_modules/@primora/client": {
+ "resolved": "packages/client",
+ "link": true
+ },
"node_modules/@primora/frontend": {
"resolved": "apps/frontend",
"link": true
@@ -6783,7 +6787,7 @@
},
"packages/api-client": {
"name": "@primora/api-client",
- "version": "0.2.0",
+ "version": "0.3.0",
"dependencies": {
"cross-fetch": "^4.1.0"
},
@@ -6791,9 +6795,13 @@
"openapi-typescript-codegen": "^0.29.0"
}
},
+ "packages/client": {
+ "name": "@primora/client",
+ "version": "0.3.0"
+ },
"packages/shared-types": {
"name": "@primora/shared-types",
- "version": "0.2.0"
+ "version": "0.3.0"
}
}
}
diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts
index 9ee8847..6a7c75b 100644
--- a/packages/api-client/src/generated/index.ts
+++ b/packages/api-client/src/generated/index.ts
@@ -23,6 +23,8 @@ export type { BucketObjectListResponse } from './models/BucketObjectListResponse
export type { Collection } from './models/Collection';
export type { CollectionID } from './models/CollectionID';
export type { CollectionListResponse } from './models/CollectionListResponse';
+export type { ComponentHealth } from './models/ComponentHealth';
+export type { ComponentID } from './models/ComponentID';
export type { ConnectionID } from './models/ConnectionID';
export type { CopyBucketObjectRequest } from './models/CopyBucketObjectRequest';
export type { CreateApiKeyRequest } from './models/CreateApiKeyRequest';
@@ -55,10 +57,16 @@ export type { Document } from './models/Document';
export type { DocumentID } from './models/DocumentID';
export type { DocumentListResponse } from './models/DocumentListResponse';
export type { ErrorEnvelope } from './models/ErrorEnvelope';
+export type { EventSeriesBucket } from './models/EventSeriesBucket';
export type { HealthStatus } from './models/HealthStatus';
+export { IngestEvent } from './models/IngestEvent';
+export type { IngestRequest } from './models/IngestRequest';
+export type { IngestResponse } from './models/IngestResponse';
export type { Invitation } from './models/Invitation';
export type { InvitationID } from './models/InvitationID';
export type { MeResponse } from './models/MeResponse';
+export type { MetricPoint } from './models/MetricPoint';
+export type { MetricSeriesResponse } from './models/MetricSeriesResponse';
export type { ObjectKey } from './models/ObjectKey';
export type { OrganizationID } from './models/OrganizationID';
export { OrganizationInvitation } from './models/OrganizationInvitation';
@@ -76,6 +84,13 @@ export type { ProjectSummary } from './models/ProjectSummary';
export type { ReadinessStatus } from './models/ReadinessStatus';
export type { SchemaQuery } from './models/SchemaQuery';
export type { TableQuery } from './models/TableQuery';
+export type { TelemetryComponent } from './models/TelemetryComponent';
+export type { TelemetryComponentListResponse } from './models/TelemetryComponentListResponse';
+export type { TelemetryEvent } from './models/TelemetryEvent';
+export type { TelemetryEventListResponse } from './models/TelemetryEventListResponse';
+export type { TelemetryIssue } from './models/TelemetryIssue';
+export type { TelemetryIssueListResponse } from './models/TelemetryIssueListResponse';
+export type { TelemetryStats } from './models/TelemetryStats';
export type { UpdateBucketObjectRequest } from './models/UpdateBucketObjectRequest';
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
@@ -103,6 +118,8 @@ export { $BucketObjectListResponse } from './schemas/$BucketObjectListResponse';
export { $Collection } from './schemas/$Collection';
export { $CollectionID } from './schemas/$CollectionID';
export { $CollectionListResponse } from './schemas/$CollectionListResponse';
+export { $ComponentHealth } from './schemas/$ComponentHealth';
+export { $ComponentID } from './schemas/$ComponentID';
export { $ConnectionID } from './schemas/$ConnectionID';
export { $CopyBucketObjectRequest } from './schemas/$CopyBucketObjectRequest';
export { $CreateApiKeyRequest } from './schemas/$CreateApiKeyRequest';
@@ -135,10 +152,16 @@ export { $Document } from './schemas/$Document';
export { $DocumentID } from './schemas/$DocumentID';
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
+export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
export { $HealthStatus } from './schemas/$HealthStatus';
+export { $IngestEvent } from './schemas/$IngestEvent';
+export { $IngestRequest } from './schemas/$IngestRequest';
+export { $IngestResponse } from './schemas/$IngestResponse';
export { $Invitation } from './schemas/$Invitation';
export { $InvitationID } from './schemas/$InvitationID';
export { $MeResponse } from './schemas/$MeResponse';
+export { $MetricPoint } from './schemas/$MetricPoint';
+export { $MetricSeriesResponse } from './schemas/$MetricSeriesResponse';
export { $ObjectKey } from './schemas/$ObjectKey';
export { $OrganizationID } from './schemas/$OrganizationID';
export { $OrganizationInvitation } from './schemas/$OrganizationInvitation';
@@ -156,6 +179,13 @@ export { $ProjectSummary } from './schemas/$ProjectSummary';
export { $ReadinessStatus } from './schemas/$ReadinessStatus';
export { $SchemaQuery } from './schemas/$SchemaQuery';
export { $TableQuery } from './schemas/$TableQuery';
+export { $TelemetryComponent } from './schemas/$TelemetryComponent';
+export { $TelemetryComponentListResponse } from './schemas/$TelemetryComponentListResponse';
+export { $TelemetryEvent } from './schemas/$TelemetryEvent';
+export { $TelemetryEventListResponse } from './schemas/$TelemetryEventListResponse';
+export { $TelemetryIssue } from './schemas/$TelemetryIssue';
+export { $TelemetryIssueListResponse } from './schemas/$TelemetryIssueListResponse';
+export { $TelemetryStats } from './schemas/$TelemetryStats';
export { $UpdateBucketObjectRequest } from './schemas/$UpdateBucketObjectRequest';
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
@@ -174,3 +204,4 @@ export { OrganizationsService } from './services/OrganizationsService';
export { PlatformService } from './services/PlatformService';
export { ProjectsService } from './services/ProjectsService';
export { StorageService } from './services/StorageService';
+export { TelemetryService } from './services/TelemetryService';
diff --git a/packages/api-client/src/generated/models/ComponentHealth.ts b/packages/api-client/src/generated/models/ComponentHealth.ts
new file mode 100644
index 0000000..0bae443
--- /dev/null
+++ b/packages/api-client/src/generated/models/ComponentHealth.ts
@@ -0,0 +1,15 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type ComponentHealth = {
+ id: string;
+ name: string;
+ kind: string;
+ last_seen_at?: string | null;
+ last_status?: string;
+ errors_24h: number;
+ events_24h: number;
+ meta?: Record;
+};
+
diff --git a/packages/api-client/src/generated/models/ComponentID.ts b/packages/api-client/src/generated/models/ComponentID.ts
new file mode 100644
index 0000000..5b87fb9
--- /dev/null
+++ b/packages/api-client/src/generated/models/ComponentID.ts
@@ -0,0 +1,5 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type ComponentID = string;
diff --git a/packages/api-client/src/generated/models/EventSeriesBucket.ts b/packages/api-client/src/generated/models/EventSeriesBucket.ts
new file mode 100644
index 0000000..1f0c8a4
--- /dev/null
+++ b/packages/api-client/src/generated/models/EventSeriesBucket.ts
@@ -0,0 +1,9 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type EventSeriesBucket = {
+ ts: string;
+ counts: Record;
+};
+
diff --git a/packages/api-client/src/generated/models/IngestEvent.ts b/packages/api-client/src/generated/models/IngestEvent.ts
new file mode 100644
index 0000000..d52f7db
--- /dev/null
+++ b/packages/api-client/src/generated/models/IngestEvent.ts
@@ -0,0 +1,39 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type IngestEvent = {
+ type: IngestEvent.type;
+ /**
+ * Auto-registered under the project on first use
+ */
+ component?: string;
+ kind?: IngestEvent.kind;
+ severity?: string;
+ message?: string;
+ payload?: Record;
+ /**
+ * Error grouping key; derived from message when omitted
+ */
+ fingerprint?: string;
+ ts?: string;
+};
+export namespace IngestEvent {
+ export enum type {
+ ERROR = 'error',
+ METRIC = 'metric',
+ LOG = 'log',
+ HEARTBEAT = 'heartbeat',
+ EVENT = 'event',
+ }
+ export enum kind {
+ FRONTEND = 'frontend',
+ BACKEND = 'backend',
+ DATABASE = 'database',
+ ANDROID = 'android',
+ DESKTOP = 'desktop',
+ WEB = 'web',
+ OTHER = 'other',
+ }
+}
+
diff --git a/packages/api-client/src/generated/models/IngestRequest.ts b/packages/api-client/src/generated/models/IngestRequest.ts
new file mode 100644
index 0000000..1d4ba76
--- /dev/null
+++ b/packages/api-client/src/generated/models/IngestRequest.ts
@@ -0,0 +1,9 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { IngestEvent } from './IngestEvent';
+export type IngestRequest = (IngestEvent | {
+ events: Array;
+});
+
diff --git a/packages/api-client/src/generated/models/IngestResponse.ts b/packages/api-client/src/generated/models/IngestResponse.ts
new file mode 100644
index 0000000..a5105b3
--- /dev/null
+++ b/packages/api-client/src/generated/models/IngestResponse.ts
@@ -0,0 +1,8 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type IngestResponse = {
+ accepted: number;
+};
+
diff --git a/packages/api-client/src/generated/models/MetricPoint.ts b/packages/api-client/src/generated/models/MetricPoint.ts
new file mode 100644
index 0000000..967e217
--- /dev/null
+++ b/packages/api-client/src/generated/models/MetricPoint.ts
@@ -0,0 +1,13 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type MetricPoint = {
+ ts: string;
+ avg?: number | null;
+ p50?: number | null;
+ p95?: number | null;
+ max?: number | null;
+ count: number;
+};
+
diff --git a/packages/api-client/src/generated/models/MetricSeriesResponse.ts b/packages/api-client/src/generated/models/MetricSeriesResponse.ts
new file mode 100644
index 0000000..4ae3738
--- /dev/null
+++ b/packages/api-client/src/generated/models/MetricSeriesResponse.ts
@@ -0,0 +1,9 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { MetricPoint } from './MetricPoint';
+export type MetricSeriesResponse = {
+ items: Array;
+};
+
diff --git a/packages/api-client/src/generated/models/TelemetryComponent.ts b/packages/api-client/src/generated/models/TelemetryComponent.ts
new file mode 100644
index 0000000..3e41c8d
--- /dev/null
+++ b/packages/api-client/src/generated/models/TelemetryComponent.ts
@@ -0,0 +1,15 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type TelemetryComponent = {
+ id: string;
+ name: string;
+ kind: string;
+ last_seen_at?: string | null;
+ last_status?: string;
+ errors_24h: number;
+ events_24h: number;
+ meta?: Record;
+};
+
diff --git a/packages/api-client/src/generated/models/TelemetryComponentListResponse.ts b/packages/api-client/src/generated/models/TelemetryComponentListResponse.ts
new file mode 100644
index 0000000..155d7d1
--- /dev/null
+++ b/packages/api-client/src/generated/models/TelemetryComponentListResponse.ts
@@ -0,0 +1,9 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { TelemetryComponent } from './TelemetryComponent';
+export type TelemetryComponentListResponse = {
+ items: Array;
+};
+
diff --git a/packages/api-client/src/generated/models/TelemetryEvent.ts b/packages/api-client/src/generated/models/TelemetryEvent.ts
new file mode 100644
index 0000000..9973311
--- /dev/null
+++ b/packages/api-client/src/generated/models/TelemetryEvent.ts
@@ -0,0 +1,17 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type TelemetryEvent = {
+ id: number;
+ project_id: string;
+ component_id?: string | null;
+ component_name?: string;
+ type: string;
+ severity: string;
+ message: string;
+ payload?: Record;
+ fingerprint?: string;
+ ts: string;
+};
+
diff --git a/packages/api-client/src/generated/models/TelemetryEventListResponse.ts b/packages/api-client/src/generated/models/TelemetryEventListResponse.ts
new file mode 100644
index 0000000..23e0741
--- /dev/null
+++ b/packages/api-client/src/generated/models/TelemetryEventListResponse.ts
@@ -0,0 +1,9 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { TelemetryEvent } from './TelemetryEvent';
+export type TelemetryEventListResponse = {
+ items: Array;
+};
+
diff --git a/packages/api-client/src/generated/models/TelemetryIssue.ts b/packages/api-client/src/generated/models/TelemetryIssue.ts
new file mode 100644
index 0000000..57df049
--- /dev/null
+++ b/packages/api-client/src/generated/models/TelemetryIssue.ts
@@ -0,0 +1,15 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export type TelemetryIssue = {
+ fingerprint: string;
+ message: string;
+ component_id?: string | null;
+ component_name?: string;
+ count: number;
+ first_seen: string;
+ last_seen: string;
+ severity: string;
+};
+
diff --git a/packages/api-client/src/generated/models/TelemetryIssueListResponse.ts b/packages/api-client/src/generated/models/TelemetryIssueListResponse.ts
new file mode 100644
index 0000000..05049d4
--- /dev/null
+++ b/packages/api-client/src/generated/models/TelemetryIssueListResponse.ts
@@ -0,0 +1,9 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { TelemetryIssue } from './TelemetryIssue';
+export type TelemetryIssueListResponse = {
+ items: Array;
+};
+
diff --git a/packages/api-client/src/generated/models/TelemetryStats.ts b/packages/api-client/src/generated/models/TelemetryStats.ts
new file mode 100644
index 0000000..d6bfe3e
--- /dev/null
+++ b/packages/api-client/src/generated/models/TelemetryStats.ts
@@ -0,0 +1,17 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { ComponentHealth } from './ComponentHealth';
+import type { EventSeriesBucket } from './EventSeriesBucket';
+export type TelemetryStats = {
+ window: number;
+ bucket_sec: number;
+ series: Array;
+ components: Array;
+ errors: number;
+ events: number;
+ metrics: number;
+ metric_names: Array;
+};
+
diff --git a/packages/api-client/src/generated/schemas/$ComponentHealth.ts b/packages/api-client/src/generated/schemas/$ComponentHealth.ts
new file mode 100644
index 0000000..3511cd2
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$ComponentHealth.ts
@@ -0,0 +1,44 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $ComponentHealth = {
+ properties: {
+ id: {
+ type: 'string',
+ isRequired: true,
+ format: 'uuid',
+ },
+ name: {
+ type: 'string',
+ isRequired: true,
+ },
+ kind: {
+ type: 'string',
+ isRequired: true,
+ },
+ last_seen_at: {
+ type: 'string',
+ isNullable: true,
+ format: 'date-time',
+ },
+ last_status: {
+ type: 'string',
+ },
+ errors_24h: {
+ type: 'number',
+ isRequired: true,
+ },
+ events_24h: {
+ type: 'number',
+ isRequired: true,
+ },
+ meta: {
+ type: 'dictionary',
+ contains: {
+ properties: {
+ },
+ },
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$ComponentID.ts b/packages/api-client/src/generated/schemas/$ComponentID.ts
new file mode 100644
index 0000000..75fa756
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$ComponentID.ts
@@ -0,0 +1,8 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $ComponentID = {
+ type: 'string',
+ format: 'uuid',
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$EventSeriesBucket.ts b/packages/api-client/src/generated/schemas/$EventSeriesBucket.ts
new file mode 100644
index 0000000..e28348e
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$EventSeriesBucket.ts
@@ -0,0 +1,20 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $EventSeriesBucket = {
+ properties: {
+ ts: {
+ type: 'string',
+ isRequired: true,
+ format: 'date-time',
+ },
+ counts: {
+ type: 'dictionary',
+ contains: {
+ type: 'number',
+ },
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$IngestEvent.ts b/packages/api-client/src/generated/schemas/$IngestEvent.ts
new file mode 100644
index 0000000..1103879
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$IngestEvent.ts
@@ -0,0 +1,40 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $IngestEvent = {
+ properties: {
+ type: {
+ type: 'Enum',
+ isRequired: true,
+ },
+ component: {
+ type: 'string',
+ description: `Auto-registered under the project on first use`,
+ },
+ kind: {
+ type: 'Enum',
+ },
+ severity: {
+ type: 'string',
+ },
+ message: {
+ type: 'string',
+ },
+ payload: {
+ type: 'dictionary',
+ contains: {
+ properties: {
+ },
+ },
+ },
+ fingerprint: {
+ type: 'string',
+ description: `Error grouping key; derived from message when omitted`,
+ },
+ ts: {
+ type: 'string',
+ format: 'date-time',
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$IngestRequest.ts b/packages/api-client/src/generated/schemas/$IngestRequest.ts
new file mode 100644
index 0000000..53a46dc
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$IngestRequest.ts
@@ -0,0 +1,20 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $IngestRequest = {
+ type: 'one-of',
+ contains: [{
+ type: 'IngestEvent',
+ }, {
+ properties: {
+ events: {
+ type: 'array',
+ contains: {
+ type: 'IngestEvent',
+ },
+ isRequired: true,
+ },
+ },
+ }],
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$IngestResponse.ts b/packages/api-client/src/generated/schemas/$IngestResponse.ts
new file mode 100644
index 0000000..5537d54
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$IngestResponse.ts
@@ -0,0 +1,12 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $IngestResponse = {
+ properties: {
+ accepted: {
+ type: 'number',
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$MetricPoint.ts b/packages/api-client/src/generated/schemas/$MetricPoint.ts
new file mode 100644
index 0000000..5b20e7c
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$MetricPoint.ts
@@ -0,0 +1,33 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $MetricPoint = {
+ properties: {
+ ts: {
+ type: 'string',
+ isRequired: true,
+ format: 'date-time',
+ },
+ avg: {
+ type: 'number',
+ isNullable: true,
+ },
+ p50: {
+ type: 'number',
+ isNullable: true,
+ },
+ p95: {
+ type: 'number',
+ isNullable: true,
+ },
+ max: {
+ type: 'number',
+ isNullable: true,
+ },
+ count: {
+ type: 'number',
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$MetricSeriesResponse.ts b/packages/api-client/src/generated/schemas/$MetricSeriesResponse.ts
new file mode 100644
index 0000000..038fa38
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$MetricSeriesResponse.ts
@@ -0,0 +1,15 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $MetricSeriesResponse = {
+ properties: {
+ items: {
+ type: 'array',
+ contains: {
+ type: 'MetricPoint',
+ },
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$TelemetryComponent.ts b/packages/api-client/src/generated/schemas/$TelemetryComponent.ts
new file mode 100644
index 0000000..2b5953d
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$TelemetryComponent.ts
@@ -0,0 +1,44 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $TelemetryComponent = {
+ properties: {
+ id: {
+ type: 'string',
+ isRequired: true,
+ format: 'uuid',
+ },
+ name: {
+ type: 'string',
+ isRequired: true,
+ },
+ kind: {
+ type: 'string',
+ isRequired: true,
+ },
+ last_seen_at: {
+ type: 'string',
+ isNullable: true,
+ format: 'date-time',
+ },
+ last_status: {
+ type: 'string',
+ },
+ errors_24h: {
+ type: 'number',
+ isRequired: true,
+ },
+ events_24h: {
+ type: 'number',
+ isRequired: true,
+ },
+ meta: {
+ type: 'dictionary',
+ contains: {
+ properties: {
+ },
+ },
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$TelemetryComponentListResponse.ts b/packages/api-client/src/generated/schemas/$TelemetryComponentListResponse.ts
new file mode 100644
index 0000000..5215443
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$TelemetryComponentListResponse.ts
@@ -0,0 +1,15 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $TelemetryComponentListResponse = {
+ properties: {
+ items: {
+ type: 'array',
+ contains: {
+ type: 'TelemetryComponent',
+ },
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$TelemetryEvent.ts b/packages/api-client/src/generated/schemas/$TelemetryEvent.ts
new file mode 100644
index 0000000..412e767
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$TelemetryEvent.ts
@@ -0,0 +1,52 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $TelemetryEvent = {
+ properties: {
+ id: {
+ type: 'number',
+ isRequired: true,
+ },
+ project_id: {
+ type: 'string',
+ isRequired: true,
+ format: 'uuid',
+ },
+ component_id: {
+ type: 'string',
+ isNullable: true,
+ format: 'uuid',
+ },
+ component_name: {
+ type: 'string',
+ },
+ type: {
+ type: 'string',
+ isRequired: true,
+ },
+ severity: {
+ type: 'string',
+ isRequired: true,
+ },
+ message: {
+ type: 'string',
+ isRequired: true,
+ },
+ payload: {
+ type: 'dictionary',
+ contains: {
+ properties: {
+ },
+ },
+ },
+ fingerprint: {
+ type: 'string',
+ },
+ ts: {
+ type: 'string',
+ isRequired: true,
+ format: 'date-time',
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$TelemetryEventListResponse.ts b/packages/api-client/src/generated/schemas/$TelemetryEventListResponse.ts
new file mode 100644
index 0000000..8f0f1fe
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$TelemetryEventListResponse.ts
@@ -0,0 +1,15 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $TelemetryEventListResponse = {
+ properties: {
+ items: {
+ type: 'array',
+ contains: {
+ type: 'TelemetryEvent',
+ },
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$TelemetryIssue.ts b/packages/api-client/src/generated/schemas/$TelemetryIssue.ts
new file mode 100644
index 0000000..68e0970
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$TelemetryIssue.ts
@@ -0,0 +1,42 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $TelemetryIssue = {
+ properties: {
+ fingerprint: {
+ type: 'string',
+ isRequired: true,
+ },
+ message: {
+ type: 'string',
+ isRequired: true,
+ },
+ component_id: {
+ type: 'string',
+ isNullable: true,
+ format: 'uuid',
+ },
+ component_name: {
+ type: 'string',
+ },
+ count: {
+ type: 'number',
+ isRequired: true,
+ },
+ first_seen: {
+ type: 'string',
+ isRequired: true,
+ format: 'date-time',
+ },
+ last_seen: {
+ type: 'string',
+ isRequired: true,
+ format: 'date-time',
+ },
+ severity: {
+ type: 'string',
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$TelemetryIssueListResponse.ts b/packages/api-client/src/generated/schemas/$TelemetryIssueListResponse.ts
new file mode 100644
index 0000000..8321917
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$TelemetryIssueListResponse.ts
@@ -0,0 +1,15 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $TelemetryIssueListResponse = {
+ properties: {
+ items: {
+ type: 'array',
+ contains: {
+ type: 'TelemetryIssue',
+ },
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/schemas/$TelemetryStats.ts b/packages/api-client/src/generated/schemas/$TelemetryStats.ts
new file mode 100644
index 0000000..c643a27
--- /dev/null
+++ b/packages/api-client/src/generated/schemas/$TelemetryStats.ts
@@ -0,0 +1,49 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export const $TelemetryStats = {
+ properties: {
+ window: {
+ type: 'number',
+ isRequired: true,
+ },
+ bucket_sec: {
+ type: 'number',
+ isRequired: true,
+ },
+ series: {
+ type: 'array',
+ contains: {
+ type: 'EventSeriesBucket',
+ },
+ isRequired: true,
+ },
+ components: {
+ type: 'array',
+ contains: {
+ type: 'ComponentHealth',
+ },
+ isRequired: true,
+ },
+ errors: {
+ type: 'number',
+ isRequired: true,
+ },
+ events: {
+ type: 'number',
+ isRequired: true,
+ },
+ metrics: {
+ type: 'number',
+ isRequired: true,
+ },
+ metric_names: {
+ type: 'array',
+ contains: {
+ type: 'string',
+ },
+ isRequired: true,
+ },
+ },
+} as const;
diff --git a/packages/api-client/src/generated/services/TelemetryService.ts b/packages/api-client/src/generated/services/TelemetryService.ts
new file mode 100644
index 0000000..a3f15ec
--- /dev/null
+++ b/packages/api-client/src/generated/services/TelemetryService.ts
@@ -0,0 +1,225 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { IngestRequest } from '../models/IngestRequest';
+import type { IngestResponse } from '../models/IngestResponse';
+import type { MetricSeriesResponse } from '../models/MetricSeriesResponse';
+import type { TelemetryComponentListResponse } from '../models/TelemetryComponentListResponse';
+import type { TelemetryEventListResponse } from '../models/TelemetryEventListResponse';
+import type { TelemetryIssueListResponse } from '../models/TelemetryIssueListResponse';
+import type { TelemetryStats } from '../models/TelemetryStats';
+import type { CancelablePromise } from '../core/CancelablePromise';
+import { OpenAPI } from '../core/OpenAPI';
+import { request as __request } from '../core/request';
+export class TelemetryService {
+ /**
+ * Ingest one telemetry event or a batch of up to 500
+ * @returns IngestResponse Accepted event count
+ * @throws ApiError
+ */
+ public static ingestTelemetry({
+ requestBody,
+ }: {
+ requestBody: IngestRequest,
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'POST',
+ url: '/ingest',
+ body: requestBody,
+ mediaType: 'application/json',
+ errors: {
+ 400: `Error response`,
+ 401: `Error response`,
+ 413: `Error response`,
+ },
+ });
+ }
+ /**
+ * @returns TelemetryEventListResponse Telemetry events, newest first
+ * @throws ApiError
+ */
+ public static listTelemetryEvents({
+ projectId,
+ type,
+ component,
+ fingerprint,
+ before,
+ limit,
+ }: {
+ projectId: string,
+ type?: 'error' | 'metric' | 'log' | 'heartbeat' | 'event',
+ component?: string,
+ fingerprint?: string,
+ before?: number,
+ limit?: number,
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/projects/{projectID}/events',
+ path: {
+ 'projectID': projectId,
+ },
+ query: {
+ 'type': type,
+ 'component': component,
+ 'fingerprint': fingerprint,
+ 'before': before,
+ 'limit': limit,
+ },
+ errors: {
+ 401: `Error response`,
+ },
+ });
+ }
+ /**
+ * @returns TelemetryIssueListResponse Error groups by fingerprint
+ * @throws ApiError
+ */
+ public static listTelemetryIssues({
+ projectId,
+ days,
+ }: {
+ projectId: string,
+ days?: number,
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/projects/{projectID}/issues',
+ path: {
+ 'projectID': projectId,
+ },
+ query: {
+ 'days': days,
+ },
+ errors: {
+ 401: `Error response`,
+ },
+ });
+ }
+ /**
+ * @returns TelemetryComponentListResponse Registered telemetry components
+ * @throws ApiError
+ */
+ public static listTelemetryComponents({
+ projectId,
+ }: {
+ projectId: string,
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/projects/{projectID}/components',
+ path: {
+ 'projectID': projectId,
+ },
+ errors: {
+ 401: `Error response`,
+ },
+ });
+ }
+ /**
+ * @returns void
+ * @throws ApiError
+ */
+ public static deleteTelemetryComponent({
+ projectId,
+ componentId,
+ }: {
+ projectId: string,
+ componentId: string,
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'DELETE',
+ url: '/projects/{projectID}/components/{componentID}',
+ path: {
+ 'projectID': projectId,
+ 'componentID': componentId,
+ },
+ errors: {
+ 401: `Error response`,
+ },
+ });
+ }
+ /**
+ * @returns TelemetryStats Aggregated telemetry statistics
+ * @throws ApiError
+ */
+ public static getTelemetryStats({
+ projectId,
+ window,
+ }: {
+ projectId: string,
+ window?: '1h' | '24h' | '7d' | '30d',
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/projects/{projectID}/telemetry/stats',
+ path: {
+ 'projectID': projectId,
+ },
+ query: {
+ 'window': window,
+ },
+ errors: {
+ 401: `Error response`,
+ },
+ });
+ }
+ /**
+ * @returns MetricSeriesResponse Aggregated metric series
+ * @throws ApiError
+ */
+ public static getTelemetryMetricSeries({
+ projectId,
+ name,
+ window,
+ }: {
+ projectId: string,
+ name: string,
+ window?: '1h' | '24h' | '7d' | '30d',
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/projects/{projectID}/telemetry/metrics/series',
+ path: {
+ 'projectID': projectId,
+ },
+ query: {
+ 'name': name,
+ 'window': window,
+ },
+ errors: {
+ 401: `Error response`,
+ },
+ });
+ }
+ /**
+ * Server-sent events stream of live telemetry. EventSource clients pass `?api_key=` or `?token=` since headers cannot be set.
+ * @returns any text/event-stream of TelemetryEvent messages
+ * @throws ApiError
+ */
+ public static streamTelemetryEvents({
+ projectId,
+ apiKey,
+ token,
+ }: {
+ projectId: string,
+ apiKey?: string,
+ token?: string,
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/projects/{projectID}/telemetry/stream',
+ path: {
+ 'projectID': projectId,
+ },
+ query: {
+ 'api_key': apiKey,
+ 'token': token,
+ },
+ errors: {
+ 401: `Error response`,
+ },
+ });
+ }
+}
diff --git a/packages/client/package.json b/packages/client/package.json
new file mode 100644
index 0000000..b6b98af
--- /dev/null
+++ b/packages/client/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@primora/client",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts"
+}
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
new file mode 100644
index 0000000..5b22013
--- /dev/null
+++ b/packages/client/src/index.ts
@@ -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;
+ /** batch flush interval ms (default 5000) */
+ flushInterval?: number;
+}
+
+interface QueuedEvent {
+ type: PrimoraEventType;
+ severity?: string;
+ message?: string;
+ payload?: Record;
+ ts: string;
+}
+
+export interface PrimoraClient {
+ captureError(err: unknown, extra?: Record): void;
+ captureMetric(m: { name: string; value: number; unit?: string } & Record): void;
+ captureLog(level: string, message: string, attrs?: Record): void;
+ captureEvent(message: string, payload?: Record): void;
+ heartbeat(status?: "up" | "degraded" | "down", meta?: Record): 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 | 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;
+}