From 8cfddf91847f2e7a28e77f29ddfff7cd0e21e539 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 16 Sep 2026 16:21:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20phase=203=20observability=20=E2=80=94?= =?UTF-8?q?=20IMS=20telemetry=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- CHANGELOG.md | 16 + ROADMAP.md | 25 +- .../backend/db/migrations/00004_telemetry.sql | 32 + apps/backend/db/queries/telemetry.sql | 102 ++++ apps/backend/internal/database/db/models.go | 21 + apps/backend/internal/database/db/querier.go | 12 + .../internal/database/db/telemetry.sql.go | 504 ++++++++++++++++ apps/backend/internal/handlers/http.go | 9 + apps/backend/internal/handlers/telemetry.go | 204 +++++++ apps/backend/internal/middleware/context.go | 13 + apps/backend/internal/services/hub.go | 50 ++ .../internal/services/platform_service.go | 3 +- apps/backend/internal/services/telemetry.go | 459 +++++++++++++++ apps/backend/openapi/openapi.yaml | 436 ++++++++++++++ apps/cli/package.json | 2 +- apps/frontend/Dockerfile | 1 + apps/frontend/package.json | 4 +- apps/frontend/src/App.tsx | 11 + apps/frontend/src/components/AppShell.tsx | 8 +- apps/frontend/src/components/Icons.tsx | 1 + apps/frontend/src/lib/demo-mode.ts | 136 +++++ apps/frontend/src/pages/TelemetryPage.tsx | 554 ++++++++++++++++++ apps/frontend/src/pages/index.ts | 1 + apps/mcp/package.json | 2 +- apps/mcp/src/server.ts | 91 +++ package-lock.json | 32 +- packages/api-client/src/generated/index.ts | 31 + .../src/generated/models/ComponentHealth.ts | 15 + .../src/generated/models/ComponentID.ts | 5 + .../src/generated/models/EventSeriesBucket.ts | 9 + .../src/generated/models/IngestEvent.ts | 39 ++ .../src/generated/models/IngestRequest.ts | 9 + .../src/generated/models/IngestResponse.ts | 8 + .../src/generated/models/MetricPoint.ts | 13 + .../generated/models/MetricSeriesResponse.ts | 9 + .../generated/models/TelemetryComponent.ts | 15 + .../models/TelemetryComponentListResponse.ts | 9 + .../src/generated/models/TelemetryEvent.ts | 17 + .../models/TelemetryEventListResponse.ts | 9 + .../src/generated/models/TelemetryIssue.ts | 15 + .../models/TelemetryIssueListResponse.ts | 9 + .../src/generated/models/TelemetryStats.ts | 17 + .../src/generated/schemas/$ComponentHealth.ts | 44 ++ .../src/generated/schemas/$ComponentID.ts | 8 + .../generated/schemas/$EventSeriesBucket.ts | 20 + .../src/generated/schemas/$IngestEvent.ts | 40 ++ .../src/generated/schemas/$IngestRequest.ts | 20 + .../src/generated/schemas/$IngestResponse.ts | 12 + .../src/generated/schemas/$MetricPoint.ts | 33 ++ .../schemas/$MetricSeriesResponse.ts | 15 + .../generated/schemas/$TelemetryComponent.ts | 44 ++ .../$TelemetryComponentListResponse.ts | 15 + .../src/generated/schemas/$TelemetryEvent.ts | 52 ++ .../schemas/$TelemetryEventListResponse.ts | 15 + .../src/generated/schemas/$TelemetryIssue.ts | 42 ++ .../schemas/$TelemetryIssueListResponse.ts | 15 + .../src/generated/schemas/$TelemetryStats.ts | 49 ++ .../generated/services/TelemetryService.ts | 225 +++++++ packages/client/package.json | 8 + packages/client/src/index.ts | 109 ++++ 60 files changed, 3694 insertions(+), 30 deletions(-) create mode 100644 apps/backend/db/migrations/00004_telemetry.sql create mode 100644 apps/backend/db/queries/telemetry.sql create mode 100644 apps/backend/internal/database/db/telemetry.sql.go create mode 100644 apps/backend/internal/handlers/telemetry.go create mode 100644 apps/backend/internal/services/hub.go create mode 100644 apps/backend/internal/services/telemetry.go create mode 100644 apps/frontend/src/pages/TelemetryPage.tsx create mode 100644 packages/api-client/src/generated/models/ComponentHealth.ts create mode 100644 packages/api-client/src/generated/models/ComponentID.ts create mode 100644 packages/api-client/src/generated/models/EventSeriesBucket.ts create mode 100644 packages/api-client/src/generated/models/IngestEvent.ts create mode 100644 packages/api-client/src/generated/models/IngestRequest.ts create mode 100644 packages/api-client/src/generated/models/IngestResponse.ts create mode 100644 packages/api-client/src/generated/models/MetricPoint.ts create mode 100644 packages/api-client/src/generated/models/MetricSeriesResponse.ts create mode 100644 packages/api-client/src/generated/models/TelemetryComponent.ts create mode 100644 packages/api-client/src/generated/models/TelemetryComponentListResponse.ts create mode 100644 packages/api-client/src/generated/models/TelemetryEvent.ts create mode 100644 packages/api-client/src/generated/models/TelemetryEventListResponse.ts create mode 100644 packages/api-client/src/generated/models/TelemetryIssue.ts create mode 100644 packages/api-client/src/generated/models/TelemetryIssueListResponse.ts create mode 100644 packages/api-client/src/generated/models/TelemetryStats.ts create mode 100644 packages/api-client/src/generated/schemas/$ComponentHealth.ts create mode 100644 packages/api-client/src/generated/schemas/$ComponentID.ts create mode 100644 packages/api-client/src/generated/schemas/$EventSeriesBucket.ts create mode 100644 packages/api-client/src/generated/schemas/$IngestEvent.ts create mode 100644 packages/api-client/src/generated/schemas/$IngestRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$IngestResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$MetricPoint.ts create mode 100644 packages/api-client/src/generated/schemas/$MetricSeriesResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$TelemetryComponent.ts create mode 100644 packages/api-client/src/generated/schemas/$TelemetryComponentListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$TelemetryEvent.ts create mode 100644 packages/api-client/src/generated/schemas/$TelemetryEventListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$TelemetryIssue.ts create mode 100644 packages/api-client/src/generated/schemas/$TelemetryIssueListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$TelemetryStats.ts create mode 100644 packages/api-client/src/generated/services/TelemetryService.ts create mode 100644 packages/client/package.json create mode 100644 packages/client/src/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b130968..2d00bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 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 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 6d00df2..8d707ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 pairing them. -## Phase 3 — Observability (IMS merge) +## Phase 3 — Observability (IMS merge) ✅ IMS (`~/Desktop/PROG+HTML/IMS`) is discontinued and folded in — same stack (Gin + pgx + Postgres), ~2k lines to port. -- **Ingest API** — `POST /v1/ingest` with per-project ingest keys - (`X-Primora-Key`); event types: error, metric, log, heartbeat, event; - component auto-registration. +- **Ingest API** — `POST /api/v1/ingest` authenticated by project `prm_` API keys + (`X-Primora-Key` or `X-API-Key`); event types: error, metric, log, heartbeat, + 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, stack payload) — the Firebase Crashlytics-shaped view. -- **Telemetry views** — metrics, logs, heartbeats per component; live updates - over SSE (port `sse.go`). -- **`@primora/client` SDK** — port of `@ims/client`: one-line web/node - integration, platform snippets in a new project **Integrate** tab. -- **Agent tooling** — `primora_*` MCP tools for issues/events (merge into - `apps/mcp` from Phase 1). -- Migration note in IMS repo → archive, point at Primora. +- **Telemetry views** — health overview (totals, activity histogram, component + health), issues, events, metric series (avg/p50/p95/max), Integrate tab; + live updates over SSE (`GET …/telemetry/stream`, `?api_key=`/`?token=` for + EventSource auth). +- **`@primora/client` SDK** — port of `@ims/client`: queued batching, + `keepalive` flush, `installAuto()` for browser error capture. +- **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 diff --git a/apps/backend/db/migrations/00004_telemetry.sql b/apps/backend/db/migrations/00004_telemetry.sql new file mode 100644 index 0000000..d8399de --- /dev/null +++ b/apps/backend/db/migrations/00004_telemetry.sql @@ -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; diff --git a/apps/backend/db/queries/telemetry.sql b/apps/backend/db/queries/telemetry.sql new file mode 100644 index 0000000..2e1ec5b --- /dev/null +++ b/apps/backend/db/queries/telemetry.sql @@ -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; diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index dba7bf1..6b422ea 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -200,6 +200,15 @@ type CoreCollection struct { 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 { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` @@ -221,6 +230,18 @@ type CoreDocument struct { 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 { ID uuid.UUID `json:"id"` Slug string `json:"slug"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 9358925..095e5e1 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -14,6 +14,7 @@ type Querier interface { AddOrganizationMember(ctx context.Context, arg AddOrganizationMemberParams) (CoreOrganizationMember, error) AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (CoreProjectMember, 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) CountBucketObjects(ctx context.Context, arg CountBucketObjectsParams) (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) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error + DeleteComponent(ctx context.Context, arg DeleteComponentParams) error DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, 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) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) GetCollectionByID(ctx context.Context, id uuid.UUID) (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) GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, 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) GetUserByAuthSubject(ctx context.Context, authSubject string) (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) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error) ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, 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) 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) ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error) ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, 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) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error) @@ -82,6 +93,7 @@ type Querier interface { UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, 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) } diff --git a/apps/backend/internal/database/db/telemetry.sql.go b/apps/backend/internal/database/db/telemetry.sql.go new file mode 100644 index 0000000..c0883b8 --- /dev/null +++ b/apps/backend/internal/database/db/telemetry.sql.go @@ -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 +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index 09344e1..727a7fc 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -70,6 +70,15 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.GET("/collections/:collectionID/documents/:documentID", h.getDocument) api.PATCH("/collections/:collectionID/documents/:documentID", h.updateDocument) 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("/projects/:projectID/db-connections", h.listDBConnections) api.POST("/projects/:projectID/db-connections", h.createDBConnection) diff --git a/apps/backend/internal/handlers/telemetry.go b/apps/backend/internal/handlers/telemetry.go new file mode 100644 index 0000000..a4090bb --- /dev/null +++ b/apps/backend/internal/handlers/telemetry.go @@ -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 + } + }) +} diff --git a/apps/backend/internal/middleware/context.go b/apps/backend/internal/middleware/context.go index 063b6df..ecccd30 100644 --- a/apps/backend/internal/middleware/context.go +++ b/apps/backend/internal/middleware/context.go @@ -74,7 +74,20 @@ func Logger(logger *slog.Logger) gin.HandlerFunc { func (m AuthMiddleware) ResolveActor() gin.HandlerFunc { return func(c *gin.Context) { apiKey := strings.TrimSpace(c.GetHeader("X-API-Key")) + if apiKey == "" { + apiKey = strings.TrimSpace(c.GetHeader("X-Primora-Key")) + } 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 ") { token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer")) if strings.HasPrefix(authz, "Bearer ") { diff --git a/apps/backend/internal/services/hub.go b/apps/backend/internal/services/hub.go new file mode 100644 index 0000000..44580b9 --- /dev/null +++ b/apps/backend/internal/services/hub.go @@ -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() +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index 3a42dc9..16195a1 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -32,6 +32,7 @@ type PlatformService struct { publicURL string dbx *dbx.Client managedDBSeeds []ManagedDBSeed + hub *EventHub } 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 { - 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) { diff --git a/apps/backend/internal/services/telemetry.go b/apps/backend/internal/services/telemetry.go new file mode 100644 index 0000000..273749a --- /dev/null +++ b/apps/backend/internal/services/telemetry.go @@ -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 +} diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index df6ee1a..c3f506c 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -13,6 +13,7 @@ tags: - name: Storage - name: Collections - name: Databases + - name: Telemetry paths: /health/liveness: get: @@ -1091,6 +1092,200 @@ paths: $ref: "#/components/responses/ErrorResponse" "502": $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: securitySchemes: bearerAuth: @@ -1151,6 +1346,13 @@ components: schema: type: string format: uuid + ComponentID: + name: componentID + in: path + required: true + schema: + type: string + format: uuid UserID: name: userID in: path @@ -2094,3 +2296,237 @@ components: output: type: string 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] diff --git a/apps/cli/package.json b/apps/cli/package.json index 67eca38..619ef19 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -23,7 +23,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { - "@primora/api-client": "0.2.0", + "@primora/api-client": "0.3.0", "@types/node": "^24.0.0", "esbuild": "^0.27.4", "typescript": "^5.9.2" diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 8a52b27..23405e8 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -8,6 +8,7 @@ COPY apps/cli/package.json ./apps/cli/package.json COPY apps/frontend/package.json ./apps/frontend/package.json COPY apps/mcp/package.json ./apps/mcp/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 RUN npm ci diff --git a/apps/frontend/package.json b/apps/frontend/package.json index c112970..2ae20bc 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -10,8 +10,8 @@ "test": "vitest run" }, "dependencies": { - "@primora/api-client": "0.2.0", - "@primora/shared-types": "0.2.0", + "@primora/api-client": "0.3.0", + "@primora/shared-types": "0.3.0", "better-auth": "^1.5.6", "solid-js": "^1.9.12" }, diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 6abe791..e9d0b63 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -40,6 +40,7 @@ import { type AuthUser, CollectionsPage, DatabasesPage, + TelemetryPage, LoginPage, MembersPage, ProjectsPage, @@ -53,6 +54,7 @@ import { IconMembers, IconStorage, IconDatabases, + IconTelemetry, IconCollections, IconAudit, IconAuth, @@ -1411,6 +1413,7 @@ export default function App() { { id: "nav-projects", label: "Go to Projects", category: "Navigate", icon: , keywords: ["project"], action: () => setActiveView("projects") }, { id: "nav-members", label: "Go to Members", category: "Navigate", icon: , keywords: ["team", "users", "invite"], action: () => setActiveView("members") }, { id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: , keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") }, + { id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: , keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") }, { id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: , keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") }, { id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: , keywords: ["documents", "database"], action: () => setActiveView("collections") }, { id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: , keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") }, @@ -1624,6 +1627,14 @@ export default function App() { /> + + + + e.id !== "collections"); +// Instagram-style bottom bar caps at five slots — Collections and Telemetry stay reachable via Menu. +const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections" && e.id !== "telemetry"); const workspaceNav: NavEntry[] = [ { id: "projects", label: "Projects", icon: IconProjects }, @@ -84,6 +87,7 @@ const viewTitle: Record = { storage: "Storage", collections: "Collections", databases: "Databases", + telemetry: "Telemetry", auth: "Authentication", audit: "Audit log", settings: "Settings", diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index 6b34166..45b71fd 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -127,6 +127,7 @@ export const IconSun = icon(() => (<>)); export const IconLayers = icon(() => (<>)); export const IconZap = icon(() => ()); +export const IconTelemetry = icon(() => ()); export const IconClock = icon(() => (<>)); export const IconLink = icon(() => (<>)); export const IconOrganization = icon(() => (<>)); diff --git a/apps/frontend/src/lib/demo-mode.ts b/apps/frontend/src/lib/demo-mode.ts index 3d70baf..b28f658 100644 --- a/apps/frontend/src/lib/demo-mode.ts +++ b/apps/frontend/src/lib/demo-mode.ts @@ -17,9 +17,14 @@ import type { DBConnection, Document, MeResponse, + MetricPoint, OrganizationSummary, ProjectOverview, ProjectSummary, + TelemetryComponent, + TelemetryEvent, + TelemetryIssue, + TelemetryStats, } from "@primora/api-client"; 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 = [ "users", "organizations", @@ -881,6 +903,120 @@ class DemoService { const cmd = data.requestBody?.command ?? ""; return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" }; } + + /* ---- telemetry ---- */ + + async ingestTelemetry(data: { requestBody?: { events?: Array> } }) { + 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) ?? {}, + 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(); + 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 { + 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(); diff --git a/apps/frontend/src/pages/TelemetryPage.tsx b/apps/frontend/src/pages/TelemetryPage.tsx new file mode 100644 index 0000000..ad43d7d --- /dev/null +++ b/apps/frontend/src/pages/TelemetryPage.tsx @@ -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("health"); + const [windowSel, setWindowSel] = createSignal("24h"); + const [stats, setStats] = createSignal(null); + const [issues, setIssues] = createSignal([]); + const [events, setEvents] = createSignal([]); + const [typeFilter, setTypeFilter] = createSignal(""); + const [fpFilter, setFpFilter] = createSignal(""); + const [metricName, setMetricName] = createSignal(""); + const [metricPoints, setMetricPoints] = createSignal([]); + const [detail, setDetail] = createSignal(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 = {}; + 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 ( +
+ + + +
{error()}
+
+ +
+ + {(t) => ( + + )} + +
+ + +
+
+
Errors
+
{stats()?.errors ?? 0}
+
+
+
Events
+
{stats()?.events ?? 0}
+
+
+
Metrics
+
{stats()?.metrics ?? 0}
+
+
+ +
+
+ Activity +
+
+ + {(b) => { + const total = () => Object.values(b.counts ?? {}).reduce((a, n) => a + n, 0); + return ( +
0 ? "var(--error, #e5484d)" : "var(--accent)", + opacity: 0.85, + }} + title={`${new Date(b.ts).toLocaleString()} — ${total()} events`} + /> + ); + }} + +
+
+ + {([t, n]) => ( + + {t}: {n} + + )} + +
+
+ +
+
+ Components +
+
+ + + + + + + + + + + + + + + + } + > + {(c) => ( + + + + + + + + + )} + + +
NameKindStatusErrors 24hEvents 24hLast seen
+ No components yet — send an event with a `component` field. +
{c.name}{c.kind} + + {c.last_status || "unknown"} + + {c.errors_24h}{c.events_24h}{fmtTime(c.last_seen_at)}
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + } + > + {(i) => ( + + + + + + + + + )} + + +
ErrorComponentCountSeverityLast seen
+ No error groups in the last 30 days. +
+ {i.message} + {i.component_name || "—"}{i.count} + {i.severity} + {fmtTime(i.last_seen)} + +
+
+
+
+ + +
+ + {(t) => ( + + )} + + + + +
+
+
+ + + + + + + + + + + + + + + } + > + {(e) => ( + setDetail(e)}> + + + + + + + )} + + +
TypeSeverityMessageComponentTime
+ No events. +
+ {e.type} + + {e.severity} + + {e.message || {e.payload?.name ? `${e.payload.name} = ${e.payload.value}` : JSON.stringify(e.payload)}} + {e.component_name || "—"}{fmtTime(e.ts)}
+
+
+
+ + +
+ No metric names seen yet.} + > + {(n) => ( + + )} + +
+
+
+ + + + + + + + + + + + + + + + } + > + {(p) => ( + + + + + + + + + )} + + +
BucketAvgp50p95MaxCount
+ Select a metric name. +
{fmtTime(p.ts)}{p.avg?.toFixed(1) ?? "—"}{p.p50?.toFixed(1) ?? "—"}{p.p95?.toFixed(1) ?? "—"}{p.max?.toFixed(1) ?? "—"}{p.count}
+
+
+
+ + +
+ + {(s) => ( +
+
+ {s.title} +
+
{s.code}
+
+ )} +
+
+

+ Create a project API key under Settings, then pass it as X-Primora-Key to POST /api/v1/ingest. Batches of up to 500 events are accepted. +

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