feat: phase 3 observability — IMS telemetry merge

Port IMS telemetry into Primora conventions:

- core.components/core.events (migration 00004 + sqlc) scoped to projects
- POST /api/v1/ingest — single or <=500-event batches via project prm_
  keys (X-Primora-Key alias), auto-registering components, permissive CORS
- Issues: fingerprint-grouped errors with count/severity/first+last seen
- Reads: events w/ filters + before-cursor, components, stats (1h/24h/7d/30d
  bucketed series + component health + totals + metric names), metric
  series (avg/p50/p95/max)
- GET .../telemetry/stream — project-scoped SSE through an in-process hub
  (buffered subs, drop-on-slow, 25s keepalives); ?api_key=/?token= auth
  for EventSource
- Telemetry page: health/issues/events/metrics/integrate tabs, live badge,
  390px-clean; demo stubs
- @primora/client SDK (createPrimoraClient: capture*/heartbeat/installAuto,
  queued keepalive flushes)
- MCP: primora_list_issues/list_events/telemetry_stats/metric_series
- IMS README deprecation note pointing here

Also fixes workspace pins to 0.3.0 — npm ci in the frontend image was
resolving the unpublished 0.2.0 spec from the registry.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-16 16:21:03 +02:00
1 parent 6507eee606
commit 8cfddf9184
60 files changed
+3694 -30

No files matched your search

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