mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: phase 3 observability — IMS telemetry merge
Port IMS telemetry into Primora conventions: - core.components/core.events (migration 00004 + sqlc) scoped to projects - POST /api/v1/ingest — single or <=500-event batches via project prm_ keys (X-Primora-Key alias), auto-registering components, permissive CORS - Issues: fingerprint-grouped errors with count/severity/first+last seen - Reads: events w/ filters + before-cursor, components, stats (1h/24h/7d/30d bucketed series + component health + totals + metric names), metric series (avg/p50/p95/max) - GET .../telemetry/stream — project-scoped SSE through an in-process hub (buffered subs, drop-on-slow, 25s keepalives); ?api_key=/?token= auth for EventSource - Telemetry page: health/issues/events/metrics/integrate tabs, live badge, 390px-clean; demo stubs - @primora/client SDK (createPrimoraClient: capture*/heartbeat/installAuto, queued keepalive flushes) - MCP: primora_list_issues/list_events/telemetry_stats/metric_series - IMS README deprecation note pointing here Also fixes workspace pins to 0.3.0 — npm ci in the frontend image was resolving the unpublished 0.2.0 spec from the registry. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6507eee606
commit
8cfddf9184
60 files changed
+3694
-30
No files matched your search
@@ -0,0 +1,32 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE core.components (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'other'
|
||||
CHECK (kind IN ('frontend','backend','database','android','desktop','web','other')),
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE core.events (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
component_id UUID REFERENCES core.components(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('error','metric','log','heartbeat','event')),
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
fingerprint TEXT NOT NULL DEFAULT '',
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_events_project_ts ON core.events(project_id, ts DESC);
|
||||
CREATE INDEX idx_events_project_type_ts ON core.events(project_id, type, ts DESC);
|
||||
CREATE INDEX idx_events_fingerprint ON core.events(fingerprint) WHERE type = 'error';
|
||||
CREATE INDEX idx_events_metric_name ON core.events(project_id, (payload->>'name')) WHERE type = 'metric';
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.events;
|
||||
DROP TABLE IF EXISTS core.components;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- name: UpsertComponent :one
|
||||
INSERT INTO core.components (project_id, name, kind)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, name) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetComponentByName :one
|
||||
SELECT * FROM core.components
|
||||
WHERE project_id = $1 AND name = $2;
|
||||
|
||||
-- name: ListComponents :many
|
||||
SELECT * FROM core.components
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
|
||||
-- name: DeleteComponent :exec
|
||||
DELETE FROM core.components
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
|
||||
-- name: InsertEvent :one
|
||||
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListEvents :many
|
||||
SELECT e.*, co.name AS component_name
|
||||
FROM core.events e
|
||||
LEFT JOIN core.components co ON co.id = e.component_id
|
||||
WHERE e.project_id = $1
|
||||
AND (sqlc.narg('type')::text IS NULL OR e.type = sqlc.narg('type'))
|
||||
AND (sqlc.narg('component')::text IS NULL OR co.name = sqlc.narg('component'))
|
||||
AND (sqlc.narg('fingerprint')::text IS NULL OR e.fingerprint = sqlc.narg('fingerprint'))
|
||||
AND (sqlc.narg('before')::bigint IS NULL OR e.id < sqlc.narg('before'))
|
||||
ORDER BY e.id DESC
|
||||
LIMIT sqlc.arg('limit');
|
||||
|
||||
-- name: ListErrorGroups :many
|
||||
SELECT e.fingerprint,
|
||||
(array_agg(e.message ORDER BY e.ts DESC))[1]::text AS message,
|
||||
coalesce((array_agg(e.component_id::text ORDER BY e.ts DESC))[1], '')::text AS component_id,
|
||||
coalesce((array_agg(co.name ORDER BY e.ts DESC))[1], '')::text AS component_name,
|
||||
count(*) AS count,
|
||||
min(e.ts)::timestamptz AS first_seen,
|
||||
max(e.ts)::timestamptz AS last_seen,
|
||||
(array_agg(e.severity ORDER BY e.ts DESC))[1]::text AS severity
|
||||
FROM core.events e
|
||||
LEFT JOIN core.components co ON co.id = e.component_id
|
||||
WHERE e.project_id = $1 AND e.type = 'error' AND e.fingerprint <> ''
|
||||
AND e.ts > now() - (sqlc.narg('days')::text || ' days')::interval
|
||||
GROUP BY e.fingerprint
|
||||
ORDER BY max(e.ts) DESC
|
||||
LIMIT 200;
|
||||
|
||||
-- name: EventTypeSeries :many
|
||||
SELECT to_timestamp(floor(extract(epoch FROM e.ts) / sqlc.arg('bucket_sec')::bigint) * sqlc.arg('bucket_sec')::bigint)::timestamptz AS bucket,
|
||||
e.type,
|
||||
count(*) AS count
|
||||
FROM core.events e
|
||||
WHERE e.project_id = $1 AND e.ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint)
|
||||
GROUP BY bucket, e.type
|
||||
ORDER BY bucket;
|
||||
|
||||
-- name: ComponentHealthRows :many
|
||||
SELECT co.id, co.name, co.kind, co.meta,
|
||||
max(e.ts)::timestamptz AS last_seen_at,
|
||||
coalesce(((array_agg(e.payload->>'status' ORDER BY e.ts DESC)
|
||||
FILTER (WHERE e.type = 'heartbeat'))[1]), '')::text AS last_status,
|
||||
count(*) FILTER (WHERE e.type = 'error' AND e.ts > now() - interval '24 hours') AS errors_24h,
|
||||
count(*) FILTER (WHERE e.ts > now() - interval '24 hours') AS events_24h
|
||||
FROM core.components co
|
||||
LEFT JOIN core.events e ON e.component_id = co.id
|
||||
WHERE co.project_id = $1
|
||||
GROUP BY co.id
|
||||
ORDER BY co.name;
|
||||
|
||||
-- name: EventTotals :one
|
||||
SELECT count(*) FILTER (WHERE type = 'error') AS errors,
|
||||
count(*) AS events,
|
||||
count(*) FILTER (WHERE type = 'metric') AS metrics
|
||||
FROM core.events
|
||||
WHERE project_id = $1 AND ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint);
|
||||
|
||||
-- name: MetricNames :many
|
||||
SELECT DISTINCT (payload->>'name')::text AS name
|
||||
FROM core.events
|
||||
WHERE project_id = $1 AND type = 'metric' AND payload ? 'name'
|
||||
ORDER BY 1
|
||||
LIMIT 50;
|
||||
|
||||
-- name: MetricSeries :many
|
||||
SELECT to_timestamp(floor(extract(epoch FROM ts) / sqlc.arg('bucket_sec')::bigint) * sqlc.arg('bucket_sec')::bigint)::timestamptz AS bucket,
|
||||
avg((payload->>'value')::float8)::float8 AS avg,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p50,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p95,
|
||||
max((payload->>'value')::float8)::float8 AS max,
|
||||
count(*) AS count
|
||||
FROM core.events
|
||||
WHERE project_id = $1 AND type = 'metric' AND payload->>'name' = sqlc.arg('name')::text
|
||||
AND ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint)
|
||||
AND jsonb_typeof(payload->'value') = 'number'
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket;
|
||||
@@ -200,6 +200,15 @@ type CoreCollection struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreComponent struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Meta []byte `json:"meta"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreDbConnection struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -221,6 +230,18 @@ type CoreDocument struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ComponentID pgtype.UUID `json:"component_id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Payload []byte `json:"payload"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Ts pgtype.Timestamptz `json:"ts"`
|
||||
}
|
||||
|
||||
type CoreOrganization struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
|
||||
@@ -14,6 +14,7 @@ type Querier interface {
|
||||
AddOrganizationMember(ctx context.Context, arg AddOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||
AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (CoreProjectMember, error)
|
||||
BootstrapOrganization(ctx context.Context, arg BootstrapOrganizationParams) (BootstrapOrganizationRow, error)
|
||||
ComponentHealthRows(ctx context.Context, projectID uuid.UUID) ([]ComponentHealthRowsRow, error)
|
||||
CountAuditLogsForProject(ctx context.Context, arg CountAuditLogsForProjectParams) (int64, error)
|
||||
CountBucketObjects(ctx context.Context, arg CountBucketObjectsParams) (int64, error)
|
||||
CountDocuments(ctx context.Context, collectionID uuid.UUID) (int64, error)
|
||||
@@ -33,17 +34,21 @@ type Querier interface {
|
||||
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
||||
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
|
||||
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
||||
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
||||
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
||||
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
||||
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
||||
GetCollectionBySlug(ctx context.Context, arg GetCollectionBySlugParams) (CoreCollection, error)
|
||||
GetComponentByName(ctx context.Context, arg GetComponentByNameParams) (CoreComponent, error)
|
||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||
@@ -55,20 +60,26 @@ type Querier interface {
|
||||
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
|
||||
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
|
||||
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
||||
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
||||
ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, error)
|
||||
ListCollections(ctx context.Context, projectID uuid.UUID) ([]CoreCollection, error)
|
||||
ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error)
|
||||
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
||||
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
|
||||
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
|
||||
MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error)
|
||||
MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error)
|
||||
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
||||
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
||||
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
||||
@@ -82,6 +93,7 @@ type Querier interface {
|
||||
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
|
||||
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error)
|
||||
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
||||
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: telemetry.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const componentHealthRows = `-- name: ComponentHealthRows :many
|
||||
SELECT co.id, co.name, co.kind, co.meta,
|
||||
max(e.ts)::timestamptz AS last_seen_at,
|
||||
coalesce(((array_agg(e.payload->>'status' ORDER BY e.ts DESC)
|
||||
FILTER (WHERE e.type = 'heartbeat'))[1]), '')::text AS last_status,
|
||||
count(*) FILTER (WHERE e.type = 'error' AND e.ts > now() - interval '24 hours') AS errors_24h,
|
||||
count(*) FILTER (WHERE e.ts > now() - interval '24 hours') AS events_24h
|
||||
FROM core.components co
|
||||
LEFT JOIN core.events e ON e.component_id = co.id
|
||||
WHERE co.project_id = $1
|
||||
GROUP BY co.id
|
||||
ORDER BY co.name
|
||||
`
|
||||
|
||||
type ComponentHealthRowsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Meta []byte `json:"meta"`
|
||||
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||
LastStatus string `json:"last_status"`
|
||||
Errors24h int64 `json:"errors_24h"`
|
||||
Events24h int64 `json:"events_24h"`
|
||||
}
|
||||
|
||||
func (q *Queries) ComponentHealthRows(ctx context.Context, projectID uuid.UUID) ([]ComponentHealthRowsRow, error) {
|
||||
rows, err := q.db.Query(ctx, componentHealthRows, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ComponentHealthRowsRow
|
||||
for rows.Next() {
|
||||
var i ComponentHealthRowsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Meta,
|
||||
&i.LastSeenAt,
|
||||
&i.LastStatus,
|
||||
&i.Errors24h,
|
||||
&i.Events24h,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const deleteComponent = `-- name: DeleteComponent :exec
|
||||
DELETE FROM core.components
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteComponentParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteComponent(ctx context.Context, arg DeleteComponentParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteComponent, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const eventTotals = `-- name: EventTotals :one
|
||||
SELECT count(*) FILTER (WHERE type = 'error') AS errors,
|
||||
count(*) AS events,
|
||||
count(*) FILTER (WHERE type = 'metric') AS metrics
|
||||
FROM core.events
|
||||
WHERE project_id = $1 AND ts > now() - make_interval(secs => $2::bigint)
|
||||
`
|
||||
|
||||
type EventTotalsParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
WindowSec int64 `json:"window_sec"`
|
||||
}
|
||||
|
||||
type EventTotalsRow struct {
|
||||
Errors int64 `json:"errors"`
|
||||
Events int64 `json:"events"`
|
||||
Metrics int64 `json:"metrics"`
|
||||
}
|
||||
|
||||
func (q *Queries) EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error) {
|
||||
row := q.db.QueryRow(ctx, eventTotals, arg.ProjectID, arg.WindowSec)
|
||||
var i EventTotalsRow
|
||||
err := row.Scan(&i.Errors, &i.Events, &i.Metrics)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const eventTypeSeries = `-- name: EventTypeSeries :many
|
||||
SELECT to_timestamp(floor(extract(epoch FROM e.ts) / $2::bigint) * $2::bigint)::timestamptz AS bucket,
|
||||
e.type,
|
||||
count(*) AS count
|
||||
FROM core.events e
|
||||
WHERE e.project_id = $1 AND e.ts > now() - make_interval(secs => $3::bigint)
|
||||
GROUP BY bucket, e.type
|
||||
ORDER BY bucket
|
||||
`
|
||||
|
||||
type EventTypeSeriesParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
BucketSec int64 `json:"bucket_sec"`
|
||||
WindowSec int64 `json:"window_sec"`
|
||||
}
|
||||
|
||||
type EventTypeSeriesRow struct {
|
||||
Bucket pgtype.Timestamptz `json:"bucket"`
|
||||
Type string `json:"type"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error) {
|
||||
rows, err := q.db.Query(ctx, eventTypeSeries, arg.ProjectID, arg.BucketSec, arg.WindowSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []EventTypeSeriesRow
|
||||
for rows.Next() {
|
||||
var i EventTypeSeriesRow
|
||||
if err := rows.Scan(&i.Bucket, &i.Type, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getComponentByName = `-- name: GetComponentByName :one
|
||||
SELECT id, project_id, name, kind, meta, created_at FROM core.components
|
||||
WHERE project_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
type GetComponentByNameParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetComponentByName(ctx context.Context, arg GetComponentByNameParams) (CoreComponent, error) {
|
||||
row := q.db.QueryRow(ctx, getComponentByName, arg.ProjectID, arg.Name)
|
||||
var i CoreComponent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Meta,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertEvent = `-- name: InsertEvent :one
|
||||
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, project_id, component_id, type, severity, message, payload, fingerprint, ts
|
||||
`
|
||||
|
||||
type InsertEventParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ComponentID pgtype.UUID `json:"component_id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Payload []byte `json:"payload"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Ts pgtype.Timestamptz `json:"ts"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) {
|
||||
row := q.db.QueryRow(ctx, insertEvent,
|
||||
arg.ProjectID,
|
||||
arg.ComponentID,
|
||||
arg.Type,
|
||||
arg.Severity,
|
||||
arg.Message,
|
||||
arg.Payload,
|
||||
arg.Fingerprint,
|
||||
arg.Ts,
|
||||
)
|
||||
var i CoreEvent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.ComponentID,
|
||||
&i.Type,
|
||||
&i.Severity,
|
||||
&i.Message,
|
||||
&i.Payload,
|
||||
&i.Fingerprint,
|
||||
&i.Ts,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listComponents = `-- name: ListComponents :many
|
||||
SELECT id, project_id, name, kind, meta, created_at FROM core.components
|
||||
WHERE project_id = $1
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error) {
|
||||
rows, err := q.db.Query(ctx, listComponents, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreComponent
|
||||
for rows.Next() {
|
||||
var i CoreComponent
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Meta,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listErrorGroups = `-- name: ListErrorGroups :many
|
||||
SELECT e.fingerprint,
|
||||
(array_agg(e.message ORDER BY e.ts DESC))[1]::text AS message,
|
||||
coalesce((array_agg(e.component_id::text ORDER BY e.ts DESC))[1], '')::text AS component_id,
|
||||
coalesce((array_agg(co.name ORDER BY e.ts DESC))[1], '')::text AS component_name,
|
||||
count(*) AS count,
|
||||
min(e.ts)::timestamptz AS first_seen,
|
||||
max(e.ts)::timestamptz AS last_seen,
|
||||
(array_agg(e.severity ORDER BY e.ts DESC))[1]::text AS severity
|
||||
FROM core.events e
|
||||
LEFT JOIN core.components co ON co.id = e.component_id
|
||||
WHERE e.project_id = $1 AND e.type = 'error' AND e.fingerprint <> ''
|
||||
AND e.ts > now() - ($2::text || ' days')::interval
|
||||
GROUP BY e.fingerprint
|
||||
ORDER BY max(e.ts) DESC
|
||||
LIMIT 200
|
||||
`
|
||||
|
||||
type ListErrorGroupsParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Days *string `json:"days"`
|
||||
}
|
||||
|
||||
type ListErrorGroupsRow struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Message string `json:"message"`
|
||||
ComponentID string `json:"component_id"`
|
||||
ComponentName string `json:"component_name"`
|
||||
Count int64 `json:"count"`
|
||||
FirstSeen pgtype.Timestamptz `json:"first_seen"`
|
||||
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listErrorGroups, arg.ProjectID, arg.Days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListErrorGroupsRow
|
||||
for rows.Next() {
|
||||
var i ListErrorGroupsRow
|
||||
if err := rows.Scan(
|
||||
&i.Fingerprint,
|
||||
&i.Message,
|
||||
&i.ComponentID,
|
||||
&i.ComponentName,
|
||||
&i.Count,
|
||||
&i.FirstSeen,
|
||||
&i.LastSeen,
|
||||
&i.Severity,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listEvents = `-- name: ListEvents :many
|
||||
SELECT e.id, e.project_id, e.component_id, e.type, e.severity, e.message, e.payload, e.fingerprint, e.ts, co.name AS component_name
|
||||
FROM core.events e
|
||||
LEFT JOIN core.components co ON co.id = e.component_id
|
||||
WHERE e.project_id = $1
|
||||
AND ($2::text IS NULL OR e.type = $2)
|
||||
AND ($3::text IS NULL OR co.name = $3)
|
||||
AND ($4::text IS NULL OR e.fingerprint = $4)
|
||||
AND ($5::bigint IS NULL OR e.id < $5)
|
||||
ORDER BY e.id DESC
|
||||
LIMIT $6
|
||||
`
|
||||
|
||||
type ListEventsParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Type *string `json:"type"`
|
||||
Component *string `json:"component"`
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
Before *int64 `json:"before"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
type ListEventsRow struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ComponentID pgtype.UUID `json:"component_id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Payload []byte `json:"payload"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Ts pgtype.Timestamptz `json:"ts"`
|
||||
ComponentName *string `json:"component_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listEvents,
|
||||
arg.ProjectID,
|
||||
arg.Type,
|
||||
arg.Component,
|
||||
arg.Fingerprint,
|
||||
arg.Before,
|
||||
arg.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListEventsRow
|
||||
for rows.Next() {
|
||||
var i ListEventsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.ComponentID,
|
||||
&i.Type,
|
||||
&i.Severity,
|
||||
&i.Message,
|
||||
&i.Payload,
|
||||
&i.Fingerprint,
|
||||
&i.Ts,
|
||||
&i.ComponentName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const metricNames = `-- name: MetricNames :many
|
||||
SELECT DISTINCT (payload->>'name')::text AS name
|
||||
FROM core.events
|
||||
WHERE project_id = $1 AND type = 'metric' AND payload ? 'name'
|
||||
ORDER BY 1
|
||||
LIMIT 50
|
||||
`
|
||||
|
||||
func (q *Queries) MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, metricNames, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const metricSeries = `-- name: MetricSeries :many
|
||||
SELECT to_timestamp(floor(extract(epoch FROM ts) / $2::bigint) * $2::bigint)::timestamptz AS bucket,
|
||||
avg((payload->>'value')::float8)::float8 AS avg,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p50,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p95,
|
||||
max((payload->>'value')::float8)::float8 AS max,
|
||||
count(*) AS count
|
||||
FROM core.events
|
||||
WHERE project_id = $1 AND type = 'metric' AND payload->>'name' = $3::text
|
||||
AND ts > now() - make_interval(secs => $4::bigint)
|
||||
AND jsonb_typeof(payload->'value') = 'number'
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`
|
||||
|
||||
type MetricSeriesParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
BucketSec int64 `json:"bucket_sec"`
|
||||
Name string `json:"name"`
|
||||
WindowSec int64 `json:"window_sec"`
|
||||
}
|
||||
|
||||
type MetricSeriesRow struct {
|
||||
Bucket pgtype.Timestamptz `json:"bucket"`
|
||||
Avg float64 `json:"avg"`
|
||||
P50 float64 `json:"p50"`
|
||||
P95 float64 `json:"p95"`
|
||||
Max float64 `json:"max"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error) {
|
||||
rows, err := q.db.Query(ctx, metricSeries,
|
||||
arg.ProjectID,
|
||||
arg.BucketSec,
|
||||
arg.Name,
|
||||
arg.WindowSec,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []MetricSeriesRow
|
||||
for rows.Next() {
|
||||
var i MetricSeriesRow
|
||||
if err := rows.Scan(
|
||||
&i.Bucket,
|
||||
&i.Avg,
|
||||
&i.P50,
|
||||
&i.P95,
|
||||
&i.Max,
|
||||
&i.Count,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertComponent = `-- name: UpsertComponent :one
|
||||
INSERT INTO core.components (project_id, name, kind)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, name) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id, project_id, name, kind, meta, created_at
|
||||
`
|
||||
|
||||
type UpsertComponentParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error) {
|
||||
row := q.db.QueryRow(ctx, upsertComponent, arg.ProjectID, arg.Name, arg.Kind)
|
||||
var i CoreComponent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Meta,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -70,6 +70,15 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.GET("/collections/:collectionID/documents/:documentID", h.getDocument)
|
||||
api.PATCH("/collections/:collectionID/documents/:documentID", h.updateDocument)
|
||||
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||
api.OPTIONS("/ingest", h.ingestOptions)
|
||||
api.POST("/ingest", h.ingest)
|
||||
api.GET("/projects/:projectID/events", h.listEvents)
|
||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||
api.GET("/projects/:projectID/components", h.listComponents)
|
||||
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
||||
api.GET("/projects/:projectID/telemetry/stats", h.telemetryStats)
|
||||
api.GET("/projects/:projectID/telemetry/metrics/series", h.metricSeries)
|
||||
api.GET("/projects/:projectID/telemetry/stream", h.telemetryStream)
|
||||
api.GET("/db/status", h.dbxStatus)
|
||||
api.GET("/projects/:projectID/db-connections", h.listDBConnections)
|
||||
api.POST("/projects/:projectID/db-connections", h.createDBConnection)
|
||||
|
||||
@@ -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 {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := strings.TrimSpace(c.GetHeader("X-API-Key"))
|
||||
if apiKey == "" {
|
||||
apiKey = strings.TrimSpace(c.GetHeader("X-Primora-Key"))
|
||||
}
|
||||
authz := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
// EventSource cannot set headers — the SSE stream accepts credentials
|
||||
// as query params instead.
|
||||
if apiKey == "" && authz == "" && strings.HasSuffix(c.Request.URL.Path, "/telemetry/stream") {
|
||||
apiKey = strings.TrimSpace(c.Query("api_key"))
|
||||
if apiKey == "" {
|
||||
if t := strings.TrimSpace(c.Query("token")); t != "" {
|
||||
authz = "Bearer " + t
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" && strings.HasPrefix(strings.ToLower(authz), "bearer ") {
|
||||
token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer"))
|
||||
if strings.HasPrefix(authz, "Bearer ") {
|
||||
|
||||
@@ -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
|
||||
dbx *dbx.Client
|
||||
managedDBSeeds []ManagedDBSeed
|
||||
hub *EventHub
|
||||
}
|
||||
|
||||
type BootstrapInput struct {
|
||||
@@ -210,7 +211,7 @@ type InvitationSummary struct {
|
||||
}
|
||||
|
||||
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed) *PlatformService {
|
||||
return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds}
|
||||
return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub()}
|
||||
}
|
||||
|
||||
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
)
|
||||
|
||||
var telemetryEventTypes = map[string]bool{
|
||||
"error": true, "metric": true, "log": true, "heartbeat": true, "event": true,
|
||||
}
|
||||
|
||||
var componentKinds = map[string]bool{
|
||||
"frontend": true, "backend": true, "database": true,
|
||||
"android": true, "desktop": true, "web": true, "other": true,
|
||||
}
|
||||
|
||||
// IngestEventInput is one event in an ingest batch.
|
||||
type IngestEventInput struct {
|
||||
Type string `json:"type"`
|
||||
Component string `json:"component"`
|
||||
Kind string `json:"kind"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
TS *time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// TelemetryEvent is the client-facing event shape.
|
||||
type TelemetryEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ComponentID *uuid.UUID `json:"component_id"`
|
||||
ComponentName string `json:"component_name,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
TS time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
type TelemetryIssue struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Message string `json:"message"`
|
||||
ComponentID *uuid.UUID `json:"component_id"`
|
||||
ComponentName string `json:"component_name,omitempty"`
|
||||
Count int64 `json:"count"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type ComponentSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||
LastStatus string `json:"last_status"`
|
||||
Errors24h int64 `json:"errors_24h"`
|
||||
Events24h int64 `json:"events_24h"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
}
|
||||
|
||||
func fingerprintOf(componentID uuid.UUID, message string) string {
|
||||
line := strings.SplitN(message, "\n", 2)[0]
|
||||
sum := sha256.Sum256([]byte(componentID.String() + "|" + line))
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// IngestEvents stores a batch of telemetry events under the ingest key's
|
||||
// project. API-key actors only — the key's project scope is the write scope.
|
||||
func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor, events []IngestEventInput) (int, error) {
|
||||
if actor == nil || actor.ProjectID == nil || actor.APIKeyID == nil {
|
||||
return 0, errors.New("ingest requires a project API key")
|
||||
}
|
||||
projectID := *actor.ProjectID
|
||||
|
||||
var inserted []TelemetryEvent
|
||||
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
||||
compCache := map[string]*uuid.UUID{}
|
||||
for _, ev := range events {
|
||||
if !telemetryEventTypes[ev.Type] {
|
||||
continue
|
||||
}
|
||||
var compID *uuid.UUID
|
||||
if ev.Component != "" {
|
||||
cached, hit := compCache[ev.Component]
|
||||
if !hit {
|
||||
kind := ev.Kind
|
||||
if !componentKinds[kind] {
|
||||
kind = "other"
|
||||
}
|
||||
row, err := q.UpsertComponent(ctx, db.UpsertComponentParams{
|
||||
ProjectID: projectID,
|
||||
Name: ev.Component,
|
||||
Kind: kind,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
id := row.ID
|
||||
cached = &id
|
||||
compCache[ev.Component] = cached
|
||||
}
|
||||
compID = cached
|
||||
}
|
||||
sev := ev.Severity
|
||||
if sev == "" {
|
||||
sev = "info"
|
||||
if ev.Type == "error" {
|
||||
sev = "error"
|
||||
}
|
||||
}
|
||||
fp := ev.Fingerprint
|
||||
if ev.Type == "error" && fp == "" && compID != nil {
|
||||
fp = fingerprintOf(*compID, ev.Message)
|
||||
}
|
||||
ts := time.Now()
|
||||
if ev.TS != nil {
|
||||
ts = *ev.TS
|
||||
}
|
||||
payload := ev.Payload
|
||||
if len(payload) == 0 {
|
||||
payload = json.RawMessage(`{}`)
|
||||
}
|
||||
var cid pgtype.UUID
|
||||
if compID != nil {
|
||||
cid = pgtype.UUID{Bytes: *compID, Valid: true}
|
||||
}
|
||||
row, err := q.InsertEvent(ctx, db.InsertEventParams{
|
||||
ProjectID: projectID,
|
||||
ComponentID: cid,
|
||||
Type: ev.Type,
|
||||
Severity: sev,
|
||||
Message: ev.Message,
|
||||
Payload: payload,
|
||||
Fingerprint: fp,
|
||||
Ts: pgtype.Timestamptz{Time: ts, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out := TelemetryEvent{
|
||||
ID: row.ID,
|
||||
ProjectID: row.ProjectID,
|
||||
Type: row.Type,
|
||||
Severity: row.Severity,
|
||||
Message: row.Message,
|
||||
Payload: row.Payload,
|
||||
Fingerprint: row.Fingerprint,
|
||||
TS: row.Ts.Time,
|
||||
}
|
||||
if compID != nil {
|
||||
out.ComponentID = compID
|
||||
out.ComponentName = ev.Component
|
||||
}
|
||||
inserted = append(inserted, out)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_ = s.repo.Queries().TouchAPIKey(ctx, *actor.APIKeyID)
|
||||
for _, ev := range inserted {
|
||||
s.hub.Broadcast(ev.ProjectID.String(), ev)
|
||||
}
|
||||
return len(inserted), nil
|
||||
}
|
||||
|
||||
// SubscribeTelemetry validates read access and returns an event channel.
|
||||
func (s *PlatformService) SubscribeTelemetry(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.hub.Subscribe(projectID.String()), nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) UnsubscribeTelemetry(projectID uuid.UUID, ch chan []byte) {
|
||||
s.hub.Unsubscribe(projectID.String(), ch)
|
||||
}
|
||||
|
||||
type EventListFilters struct {
|
||||
Type string
|
||||
Component string
|
||||
Fingerprint string
|
||||
Before *int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListEvents(ctx context.Context, actor *models.Actor, projectID uuid.UUID, f EventListFilters) ([]TelemetryEvent, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Limit <= 0 || f.Limit > 500 {
|
||||
f.Limit = 100
|
||||
}
|
||||
rows, err := s.repo.Queries().ListEvents(ctx, db.ListEventsParams{
|
||||
ProjectID: projectID,
|
||||
Type: strPtr(f.Type),
|
||||
Component: strPtr(f.Component),
|
||||
Fingerprint: strPtr(f.Fingerprint),
|
||||
Before: f.Before,
|
||||
Limit: int32(f.Limit),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TelemetryEvent, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toTelemetryEvent(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListIssues(ctx context.Context, actor *models.Actor, projectID uuid.UUID, days int) ([]TelemetryIssue, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if days <= 0 || days > 365 {
|
||||
days = 30
|
||||
}
|
||||
rows, err := s.repo.Queries().ListErrorGroups(ctx, db.ListErrorGroupsParams{
|
||||
ProjectID: projectID,
|
||||
Days: strPtr(fmt.Sprint(days)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TelemetryIssue, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
issue := TelemetryIssue{
|
||||
Fingerprint: r.Fingerprint,
|
||||
Message: r.Message,
|
||||
Count: r.Count,
|
||||
Severity: r.Severity,
|
||||
}
|
||||
if r.FirstSeen.Valid {
|
||||
issue.FirstSeen = r.FirstSeen.Time
|
||||
}
|
||||
if r.LastSeen.Valid {
|
||||
issue.LastSeen = r.LastSeen.Time
|
||||
}
|
||||
if id, err := uuid.Parse(r.ComponentID); err == nil {
|
||||
issue.ComponentID = &id
|
||||
}
|
||||
issue.ComponentName = r.ComponentName
|
||||
out = append(out, issue)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func windowSeconds(w string) int64 {
|
||||
switch w {
|
||||
case "1h":
|
||||
return 3600
|
||||
case "7d":
|
||||
return 7 * 86400
|
||||
case "30d":
|
||||
return 30 * 86400
|
||||
default:
|
||||
return 86400
|
||||
}
|
||||
}
|
||||
|
||||
func bucketSeconds(window int64) int64 {
|
||||
switch {
|
||||
case window <= 3600:
|
||||
return 60
|
||||
case window <= 86400:
|
||||
return 900
|
||||
case window <= 7*86400:
|
||||
return 3600
|
||||
default:
|
||||
return 86400
|
||||
}
|
||||
}
|
||||
|
||||
type EventSeriesBucket struct {
|
||||
TS time.Time `json:"ts"`
|
||||
Counts map[string]int64 `json:"counts"`
|
||||
}
|
||||
|
||||
type MetricPoint struct {
|
||||
TS time.Time `json:"ts"`
|
||||
Avg *float64 `json:"avg"`
|
||||
P50 *float64 `json:"p50"`
|
||||
P95 *float64 `json:"p95"`
|
||||
Max *float64 `json:"max"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type TelemetryStatsResult struct {
|
||||
Window int64 `json:"window"`
|
||||
BucketSec int64 `json:"bucket_sec"`
|
||||
Series []EventSeriesBucket `json:"series"`
|
||||
Components []ComponentSummary `json:"components"`
|
||||
Errors int64 `json:"errors"`
|
||||
Events int64 `json:"events"`
|
||||
Metrics int64 `json:"metrics"`
|
||||
MetricNames []string `json:"metric_names"`
|
||||
}
|
||||
|
||||
func (s *PlatformService) TelemetryStats(ctx context.Context, actor *models.Actor, projectID uuid.UUID, window string) (*TelemetryStatsResult, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
win := windowSeconds(window)
|
||||
bucket := bucketSeconds(win)
|
||||
|
||||
seriesRows, err := s.repo.Queries().EventTypeSeries(ctx, db.EventTypeSeriesParams{
|
||||
ProjectID: projectID,
|
||||
BucketSec: bucket,
|
||||
WindowSec: win,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
series := make([]EventSeriesBucket, 0, len(seriesRows))
|
||||
for _, r := range seriesRows {
|
||||
n := len(series)
|
||||
if n == 0 || !series[n-1].TS.Equal(r.Bucket.Time) {
|
||||
series = append(series, EventSeriesBucket{TS: r.Bucket.Time, Counts: map[string]int64{}})
|
||||
n++
|
||||
}
|
||||
series[n-1].Counts[r.Type] = r.Count
|
||||
}
|
||||
|
||||
compRows, err := s.repo.Queries().ComponentHealthRows(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
components := make([]ComponentSummary, 0, len(compRows))
|
||||
for _, r := range compRows {
|
||||
c := ComponentSummary{ID: r.ID, Name: r.Name, Kind: r.Kind, Errors24h: r.Errors24h, Events24h: r.Events24h}
|
||||
if r.LastSeenAt.Valid {
|
||||
t := r.LastSeenAt.Time
|
||||
c.LastSeenAt = &t
|
||||
}
|
||||
c.LastStatus = r.LastStatus
|
||||
if len(r.Meta) > 0 {
|
||||
_ = json.Unmarshal(r.Meta, &c.Meta)
|
||||
}
|
||||
components = append(components, c)
|
||||
}
|
||||
|
||||
totals, err := s.repo.Queries().EventTotals(ctx, db.EventTotalsParams{ProjectID: projectID, WindowSec: win})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names, err := s.repo.Queries().MetricNames(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
return &TelemetryStatsResult{
|
||||
Window: win,
|
||||
BucketSec: bucket,
|
||||
Series: series,
|
||||
Components: components,
|
||||
Errors: totals.Errors,
|
||||
Events: totals.Events,
|
||||
Metrics: totals.Metrics,
|
||||
MetricNames: names,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) MetricSeriesData(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name, window string) ([]MetricPoint, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name == "" {
|
||||
return nil, errors.New("name required")
|
||||
}
|
||||
win := windowSeconds(window)
|
||||
rows, err := s.repo.Queries().MetricSeries(ctx, db.MetricSeriesParams{
|
||||
ProjectID: projectID,
|
||||
Name: name,
|
||||
BucketSec: bucketSeconds(win),
|
||||
WindowSec: win,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]MetricPoint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, MetricPoint{TS: r.Bucket.Time, Avg: &r.Avg, P50: &r.P50, P95: &r.P95, Max: &r.Max, Count: r.Count})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListComponents(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ComponentSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListComponents(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ComponentSummary, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
c := ComponentSummary{ID: r.ID, Name: r.Name, Kind: r.Kind}
|
||||
if len(r.Meta) > 0 {
|
||||
_ = json.Unmarshal(r.Meta, &c.Meta)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteComponent(ctx context.Context, actor *models.Actor, projectID, componentID uuid.UUID) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Queries().DeleteComponent(ctx, db.DeleteComponentParams{ID: componentID, ProjectID: projectID})
|
||||
}
|
||||
|
||||
func toTelemetryEvent(r db.ListEventsRow) TelemetryEvent {
|
||||
ev := TelemetryEvent{
|
||||
ID: r.ID,
|
||||
ProjectID: r.ProjectID,
|
||||
Type: r.Type,
|
||||
Severity: r.Severity,
|
||||
Message: r.Message,
|
||||
Payload: r.Payload,
|
||||
Fingerprint: r.Fingerprint,
|
||||
TS: r.Ts.Time,
|
||||
}
|
||||
if r.ComponentID.Valid {
|
||||
id := uuid.UUID(r.ComponentID.Bytes)
|
||||
ev.ComponentID = &id
|
||||
}
|
||||
if r.ComponentName != nil {
|
||||
ev.ComponentName = *r.ComponentName
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -13,6 +13,7 @@ tags:
|
||||
- name: Storage
|
||||
- name: Collections
|
||||
- name: Databases
|
||||
- name: Telemetry
|
||||
paths:
|
||||
/health/liveness:
|
||||
get:
|
||||
@@ -1091,6 +1092,200 @@ paths:
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"502":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/ingest:
|
||||
post:
|
||||
tags: [Telemetry]
|
||||
operationId: ingestTelemetry
|
||||
summary: Ingest one telemetry event or a batch of up to 500
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/IngestRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: Accepted event count
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/IngestResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"413":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/events:
|
||||
get:
|
||||
tags: [Telemetry]
|
||||
operationId: listTelemetryEvents
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: type
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [error, metric, log, heartbeat, event]
|
||||
- name: component
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: fingerprint
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: before
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Telemetry events, newest first
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TelemetryEventListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/issues:
|
||||
get:
|
||||
tags: [Telemetry]
|
||||
operationId: listTelemetryIssues
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: days
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Error groups by fingerprint
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TelemetryIssueListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/components:
|
||||
get:
|
||||
tags: [Telemetry]
|
||||
operationId: listTelemetryComponents
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Registered telemetry components
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TelemetryComponentListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/components/{componentID}:
|
||||
delete:
|
||||
tags: [Telemetry]
|
||||
operationId: deleteTelemetryComponent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- $ref: "#/components/parameters/ComponentID"
|
||||
responses:
|
||||
"204":
|
||||
description: Component deleted
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/telemetry/stats:
|
||||
get:
|
||||
tags: [Telemetry]
|
||||
operationId: getTelemetryStats
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: window
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d, 30d]
|
||||
responses:
|
||||
"200":
|
||||
description: Aggregated telemetry statistics
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TelemetryStats"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/telemetry/metrics/series:
|
||||
get:
|
||||
tags: [Telemetry]
|
||||
operationId: getTelemetryMetricSeries
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: name
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: window
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d, 30d]
|
||||
responses:
|
||||
"200":
|
||||
description: Aggregated metric series
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MetricSeriesResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/telemetry/stream:
|
||||
get:
|
||||
tags: [Telemetry]
|
||||
operationId: streamTelemetryEvents
|
||||
summary: Server-sent events stream of live telemetry. EventSource clients pass `?api_key=` or `?token=` since headers cannot be set.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: api_key
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: token
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: text/event-stream of TelemetryEvent messages
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -1151,6 +1346,13 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
ComponentID:
|
||||
name: componentID
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
UserID:
|
||||
name: userID
|
||||
in: path
|
||||
@@ -2094,3 +2296,237 @@ components:
|
||||
output:
|
||||
type: string
|
||||
required: [output]
|
||||
IngestEvent:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [error, metric, log, heartbeat, event]
|
||||
component:
|
||||
type: string
|
||||
description: Auto-registered under the project on first use
|
||||
kind:
|
||||
type: string
|
||||
enum: [frontend, backend, database, android, desktop, web, other]
|
||||
severity:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
payload:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
fingerprint:
|
||||
type: string
|
||||
description: Error grouping key; derived from message when omitted
|
||||
ts:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [type]
|
||||
IngestRequest:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/IngestEvent"
|
||||
- type: object
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/IngestEvent"
|
||||
maxItems: 500
|
||||
required: [events]
|
||||
IngestResponse:
|
||||
type: object
|
||||
properties:
|
||||
accepted:
|
||||
type: integer
|
||||
required: [accepted]
|
||||
TelemetryEvent:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
project_id:
|
||||
type: string
|
||||
format: uuid
|
||||
component_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
component_name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
payload:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
fingerprint:
|
||||
type: string
|
||||
ts:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, project_id, type, severity, message, ts]
|
||||
TelemetryEventListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TelemetryEvent"
|
||||
required: [items]
|
||||
TelemetryIssue:
|
||||
type: object
|
||||
properties:
|
||||
fingerprint:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
component_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
component_name:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
first_seen:
|
||||
type: string
|
||||
format: date-time
|
||||
last_seen:
|
||||
type: string
|
||||
format: date-time
|
||||
severity:
|
||||
type: string
|
||||
required: [fingerprint, message, count, first_seen, last_seen, severity]
|
||||
TelemetryIssueListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TelemetryIssue"
|
||||
required: [items]
|
||||
TelemetryComponent:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
last_seen_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_status:
|
||||
type: string
|
||||
errors_24h:
|
||||
type: integer
|
||||
events_24h:
|
||||
type: integer
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [id, name, kind, errors_24h, events_24h]
|
||||
TelemetryComponentListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TelemetryComponent"
|
||||
required: [items]
|
||||
ComponentHealth:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
last_seen_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_status:
|
||||
type: string
|
||||
errors_24h:
|
||||
type: integer
|
||||
events_24h:
|
||||
type: integer
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [id, name, kind, errors_24h, events_24h]
|
||||
EventSeriesBucket:
|
||||
type: object
|
||||
properties:
|
||||
ts:
|
||||
type: string
|
||||
format: date-time
|
||||
counts:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
required: [ts, counts]
|
||||
TelemetryStats:
|
||||
type: object
|
||||
properties:
|
||||
window:
|
||||
type: integer
|
||||
bucket_sec:
|
||||
type: integer
|
||||
series:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/EventSeriesBucket"
|
||||
components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ComponentHealth"
|
||||
errors:
|
||||
type: integer
|
||||
events:
|
||||
type: integer
|
||||
metrics:
|
||||
type: integer
|
||||
metric_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required: [window, bucket_sec, series, components, errors, events, metrics, metric_names]
|
||||
MetricPoint:
|
||||
type: object
|
||||
properties:
|
||||
ts:
|
||||
type: string
|
||||
format: date-time
|
||||
avg:
|
||||
type: number
|
||||
nullable: true
|
||||
p50:
|
||||
type: number
|
||||
nullable: true
|
||||
p95:
|
||||
type: number
|
||||
nullable: true
|
||||
max:
|
||||
type: number
|
||||
nullable: true
|
||||
count:
|
||||
type: integer
|
||||
required: [ts, count]
|
||||
MetricSeriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MetricPoint"
|
||||
required: [items]
|
||||
@@ -23,7 +23,7 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@primora/api-client": "0.2.0",
|
||||
"@primora/api-client": "0.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.2"
|
||||
|
||||
@@ -8,6 +8,7 @@ COPY apps/cli/package.json ./apps/cli/package.json
|
||||
COPY apps/frontend/package.json ./apps/frontend/package.json
|
||||
COPY apps/mcp/package.json ./apps/mcp/package.json
|
||||
COPY packages/api-client/package.json ./packages/api-client/package.json
|
||||
COPY packages/client/package.json ./packages/client/package.json
|
||||
COPY packages/shared-types/package.json ./packages/shared-types/package.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primora/api-client": "0.2.0",
|
||||
"@primora/shared-types": "0.2.0",
|
||||
"@primora/api-client": "0.3.0",
|
||||
"@primora/shared-types": "0.3.0",
|
||||
"better-auth": "^1.5.6",
|
||||
"solid-js": "^1.9.12"
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type AuthUser,
|
||||
CollectionsPage,
|
||||
DatabasesPage,
|
||||
TelemetryPage,
|
||||
LoginPage,
|
||||
MembersPage,
|
||||
ProjectsPage,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
IconMembers,
|
||||
IconStorage,
|
||||
IconDatabases,
|
||||
IconTelemetry,
|
||||
IconCollections,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
@@ -1411,6 +1413,7 @@ export default function App() {
|
||||
{ id: "nav-projects", label: "Go to Projects", category: "Navigate", icon: <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-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-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") },
|
||||
@@ -1624,6 +1627,14 @@ export default function App() {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "telemetry"}>
|
||||
<TelemetryPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "storage"}>
|
||||
<StoragePage
|
||||
buckets={buckets()}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconStorage,
|
||||
IconCollections,
|
||||
IconDatabases,
|
||||
IconTelemetry,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
IconSettings,
|
||||
@@ -26,6 +27,7 @@ export type ViewType =
|
||||
| "storage"
|
||||
| "collections"
|
||||
| "databases"
|
||||
| "telemetry"
|
||||
| "auth"
|
||||
| "audit"
|
||||
| "settings";
|
||||
@@ -40,14 +42,15 @@ interface NavEntry {
|
||||
const projectNav: NavEntry[] = [
|
||||
{ id: "dashboard", label: "Overview", icon: IconOverview },
|
||||
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
|
||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
|
||||
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
|
||||
];
|
||||
|
||||
// Instagram-style bottom bar caps at five slots — Collections and Audit stay reachable via Menu.
|
||||
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections");
|
||||
// Instagram-style bottom bar caps at five slots — Collections and Telemetry stay reachable via Menu.
|
||||
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections" && e.id !== "telemetry");
|
||||
|
||||
const workspaceNav: NavEntry[] = [
|
||||
{ id: "projects", label: "Projects", icon: IconProjects },
|
||||
@@ -84,6 +87,7 @@ const viewTitle: Record<ViewType, string> = {
|
||||
storage: "Storage",
|
||||
collections: "Collections",
|
||||
databases: "Databases",
|
||||
telemetry: "Telemetry",
|
||||
auth: "Authentication",
|
||||
audit: "Audit log",
|
||||
settings: "Settings",
|
||||
|
||||
@@ -127,6 +127,7 @@ export const IconSun = icon(() => (<><circle cx="12" cy="12" r="4" /><path d="M1
|
||||
export const IconMoon = icon(() => (<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />));
|
||||
export const 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 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 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" /></>));
|
||||
|
||||
@@ -17,9 +17,14 @@ import type {
|
||||
DBConnection,
|
||||
Document,
|
||||
MeResponse,
|
||||
MetricPoint,
|
||||
OrganizationSummary,
|
||||
ProjectOverview,
|
||||
ProjectSummary,
|
||||
TelemetryComponent,
|
||||
TelemetryEvent,
|
||||
TelemetryIssue,
|
||||
TelemetryStats,
|
||||
} from "@primora/api-client";
|
||||
|
||||
const STORAGE_KEY = "primora_demo_mode";
|
||||
@@ -353,6 +358,23 @@ let dbConnections: DBConnection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const demoComponents: TelemetryComponent[] = [
|
||||
{ id: "cmp-1", name: "web", kind: "frontend", last_seen_at: ago(0.02), last_status: "up", errors_24h: 0, events_24h: 88, meta: {} },
|
||||
{ id: "cmp-2", name: "api", kind: "backend", last_seen_at: ago(0.014), last_status: "degraded", errors_24h: 4, events_24h: 120, meta: {} },
|
||||
{ id: "cmp-3", name: "worker", kind: "backend", last_seen_at: ago(0.03), last_status: "up", errors_24h: 0, events_24h: 60, meta: {} },
|
||||
];
|
||||
|
||||
const demoEventsSeed: TelemetryEvent[] = [
|
||||
{ id: 42, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "error", severity: "error", message: "TypeError: Cannot read properties of undefined (reading 'id')", payload: { stack: "at getUser (api/src/users.ts:41)" }, fingerprint: "a1b2c3d4e5f60718", ts: ago(0.013) },
|
||||
{ id: 41, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "metric", severity: "info", message: "", payload: { name: "http.req.ms", value: 132, route: "/projects" }, fingerprint: "", ts: ago(0.02) },
|
||||
{ id: 40, project_id: "proj-1", component_id: "cmp-1", component_name: "web", type: "log", severity: "warn", message: "Slow route render: /settings", payload: { ms: 940 }, fingerprint: "", ts: ago(0.03) },
|
||||
{ id: 39, project_id: "proj-1", component_id: "cmp-3", component_name: "worker", type: "heartbeat", severity: "info", message: "", payload: { status: "up" }, fingerprint: "", ts: ago(0.04) },
|
||||
{ id: 38, project_id: "proj-1", component_id: "cmp-1", component_name: "web", type: "event", severity: "info", message: "checkout.completed", payload: { total: 129 }, fingerprint: "", ts: ago(0.06) },
|
||||
{ id: 37, project_id: "proj-1", component_id: "cmp-2", component_name: "api", type: "error", severity: "error", message: "TypeError: Cannot read properties of undefined (reading 'id')", payload: { stack: "at getUser (api/src/users.ts:41)" }, fingerprint: "a1b2c3d4e5f60718", ts: ago(3.5) },
|
||||
];
|
||||
|
||||
let demoEvents: TelemetryEvent[] = [...demoEventsSeed];
|
||||
|
||||
const demoTables = [
|
||||
"users",
|
||||
"organizations",
|
||||
@@ -881,6 +903,120 @@ class DemoService {
|
||||
const cmd = data.requestBody?.command ?? "";
|
||||
return { output: cmd.trim().toUpperCase() === "PING" ? "PONG" : "OK" };
|
||||
}
|
||||
|
||||
/* ---- telemetry ---- */
|
||||
|
||||
async ingestTelemetry(data: { requestBody?: { events?: Array<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();
|
||||
@@ -0,0 +1,554 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import {
|
||||
OpenAPI,
|
||||
TelemetryService,
|
||||
type MetricPoint,
|
||||
type TelemetryEvent,
|
||||
type TelemetryIssue,
|
||||
type TelemetryStats,
|
||||
} from "@primora/api-client";
|
||||
import { fetchApiToken } from "../lib/auth-client";
|
||||
import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Select } from "../components/Input";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconRefresh,
|
||||
} from "../components/Icons";
|
||||
|
||||
interface TelemetryPageProps {
|
||||
projectID?: string;
|
||||
canManage: boolean;
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
type Tab = "health" | "issues" | "events" | "metrics" | "integrate";
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const svc = (demo: boolean) =>
|
||||
(demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService);
|
||||
|
||||
const sevVariant = (s?: string) =>
|
||||
s === "error" || s === "fatal" ? "error" : s === "warn" || s === "warning" ? "warning" : s === "info" ? "primary" : "neutral";
|
||||
|
||||
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||
|
||||
const snippetWeb = `import { createPrimoraClient } from "@primora/client";
|
||||
|
||||
const primora = createPrimoraClient({
|
||||
endpoint: window.location.origin, // your Primora deployment
|
||||
key: "prm_…", // project API key
|
||||
component: "web",
|
||||
kind: "frontend",
|
||||
});
|
||||
|
||||
primora.installAuto(); // window.onerror + unhandledrejection
|
||||
primora.captureMetric({ name: "app.start.ms", value: performance.now() });`;
|
||||
|
||||
const snippetNode = `import { createPrimoraClient } from "@primora/client";
|
||||
|
||||
const primora = createPrimoraClient({
|
||||
endpoint: "https://primora.example.com",
|
||||
key: process.env.PRIMORA_KEY!, // X-Primora-Key
|
||||
component: "api",
|
||||
kind: "backend",
|
||||
});
|
||||
|
||||
setInterval(() => primora.heartbeat("up"), 30_000);
|
||||
process.on("uncaughtException", (e) => primora.captureError(e));`;
|
||||
|
||||
const snippetCurl = `curl -X POST "$ORIGIN/api/v1/ingest" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Primora-Key: prm_…" \\
|
||||
-d '{"type":"event","component":"cli","message":"deploy.done","payload":{"sha":"abc123"}}'`;
|
||||
|
||||
export function TelemetryPage(props: TelemetryPageProps) {
|
||||
const [tab, setTab] = createSignal<Tab>("health");
|
||||
const [windowSel, setWindowSel] = createSignal("24h");
|
||||
const [stats, setStats] = createSignal<TelemetryStats | null>(null);
|
||||
const [issues, setIssues] = createSignal<TelemetryIssue[]>([]);
|
||||
const [events, setEvents] = createSignal<TelemetryEvent[]>([]);
|
||||
const [typeFilter, setTypeFilter] = createSignal("");
|
||||
const [fpFilter, setFpFilter] = createSignal("");
|
||||
const [metricName, setMetricName] = createSignal("");
|
||||
const [metricPoints, setMetricPoints] = createSignal<MetricPoint[]>([]);
|
||||
const [detail, setDetail] = createSignal<TelemetryEvent | null>(null);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal("");
|
||||
const [live, setLive] = createSignal(false);
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.projectID) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const s = svc(props.demoMode);
|
||||
const [st, iss, ev] = await Promise.all([
|
||||
s.getTelemetryStats({ projectId: props.projectID, window: windowSel() as "1h" | "24h" | "7d" | "30d" }),
|
||||
s.listTelemetryIssues({ projectId: props.projectID }),
|
||||
s.listTelemetryEvents({
|
||||
projectId: props.projectID,
|
||||
type: (typeFilter() || undefined) as "error" | "metric" | "log" | "heartbeat" | "event" | undefined,
|
||||
fingerprint: fpFilter() || undefined,
|
||||
limit: 100,
|
||||
}),
|
||||
]);
|
||||
setStats(st);
|
||||
setIssues(iss.items ?? []);
|
||||
setEvents(ev.items ?? []);
|
||||
if (!metricName() && st.metric_names?.length) setMetricName(st.metric_names[0]);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshMetric = async (name: string) => {
|
||||
if (!props.projectID || !name) {
|
||||
setMetricPoints([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await svc(props.demoMode).getTelemetryMetricSeries({
|
||||
projectId: props.projectID,
|
||||
name,
|
||||
window: windowSel() as "1h" | "24h" | "7d" | "30d",
|
||||
});
|
||||
setMetricPoints(res.items ?? []);
|
||||
} catch {
|
||||
setMetricPoints([]);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.projectID) return;
|
||||
void refresh();
|
||||
|
||||
if (props.demoMode) return;
|
||||
let es: EventSource | null = null;
|
||||
let cancelled = false;
|
||||
void fetchApiToken().then((token) => {
|
||||
if (cancelled || !token || !props.projectID) return;
|
||||
const url = `${OpenAPI.BASE}/projects/${props.projectID}/telemetry/stream?token=${encodeURIComponent(token)}`;
|
||||
es = new EventSource(url);
|
||||
es.addEventListener("event", (m) => {
|
||||
setLive(true);
|
||||
try {
|
||||
const ev = JSON.parse((m as MessageEvent).data) as TelemetryEvent;
|
||||
const tf = typeFilter();
|
||||
const ff = fpFilter();
|
||||
if ((!tf || ev.type === tf) && (!ff || ev.fingerprint === ff)) {
|
||||
setEvents((prev) => [ev, ...prev].slice(0, 200));
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
});
|
||||
es.onerror = () => setLive(false);
|
||||
es.onopen = () => setLive(true);
|
||||
});
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
es?.close();
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
void refreshMetric(metricName());
|
||||
});
|
||||
|
||||
const typeCounts = createMemo(() => {
|
||||
const totals: Record<string, number> = {};
|
||||
for (const b of stats()?.series ?? []) {
|
||||
for (const [t, n] of Object.entries(b.counts ?? {})) {
|
||||
totals[t] = (totals[t] ?? 0) + n;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
});
|
||||
|
||||
const maxBucket = createMemo(() =>
|
||||
Math.max(1, ...(stats()?.series ?? []).map((b) => Object.values(b.counts ?? {}).reduce((a, n) => a + n, 0))),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Telemetry</h1>
|
||||
<p class="page-description">Errors, metrics, logs, and heartbeats across your components.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={live()}>
|
||||
<Badge variant="success">Live</Badge>
|
||||
</Show>
|
||||
<Select
|
||||
value={windowSel()}
|
||||
onChange={(e) => {
|
||||
setWindowSel(e.currentTarget.value);
|
||||
void refresh();
|
||||
}}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="24h">24h</option>
|
||||
<option value="7d">7d</option>
|
||||
<option value="30d">30d</option>
|
||||
</Select>
|
||||
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
|
||||
<IconRefresh class="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="message message-error">{error()}</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex gap-1 mb-4 flex-wrap">
|
||||
<For each={["health", "issues", "events", "metrics", "integrate"] as Tab[]}>
|
||||
{(t) => (
|
||||
<button
|
||||
class={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${tab() === t ? "bg-surface-2 text-text-1" : "text-text-2 hover:text-text-1"}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === "issues" ? `Issues (${issues().length})` : t[0].toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={tab() === "health"}>
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr))">
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-text-2">Errors</div>
|
||||
<div class="text-2xl font-semibold">{stats()?.errors ?? 0}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-text-2">Events</div>
|
||||
<div class="text-2xl font-semibold">{stats()?.events ?? 0}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-text-2">Metrics</div>
|
||||
<div class="text-2xl font-semibold">{stats()?.metrics ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush mt-4">
|
||||
<div class="card-header">
|
||||
<span class="card-header-title">Activity</span>
|
||||
</div>
|
||||
<div class="p-4 flex items-end gap-1 overflow-x-auto" style="min-height: 6rem">
|
||||
<For each={stats()?.series ?? []}>
|
||||
{(b) => {
|
||||
const total = () => Object.values(b.counts ?? {}).reduce((a, n) => a + n, 0);
|
||||
return (
|
||||
<div
|
||||
class="flex-1 rounded-sm"
|
||||
style={{
|
||||
"min-width": "6px",
|
||||
height: `${Math.max(4, (total() / maxBucket()) * 80)}px`,
|
||||
background: (b.counts?.error ?? 0) > 0 ? "var(--error, #e5484d)" : "var(--accent)",
|
||||
opacity: 0.85,
|
||||
}}
|
||||
title={`${new Date(b.ts).toLocaleString()} — ${total()} events`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<div class="px-4 pb-3 flex gap-3 flex-wrap text-xs text-text-2">
|
||||
<For each={Object.entries(typeCounts())}>
|
||||
{([t, n]) => (
|
||||
<span>
|
||||
{t}: {n}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush mt-4">
|
||||
<div class="card-header">
|
||||
<span class="card-header-title">Components</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Errors 24h</th>
|
||||
<th>Events 24h</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For
|
||||
each={stats()?.components ?? []}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colspan={6} class="text-center text-text-2 py-6">
|
||||
No components yet — send an event with a `component` field.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
>
|
||||
{(c) => (
|
||||
<tr>
|
||||
<td class="font-medium">{c.name}</td>
|
||||
<td>{c.kind}</td>
|
||||
<td>
|
||||
<Badge variant={c.last_status === "up" ? "success" : c.last_status === "down" ? "error" : c.last_status === "degraded" ? "warning" : "neutral"}>
|
||||
{c.last_status || "unknown"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>{c.errors_24h}</td>
|
||||
<td>{c.events_24h}</td>
|
||||
<td class="text-text-2">{fmtTime(c.last_seen_at)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={tab() === "issues"}>
|
||||
<div class="card card-flush">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Error</th>
|
||||
<th>Component</th>
|
||||
<th>Count</th>
|
||||
<th>Severity</th>
|
||||
<th>Last seen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For
|
||||
each={issues()}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colspan={6} class="text-center text-text-2 py-6">
|
||||
No error groups in the last 30 days.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
>
|
||||
{(i) => (
|
||||
<tr>
|
||||
<td class="max-w-0 truncate" style="max-width: 24rem" title={i.message}>
|
||||
{i.message}
|
||||
</td>
|
||||
<td>{i.component_name || "—"}</td>
|
||||
<td>{i.count}</td>
|
||||
<td>
|
||||
<Badge variant={sevVariant(i.severity)}>{i.severity}</Badge>
|
||||
</td>
|
||||
<td class="text-text-2">{fmtTime(i.last_seen)}</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onClick={() => {
|
||||
setFpFilter(i.fingerprint);
|
||||
setTab("events");
|
||||
void refresh();
|
||||
}}
|
||||
>
|
||||
<IconChevronRight class="w-4 h-4" />
|
||||
Events
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={tab() === "events"}>
|
||||
<div class="flex gap-2 mb-3 flex-wrap items-center">
|
||||
<For each={["", "error", "metric", "log", "heartbeat", "event"]}>
|
||||
{(t) => (
|
||||
<button
|
||||
class={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${typeFilter() === t ? "bg-accent text-white" : "bg-surface-2 text-text-2 hover:text-text-1"}`}
|
||||
onClick={() => {
|
||||
setTypeFilter(t);
|
||||
void refresh();
|
||||
}}
|
||||
>
|
||||
{t || "all"}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={fpFilter()}>
|
||||
<button
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-surface-2 text-text-1"
|
||||
onClick={() => {
|
||||
setFpFilter("");
|
||||
void refresh();
|
||||
}}
|
||||
>
|
||||
fingerprint: {fpFilter().slice(0, 8)}… ✕
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="card card-flush">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Severity</th>
|
||||
<th>Message</th>
|
||||
<th>Component</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For
|
||||
each={events()}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colspan={5} class="text-center text-text-2 py-6">
|
||||
No events.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
>
|
||||
{(e) => (
|
||||
<tr class="cursor-pointer" onClick={() => setDetail(e)}>
|
||||
<td>
|
||||
<Badge variant={e.type === "error" ? "error" : "neutral"}>{e.type}</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant={sevVariant(e.severity)}>{e.severity}</Badge>
|
||||
</td>
|
||||
<td class="max-w-0 truncate" style="max-width: 22rem" title={e.message}>
|
||||
{e.message || <span class="text-text-2">{e.payload?.name ? `${e.payload.name} = ${e.payload.value}` : JSON.stringify(e.payload)}</span>}
|
||||
</td>
|
||||
<td>{e.component_name || "—"}</td>
|
||||
<td class="text-text-2 whitespace-nowrap">{fmtTime(e.ts)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={tab() === "metrics"}>
|
||||
<div class="flex gap-2 mb-3 flex-wrap">
|
||||
<For
|
||||
each={stats()?.metric_names ?? []}
|
||||
fallback={<span class="text-text-2 text-sm">No metric names seen yet.</span>}
|
||||
>
|
||||
{(n) => (
|
||||
<button
|
||||
class={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${metricName() === n ? "bg-accent text-white" : "bg-surface-2 text-text-2 hover:text-text-1"}`}
|
||||
onClick={() => setMetricName(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="card card-flush">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Avg</th>
|
||||
<th>p50</th>
|
||||
<th>p95</th>
|
||||
<th>Max</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For
|
||||
each={metricPoints()}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colspan={6} class="text-center text-text-2 py-6">
|
||||
Select a metric name.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
>
|
||||
{(p) => (
|
||||
<tr>
|
||||
<td class="text-text-2 whitespace-nowrap">{fmtTime(p.ts)}</td>
|
||||
<td>{p.avg?.toFixed(1) ?? "—"}</td>
|
||||
<td>{p.p50?.toFixed(1) ?? "—"}</td>
|
||||
<td>{p.p95?.toFixed(1) ?? "—"}</td>
|
||||
<td>{p.max?.toFixed(1) ?? "—"}</td>
|
||||
<td>{p.count}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={tab() === "integrate"}>
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr))">
|
||||
<For each={[{ title: "Web / browser", code: snippetWeb }, { title: "Node / server", code: snippetNode }, { title: "cURL", code: snippetCurl }]}>
|
||||
{(s) => (
|
||||
<div class="card card-flush">
|
||||
<div class="card-header">
|
||||
<span class="card-header-title">{s.title}</span>
|
||||
</div>
|
||||
<pre class="p-4 text-xs overflow-x-auto" style="white-space: pre">{s.code}</pre>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p class="text-sm text-text-2 mt-4">
|
||||
Create a project API key under Settings, then pass it as <code>X-Primora-Key</code> to <code>POST /api/v1/ingest</code>. Batches of up to 500 events are accepted.
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Modal
|
||||
open={detail() !== null}
|
||||
onClose={() => setDetail(null)}
|
||||
title={detail()?.message || detail()?.type || "Event"}
|
||||
>
|
||||
<Show when={detail()}>
|
||||
{(e) => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Badge variant={e().type === "error" ? "error" : "neutral"}>{e().type}</Badge>
|
||||
<Badge variant={sevVariant(e().severity)}>{e().severity}</Badge>
|
||||
<Show when={e().component_name}>
|
||||
<Badge variant="neutral">{e().component_name}</Badge>
|
||||
</Show>
|
||||
<Show when={e().fingerprint}>
|
||||
<Badge variant="neutral">fp {e().fingerprint!.slice(0, 8)}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-xs text-text-2">{fmtTime(e().ts)}</div>
|
||||
<pre class="text-xs p-3 rounded-lg bg-surface-2 overflow-x-auto" style="white-space: pre-wrap">
|
||||
{JSON.stringify(e().payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export { MembersPage } from "./MembersPage";
|
||||
export { StoragePage } from "./StoragePage";
|
||||
export { CollectionsPage } from "./CollectionsPage";
|
||||
export { DatabasesPage } from "./DatabasesPage";
|
||||
export { TelemetryPage } from "./TelemetryPage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { AuditPage } from "./AuditPage";
|
||||
export { AuthPage, type AuthUser } from "./AuthPage";
|
||||
@@ -22,7 +22,7 @@
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@primora/api-client": "0.2.0",
|
||||
"@primora/api-client": "0.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"typescript": "^5.9.2"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PlatformService,
|
||||
ProjectsService,
|
||||
StorageService,
|
||||
TelemetryService,
|
||||
} from "@primora/api-client";
|
||||
|
||||
import { configureClient, loadConfig, type McpConfig } from "./config.js";
|
||||
@@ -362,4 +363,94 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
const eventType = z.enum(["error", "metric", "log", "heartbeat", "event"]);
|
||||
|
||||
server.registerTool(
|
||||
"primora_list_issues",
|
||||
{
|
||||
description: "Error groups by fingerprint — count, severity, first/last seen",
|
||||
inputSchema: {
|
||||
projectId: z.string().optional(),
|
||||
days: z.number().int().optional().describe("Lookback window (default 30)"),
|
||||
},
|
||||
},
|
||||
async ({ projectId: p, days }) => {
|
||||
try {
|
||||
return ok(await TelemetryService.listTelemetryIssues({ projectId: projectId(p), days }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_list_events",
|
||||
{
|
||||
description: "Telemetry events, newest first — errors, metrics, logs, heartbeats",
|
||||
inputSchema: {
|
||||
projectId: z.string().optional(),
|
||||
type: eventType.optional(),
|
||||
component: z.string().optional(),
|
||||
fingerprint: z.string().optional().describe("Limit to one error group"),
|
||||
limit: z.number().int().optional(),
|
||||
before: z.number().int().optional().describe("Event id cursor for pagination"),
|
||||
},
|
||||
},
|
||||
async ({ projectId: p, type, component, fingerprint, limit, before }) => {
|
||||
try {
|
||||
return ok(
|
||||
await TelemetryService.listTelemetryEvents({
|
||||
projectId: projectId(p),
|
||||
type,
|
||||
component,
|
||||
fingerprint,
|
||||
limit,
|
||||
before,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_telemetry_stats",
|
||||
{
|
||||
description: "Aggregated telemetry: event series, component health, totals, metric names",
|
||||
inputSchema: {
|
||||
projectId: z.string().optional(),
|
||||
window: z.enum(["1h", "24h", "7d", "30d"]).optional(),
|
||||
},
|
||||
},
|
||||
async ({ projectId: p, window }) => {
|
||||
try {
|
||||
return ok(await TelemetryService.getTelemetryStats({ projectId: projectId(p), window }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_metric_series",
|
||||
{
|
||||
description: "Time series for one metric name — avg, p50, p95, max per bucket",
|
||||
inputSchema: {
|
||||
projectId: z.string().optional(),
|
||||
name: z.string(),
|
||||
window: z.enum(["1h", "24h", "7d", "30d"]).optional(),
|
||||
},
|
||||
},
|
||||
async ({ projectId: p, name, window }) => {
|
||||
try {
|
||||
return ok(
|
||||
await TelemetryService.getTelemetryMetricSeries({ projectId: projectId(p), name, window }),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
Reference in new issue
Block a user