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
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Telemetry (IMS merge)** — `POST /api/v1/ingest` accepts a single event or a ≤500-event batch, authenticated by existing project `prm_` API keys via `X-Primora-Key` (or `X-API-Key`); `OPTIONS` preflight + permissive CORS for browser SDKs. Components auto-register on first use (`frontend`, `backend`, `database`, `android`, `desktop`, `web`, `other`). Event types: `error`, `metric`, `log`, `heartbeat`, `event`; missing fingerprints are derived from component + first message line.
|
||||||
|
- **Issue grouping** — `GET /projects/:id/issues` groups errors by fingerprint (count, severity, first/last seen, latest component).
|
||||||
|
- **Telemetry read APIs** — `GET …/events` (type/component/fingerprint filters, `before` cursor), `GET …/components`, `DELETE …/components/:id`, `GET …/telemetry/stats` (bucketed series + component health + totals + metric names over 1h/24h/7d/30d), `GET …/telemetry/metrics/series` (avg/p50/p95/max per bucket).
|
||||||
|
- **Live SSE stream** — `GET …/telemetry/stream` pushes inserted events through an in-process hub (buffered subs, drop-on-slow, 25s keepalives). `?api_key=`/`?token=` query auth for `EventSource` clients.
|
||||||
|
- **Telemetry page** — SolidJS view: health tab (totals, activity histogram, component status table), issues with drill-through to filtered events, event list with payload modal, metric series table, and an Integrate tab with SDK/cURL snippets. Live badge driven by the SSE stream; demo-mode stubs included.
|
||||||
|
- **`@primora/client` SDK** — `createPrimoraClient({ endpoint, key, component, kind, base })` with `captureError`, `captureMetric`, `captureLog`, `captureEvent`, `heartbeat`, `installAuto`, queued batching and `keepalive` flushes.
|
||||||
|
- **New `primora_*` MCP tools** — `primora_list_issues`, `primora_list_events`, `primora_telemetry_stats`, `primora_metric_series`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Workspace dependency pins updated to `0.3.0` (`@primora/api-client`, `@primora/shared-types`) — `npm ci` in the frontend image was resolving the unpublished `0.2.0` spec from the registry.
|
||||||
|
|
||||||
## [0.3.0] - 2026-09-16
|
## [0.3.0] - 2026-09-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+14
-11
@@ -62,23 +62,26 @@ as REST (pattern already proven in IMS's `internal/app/dbx.go`).
|
|||||||
(e.g. Postgres → Dragonfly cache warm), plus documented patterns for
|
(e.g. Postgres → Dragonfly cache warm), plus documented patterns for
|
||||||
pairing them.
|
pairing them.
|
||||||
|
|
||||||
## Phase 3 — Observability (IMS merge)
|
## Phase 3 — Observability (IMS merge) ✅
|
||||||
|
|
||||||
IMS (`~/Desktop/PROG+HTML/IMS`) is discontinued and folded in — same stack
|
IMS (`~/Desktop/PROG+HTML/IMS`) is discontinued and folded in — same stack
|
||||||
(Gin + pgx + Postgres), ~2k lines to port.
|
(Gin + pgx + Postgres), ~2k lines to port.
|
||||||
|
|
||||||
- **Ingest API** — `POST /v1/ingest` with per-project ingest keys
|
- **Ingest API** — `POST /api/v1/ingest` authenticated by project `prm_` API keys
|
||||||
(`X-Primora-Key`); event types: error, metric, log, heartbeat, event;
|
(`X-Primora-Key` or `X-API-Key`); event types: error, metric, log, heartbeat,
|
||||||
component auto-registration.
|
event; component auto-registration; single event or ≤500-event batch;
|
||||||
|
permissive CORS so browser SDKs can post directly.
|
||||||
- **Issues** — error groups by fingerprint (count, first/last seen, severity,
|
- **Issues** — error groups by fingerprint (count, first/last seen, severity,
|
||||||
stack payload) — the Firebase Crashlytics-shaped view.
|
stack payload) — the Firebase Crashlytics-shaped view.
|
||||||
- **Telemetry views** — metrics, logs, heartbeats per component; live updates
|
- **Telemetry views** — health overview (totals, activity histogram, component
|
||||||
over SSE (port `sse.go`).
|
health), issues, events, metric series (avg/p50/p95/max), Integrate tab;
|
||||||
- **`@primora/client` SDK** — port of `@ims/client`: one-line web/node
|
live updates over SSE (`GET …/telemetry/stream`, `?api_key=`/`?token=` for
|
||||||
integration, platform snippets in a new project **Integrate** tab.
|
EventSource auth).
|
||||||
- **Agent tooling** — `primora_*` MCP tools for issues/events (merge into
|
- **`@primora/client` SDK** — port of `@ims/client`: queued batching,
|
||||||
`apps/mcp` from Phase 1).
|
`keepalive` flush, `installAuto()` for browser error capture.
|
||||||
- Migration note in IMS repo → archive, point at Primora.
|
- **Agent tooling** — `primora_list_issues`, `primora_list_events`,
|
||||||
|
`primora_telemetry_stats`, `primora_metric_series` in `apps/mcp`.
|
||||||
|
- Migration note in IMS repo README → archive, point at Primora.
|
||||||
|
|
||||||
## Phase 4 — Integrations
|
## Phase 4 — Integrations
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.components (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'other'
|
||||||
|
CHECK (kind IN ('frontend','backend','database','android','desktop','web','other')),
|
||||||
|
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE core.events (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
component_id UUID REFERENCES core.components(id) ON DELETE SET NULL,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('error','metric','log','heartbeat','event')),
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
fingerprint TEXT NOT NULL DEFAULT '',
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_events_project_ts ON core.events(project_id, ts DESC);
|
||||||
|
CREATE INDEX idx_events_project_type_ts ON core.events(project_id, type, ts DESC);
|
||||||
|
CREATE INDEX idx_events_fingerprint ON core.events(fingerprint) WHERE type = 'error';
|
||||||
|
CREATE INDEX idx_events_metric_name ON core.events(project_id, (payload->>'name')) WHERE type = 'metric';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.events;
|
||||||
|
DROP TABLE IF EXISTS core.components;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- name: UpsertComponent :one
|
||||||
|
INSERT INTO core.components (project_id, name, kind)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (project_id, name) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetComponentByName :one
|
||||||
|
SELECT * FROM core.components
|
||||||
|
WHERE project_id = $1 AND name = $2;
|
||||||
|
|
||||||
|
-- name: ListComponents :many
|
||||||
|
SELECT * FROM core.components
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: DeleteComponent :exec
|
||||||
|
DELETE FROM core.components
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: InsertEvent :one
|
||||||
|
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListEvents :many
|
||||||
|
SELECT e.*, co.name AS component_name
|
||||||
|
FROM core.events e
|
||||||
|
LEFT JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1
|
||||||
|
AND (sqlc.narg('type')::text IS NULL OR e.type = sqlc.narg('type'))
|
||||||
|
AND (sqlc.narg('component')::text IS NULL OR co.name = sqlc.narg('component'))
|
||||||
|
AND (sqlc.narg('fingerprint')::text IS NULL OR e.fingerprint = sqlc.narg('fingerprint'))
|
||||||
|
AND (sqlc.narg('before')::bigint IS NULL OR e.id < sqlc.narg('before'))
|
||||||
|
ORDER BY e.id DESC
|
||||||
|
LIMIT sqlc.arg('limit');
|
||||||
|
|
||||||
|
-- name: ListErrorGroups :many
|
||||||
|
SELECT e.fingerprint,
|
||||||
|
(array_agg(e.message ORDER BY e.ts DESC))[1]::text AS message,
|
||||||
|
coalesce((array_agg(e.component_id::text ORDER BY e.ts DESC))[1], '')::text AS component_id,
|
||||||
|
coalesce((array_agg(co.name ORDER BY e.ts DESC))[1], '')::text AS component_name,
|
||||||
|
count(*) AS count,
|
||||||
|
min(e.ts)::timestamptz AS first_seen,
|
||||||
|
max(e.ts)::timestamptz AS last_seen,
|
||||||
|
(array_agg(e.severity ORDER BY e.ts DESC))[1]::text AS severity
|
||||||
|
FROM core.events e
|
||||||
|
LEFT JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1 AND e.type = 'error' AND e.fingerprint <> ''
|
||||||
|
AND e.ts > now() - (sqlc.narg('days')::text || ' days')::interval
|
||||||
|
GROUP BY e.fingerprint
|
||||||
|
ORDER BY max(e.ts) DESC
|
||||||
|
LIMIT 200;
|
||||||
|
|
||||||
|
-- name: EventTypeSeries :many
|
||||||
|
SELECT to_timestamp(floor(extract(epoch FROM e.ts) / sqlc.arg('bucket_sec')::bigint) * sqlc.arg('bucket_sec')::bigint)::timestamptz AS bucket,
|
||||||
|
e.type,
|
||||||
|
count(*) AS count
|
||||||
|
FROM core.events e
|
||||||
|
WHERE e.project_id = $1 AND e.ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint)
|
||||||
|
GROUP BY bucket, e.type
|
||||||
|
ORDER BY bucket;
|
||||||
|
|
||||||
|
-- name: ComponentHealthRows :many
|
||||||
|
SELECT co.id, co.name, co.kind, co.meta,
|
||||||
|
max(e.ts)::timestamptz AS last_seen_at,
|
||||||
|
coalesce(((array_agg(e.payload->>'status' ORDER BY e.ts DESC)
|
||||||
|
FILTER (WHERE e.type = 'heartbeat'))[1]), '')::text AS last_status,
|
||||||
|
count(*) FILTER (WHERE e.type = 'error' AND e.ts > now() - interval '24 hours') AS errors_24h,
|
||||||
|
count(*) FILTER (WHERE e.ts > now() - interval '24 hours') AS events_24h
|
||||||
|
FROM core.components co
|
||||||
|
LEFT JOIN core.events e ON e.component_id = co.id
|
||||||
|
WHERE co.project_id = $1
|
||||||
|
GROUP BY co.id
|
||||||
|
ORDER BY co.name;
|
||||||
|
|
||||||
|
-- name: EventTotals :one
|
||||||
|
SELECT count(*) FILTER (WHERE type = 'error') AS errors,
|
||||||
|
count(*) AS events,
|
||||||
|
count(*) FILTER (WHERE type = 'metric') AS metrics
|
||||||
|
FROM core.events
|
||||||
|
WHERE project_id = $1 AND ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint);
|
||||||
|
|
||||||
|
-- name: MetricNames :many
|
||||||
|
SELECT DISTINCT (payload->>'name')::text AS name
|
||||||
|
FROM core.events
|
||||||
|
WHERE project_id = $1 AND type = 'metric' AND payload ? 'name'
|
||||||
|
ORDER BY 1
|
||||||
|
LIMIT 50;
|
||||||
|
|
||||||
|
-- name: MetricSeries :many
|
||||||
|
SELECT to_timestamp(floor(extract(epoch FROM ts) / sqlc.arg('bucket_sec')::bigint) * sqlc.arg('bucket_sec')::bigint)::timestamptz AS bucket,
|
||||||
|
avg((payload->>'value')::float8)::float8 AS avg,
|
||||||
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p50,
|
||||||
|
percentile_cont(0.95) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p95,
|
||||||
|
max((payload->>'value')::float8)::float8 AS max,
|
||||||
|
count(*) AS count
|
||||||
|
FROM core.events
|
||||||
|
WHERE project_id = $1 AND type = 'metric' AND payload->>'name' = sqlc.arg('name')::text
|
||||||
|
AND ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint)
|
||||||
|
AND jsonb_typeof(payload->'value') = 'number'
|
||||||
|
GROUP BY bucket
|
||||||
|
ORDER BY bucket;
|
||||||
@@ -200,6 +200,15 @@ type CoreCollection struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreComponent struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Meta []byte `json:"meta"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreDbConnection struct {
|
type CoreDbConnection struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -221,6 +230,18 @@ type CoreDocument struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
ComponentID pgtype.UUID `json:"component_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Ts pgtype.Timestamptz `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreOrganization struct {
|
type CoreOrganization struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Querier interface {
|
|||||||
AddOrganizationMember(ctx context.Context, arg AddOrganizationMemberParams) (CoreOrganizationMember, error)
|
AddOrganizationMember(ctx context.Context, arg AddOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||||
AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (CoreProjectMember, error)
|
AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (CoreProjectMember, error)
|
||||||
BootstrapOrganization(ctx context.Context, arg BootstrapOrganizationParams) (BootstrapOrganizationRow, error)
|
BootstrapOrganization(ctx context.Context, arg BootstrapOrganizationParams) (BootstrapOrganizationRow, error)
|
||||||
|
ComponentHealthRows(ctx context.Context, projectID uuid.UUID) ([]ComponentHealthRowsRow, error)
|
||||||
CountAuditLogsForProject(ctx context.Context, arg CountAuditLogsForProjectParams) (int64, error)
|
CountAuditLogsForProject(ctx context.Context, arg CountAuditLogsForProjectParams) (int64, error)
|
||||||
CountBucketObjects(ctx context.Context, arg CountBucketObjectsParams) (int64, error)
|
CountBucketObjects(ctx context.Context, arg CountBucketObjectsParams) (int64, error)
|
||||||
CountDocuments(ctx context.Context, collectionID uuid.UUID) (int64, error)
|
CountDocuments(ctx context.Context, collectionID uuid.UUID) (int64, error)
|
||||||
@@ -33,17 +34,21 @@ type Querier interface {
|
|||||||
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
||||||
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||||
|
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||||
|
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
|
||||||
|
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
||||||
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
||||||
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
||||||
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
||||||
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
||||||
GetCollectionBySlug(ctx context.Context, arg GetCollectionBySlugParams) (CoreCollection, error)
|
GetCollectionBySlug(ctx context.Context, arg GetCollectionBySlugParams) (CoreCollection, error)
|
||||||
|
GetComponentByName(ctx context.Context, arg GetComponentByNameParams) (CoreComponent, error)
|
||||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||||
@@ -55,20 +60,26 @@ type Querier interface {
|
|||||||
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
|
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
|
||||||
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
||||||
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
|
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
|
||||||
|
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||||
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
||||||
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||||
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
||||||
ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, error)
|
ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, error)
|
||||||
ListCollections(ctx context.Context, projectID uuid.UUID) ([]CoreCollection, error)
|
ListCollections(ctx context.Context, projectID uuid.UUID) ([]CoreCollection, error)
|
||||||
|
ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error)
|
||||||
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||||
|
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||||
|
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||||
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
||||||
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
|
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
|
||||||
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
|
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
|
||||||
MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error)
|
MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error)
|
||||||
|
MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error)
|
||||||
|
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
||||||
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
||||||
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||||
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
||||||
@@ -82,6 +93,7 @@ type Querier interface {
|
|||||||
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
||||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
|
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
|
||||||
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error)
|
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error)
|
||||||
|
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
||||||
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: telemetry.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const componentHealthRows = `-- name: ComponentHealthRows :many
|
||||||
|
SELECT co.id, co.name, co.kind, co.meta,
|
||||||
|
max(e.ts)::timestamptz AS last_seen_at,
|
||||||
|
coalesce(((array_agg(e.payload->>'status' ORDER BY e.ts DESC)
|
||||||
|
FILTER (WHERE e.type = 'heartbeat'))[1]), '')::text AS last_status,
|
||||||
|
count(*) FILTER (WHERE e.type = 'error' AND e.ts > now() - interval '24 hours') AS errors_24h,
|
||||||
|
count(*) FILTER (WHERE e.ts > now() - interval '24 hours') AS events_24h
|
||||||
|
FROM core.components co
|
||||||
|
LEFT JOIN core.events e ON e.component_id = co.id
|
||||||
|
WHERE co.project_id = $1
|
||||||
|
GROUP BY co.id
|
||||||
|
ORDER BY co.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type ComponentHealthRowsRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Meta []byte `json:"meta"`
|
||||||
|
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||||
|
LastStatus string `json:"last_status"`
|
||||||
|
Errors24h int64 `json:"errors_24h"`
|
||||||
|
Events24h int64 `json:"events_24h"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ComponentHealthRows(ctx context.Context, projectID uuid.UUID) ([]ComponentHealthRowsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, componentHealthRows, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ComponentHealthRowsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ComponentHealthRowsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Meta,
|
||||||
|
&i.LastSeenAt,
|
||||||
|
&i.LastStatus,
|
||||||
|
&i.Errors24h,
|
||||||
|
&i.Events24h,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComponent = `-- name: DeleteComponent :exec
|
||||||
|
DELETE FROM core.components
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteComponentParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteComponent(ctx context.Context, arg DeleteComponentParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteComponent, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTotals = `-- name: EventTotals :one
|
||||||
|
SELECT count(*) FILTER (WHERE type = 'error') AS errors,
|
||||||
|
count(*) AS events,
|
||||||
|
count(*) FILTER (WHERE type = 'metric') AS metrics
|
||||||
|
FROM core.events
|
||||||
|
WHERE project_id = $1 AND ts > now() - make_interval(secs => $2::bigint)
|
||||||
|
`
|
||||||
|
|
||||||
|
type EventTotalsParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
WindowSec int64 `json:"window_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventTotalsRow struct {
|
||||||
|
Errors int64 `json:"errors"`
|
||||||
|
Events int64 `json:"events"`
|
||||||
|
Metrics int64 `json:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, eventTotals, arg.ProjectID, arg.WindowSec)
|
||||||
|
var i EventTotalsRow
|
||||||
|
err := row.Scan(&i.Errors, &i.Events, &i.Metrics)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTypeSeries = `-- name: EventTypeSeries :many
|
||||||
|
SELECT to_timestamp(floor(extract(epoch FROM e.ts) / $2::bigint) * $2::bigint)::timestamptz AS bucket,
|
||||||
|
e.type,
|
||||||
|
count(*) AS count
|
||||||
|
FROM core.events e
|
||||||
|
WHERE e.project_id = $1 AND e.ts > now() - make_interval(secs => $3::bigint)
|
||||||
|
GROUP BY bucket, e.type
|
||||||
|
ORDER BY bucket
|
||||||
|
`
|
||||||
|
|
||||||
|
type EventTypeSeriesParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
BucketSec int64 `json:"bucket_sec"`
|
||||||
|
WindowSec int64 `json:"window_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventTypeSeriesRow struct {
|
||||||
|
Bucket pgtype.Timestamptz `json:"bucket"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, eventTypeSeries, arg.ProjectID, arg.BucketSec, arg.WindowSec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []EventTypeSeriesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i EventTypeSeriesRow
|
||||||
|
if err := rows.Scan(&i.Bucket, &i.Type, &i.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getComponentByName = `-- name: GetComponentByName :one
|
||||||
|
SELECT id, project_id, name, kind, meta, created_at FROM core.components
|
||||||
|
WHERE project_id = $1 AND name = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetComponentByNameParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetComponentByName(ctx context.Context, arg GetComponentByNameParams) (CoreComponent, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getComponentByName, arg.ProjectID, arg.Name)
|
||||||
|
var i CoreComponent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Meta,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertEvent = `-- name: InsertEvent :one
|
||||||
|
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, project_id, component_id, type, severity, message, payload, fingerprint, ts
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertEventParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
ComponentID pgtype.UUID `json:"component_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Ts pgtype.Timestamptz `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertEvent,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.ComponentID,
|
||||||
|
arg.Type,
|
||||||
|
arg.Severity,
|
||||||
|
arg.Message,
|
||||||
|
arg.Payload,
|
||||||
|
arg.Fingerprint,
|
||||||
|
arg.Ts,
|
||||||
|
)
|
||||||
|
var i CoreEvent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.ComponentID,
|
||||||
|
&i.Type,
|
||||||
|
&i.Severity,
|
||||||
|
&i.Message,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Fingerprint,
|
||||||
|
&i.Ts,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listComponents = `-- name: ListComponents :many
|
||||||
|
SELECT id, project_id, name, kind, meta, created_at FROM core.components
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listComponents, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreComponent
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreComponent
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Meta,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listErrorGroups = `-- name: ListErrorGroups :many
|
||||||
|
SELECT e.fingerprint,
|
||||||
|
(array_agg(e.message ORDER BY e.ts DESC))[1]::text AS message,
|
||||||
|
coalesce((array_agg(e.component_id::text ORDER BY e.ts DESC))[1], '')::text AS component_id,
|
||||||
|
coalesce((array_agg(co.name ORDER BY e.ts DESC))[1], '')::text AS component_name,
|
||||||
|
count(*) AS count,
|
||||||
|
min(e.ts)::timestamptz AS first_seen,
|
||||||
|
max(e.ts)::timestamptz AS last_seen,
|
||||||
|
(array_agg(e.severity ORDER BY e.ts DESC))[1]::text AS severity
|
||||||
|
FROM core.events e
|
||||||
|
LEFT JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1 AND e.type = 'error' AND e.fingerprint <> ''
|
||||||
|
AND e.ts > now() - ($2::text || ' days')::interval
|
||||||
|
GROUP BY e.fingerprint
|
||||||
|
ORDER BY max(e.ts) DESC
|
||||||
|
LIMIT 200
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListErrorGroupsParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Days *string `json:"days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListErrorGroupsRow struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ComponentID string `json:"component_id"`
|
||||||
|
ComponentName string `json:"component_name"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
FirstSeen pgtype.Timestamptz `json:"first_seen"`
|
||||||
|
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listErrorGroups, arg.ProjectID, arg.Days)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListErrorGroupsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListErrorGroupsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Fingerprint,
|
||||||
|
&i.Message,
|
||||||
|
&i.ComponentID,
|
||||||
|
&i.ComponentName,
|
||||||
|
&i.Count,
|
||||||
|
&i.FirstSeen,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.Severity,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEvents = `-- name: ListEvents :many
|
||||||
|
SELECT e.id, e.project_id, e.component_id, e.type, e.severity, e.message, e.payload, e.fingerprint, e.ts, co.name AS component_name
|
||||||
|
FROM core.events e
|
||||||
|
LEFT JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1
|
||||||
|
AND ($2::text IS NULL OR e.type = $2)
|
||||||
|
AND ($3::text IS NULL OR co.name = $3)
|
||||||
|
AND ($4::text IS NULL OR e.fingerprint = $4)
|
||||||
|
AND ($5::bigint IS NULL OR e.id < $5)
|
||||||
|
ORDER BY e.id DESC
|
||||||
|
LIMIT $6
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListEventsParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Type *string `json:"type"`
|
||||||
|
Component *string `json:"component"`
|
||||||
|
Fingerprint *string `json:"fingerprint"`
|
||||||
|
Before *int64 `json:"before"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListEventsRow struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
ComponentID pgtype.UUID `json:"component_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Ts pgtype.Timestamptz `json:"ts"`
|
||||||
|
ComponentName *string `json:"component_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEvents,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Type,
|
||||||
|
arg.Component,
|
||||||
|
arg.Fingerprint,
|
||||||
|
arg.Before,
|
||||||
|
arg.Limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListEventsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListEventsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.ComponentID,
|
||||||
|
&i.Type,
|
||||||
|
&i.Severity,
|
||||||
|
&i.Message,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Fingerprint,
|
||||||
|
&i.Ts,
|
||||||
|
&i.ComponentName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricNames = `-- name: MetricNames :many
|
||||||
|
SELECT DISTINCT (payload->>'name')::text AS name
|
||||||
|
FROM core.events
|
||||||
|
WHERE project_id = $1 AND type = 'metric' AND payload ? 'name'
|
||||||
|
ORDER BY 1
|
||||||
|
LIMIT 50
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error) {
|
||||||
|
rows, err := q.db.Query(ctx, metricNames, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, name)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricSeries = `-- name: MetricSeries :many
|
||||||
|
SELECT to_timestamp(floor(extract(epoch FROM ts) / $2::bigint) * $2::bigint)::timestamptz AS bucket,
|
||||||
|
avg((payload->>'value')::float8)::float8 AS avg,
|
||||||
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p50,
|
||||||
|
percentile_cont(0.95) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p95,
|
||||||
|
max((payload->>'value')::float8)::float8 AS max,
|
||||||
|
count(*) AS count
|
||||||
|
FROM core.events
|
||||||
|
WHERE project_id = $1 AND type = 'metric' AND payload->>'name' = $3::text
|
||||||
|
AND ts > now() - make_interval(secs => $4::bigint)
|
||||||
|
AND jsonb_typeof(payload->'value') = 'number'
|
||||||
|
GROUP BY bucket
|
||||||
|
ORDER BY bucket
|
||||||
|
`
|
||||||
|
|
||||||
|
type MetricSeriesParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
BucketSec int64 `json:"bucket_sec"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
WindowSec int64 `json:"window_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricSeriesRow struct {
|
||||||
|
Bucket pgtype.Timestamptz `json:"bucket"`
|
||||||
|
Avg float64 `json:"avg"`
|
||||||
|
P50 float64 `json:"p50"`
|
||||||
|
P95 float64 `json:"p95"`
|
||||||
|
Max float64 `json:"max"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, metricSeries,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.BucketSec,
|
||||||
|
arg.Name,
|
||||||
|
arg.WindowSec,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []MetricSeriesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i MetricSeriesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Bucket,
|
||||||
|
&i.Avg,
|
||||||
|
&i.P50,
|
||||||
|
&i.P95,
|
||||||
|
&i.Max,
|
||||||
|
&i.Count,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertComponent = `-- name: UpsertComponent :one
|
||||||
|
INSERT INTO core.components (project_id, name, kind)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (project_id, name) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id, project_id, name, kind, meta, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertComponentParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error) {
|
||||||
|
row := q.db.QueryRow(ctx, upsertComponent, arg.ProjectID, arg.Name, arg.Kind)
|
||||||
|
var i CoreComponent
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Meta,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -70,6 +70,15 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.GET("/collections/:collectionID/documents/:documentID", h.getDocument)
|
api.GET("/collections/:collectionID/documents/:documentID", h.getDocument)
|
||||||
api.PATCH("/collections/:collectionID/documents/:documentID", h.updateDocument)
|
api.PATCH("/collections/:collectionID/documents/:documentID", h.updateDocument)
|
||||||
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||||
|
api.OPTIONS("/ingest", h.ingestOptions)
|
||||||
|
api.POST("/ingest", h.ingest)
|
||||||
|
api.GET("/projects/:projectID/events", h.listEvents)
|
||||||
|
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||||
|
api.GET("/projects/:projectID/components", h.listComponents)
|
||||||
|
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
||||||
|
api.GET("/projects/:projectID/telemetry/stats", h.telemetryStats)
|
||||||
|
api.GET("/projects/:projectID/telemetry/metrics/series", h.metricSeries)
|
||||||
|
api.GET("/projects/:projectID/telemetry/stream", h.telemetryStream)
|
||||||
api.GET("/db/status", h.dbxStatus)
|
api.GET("/db/status", h.dbxStatus)
|
||||||
api.GET("/projects/:projectID/db-connections", h.listDBConnections)
|
api.GET("/projects/:projectID/db-connections", h.listDBConnections)
|
||||||
api.POST("/projects/:projectID/db-connections", h.createDBConnection)
|
api.POST("/projects/:projectID/db-connections", h.createDBConnection)
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ingestCORS — SDKs send telemetry from arbitrary origins (browsers, phones,
|
||||||
|
// desktops), so this one route allows any origin.
|
||||||
|
func ingestCORS(c *gin.Context) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, X-Primora-Key, X-API-Key, Authorization")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) ingestOptions(c *gin.Context) {
|
||||||
|
ingestCORS(c)
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) ingest(c *gin.Context) {
|
||||||
|
ingestCORS(c)
|
||||||
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if actor.APIKeyID == nil || actor.ProjectID == nil {
|
||||||
|
apperrors.Abort(c, http.StatusUnauthorized, "api_key_required", "ingest requires a project API key (X-Primora-Key)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "unreadable body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var events []services.IngestEventInput
|
||||||
|
var batch struct {
|
||||||
|
Events []services.IngestEventInput `json:"events"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &batch); err == nil && len(batch.Events) > 0 {
|
||||||
|
events = batch.Events
|
||||||
|
} else {
|
||||||
|
var single services.IngestEventInput
|
||||||
|
if err := json.Unmarshal(raw, &single); err != nil || single.Type == "" {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "expected {type,...} or {events:[...]}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events = []services.IngestEventInput{single}
|
||||||
|
}
|
||||||
|
if len(events) > 500 {
|
||||||
|
apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_many_events", "max 500 events per batch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted, err := h.Platform.IngestEvents(c.Request.Context(), actor, events)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"accepted": accepted})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listEvents(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f := services.EventListFilters{
|
||||||
|
Type: strings.TrimSpace(c.Query("type")),
|
||||||
|
Component: strings.TrimSpace(c.Query("component")),
|
||||||
|
Fingerprint: strings.TrimSpace(c.Query("fingerprint")),
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseInt(c.Query("before"), 10, 64); err == nil && v > 0 {
|
||||||
|
f.Before = &v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(c.Query("limit")); err == nil {
|
||||||
|
f.Limit = v
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListEvents(c.Request.Context(), actor, projectID, f)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listIssues(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||||
|
result, err := h.Platform.ListIssues(c.Request.Context(), actor, projectID, days)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listComponents(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListComponents(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteComponent(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
componentID, ok := parseUUIDParam(c, "componentID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteComponent(c.Request.Context(), actor, projectID, componentID); err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) telemetryStats(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.TelemetryStats(c.Request.Context(), actor, projectID, c.DefaultQuery("window", "24h"))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) metricSeries(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.MetricSeriesData(c.Request.Context(), actor, projectID,
|
||||||
|
strings.TrimSpace(c.Query("name")), c.DefaultQuery("window", "24h"))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) telemetryStream(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, err := h.Platform.SubscribeTelemetry(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer h.Platform.UnsubscribeTelemetry(projectID, ch)
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
if _, err := c.Writer.WriteString(": connected\n\n"); err == nil {
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
keepalive := time.NewTicker(25 * time.Second)
|
||||||
|
defer keepalive.Stop()
|
||||||
|
|
||||||
|
c.Stream(func(w io.Writer) bool {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.SSEvent("event", json.RawMessage(msg))
|
||||||
|
return true
|
||||||
|
case <-keepalive.C:
|
||||||
|
c.SSEvent("ping", "keepalive")
|
||||||
|
return true
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -74,7 +74,20 @@ func Logger(logger *slog.Logger) gin.HandlerFunc {
|
|||||||
func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
apiKey := strings.TrimSpace(c.GetHeader("X-API-Key"))
|
apiKey := strings.TrimSpace(c.GetHeader("X-API-Key"))
|
||||||
|
if apiKey == "" {
|
||||||
|
apiKey = strings.TrimSpace(c.GetHeader("X-Primora-Key"))
|
||||||
|
}
|
||||||
authz := strings.TrimSpace(c.GetHeader("Authorization"))
|
authz := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||||
|
// EventSource cannot set headers — the SSE stream accepts credentials
|
||||||
|
// as query params instead.
|
||||||
|
if apiKey == "" && authz == "" && strings.HasSuffix(c.Request.URL.Path, "/telemetry/stream") {
|
||||||
|
apiKey = strings.TrimSpace(c.Query("api_key"))
|
||||||
|
if apiKey == "" {
|
||||||
|
if t := strings.TrimSpace(c.Query("token")); t != "" {
|
||||||
|
authz = "Bearer " + t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if apiKey == "" && strings.HasPrefix(strings.ToLower(authz), "bearer ") {
|
if apiKey == "" && strings.HasPrefix(strings.ToLower(authz), "bearer ") {
|
||||||
token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer"))
|
token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer"))
|
||||||
if strings.HasPrefix(authz, "Bearer ") {
|
if strings.HasPrefix(authz, "Bearer ") {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventHub fans out ingested telemetry events to SSE subscribers, keyed by
|
||||||
|
// project ID. Slow consumers are dropped rather than back-pressured.
|
||||||
|
type EventHub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
subs map[string]map[chan []byte]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEventHub() *EventHub {
|
||||||
|
return &EventHub{subs: map[string]map[chan []byte]struct{}{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Subscribe(projectID string) chan []byte {
|
||||||
|
ch := make(chan []byte, 64)
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.subs[projectID] == nil {
|
||||||
|
h.subs[projectID] = map[chan []byte]struct{}{}
|
||||||
|
}
|
||||||
|
h.subs[projectID][ch] = struct{}{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Unsubscribe(projectID string, ch chan []byte) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.subs[projectID], ch)
|
||||||
|
h.mu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Broadcast(projectID string, v any) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
for ch := range h.subs[projectID] {
|
||||||
|
select {
|
||||||
|
case ch <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ type PlatformService struct {
|
|||||||
publicURL string
|
publicURL string
|
||||||
dbx *dbx.Client
|
dbx *dbx.Client
|
||||||
managedDBSeeds []ManagedDBSeed
|
managedDBSeeds []ManagedDBSeed
|
||||||
|
hub *EventHub
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
@@ -210,7 +211,7 @@ type InvitationSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed) *PlatformService {
|
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed) *PlatformService {
|
||||||
return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds}
|
return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var telemetryEventTypes = map[string]bool{
|
||||||
|
"error": true, "metric": true, "log": true, "heartbeat": true, "event": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentKinds = map[string]bool{
|
||||||
|
"frontend": true, "backend": true, "database": true,
|
||||||
|
"android": true, "desktop": true, "web": true, "other": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestEventInput is one event in an ingest batch.
|
||||||
|
type IngestEventInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
TS *time.Time `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelemetryEvent is the client-facing event shape.
|
||||||
|
type TelemetryEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
ComponentID *uuid.UUID `json:"component_id"`
|
||||||
|
ComponentName string `json:"component_name,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
TS time.Time `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelemetryIssue struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ComponentID *uuid.UUID `json:"component_id"`
|
||||||
|
ComponentName string `json:"component_name,omitempty"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
|
LastSeen time.Time `json:"last_seen"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentSummary struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||||
|
LastStatus string `json:"last_status"`
|
||||||
|
Errors24h int64 `json:"errors_24h"`
|
||||||
|
Events24h int64 `json:"events_24h"`
|
||||||
|
Meta map[string]any `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintOf(componentID uuid.UUID, message string) string {
|
||||||
|
line := strings.SplitN(message, "\n", 2)[0]
|
||||||
|
sum := sha256.Sum256([]byte(componentID.String() + "|" + line))
|
||||||
|
return hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestEvents stores a batch of telemetry events under the ingest key's
|
||||||
|
// project. API-key actors only — the key's project scope is the write scope.
|
||||||
|
func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor, events []IngestEventInput) (int, error) {
|
||||||
|
if actor == nil || actor.ProjectID == nil || actor.APIKeyID == nil {
|
||||||
|
return 0, errors.New("ingest requires a project API key")
|
||||||
|
}
|
||||||
|
projectID := *actor.ProjectID
|
||||||
|
|
||||||
|
var inserted []TelemetryEvent
|
||||||
|
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
||||||
|
compCache := map[string]*uuid.UUID{}
|
||||||
|
for _, ev := range events {
|
||||||
|
if !telemetryEventTypes[ev.Type] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var compID *uuid.UUID
|
||||||
|
if ev.Component != "" {
|
||||||
|
cached, hit := compCache[ev.Component]
|
||||||
|
if !hit {
|
||||||
|
kind := ev.Kind
|
||||||
|
if !componentKinds[kind] {
|
||||||
|
kind = "other"
|
||||||
|
}
|
||||||
|
row, err := q.UpsertComponent(ctx, db.UpsertComponentParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: ev.Component,
|
||||||
|
Kind: kind,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := row.ID
|
||||||
|
cached = &id
|
||||||
|
compCache[ev.Component] = cached
|
||||||
|
}
|
||||||
|
compID = cached
|
||||||
|
}
|
||||||
|
sev := ev.Severity
|
||||||
|
if sev == "" {
|
||||||
|
sev = "info"
|
||||||
|
if ev.Type == "error" {
|
||||||
|
sev = "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fp := ev.Fingerprint
|
||||||
|
if ev.Type == "error" && fp == "" && compID != nil {
|
||||||
|
fp = fingerprintOf(*compID, ev.Message)
|
||||||
|
}
|
||||||
|
ts := time.Now()
|
||||||
|
if ev.TS != nil {
|
||||||
|
ts = *ev.TS
|
||||||
|
}
|
||||||
|
payload := ev.Payload
|
||||||
|
if len(payload) == 0 {
|
||||||
|
payload = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
var cid pgtype.UUID
|
||||||
|
if compID != nil {
|
||||||
|
cid = pgtype.UUID{Bytes: *compID, Valid: true}
|
||||||
|
}
|
||||||
|
row, err := q.InsertEvent(ctx, db.InsertEventParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
ComponentID: cid,
|
||||||
|
Type: ev.Type,
|
||||||
|
Severity: sev,
|
||||||
|
Message: ev.Message,
|
||||||
|
Payload: payload,
|
||||||
|
Fingerprint: fp,
|
||||||
|
Ts: pgtype.Timestamptz{Time: ts, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out := TelemetryEvent{
|
||||||
|
ID: row.ID,
|
||||||
|
ProjectID: row.ProjectID,
|
||||||
|
Type: row.Type,
|
||||||
|
Severity: row.Severity,
|
||||||
|
Message: row.Message,
|
||||||
|
Payload: row.Payload,
|
||||||
|
Fingerprint: row.Fingerprint,
|
||||||
|
TS: row.Ts.Time,
|
||||||
|
}
|
||||||
|
if compID != nil {
|
||||||
|
out.ComponentID = compID
|
||||||
|
out.ComponentName = ev.Component
|
||||||
|
}
|
||||||
|
inserted = append(inserted, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
_ = s.repo.Queries().TouchAPIKey(ctx, *actor.APIKeyID)
|
||||||
|
for _, ev := range inserted {
|
||||||
|
s.hub.Broadcast(ev.ProjectID.String(), ev)
|
||||||
|
}
|
||||||
|
return len(inserted), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeTelemetry validates read access and returns an event channel.
|
||||||
|
func (s *PlatformService) SubscribeTelemetry(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.hub.Subscribe(projectID.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) UnsubscribeTelemetry(projectID uuid.UUID, ch chan []byte) {
|
||||||
|
s.hub.Unsubscribe(projectID.String(), ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventListFilters struct {
|
||||||
|
Type string
|
||||||
|
Component string
|
||||||
|
Fingerprint string
|
||||||
|
Before *int64
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListEvents(ctx context.Context, actor *models.Actor, projectID uuid.UUID, f EventListFilters) ([]TelemetryEvent, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if f.Limit <= 0 || f.Limit > 500 {
|
||||||
|
f.Limit = 100
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListEvents(ctx, db.ListEventsParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: strPtr(f.Type),
|
||||||
|
Component: strPtr(f.Component),
|
||||||
|
Fingerprint: strPtr(f.Fingerprint),
|
||||||
|
Before: f.Before,
|
||||||
|
Limit: int32(f.Limit),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]TelemetryEvent, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, toTelemetryEvent(r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListIssues(ctx context.Context, actor *models.Actor, projectID uuid.UUID, days int) ([]TelemetryIssue, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if days <= 0 || days > 365 {
|
||||||
|
days = 30
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListErrorGroups(ctx, db.ListErrorGroupsParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Days: strPtr(fmt.Sprint(days)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]TelemetryIssue, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
issue := TelemetryIssue{
|
||||||
|
Fingerprint: r.Fingerprint,
|
||||||
|
Message: r.Message,
|
||||||
|
Count: r.Count,
|
||||||
|
Severity: r.Severity,
|
||||||
|
}
|
||||||
|
if r.FirstSeen.Valid {
|
||||||
|
issue.FirstSeen = r.FirstSeen.Time
|
||||||
|
}
|
||||||
|
if r.LastSeen.Valid {
|
||||||
|
issue.LastSeen = r.LastSeen.Time
|
||||||
|
}
|
||||||
|
if id, err := uuid.Parse(r.ComponentID); err == nil {
|
||||||
|
issue.ComponentID = &id
|
||||||
|
}
|
||||||
|
issue.ComponentName = r.ComponentName
|
||||||
|
out = append(out, issue)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowSeconds(w string) int64 {
|
||||||
|
switch w {
|
||||||
|
case "1h":
|
||||||
|
return 3600
|
||||||
|
case "7d":
|
||||||
|
return 7 * 86400
|
||||||
|
case "30d":
|
||||||
|
return 30 * 86400
|
||||||
|
default:
|
||||||
|
return 86400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketSeconds(window int64) int64 {
|
||||||
|
switch {
|
||||||
|
case window <= 3600:
|
||||||
|
return 60
|
||||||
|
case window <= 86400:
|
||||||
|
return 900
|
||||||
|
case window <= 7*86400:
|
||||||
|
return 3600
|
||||||
|
default:
|
||||||
|
return 86400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventSeriesBucket struct {
|
||||||
|
TS time.Time `json:"ts"`
|
||||||
|
Counts map[string]int64 `json:"counts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricPoint struct {
|
||||||
|
TS time.Time `json:"ts"`
|
||||||
|
Avg *float64 `json:"avg"`
|
||||||
|
P50 *float64 `json:"p50"`
|
||||||
|
P95 *float64 `json:"p95"`
|
||||||
|
Max *float64 `json:"max"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelemetryStatsResult struct {
|
||||||
|
Window int64 `json:"window"`
|
||||||
|
BucketSec int64 `json:"bucket_sec"`
|
||||||
|
Series []EventSeriesBucket `json:"series"`
|
||||||
|
Components []ComponentSummary `json:"components"`
|
||||||
|
Errors int64 `json:"errors"`
|
||||||
|
Events int64 `json:"events"`
|
||||||
|
Metrics int64 `json:"metrics"`
|
||||||
|
MetricNames []string `json:"metric_names"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) TelemetryStats(ctx context.Context, actor *models.Actor, projectID uuid.UUID, window string) (*TelemetryStatsResult, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
win := windowSeconds(window)
|
||||||
|
bucket := bucketSeconds(win)
|
||||||
|
|
||||||
|
seriesRows, err := s.repo.Queries().EventTypeSeries(ctx, db.EventTypeSeriesParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
BucketSec: bucket,
|
||||||
|
WindowSec: win,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
series := make([]EventSeriesBucket, 0, len(seriesRows))
|
||||||
|
for _, r := range seriesRows {
|
||||||
|
n := len(series)
|
||||||
|
if n == 0 || !series[n-1].TS.Equal(r.Bucket.Time) {
|
||||||
|
series = append(series, EventSeriesBucket{TS: r.Bucket.Time, Counts: map[string]int64{}})
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
series[n-1].Counts[r.Type] = r.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
compRows, err := s.repo.Queries().ComponentHealthRows(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
components := make([]ComponentSummary, 0, len(compRows))
|
||||||
|
for _, r := range compRows {
|
||||||
|
c := ComponentSummary{ID: r.ID, Name: r.Name, Kind: r.Kind, Errors24h: r.Errors24h, Events24h: r.Events24h}
|
||||||
|
if r.LastSeenAt.Valid {
|
||||||
|
t := r.LastSeenAt.Time
|
||||||
|
c.LastSeenAt = &t
|
||||||
|
}
|
||||||
|
c.LastStatus = r.LastStatus
|
||||||
|
if len(r.Meta) > 0 {
|
||||||
|
_ = json.Unmarshal(r.Meta, &c.Meta)
|
||||||
|
}
|
||||||
|
components = append(components, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
totals, err := s.repo.Queries().EventTotals(ctx, db.EventTotalsParams{ProjectID: projectID, WindowSec: win})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
names, err := s.repo.Queries().MetricNames(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if names == nil {
|
||||||
|
names = []string{}
|
||||||
|
}
|
||||||
|
return &TelemetryStatsResult{
|
||||||
|
Window: win,
|
||||||
|
BucketSec: bucket,
|
||||||
|
Series: series,
|
||||||
|
Components: components,
|
||||||
|
Errors: totals.Errors,
|
||||||
|
Events: totals.Events,
|
||||||
|
Metrics: totals.Metrics,
|
||||||
|
MetricNames: names,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) MetricSeriesData(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name, window string) ([]MetricPoint, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("name required")
|
||||||
|
}
|
||||||
|
win := windowSeconds(window)
|
||||||
|
rows, err := s.repo.Queries().MetricSeries(ctx, db.MetricSeriesParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: name,
|
||||||
|
BucketSec: bucketSeconds(win),
|
||||||
|
WindowSec: win,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]MetricPoint, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, MetricPoint{TS: r.Bucket.Time, Avg: &r.Avg, P50: &r.P50, P95: &r.P95, Max: &r.Max, Count: r.Count})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListComponents(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ComponentSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListComponents(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ComponentSummary, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
c := ComponentSummary{ID: r.ID, Name: r.Name, Kind: r.Kind}
|
||||||
|
if len(r.Meta) > 0 {
|
||||||
|
_ = json.Unmarshal(r.Meta, &c.Meta)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) DeleteComponent(ctx context.Context, actor *models.Actor, projectID, componentID uuid.UUID) error {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.Queries().DeleteComponent(ctx, db.DeleteComponentParams{ID: componentID, ProjectID: projectID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTelemetryEvent(r db.ListEventsRow) TelemetryEvent {
|
||||||
|
ev := TelemetryEvent{
|
||||||
|
ID: r.ID,
|
||||||
|
ProjectID: r.ProjectID,
|
||||||
|
Type: r.Type,
|
||||||
|
Severity: r.Severity,
|
||||||
|
Message: r.Message,
|
||||||
|
Payload: r.Payload,
|
||||||
|
Fingerprint: r.Fingerprint,
|
||||||
|
TS: r.Ts.Time,
|
||||||
|
}
|
||||||
|
if r.ComponentID.Valid {
|
||||||
|
id := uuid.UUID(r.ComponentID.Bytes)
|
||||||
|
ev.ComponentID = &id
|
||||||
|
}
|
||||||
|
if r.ComponentName != nil {
|
||||||
|
ev.ComponentName = *r.ComponentName
|
||||||
|
}
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ tags:
|
|||||||
- name: Storage
|
- name: Storage
|
||||||
- name: Collections
|
- name: Collections
|
||||||
- name: Databases
|
- name: Databases
|
||||||
|
- name: Telemetry
|
||||||
paths:
|
paths:
|
||||||
/health/liveness:
|
/health/liveness:
|
||||||
get:
|
get:
|
||||||
@@ -1091,6 +1092,200 @@ paths:
|
|||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
"502":
|
"502":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/ingest:
|
||||||
|
post:
|
||||||
|
tags: [Telemetry]
|
||||||
|
operationId: ingestTelemetry
|
||||||
|
summary: Ingest one telemetry event or a batch of up to 500
|
||||||
|
security:
|
||||||
|
- apiKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/IngestRequest"
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Accepted event count
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/IngestResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"413":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/events:
|
||||||
|
get:
|
||||||
|
tags: [Telemetry]
|
||||||
|
operationId: listTelemetryEvents
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: type
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [error, metric, log, heartbeat, event]
|
||||||
|
- name: component
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: fingerprint
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: before
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Telemetry events, newest first
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TelemetryEventListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/issues:
|
||||||
|
get:
|
||||||
|
tags: [Telemetry]
|
||||||
|
operationId: listTelemetryIssues
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: days
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Error groups by fingerprint
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TelemetryIssueListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/components:
|
||||||
|
get:
|
||||||
|
tags: [Telemetry]
|
||||||
|
operationId: listTelemetryComponents
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Registered telemetry components
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TelemetryComponentListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/components/{componentID}:
|
||||||
|
delete:
|
||||||
|
tags: [Telemetry]
|
||||||
|
operationId: deleteTelemetryComponent
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/ComponentID"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Component deleted
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/telemetry/stats:
|
||||||
|
get:
|
||||||
|
tags: [Telemetry]
|
||||||
|
operationId: getTelemetryStats
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: window
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [1h, 24h, 7d, 30d]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aggregated telemetry statistics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TelemetryStats"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/telemetry/metrics/series:
|
||||||
|
get:
|
||||||
|
tags: [Telemetry]
|
||||||
|
operationId: getTelemetryMetricSeries
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: name
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: window
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [1h, 24h, 7d, 30d]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aggregated metric series
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MetricSeriesResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/telemetry/stream:
|
||||||
|
get:
|
||||||
|
tags: [Telemetry]
|
||||||
|
operationId: streamTelemetryEvents
|
||||||
|
summary: Server-sent events stream of live telemetry. EventSource clients pass `?api_key=` or `?token=` since headers cannot be set.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: api_key
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: text/event-stream of TelemetryEvent messages
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -1151,6 +1346,13 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
ComponentID:
|
||||||
|
name: componentID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
UserID:
|
UserID:
|
||||||
name: userID
|
name: userID
|
||||||
in: path
|
in: path
|
||||||
@@ -2094,3 +2296,237 @@ components:
|
|||||||
output:
|
output:
|
||||||
type: string
|
type: string
|
||||||
required: [output]
|
required: [output]
|
||||||
|
IngestEvent:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [error, metric, log, heartbeat, event]
|
||||||
|
component:
|
||||||
|
type: string
|
||||||
|
description: Auto-registered under the project on first use
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [frontend, backend, database, android, desktop, web, other]
|
||||||
|
severity:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
payload:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
fingerprint:
|
||||||
|
type: string
|
||||||
|
description: Error grouping key; derived from message when omitted
|
||||||
|
ts:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [type]
|
||||||
|
IngestRequest:
|
||||||
|
oneOf:
|
||||||
|
- $ref: "#/components/schemas/IngestEvent"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
events:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/IngestEvent"
|
||||||
|
maxItems: 500
|
||||||
|
required: [events]
|
||||||
|
IngestResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
accepted:
|
||||||
|
type: integer
|
||||||
|
required: [accepted]
|
||||||
|
TelemetryEvent:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
component_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
component_name:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
payload:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
fingerprint:
|
||||||
|
type: string
|
||||||
|
ts:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [id, project_id, type, severity, message, ts]
|
||||||
|
TelemetryEventListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/TelemetryEvent"
|
||||||
|
required: [items]
|
||||||
|
TelemetryIssue:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
fingerprint:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
component_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
component_name:
|
||||||
|
type: string
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
first_seen:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_seen:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
severity:
|
||||||
|
type: string
|
||||||
|
required: [fingerprint, message, count, first_seen, last_seen, severity]
|
||||||
|
TelemetryIssueListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/TelemetryIssue"
|
||||||
|
required: [items]
|
||||||
|
TelemetryComponent:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
last_seen_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
last_status:
|
||||||
|
type: string
|
||||||
|
errors_24h:
|
||||||
|
type: integer
|
||||||
|
events_24h:
|
||||||
|
type: integer
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [id, name, kind, errors_24h, events_24h]
|
||||||
|
TelemetryComponentListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/TelemetryComponent"
|
||||||
|
required: [items]
|
||||||
|
ComponentHealth:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
last_seen_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
last_status:
|
||||||
|
type: string
|
||||||
|
errors_24h:
|
||||||
|
type: integer
|
||||||
|
events_24h:
|
||||||
|
type: integer
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [id, name, kind, errors_24h, events_24h]
|
||||||
|
EventSeriesBucket:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ts:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
counts:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
required: [ts, counts]
|
||||||
|
TelemetryStats:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
window:
|
||||||
|
type: integer
|
||||||
|
bucket_sec:
|
||||||
|
type: integer
|
||||||
|
series:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/EventSeriesBucket"
|
||||||
|
components:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ComponentHealth"
|
||||||
|
errors:
|
||||||
|
type: integer
|
||||||
|
events:
|
||||||
|
type: integer
|
||||||
|
metrics:
|
||||||
|
type: integer
|
||||||
|
metric_names:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required: [window, bucket_sec, series, components, errors, events, metrics, metric_names]
|
||||||
|
MetricPoint:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ts:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
avg:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
p50:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
p95:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
max:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
required: [ts, count]
|
||||||
|
MetricSeriesResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/MetricPoint"
|
||||||
|
required: [items]
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primora/api-client": "0.2.0",
|
"@primora/api-client": "0.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ COPY apps/cli/package.json ./apps/cli/package.json
|
|||||||
COPY apps/frontend/package.json ./apps/frontend/package.json
|
COPY apps/frontend/package.json ./apps/frontend/package.json
|
||||||
COPY apps/mcp/package.json ./apps/mcp/package.json
|
COPY apps/mcp/package.json ./apps/mcp/package.json
|
||||||
COPY packages/api-client/package.json ./packages/api-client/package.json
|
COPY packages/api-client/package.json ./packages/api-client/package.json
|
||||||
|
COPY packages/client/package.json ./packages/client/package.json
|
||||||
COPY packages/shared-types/package.json ./packages/shared-types/package.json
|
COPY packages/shared-types/package.json ./packages/shared-types/package.json
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primora/api-client": "0.2.0",
|
"@primora/api-client": "0.3.0",
|
||||||
"@primora/shared-types": "0.2.0",
|
"@primora/shared-types": "0.3.0",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
"solid-js": "^1.9.12"
|
"solid-js": "^1.9.12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
type AuthUser,
|
type AuthUser,
|
||||||
CollectionsPage,
|
CollectionsPage,
|
||||||
DatabasesPage,
|
DatabasesPage,
|
||||||
|
TelemetryPage,
|
||||||
LoginPage,
|
LoginPage,
|
||||||
MembersPage,
|
MembersPage,
|
||||||
ProjectsPage,
|
ProjectsPage,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
IconMembers,
|
IconMembers,
|
||||||
IconStorage,
|
IconStorage,
|
||||||
IconDatabases,
|
IconDatabases,
|
||||||
|
IconTelemetry,
|
||||||
IconCollections,
|
IconCollections,
|
||||||
IconAudit,
|
IconAudit,
|
||||||
IconAuth,
|
IconAuth,
|
||||||
@@ -1411,6 +1413,7 @@ export default function App() {
|
|||||||
{ id: "nav-projects", label: "Go to Projects", category: "Navigate", icon: <IconProjects class="w-4 h-4" />, keywords: ["project"], action: () => setActiveView("projects") },
|
{ id: "nav-projects", label: "Go to Projects", category: "Navigate", icon: <IconProjects class="w-4 h-4" />, keywords: ["project"], action: () => setActiveView("projects") },
|
||||||
{ id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") },
|
{ id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") },
|
||||||
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
|
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
|
||||||
|
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
|
||||||
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
||||||
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
||||||
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
||||||
@@ -1624,6 +1627,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeView() === "telemetry"}>
|
||||||
|
<TelemetryPage
|
||||||
|
projectID={activeProject()?.id}
|
||||||
|
canManage={canUpdateProject()}
|
||||||
|
demoMode={isDemo}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={activeView() === "storage"}>
|
<Show when={activeView() === "storage"}>
|
||||||
<StoragePage
|
<StoragePage
|
||||||
buckets={buckets()}
|
buckets={buckets()}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconStorage,
|
IconStorage,
|
||||||
IconCollections,
|
IconCollections,
|
||||||
IconDatabases,
|
IconDatabases,
|
||||||
|
IconTelemetry,
|
||||||
IconAudit,
|
IconAudit,
|
||||||
IconAuth,
|
IconAuth,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -26,6 +27,7 @@ export type ViewType =
|
|||||||
| "storage"
|
| "storage"
|
||||||
| "collections"
|
| "collections"
|
||||||
| "databases"
|
| "databases"
|
||||||
|
| "telemetry"
|
||||||
| "auth"
|
| "auth"
|
||||||
| "audit"
|
| "audit"
|
||||||
| "settings";
|
| "settings";
|
||||||
@@ -40,14 +42,15 @@ interface NavEntry {
|
|||||||
const projectNav: NavEntry[] = [
|
const projectNav: NavEntry[] = [
|
||||||
{ id: "dashboard", label: "Overview", icon: IconOverview },
|
{ id: "dashboard", label: "Overview", icon: IconOverview },
|
||||||
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
|
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
|
||||||
|
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||||
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
|
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
|
||||||
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
|
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Instagram-style bottom bar caps at five slots — Collections and Audit stay reachable via Menu.
|
// Instagram-style bottom bar caps at five slots — Collections and Telemetry stay reachable via Menu.
|
||||||
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections");
|
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections" && e.id !== "telemetry");
|
||||||
|
|
||||||
const workspaceNav: NavEntry[] = [
|
const workspaceNav: NavEntry[] = [
|
||||||
{ id: "projects", label: "Projects", icon: IconProjects },
|
{ id: "projects", label: "Projects", icon: IconProjects },
|
||||||
@@ -84,6 +87,7 @@ const viewTitle: Record<ViewType, string> = {
|
|||||||
storage: "Storage",
|
storage: "Storage",
|
||||||
collections: "Collections",
|
collections: "Collections",
|
||||||
databases: "Databases",
|
databases: "Databases",
|
||||||
|
telemetry: "Telemetry",
|
||||||
auth: "Authentication",
|
auth: "Authentication",
|
||||||
audit: "Audit log",
|
audit: "Audit log",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export const IconSun = icon(() => (<><circle cx="12" cy="12" r="4" /><path d="M1
|
|||||||
export const IconMoon = icon(() => (<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />));
|
export const IconMoon = icon(() => (<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />));
|
||||||
export const IconLayers = icon(() => (<><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></>));
|
export const IconLayers = icon(() => (<><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></>));
|
||||||
export const IconZap = icon(() => (<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />));
|
export const IconZap = icon(() => (<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />));
|
||||||
|
export const IconTelemetry = icon(() => (<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />));
|
||||||
export const IconClock = icon(() => (<><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>));
|
export const IconClock = icon(() => (<><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>));
|
||||||
export const IconLink = icon(() => (<><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></>));
|
export const IconLink = icon(() => (<><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></>));
|
||||||
export const IconOrganization = icon(() => (<><rect x="4" y="2" width="16" height="20" rx="2" /><path d="M9 22v-4h6v4" /><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M8 10h.01M16 10h.01M8 14h.01M16 14h.01M12 14h.01" /></>));
|
export const IconOrganization = icon(() => (<><rect x="4" y="2" width="16" height="20" rx="2" /><path d="M9 22v-4h6v4" /><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M8 10h.01M16 10h.01M8 14h.01M16 14h.01M12 14h.01" /></>));
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ import type {
|
|||||||
DBConnection,
|
DBConnection,
|
||||||
Document,
|
Document,
|
||||||
MeResponse,
|
MeResponse,
|
||||||
|
MetricPoint,
|
||||||
OrganizationSummary,
|
OrganizationSummary,
|
||||||
ProjectOverview,
|
ProjectOverview,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
|
TelemetryComponent,
|
||||||
|
TelemetryEvent,
|
||||||
|
TelemetryIssue,
|
||||||
|
TelemetryStats,
|
||||||
} from "@primora/api-client";
|
} from "@primora/api-client";
|
||||||
|
|
||||||
const STORAGE_KEY = "primora_demo_mode";
|
const STORAGE_KEY = "primora_demo_mode";
|
||||||
@@ -353,6 +358,23 @@ let dbConnections: DBConnection[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const demoComponents: TelemetryComponent[] = [
|
||||||
|
{ id: "cmp-1", name: "web", kind: "frontend", last_seen_at: ago(0.02), last_status: "up", errors_24h: 0, events_24h: 88, meta: {} },
|
||||||
|
{ id: "cmp-2", name: "api", kind: "backend", last_seen_at: ago(0.014), last_status: "degraded", errors_24h: 4, events_24h: 120, meta: {} },
|
||||||
|
{ id: "cmp-3", name: "worker", kind: "backend", last_seen_at: ago(0.03), last_status: "up", errors_24h: 0, events_24h: 60, meta: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
const demoEventsSeed: TelemetryEvent[] = [
|
||||||
|
{ id: 42, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "error", severity: "error", message: "TypeError: Cannot read properties of undefined (reading 'id')", payload: { stack: "at getUser (api/src/users.ts:41)" }, fingerprint: "a1b2c3d4e5f60718", ts: ago(0.013) },
|
||||||
|
{ id: 41, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "metric", severity: "info", message: "", payload: { name: "http.req.ms", value: 132, route: "/projects" }, fingerprint: "", ts: ago(0.02) },
|
||||||
|
{ id: 40, project_id: "proj-1", component_id: "cmp-1", component_name: "web", type: "log", severity: "warn", message: "Slow route render: /settings", payload: { ms: 940 }, fingerprint: "", ts: ago(0.03) },
|
||||||
|
{ id: 39, project_id: "proj-1", component_id: "cmp-3", component_name: "worker", type: "heartbeat", severity: "info", message: "", payload: { status: "up" }, fingerprint: "", ts: ago(0.04) },
|
||||||
|
{ id: 38, project_id: "proj-1", component_id: "cmp-1", component_name: "web", type: "event", severity: "info", message: "checkout.completed", payload: { total: 129 }, fingerprint: "", ts: ago(0.06) },
|
||||||
|
{ id: 37, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "error", severity: "error", message: "TypeError: Cannot read properties of undefined (reading 'id')", payload: { stack: "at getUser (api/src/users.ts:41)" }, fingerprint: "a1b2c3d4e5f60718", ts: ago(3.5) },
|
||||||
|
];
|
||||||
|
|
||||||
|
let demoEvents: TelemetryEvent[] = [...demoEventsSeed];
|
||||||
|
|
||||||
const demoTables = [
|
const demoTables = [
|
||||||
"users",
|
"users",
|
||||||
"organizations",
|
"organizations",
|
||||||
@@ -881,6 +903,120 @@ class DemoService {
|
|||||||
const cmd = data.requestBody?.command ?? "";
|
const cmd = data.requestBody?.command ?? "";
|
||||||
return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" };
|
return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- telemetry ---- */
|
||||||
|
|
||||||
|
async ingestTelemetry(data: { requestBody?: { events?: Array<Record<string, unknown>> } }) {
|
||||||
|
await this.delay();
|
||||||
|
const events = data.requestBody?.events ?? [];
|
||||||
|
events.forEach((e) => {
|
||||||
|
demoEvents = [
|
||||||
|
{
|
||||||
|
id: (demoEvents[0]?.id ?? 0) + 1,
|
||||||
|
project_id: "proj-1",
|
||||||
|
component_id: demoComponents.find((c) => c.name === e.component)?.id ?? null,
|
||||||
|
component_name: String(e.component ?? ""),
|
||||||
|
type: String(e.type ?? "event"),
|
||||||
|
severity: String(e.severity ?? "info"),
|
||||||
|
message: String(e.message ?? ""),
|
||||||
|
payload: (e.payload as Record<string, unknown>) ?? {},
|
||||||
|
fingerprint: String(e.fingerprint ?? ""),
|
||||||
|
ts: String(e.ts ?? new Date().toISOString()),
|
||||||
|
},
|
||||||
|
...demoEvents,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return { accepted: events.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTelemetryEvents(data: { type?: string; component?: string; fingerprint?: string; limit?: number }) {
|
||||||
|
await this.delay();
|
||||||
|
let items = demoEvents;
|
||||||
|
if (data.type) items = items.filter((e) => e.type === data.type);
|
||||||
|
if (data.component) items = items.filter((e) => e.component_name === data.component);
|
||||||
|
if (data.fingerprint) items = items.filter((e) => e.fingerprint === data.fingerprint);
|
||||||
|
return { items: items.slice(0, data.limit ?? 100) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTelemetryIssues() {
|
||||||
|
await this.delay();
|
||||||
|
const groups = new Map<string, TelemetryIssue>();
|
||||||
|
for (const e of demoEvents.filter((e) => e.type === "error" && e.fingerprint)) {
|
||||||
|
const fp = e.fingerprint!;
|
||||||
|
const g = groups.get(fp);
|
||||||
|
if (g) {
|
||||||
|
g.count += 1;
|
||||||
|
if (e.ts < g.first_seen) g.first_seen = e.ts;
|
||||||
|
if (e.ts > g.last_seen) g.last_seen = e.ts;
|
||||||
|
} else {
|
||||||
|
groups.set(fp, {
|
||||||
|
fingerprint: fp,
|
||||||
|
message: e.message,
|
||||||
|
component_id: e.component_id,
|
||||||
|
component_name: e.component_name,
|
||||||
|
count: 1,
|
||||||
|
first_seen: e.ts,
|
||||||
|
last_seen: e.ts,
|
||||||
|
severity: e.severity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { items: [...groups.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTelemetryComponents() {
|
||||||
|
await this.delay();
|
||||||
|
return { items: demoComponents };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTelemetryComponent(data: { componentId?: string }) {
|
||||||
|
await this.delay();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTelemetryStats(data: { window?: string }): Promise<TelemetryStats> {
|
||||||
|
await this.delay();
|
||||||
|
const now = Date.now();
|
||||||
|
const bucket = 900_000;
|
||||||
|
const series = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
ts: new Date(now - (11 - i) * bucket).toISOString(),
|
||||||
|
counts: {
|
||||||
|
error: i === 9 ? 2 : i % 5 === 0 ? 1 : 0,
|
||||||
|
event: i % 3,
|
||||||
|
metric: i * 2 + 4,
|
||||||
|
log: i % 4,
|
||||||
|
heartbeat: 4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
window: 86400,
|
||||||
|
bucket_sec: 900,
|
||||||
|
series,
|
||||||
|
components: demoComponents,
|
||||||
|
errors: demoEvents.filter((e) => e.type === "error").length,
|
||||||
|
events: demoEvents.length,
|
||||||
|
metrics: 46,
|
||||||
|
metric_names: ["http.req.ms", "db.query.ms"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTelemetryMetricSeries(): Promise<{ items: MetricPoint[] }> {
|
||||||
|
await this.delay();
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
items: Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const avg = 110 + Math.sin(i / 2) * 40;
|
||||||
|
return {
|
||||||
|
ts: new Date(now - (11 - i) * 900_000).toISOString(),
|
||||||
|
avg,
|
||||||
|
p50: avg * 0.9,
|
||||||
|
p95: avg * 1.8,
|
||||||
|
max: avg * 2.2,
|
||||||
|
count: 12 + i,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const demoService = new DemoService();
|
export const demoService = new DemoService();
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||||
|
import {
|
||||||
|
OpenAPI,
|
||||||
|
TelemetryService,
|
||||||
|
type MetricPoint,
|
||||||
|
type TelemetryEvent,
|
||||||
|
type TelemetryIssue,
|
||||||
|
type TelemetryStats,
|
||||||
|
} from "@primora/api-client";
|
||||||
|
import { fetchApiToken } from "../lib/auth-client";
|
||||||
|
import { demoService } from "../lib/demo-mode";
|
||||||
|
import { Badge } from "../components/Badge";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
import { Select } from "../components/Input";
|
||||||
|
import {
|
||||||
|
IconChevronRight,
|
||||||
|
IconRefresh,
|
||||||
|
} from "../components/Icons";
|
||||||
|
|
||||||
|
interface TelemetryPageProps {
|
||||||
|
projectID?: string;
|
||||||
|
canManage: boolean;
|
||||||
|
demoMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = "health" | "issues" | "events" | "metrics" | "integrate";
|
||||||
|
|
||||||
|
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
|
const svc = (demo: boolean) =>
|
||||||
|
(demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService);
|
||||||
|
|
||||||
|
const sevVariant = (s?: string) =>
|
||||||
|
s === "error" || s === "fatal" ? "error" : s === "warn" || s === "warning" ? "warning" : s === "info" ? "primary" : "neutral";
|
||||||
|
|
||||||
|
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||||
|
|
||||||
|
const snippetWeb = `import { createPrimoraClient } from "@primora/client";
|
||||||
|
|
||||||
|
const primora = createPrimoraClient({
|
||||||
|
endpoint: window.location.origin, // your Primora deployment
|
||||||
|
key: "prm_…", // project API key
|
||||||
|
component: "web",
|
||||||
|
kind: "frontend",
|
||||||
|
});
|
||||||
|
|
||||||
|
primora.installAuto(); // window.onerror + unhandledrejection
|
||||||
|
primora.captureMetric({ name: "app.start.ms", value: performance.now() });`;
|
||||||
|
|
||||||
|
const snippetNode = `import { createPrimoraClient } from "@primora/client";
|
||||||
|
|
||||||
|
const primora = createPrimoraClient({
|
||||||
|
endpoint: "https://primora.example.com",
|
||||||
|
key: process.env.PRIMORA_KEY!, // X-Primora-Key
|
||||||
|
component: "api",
|
||||||
|
kind: "backend",
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => primora.heartbeat("up"), 30_000);
|
||||||
|
process.on("uncaughtException", (e) => primora.captureError(e));`;
|
||||||
|
|
||||||
|
const snippetCurl = `curl -X POST "$ORIGIN/api/v1/ingest" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "X-Primora-Key: prm_…" \\
|
||||||
|
-d '{"type":"event","component":"cli","message":"deploy.done","payload":{"sha":"abc123"}}'`;
|
||||||
|
|
||||||
|
export function TelemetryPage(props: TelemetryPageProps) {
|
||||||
|
const [tab, setTab] = createSignal<Tab>("health");
|
||||||
|
const [windowSel, setWindowSel] = createSignal("24h");
|
||||||
|
const [stats, setStats] = createSignal<TelemetryStats | null>(null);
|
||||||
|
const [issues, setIssues] = createSignal<TelemetryIssue[]>([]);
|
||||||
|
const [events, setEvents] = createSignal<TelemetryEvent[]>([]);
|
||||||
|
const [typeFilter, setTypeFilter] = createSignal("");
|
||||||
|
const [fpFilter, setFpFilter] = createSignal("");
|
||||||
|
const [metricName, setMetricName] = createSignal("");
|
||||||
|
const [metricPoints, setMetricPoints] = createSignal<MetricPoint[]>([]);
|
||||||
|
const [detail, setDetail] = createSignal<TelemetryEvent | null>(null);
|
||||||
|
const [loading, setLoading] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal("");
|
||||||
|
const [live, setLive] = createSignal(false);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (!props.projectID) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const s = svc(props.demoMode);
|
||||||
|
const [st, iss, ev] = await Promise.all([
|
||||||
|
s.getTelemetryStats({ projectId: props.projectID, window: windowSel() as "1h" | "24h" | "7d" | "30d" }),
|
||||||
|
s.listTelemetryIssues({ projectId: props.projectID }),
|
||||||
|
s.listTelemetryEvents({
|
||||||
|
projectId: props.projectID,
|
||||||
|
type: (typeFilter() || undefined) as "error" | "metric" | "log" | "heartbeat" | "event" | undefined,
|
||||||
|
fingerprint: fpFilter() || undefined,
|
||||||
|
limit: 100,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setStats(st);
|
||||||
|
setIssues(iss.items ?? []);
|
||||||
|
setEvents(ev.items ?? []);
|
||||||
|
if (!metricName() && st.metric_names?.length) setMetricName(st.metric_names[0]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshMetric = async (name: string) => {
|
||||||
|
if (!props.projectID || !name) {
|
||||||
|
setMetricPoints([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await svc(props.demoMode).getTelemetryMetricSeries({
|
||||||
|
projectId: props.projectID,
|
||||||
|
name,
|
||||||
|
window: windowSel() as "1h" | "24h" | "7d" | "30d",
|
||||||
|
});
|
||||||
|
setMetricPoints(res.items ?? []);
|
||||||
|
} catch {
|
||||||
|
setMetricPoints([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.projectID) return;
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
if (props.demoMode) return;
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
void fetchApiToken().then((token) => {
|
||||||
|
if (cancelled || !token || !props.projectID) return;
|
||||||
|
const url = `${OpenAPI.BASE}/projects/${props.projectID}/telemetry/stream?token=${encodeURIComponent(token)}`;
|
||||||
|
es = new EventSource(url);
|
||||||
|
es.addEventListener("event", (m) => {
|
||||||
|
setLive(true);
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse((m as MessageEvent).data) as TelemetryEvent;
|
||||||
|
const tf = typeFilter();
|
||||||
|
const ff = fpFilter();
|
||||||
|
if ((!tf || ev.type === tf) && (!ff || ev.fingerprint === ff)) {
|
||||||
|
setEvents((prev) => [ev, ...prev].slice(0, 200));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed frames */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
es.onerror = () => setLive(false);
|
||||||
|
es.onopen = () => setLive(true);
|
||||||
|
});
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true;
|
||||||
|
es?.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void refreshMetric(metricName());
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeCounts = createMemo(() => {
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
for (const b of stats()?.series ?? []) {
|
||||||
|
for (const [t, n] of Object.entries(b.counts ?? {})) {
|
||||||
|
totals[t] = (totals[t] ?? 0) + n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxBucket = createMemo(() =>
|
||||||
|
Math.max(1, ...(stats()?.series ?? []).map((b) => Object.values(b.counts ?? {}).reduce((a, n) => a + n, 0))),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Telemetry</h1>
|
||||||
|
<p class="page-description">Errors, metrics, logs, and heartbeats across your components.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Show when={live()}>
|
||||||
|
<Badge variant="success">Live</Badge>
|
||||||
|
</Show>
|
||||||
|
<Select
|
||||||
|
value={windowSel()}
|
||||||
|
onChange={(e) => {
|
||||||
|
setWindowSel(e.currentTarget.value);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="1h">1h</option>
|
||||||
|
<option value="24h">24h</option>
|
||||||
|
<option value="7d">7d</option>
|
||||||
|
<option value="30d">30d</option>
|
||||||
|
</Select>
|
||||||
|
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
|
||||||
|
<IconRefresh class="w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={error()}>
|
||||||
|
<div class="message message-error">{error()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex gap-1 mb-4 flex-wrap">
|
||||||
|
<For each={["health", "issues", "events", "metrics", "integrate"] as Tab[]}>
|
||||||
|
{(t) => (
|
||||||
|
<button
|
||||||
|
class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${tab() === t ? "bg-surface-2 text-text-1" : "text-text-2 hover:text-text-1"}`}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
>
|
||||||
|
{t === "issues" ? `Issues (${issues().length})` : t[0].toUpperCase() + t.slice(1)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={tab() === "health"}>
|
||||||
|
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr))">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-xs text-text-2">Errors</div>
|
||||||
|
<div class="text-2xl font-semibold">{stats()?.errors ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-xs text-text-2">Events</div>
|
||||||
|
<div class="text-2xl font-semibold">{stats()?.events ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="text-xs text-text-2">Metrics</div>
|
||||||
|
<div class="text-2xl font-semibold">{stats()?.metrics ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-flush mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-header-title">Activity</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 flex items-end gap-1 overflow-x-auto" style="min-height: 6rem">
|
||||||
|
<For each={stats()?.series ?? []}>
|
||||||
|
{(b) => {
|
||||||
|
const total = () => Object.values(b.counts ?? {}).reduce((a, n) => a + n, 0);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="flex-1 rounded-sm"
|
||||||
|
style={{
|
||||||
|
"min-width": "6px",
|
||||||
|
height: `${Math.max(4, (total() / maxBucket()) * 80)}px`,
|
||||||
|
background: (b.counts?.error ?? 0) > 0 ? "var(--error, #e5484d)" : "var(--accent)",
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
title={`${new Date(b.ts).toLocaleString()} — ${total()} events`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 pb-3 flex gap-3 flex-wrap text-xs text-text-2">
|
||||||
|
<For each={Object.entries(typeCounts())}>
|
||||||
|
{([t, n]) => (
|
||||||
|
<span>
|
||||||
|
{t}: {n}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-flush mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-header-title">Components</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Errors 24h</th>
|
||||||
|
<th>Events 24h</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For
|
||||||
|
each={stats()?.components ?? []}
|
||||||
|
fallback={
|
||||||
|
<tr>
|
||||||
|
<td colspan={6} class="text-center text-text-2 py-6">
|
||||||
|
No components yet — send an event with a `component` field.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(c) => (
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{c.name}</td>
|
||||||
|
<td>{c.kind}</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant={c.last_status === "up" ? "success" : c.last_status === "down" ? "error" : c.last_status === "degraded" ? "warning" : "neutral"}>
|
||||||
|
{c.last_status || "unknown"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td>{c.errors_24h}</td>
|
||||||
|
<td>{c.events_24h}</td>
|
||||||
|
<td class="text-text-2">{fmtTime(c.last_seen_at)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={tab() === "issues"}>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Error</th>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Count</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For
|
||||||
|
each={issues()}
|
||||||
|
fallback={
|
||||||
|
<tr>
|
||||||
|
<td colspan={6} class="text-center text-text-2 py-6">
|
||||||
|
No error groups in the last 30 days.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<tr>
|
||||||
|
<td class="max-w-0 truncate" style="max-width: 24rem" title={i.message}>
|
||||||
|
{i.message}
|
||||||
|
</td>
|
||||||
|
<td>{i.component_name || "—"}</td>
|
||||||
|
<td>{i.count}</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant={sevVariant(i.severity)}>{i.severity}</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="text-text-2">{fmtTime(i.last_seen)}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setFpFilter(i.fingerprint);
|
||||||
|
setTab("events");
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconChevronRight class="w-4 h-4" />
|
||||||
|
Events
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={tab() === "events"}>
|
||||||
|
<div class="flex gap-2 mb-3 flex-wrap items-center">
|
||||||
|
<For each={["", "error", "metric", "log", "heartbeat", "event"]}>
|
||||||
|
{(t) => (
|
||||||
|
<button
|
||||||
|
class={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${typeFilter() === t ? "bg-accent text-white" : "bg-surface-2 text-text-2 hover:text-text-1"}`}
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter(t);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t || "all"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={fpFilter()}>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 rounded-lg text-xs font-medium bg-surface-2 text-text-1"
|
||||||
|
onClick={() => {
|
||||||
|
setFpFilter("");
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
fingerprint: {fpFilter().slice(0, 8)}… ✕
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For
|
||||||
|
each={events()}
|
||||||
|
fallback={
|
||||||
|
<tr>
|
||||||
|
<td colspan={5} class="text-center text-text-2 py-6">
|
||||||
|
No events.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(e) => (
|
||||||
|
<tr class="cursor-pointer" onClick={() => setDetail(e)}>
|
||||||
|
<td>
|
||||||
|
<Badge variant={e.type === "error" ? "error" : "neutral"}>{e.type}</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant={sevVariant(e.severity)}>{e.severity}</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="max-w-0 truncate" style="max-width: 22rem" title={e.message}>
|
||||||
|
{e.message || <span class="text-text-2">{e.payload?.name ? `${e.payload.name} = ${e.payload.value}` : JSON.stringify(e.payload)}</span>}
|
||||||
|
</td>
|
||||||
|
<td>{e.component_name || "—"}</td>
|
||||||
|
<td class="text-text-2 whitespace-nowrap">{fmtTime(e.ts)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={tab() === "metrics"}>
|
||||||
|
<div class="flex gap-2 mb-3 flex-wrap">
|
||||||
|
<For
|
||||||
|
each={stats()?.metric_names ?? []}
|
||||||
|
fallback={<span class="text-text-2 text-sm">No metric names seen yet.</span>}
|
||||||
|
>
|
||||||
|
{(n) => (
|
||||||
|
<button
|
||||||
|
class={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${metricName() === n ? "bg-accent text-white" : "bg-surface-2 text-text-2 hover:text-text-1"}`}
|
||||||
|
onClick={() => setMetricName(n)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bucket</th>
|
||||||
|
<th>Avg</th>
|
||||||
|
<th>p50</th>
|
||||||
|
<th>p95</th>
|
||||||
|
<th>Max</th>
|
||||||
|
<th>Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For
|
||||||
|
each={metricPoints()}
|
||||||
|
fallback={
|
||||||
|
<tr>
|
||||||
|
<td colspan={6} class="text-center text-text-2 py-6">
|
||||||
|
Select a metric name.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(p) => (
|
||||||
|
<tr>
|
||||||
|
<td class="text-text-2 whitespace-nowrap">{fmtTime(p.ts)}</td>
|
||||||
|
<td>{p.avg?.toFixed(1) ?? "—"}</td>
|
||||||
|
<td>{p.p50?.toFixed(1) ?? "—"}</td>
|
||||||
|
<td>{p.p95?.toFixed(1) ?? "—"}</td>
|
||||||
|
<td>{p.max?.toFixed(1) ?? "—"}</td>
|
||||||
|
<td>{p.count}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={tab() === "integrate"}>
|
||||||
|
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr))">
|
||||||
|
<For each={[{ title: "Web / browser", code: snippetWeb }, { title: "Node / server", code: snippetNode }, { title: "cURL", code: snippetCurl }]}>
|
||||||
|
{(s) => (
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-header-title">{s.title}</span>
|
||||||
|
</div>
|
||||||
|
<pre class="p-4 text-xs overflow-x-auto" style="white-space: pre">{s.code}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-text-2 mt-4">
|
||||||
|
Create a project API key under Settings, then pass it as <code>X-Primora-Key</code> to <code>POST /api/v1/ingest</code>. Batches of up to 500 events are accepted.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={detail() !== null}
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
title={detail()?.message || detail()?.type || "Event"}
|
||||||
|
>
|
||||||
|
<Show when={detail()}>
|
||||||
|
{(e) => (
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<Badge variant={e().type === "error" ? "error" : "neutral"}>{e().type}</Badge>
|
||||||
|
<Badge variant={sevVariant(e().severity)}>{e().severity}</Badge>
|
||||||
|
<Show when={e().component_name}>
|
||||||
|
<Badge variant="neutral">{e().component_name}</Badge>
|
||||||
|
</Show>
|
||||||
|
<Show when={e().fingerprint}>
|
||||||
|
<Badge variant="neutral">fp {e().fingerprint!.slice(0, 8)}</Badge>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-text-2">{fmtTime(e().ts)}</div>
|
||||||
|
<pre class="text-xs p-3 rounded-lg bg-surface-2 overflow-x-auto" style="white-space: pre-wrap">
|
||||||
|
{JSON.stringify(e().payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export { MembersPage } from "./MembersPage";
|
|||||||
export { StoragePage } from "./StoragePage";
|
export { StoragePage } from "./StoragePage";
|
||||||
export { CollectionsPage } from "./CollectionsPage";
|
export { CollectionsPage } from "./CollectionsPage";
|
||||||
export { DatabasesPage } from "./DatabasesPage";
|
export { DatabasesPage } from "./DatabasesPage";
|
||||||
|
export { TelemetryPage } from "./TelemetryPage";
|
||||||
export { SettingsPage } from "./SettingsPage";
|
export { SettingsPage } from "./SettingsPage";
|
||||||
export { AuditPage } from "./AuditPage";
|
export { AuditPage } from "./AuditPage";
|
||||||
export { AuthPage, type AuthUser } from "./AuthPage";
|
export { AuthPage, type AuthUser } from "./AuthPage";
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primora/api-client": "0.2.0",
|
"@primora/api-client": "0.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
PlatformService,
|
PlatformService,
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
StorageService,
|
StorageService,
|
||||||
|
TelemetryService,
|
||||||
} from "@primora/api-client";
|
} from "@primora/api-client";
|
||||||
|
|
||||||
import { configureClient, loadConfig, type McpConfig } from "./config.js";
|
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());
|
await server.connect(new StdioServerTransport());
|
||||||
Generated
+20
-12
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "primora",
|
"name": "primora",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "primora",
|
"name": "primora",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"apps/auth": {
|
"apps/auth": {
|
||||||
"name": "@primora/auth",
|
"name": "@primora/auth",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.4",
|
"@hono/node-server": "^1.19.4",
|
||||||
"@hono/zod-validator": "^0.7.2",
|
"@hono/zod-validator": "^0.7.2",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"apps/cli": {
|
"apps/cli": {
|
||||||
"name": "@primora/cli",
|
"name": "@primora/cli",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.8.1",
|
"@clack/prompts": "^1.8.1",
|
||||||
"cac": "^7.0.0",
|
"cac": "^7.0.0",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"primora": "dist/cli.js"
|
"primora": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primora/api-client": "0.2.0",
|
"@primora/api-client": "0.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
@@ -67,10 +67,10 @@
|
|||||||
},
|
},
|
||||||
"apps/frontend": {
|
"apps/frontend": {
|
||||||
"name": "@primora/frontend",
|
"name": "@primora/frontend",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primora/api-client": "0.2.0",
|
"@primora/api-client": "0.3.0",
|
||||||
"@primora/shared-types": "0.2.0",
|
"@primora/shared-types": "0.3.0",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
"solid-js": "^1.9.12"
|
"solid-js": "^1.9.12"
|
||||||
},
|
},
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
},
|
},
|
||||||
"apps/mcp": {
|
"apps/mcp": {
|
||||||
"name": "@primora/mcp",
|
"name": "@primora/mcp",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
"primora-mcp": "dist/server.js"
|
"primora-mcp": "dist/server.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primora/api-client": "0.2.0",
|
"@primora/api-client": "0.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
@@ -1353,6 +1353,10 @@
|
|||||||
"resolved": "apps/cli",
|
"resolved": "apps/cli",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@primora/client": {
|
||||||
|
"resolved": "packages/client",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@primora/frontend": {
|
"node_modules/@primora/frontend": {
|
||||||
"resolved": "apps/frontend",
|
"resolved": "apps/frontend",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -6783,7 +6787,7 @@
|
|||||||
},
|
},
|
||||||
"packages/api-client": {
|
"packages/api-client": {
|
||||||
"name": "@primora/api-client",
|
"name": "@primora/api-client",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-fetch": "^4.1.0"
|
"cross-fetch": "^4.1.0"
|
||||||
},
|
},
|
||||||
@@ -6791,9 +6795,13 @@
|
|||||||
"openapi-typescript-codegen": "^0.29.0"
|
"openapi-typescript-codegen": "^0.29.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/client": {
|
||||||
|
"name": "@primora/client",
|
||||||
|
"version": "0.3.0"
|
||||||
|
},
|
||||||
"packages/shared-types": {
|
"packages/shared-types": {
|
||||||
"name": "@primora/shared-types",
|
"name": "@primora/shared-types",
|
||||||
"version": "0.2.0"
|
"version": "0.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,8 @@ export type { BucketObjectListResponse } from './models/BucketObjectListResponse
|
|||||||
export type { Collection } from './models/Collection';
|
export type { Collection } from './models/Collection';
|
||||||
export type { CollectionID } from './models/CollectionID';
|
export type { CollectionID } from './models/CollectionID';
|
||||||
export type { CollectionListResponse } from './models/CollectionListResponse';
|
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 { ConnectionID } from './models/ConnectionID';
|
||||||
export type { CopyBucketObjectRequest } from './models/CopyBucketObjectRequest';
|
export type { CopyBucketObjectRequest } from './models/CopyBucketObjectRequest';
|
||||||
export type { CreateApiKeyRequest } from './models/CreateApiKeyRequest';
|
export type { CreateApiKeyRequest } from './models/CreateApiKeyRequest';
|
||||||
@@ -55,10 +57,16 @@ export type { Document } from './models/Document';
|
|||||||
export type { DocumentID } from './models/DocumentID';
|
export type { DocumentID } from './models/DocumentID';
|
||||||
export type { DocumentListResponse } from './models/DocumentListResponse';
|
export type { DocumentListResponse } from './models/DocumentListResponse';
|
||||||
export type { ErrorEnvelope } from './models/ErrorEnvelope';
|
export type { ErrorEnvelope } from './models/ErrorEnvelope';
|
||||||
|
export type { EventSeriesBucket } from './models/EventSeriesBucket';
|
||||||
export type { HealthStatus } from './models/HealthStatus';
|
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 { Invitation } from './models/Invitation';
|
||||||
export type { InvitationID } from './models/InvitationID';
|
export type { InvitationID } from './models/InvitationID';
|
||||||
export type { MeResponse } from './models/MeResponse';
|
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 { ObjectKey } from './models/ObjectKey';
|
||||||
export type { OrganizationID } from './models/OrganizationID';
|
export type { OrganizationID } from './models/OrganizationID';
|
||||||
export { OrganizationInvitation } from './models/OrganizationInvitation';
|
export { OrganizationInvitation } from './models/OrganizationInvitation';
|
||||||
@@ -76,6 +84,13 @@ export type { ProjectSummary } from './models/ProjectSummary';
|
|||||||
export type { ReadinessStatus } from './models/ReadinessStatus';
|
export type { ReadinessStatus } from './models/ReadinessStatus';
|
||||||
export type { SchemaQuery } from './models/SchemaQuery';
|
export type { SchemaQuery } from './models/SchemaQuery';
|
||||||
export type { TableQuery } from './models/TableQuery';
|
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 type { UpdateBucketObjectRequest } from './models/UpdateBucketObjectRequest';
|
||||||
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
|
export { UpdateBucketRequest } from './models/UpdateBucketRequest';
|
||||||
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
|
export type { UpdateCollectionRequest } from './models/UpdateCollectionRequest';
|
||||||
@@ -103,6 +118,8 @@ export { $BucketObjectListResponse } from './schemas/$BucketObjectListResponse';
|
|||||||
export { $Collection } from './schemas/$Collection';
|
export { $Collection } from './schemas/$Collection';
|
||||||
export { $CollectionID } from './schemas/$CollectionID';
|
export { $CollectionID } from './schemas/$CollectionID';
|
||||||
export { $CollectionListResponse } from './schemas/$CollectionListResponse';
|
export { $CollectionListResponse } from './schemas/$CollectionListResponse';
|
||||||
|
export { $ComponentHealth } from './schemas/$ComponentHealth';
|
||||||
|
export { $ComponentID } from './schemas/$ComponentID';
|
||||||
export { $ConnectionID } from './schemas/$ConnectionID';
|
export { $ConnectionID } from './schemas/$ConnectionID';
|
||||||
export { $CopyBucketObjectRequest } from './schemas/$CopyBucketObjectRequest';
|
export { $CopyBucketObjectRequest } from './schemas/$CopyBucketObjectRequest';
|
||||||
export { $CreateApiKeyRequest } from './schemas/$CreateApiKeyRequest';
|
export { $CreateApiKeyRequest } from './schemas/$CreateApiKeyRequest';
|
||||||
@@ -135,10 +152,16 @@ export { $Document } from './schemas/$Document';
|
|||||||
export { $DocumentID } from './schemas/$DocumentID';
|
export { $DocumentID } from './schemas/$DocumentID';
|
||||||
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
||||||
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
||||||
|
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
|
||||||
export { $HealthStatus } from './schemas/$HealthStatus';
|
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 { $Invitation } from './schemas/$Invitation';
|
||||||
export { $InvitationID } from './schemas/$InvitationID';
|
export { $InvitationID } from './schemas/$InvitationID';
|
||||||
export { $MeResponse } from './schemas/$MeResponse';
|
export { $MeResponse } from './schemas/$MeResponse';
|
||||||
|
export { $MetricPoint } from './schemas/$MetricPoint';
|
||||||
|
export { $MetricSeriesResponse } from './schemas/$MetricSeriesResponse';
|
||||||
export { $ObjectKey } from './schemas/$ObjectKey';
|
export { $ObjectKey } from './schemas/$ObjectKey';
|
||||||
export { $OrganizationID } from './schemas/$OrganizationID';
|
export { $OrganizationID } from './schemas/$OrganizationID';
|
||||||
export { $OrganizationInvitation } from './schemas/$OrganizationInvitation';
|
export { $OrganizationInvitation } from './schemas/$OrganizationInvitation';
|
||||||
@@ -156,6 +179,13 @@ export { $ProjectSummary } from './schemas/$ProjectSummary';
|
|||||||
export { $ReadinessStatus } from './schemas/$ReadinessStatus';
|
export { $ReadinessStatus } from './schemas/$ReadinessStatus';
|
||||||
export { $SchemaQuery } from './schemas/$SchemaQuery';
|
export { $SchemaQuery } from './schemas/$SchemaQuery';
|
||||||
export { $TableQuery } from './schemas/$TableQuery';
|
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 { $UpdateBucketObjectRequest } from './schemas/$UpdateBucketObjectRequest';
|
||||||
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
|
export { $UpdateBucketRequest } from './schemas/$UpdateBucketRequest';
|
||||||
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
|
export { $UpdateCollectionRequest } from './schemas/$UpdateCollectionRequest';
|
||||||
@@ -174,3 +204,4 @@ export { OrganizationsService } from './services/OrganizationsService';
|
|||||||
export { PlatformService } from './services/PlatformService';
|
export { PlatformService } from './services/PlatformService';
|
||||||
export { ProjectsService } from './services/ProjectsService';
|
export { ProjectsService } from './services/ProjectsService';
|
||||||
export { StorageService } from './services/StorageService';
|
export { StorageService } from './services/StorageService';
|
||||||
|
export { TelemetryService } from './services/TelemetryService';
|
||||||
@@ -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<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type ComponentID = string;
|
||||||
@@ -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<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<string, any>;
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<IngestEvent>;
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type IngestResponse = {
|
||||||
|
accepted: number;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<MetricPoint>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<TelemetryComponent>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<string, any>;
|
||||||
|
fingerprint?: string;
|
||||||
|
ts: string;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<TelemetryEvent>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<TelemetryIssue>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<EventSeriesBucket>;
|
||||||
|
components: Array<ComponentHealth>;
|
||||||
|
errors: number;
|
||||||
|
events: number;
|
||||||
|
metrics: number;
|
||||||
|
metric_names: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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<IngestResponse> {
|
||||||
|
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<TelemetryEventListResponse> {
|
||||||
|
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<TelemetryIssueListResponse> {
|
||||||
|
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<TelemetryComponentListResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<TelemetryStats> {
|
||||||
|
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<MetricSeriesResponse> {
|
||||||
|
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<any> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/projects/{projectID}/telemetry/stream',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
'api_key': apiKey,
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
401: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@primora/client",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./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<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