feat: phase 3 observability — IMS telemetry merge

Port IMS telemetry into Primora conventions:

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

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

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

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

No files matched your search

@@ -0,0 +1,32 @@
-- +goose Up
CREATE TABLE core.components (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'other'
CHECK (kind IN ('frontend','backend','database','android','desktop','web','other')),
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
CREATE TABLE core.events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
component_id UUID REFERENCES core.components(id) ON DELETE SET NULL,
type TEXT NOT NULL CHECK (type IN ('error','metric','log','heartbeat','event')),
severity TEXT NOT NULL DEFAULT 'info',
message TEXT NOT NULL DEFAULT '',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
fingerprint TEXT NOT NULL DEFAULT '',
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_events_project_ts ON core.events(project_id, ts DESC);
CREATE INDEX idx_events_project_type_ts ON core.events(project_id, type, ts DESC);
CREATE INDEX idx_events_fingerprint ON core.events(fingerprint) WHERE type = 'error';
CREATE INDEX idx_events_metric_name ON core.events(project_id, (payload->>'name')) WHERE type = 'metric';
-- +goose Down
DROP TABLE IF EXISTS core.events;
DROP TABLE IF EXISTS core.components;
+102
View File
@@ -0,0 +1,102 @@
-- name: UpsertComponent :one
INSERT INTO core.components (project_id, name, kind)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, name) DO UPDATE SET name = EXCLUDED.name
RETURNING *;
-- name: GetComponentByName :one
SELECT * FROM core.components
WHERE project_id = $1 AND name = $2;
-- name: ListComponents :many
SELECT * FROM core.components
WHERE project_id = $1
ORDER BY name;
-- name: DeleteComponent :exec
DELETE FROM core.components
WHERE id = $1 AND project_id = $2;
-- name: InsertEvent :one
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: ListEvents :many
SELECT e.*, co.name AS component_name
FROM core.events e
LEFT JOIN core.components co ON co.id = e.component_id
WHERE e.project_id = $1
AND (sqlc.narg('type')::text IS NULL OR e.type = sqlc.narg('type'))
AND (sqlc.narg('component')::text IS NULL OR co.name = sqlc.narg('component'))
AND (sqlc.narg('fingerprint')::text IS NULL OR e.fingerprint = sqlc.narg('fingerprint'))
AND (sqlc.narg('before')::bigint IS NULL OR e.id < sqlc.narg('before'))
ORDER BY e.id DESC
LIMIT sqlc.arg('limit');
-- name: ListErrorGroups :many
SELECT e.fingerprint,
(array_agg(e.message ORDER BY e.ts DESC))[1]::text AS message,
coalesce((array_agg(e.component_id::text ORDER BY e.ts DESC))[1], '')::text AS component_id,
coalesce((array_agg(co.name ORDER BY e.ts DESC))[1], '')::text AS component_name,
count(*) AS count,
min(e.ts)::timestamptz AS first_seen,
max(e.ts)::timestamptz AS last_seen,
(array_agg(e.severity ORDER BY e.ts DESC))[1]::text AS severity
FROM core.events e
LEFT JOIN core.components co ON co.id = e.component_id
WHERE e.project_id = $1 AND e.type = 'error' AND e.fingerprint <> ''
AND e.ts > now() - (sqlc.narg('days')::text || ' days')::interval
GROUP BY e.fingerprint
ORDER BY max(e.ts) DESC
LIMIT 200;
-- name: EventTypeSeries :many
SELECT to_timestamp(floor(extract(epoch FROM e.ts) / sqlc.arg('bucket_sec')::bigint) * sqlc.arg('bucket_sec')::bigint)::timestamptz AS bucket,
e.type,
count(*) AS count
FROM core.events e
WHERE e.project_id = $1 AND e.ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint)
GROUP BY bucket, e.type
ORDER BY bucket;
-- name: ComponentHealthRows :many
SELECT co.id, co.name, co.kind, co.meta,
max(e.ts)::timestamptz AS last_seen_at,
coalesce(((array_agg(e.payload->>'status' ORDER BY e.ts DESC)
FILTER (WHERE e.type = 'heartbeat'))[1]), '')::text AS last_status,
count(*) FILTER (WHERE e.type = 'error' AND e.ts > now() - interval '24 hours') AS errors_24h,
count(*) FILTER (WHERE e.ts > now() - interval '24 hours') AS events_24h
FROM core.components co
LEFT JOIN core.events e ON e.component_id = co.id
WHERE co.project_id = $1
GROUP BY co.id
ORDER BY co.name;
-- name: EventTotals :one
SELECT count(*) FILTER (WHERE type = 'error') AS errors,
count(*) AS events,
count(*) FILTER (WHERE type = 'metric') AS metrics
FROM core.events
WHERE project_id = $1 AND ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint);
-- name: MetricNames :many
SELECT DISTINCT (payload->>'name')::text AS name
FROM core.events
WHERE project_id = $1 AND type = 'metric' AND payload ? 'name'
ORDER BY 1
LIMIT 50;
-- name: MetricSeries :many
SELECT to_timestamp(floor(extract(epoch FROM ts) / sqlc.arg('bucket_sec')::bigint) * sqlc.arg('bucket_sec')::bigint)::timestamptz AS bucket,
avg((payload->>'value')::float8)::float8 AS avg,
percentile_cont(0.5) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY (payload->>'value')::float8)::float8 AS p95,
max((payload->>'value')::float8)::float8 AS max,
count(*) AS count
FROM core.events
WHERE project_id = $1 AND type = 'metric' AND payload->>'name' = sqlc.arg('name')::text
AND ts > now() - make_interval(secs => sqlc.arg('window_sec')::bigint)
AND jsonb_typeof(payload->'value') = 'number'
GROUP BY bucket
ORDER BY bucket;
@@ -200,6 +200,15 @@ type CoreCollection struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
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
}
+9
View File
@@ -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)
+204
View File
@@ -0,0 +1,204 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
// ingestCORS — SDKs send telemetry from arbitrary origins (browsers, phones,
// desktops), so this one route allows any origin.
func ingestCORS(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Headers", "Content-Type, X-Primora-Key, X-API-Key, Authorization")
c.Header("Access-Control-Allow-Methods", "POST")
}
func (h *HTTPHandler) ingestOptions(c *gin.Context) {
ingestCORS(c)
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) ingest(c *gin.Context) {
ingestCORS(c)
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
if actor.APIKeyID == nil || actor.ProjectID == nil {
apperrors.Abort(c, http.StatusUnauthorized, "api_key_required", "ingest requires a project API key (X-Primora-Key)")
return
}
raw, err := c.GetRawData()
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "unreadable body")
return
}
var events []services.IngestEventInput
var batch struct {
Events []services.IngestEventInput `json:"events"`
}
if err := json.Unmarshal(raw, &batch); err == nil && len(batch.Events) > 0 {
events = batch.Events
} else {
var single services.IngestEventInput
if err := json.Unmarshal(raw, &single); err != nil || single.Type == "" {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "expected {type,...} or {events:[...]}")
return
}
events = []services.IngestEventInput{single}
}
if len(events) > 500 {
apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_many_events", "max 500 events per batch")
return
}
accepted, err := h.Platform.IngestEvents(c.Request.Context(), actor, events)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"accepted": accepted})
}
func (h *HTTPHandler) listEvents(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
f := services.EventListFilters{
Type: strings.TrimSpace(c.Query("type")),
Component: strings.TrimSpace(c.Query("component")),
Fingerprint: strings.TrimSpace(c.Query("fingerprint")),
}
if v, err := strconv.ParseInt(c.Query("before"), 10, 64); err == nil && v > 0 {
f.Before = &v
}
if v, err := strconv.Atoi(c.Query("limit")); err == nil {
f.Limit = v
}
result, err := h.Platform.ListEvents(c.Request.Context(), actor, projectID, f)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) listIssues(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
result, err := h.Platform.ListIssues(c.Request.Context(), actor, projectID, days)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) listComponents(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListComponents(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) deleteComponent(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
componentID, ok := parseUUIDParam(c, "componentID")
if !ok {
return
}
if err := h.Platform.DeleteComponent(c.Request.Context(), actor, projectID, componentID); err != nil {
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) telemetryStats(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.TelemetryStats(c.Request.Context(), actor, projectID, c.DefaultQuery("window", "24h"))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) metricSeries(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.MetricSeriesData(c.Request.Context(), actor, projectID,
strings.TrimSpace(c.Query("name")), c.DefaultQuery("window", "24h"))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) telemetryStream(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
ch, err := h.Platform.SubscribeTelemetry(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
defer h.Platform.UnsubscribeTelemetry(projectID, ch)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
if _, err := c.Writer.WriteString(": connected\n\n"); err == nil {
c.Writer.Flush()
}
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
c.Stream(func(w io.Writer) bool {
select {
case msg, ok := <-ch:
if !ok {
return false
}
c.SSEvent("event", json.RawMessage(msg))
return true
case <-keepalive.C:
c.SSEvent("ping", "keepalive")
return true
case <-c.Request.Context().Done():
return false
}
})
}
@@ -74,7 +74,20 @@ func Logger(logger *slog.Logger) gin.HandlerFunc {
func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
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 ") {
+50
View File
@@ -0,0 +1,50 @@
package services
import (
"encoding/json"
"sync"
)
// EventHub fans out ingested telemetry events to SSE subscribers, keyed by
// project ID. Slow consumers are dropped rather than back-pressured.
type EventHub struct {
mu sync.Mutex
subs map[string]map[chan []byte]struct{}
}
func NewEventHub() *EventHub {
return &EventHub{subs: map[string]map[chan []byte]struct{}{}}
}
func (h *EventHub) Subscribe(projectID string) chan []byte {
ch := make(chan []byte, 64)
h.mu.Lock()
if h.subs[projectID] == nil {
h.subs[projectID] = map[chan []byte]struct{}{}
}
h.subs[projectID][ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *EventHub) Unsubscribe(projectID string, ch chan []byte) {
h.mu.Lock()
delete(h.subs[projectID], ch)
h.mu.Unlock()
close(ch)
}
func (h *EventHub) Broadcast(projectID string, v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
h.mu.Lock()
for ch := range h.subs[projectID] {
select {
case ch <- data:
default:
}
}
h.mu.Unlock()
}
@@ -32,6 +32,7 @@ type PlatformService struct {
publicURL string
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) {
+459
View File
@@ -0,0 +1,459 @@
package services
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
var telemetryEventTypes = map[string]bool{
"error": true, "metric": true, "log": true, "heartbeat": true, "event": true,
}
var componentKinds = map[string]bool{
"frontend": true, "backend": true, "database": true,
"android": true, "desktop": true, "web": true, "other": true,
}
// IngestEventInput is one event in an ingest batch.
type IngestEventInput struct {
Type string `json:"type"`
Component string `json:"component"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Message string `json:"message"`
Payload json.RawMessage `json:"payload"`
Fingerprint string `json:"fingerprint"`
TS *time.Time `json:"ts"`
}
// TelemetryEvent is the client-facing event shape.
type TelemetryEvent struct {
ID int64 `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ComponentID *uuid.UUID `json:"component_id"`
ComponentName string `json:"component_name,omitempty"`
Type string `json:"type"`
Severity string `json:"severity"`
Message string `json:"message"`
Payload json.RawMessage `json:"payload"`
Fingerprint string `json:"fingerprint"`
TS time.Time `json:"ts"`
}
type TelemetryIssue struct {
Fingerprint string `json:"fingerprint"`
Message string `json:"message"`
ComponentID *uuid.UUID `json:"component_id"`
ComponentName string `json:"component_name,omitempty"`
Count int64 `json:"count"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
Severity string `json:"severity"`
}
type ComponentSummary struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
LastSeenAt *time.Time `json:"last_seen_at"`
LastStatus string `json:"last_status"`
Errors24h int64 `json:"errors_24h"`
Events24h int64 `json:"events_24h"`
Meta map[string]any `json:"meta"`
}
func fingerprintOf(componentID uuid.UUID, message string) string {
line := strings.SplitN(message, "\n", 2)[0]
sum := sha256.Sum256([]byte(componentID.String() + "|" + line))
return hex.EncodeToString(sum[:])[:16]
}
// IngestEvents stores a batch of telemetry events under the ingest key's
// project. API-key actors only — the key's project scope is the write scope.
func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor, events []IngestEventInput) (int, error) {
if actor == nil || actor.ProjectID == nil || actor.APIKeyID == nil {
return 0, errors.New("ingest requires a project API key")
}
projectID := *actor.ProjectID
var inserted []TelemetryEvent
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
compCache := map[string]*uuid.UUID{}
for _, ev := range events {
if !telemetryEventTypes[ev.Type] {
continue
}
var compID *uuid.UUID
if ev.Component != "" {
cached, hit := compCache[ev.Component]
if !hit {
kind := ev.Kind
if !componentKinds[kind] {
kind = "other"
}
row, err := q.UpsertComponent(ctx, db.UpsertComponentParams{
ProjectID: projectID,
Name: ev.Component,
Kind: kind,
})
if err != nil {
continue
}
id := row.ID
cached = &id
compCache[ev.Component] = cached
}
compID = cached
}
sev := ev.Severity
if sev == "" {
sev = "info"
if ev.Type == "error" {
sev = "error"
}
}
fp := ev.Fingerprint
if ev.Type == "error" && fp == "" && compID != nil {
fp = fingerprintOf(*compID, ev.Message)
}
ts := time.Now()
if ev.TS != nil {
ts = *ev.TS
}
payload := ev.Payload
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
var cid pgtype.UUID
if compID != nil {
cid = pgtype.UUID{Bytes: *compID, Valid: true}
}
row, err := q.InsertEvent(ctx, db.InsertEventParams{
ProjectID: projectID,
ComponentID: cid,
Type: ev.Type,
Severity: sev,
Message: ev.Message,
Payload: payload,
Fingerprint: fp,
Ts: pgtype.Timestamptz{Time: ts, Valid: true},
})
if err != nil {
continue
}
out := TelemetryEvent{
ID: row.ID,
ProjectID: row.ProjectID,
Type: row.Type,
Severity: row.Severity,
Message: row.Message,
Payload: row.Payload,
Fingerprint: row.Fingerprint,
TS: row.Ts.Time,
}
if compID != nil {
out.ComponentID = compID
out.ComponentName = ev.Component
}
inserted = append(inserted, out)
}
return nil
})
if err != nil {
return 0, err
}
_ = s.repo.Queries().TouchAPIKey(ctx, *actor.APIKeyID)
for _, ev := range inserted {
s.hub.Broadcast(ev.ProjectID.String(), ev)
}
return len(inserted), nil
}
// SubscribeTelemetry validates read access and returns an event channel.
func (s *PlatformService) SubscribeTelemetry(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
return s.hub.Subscribe(projectID.String()), nil
}
func (s *PlatformService) UnsubscribeTelemetry(projectID uuid.UUID, ch chan []byte) {
s.hub.Unsubscribe(projectID.String(), ch)
}
type EventListFilters struct {
Type string
Component string
Fingerprint string
Before *int64
Limit int
}
func (s *PlatformService) ListEvents(ctx context.Context, actor *models.Actor, projectID uuid.UUID, f EventListFilters) ([]TelemetryEvent, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if f.Limit <= 0 || f.Limit > 500 {
f.Limit = 100
}
rows, err := s.repo.Queries().ListEvents(ctx, db.ListEventsParams{
ProjectID: projectID,
Type: strPtr(f.Type),
Component: strPtr(f.Component),
Fingerprint: strPtr(f.Fingerprint),
Before: f.Before,
Limit: int32(f.Limit),
})
if err != nil {
return nil, err
}
out := make([]TelemetryEvent, 0, len(rows))
for _, r := range rows {
out = append(out, toTelemetryEvent(r))
}
return out, nil
}
func (s *PlatformService) ListIssues(ctx context.Context, actor *models.Actor, projectID uuid.UUID, days int) ([]TelemetryIssue, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if days <= 0 || days > 365 {
days = 30
}
rows, err := s.repo.Queries().ListErrorGroups(ctx, db.ListErrorGroupsParams{
ProjectID: projectID,
Days: strPtr(fmt.Sprint(days)),
})
if err != nil {
return nil, err
}
out := make([]TelemetryIssue, 0, len(rows))
for _, r := range rows {
issue := TelemetryIssue{
Fingerprint: r.Fingerprint,
Message: r.Message,
Count: r.Count,
Severity: r.Severity,
}
if r.FirstSeen.Valid {
issue.FirstSeen = r.FirstSeen.Time
}
if r.LastSeen.Valid {
issue.LastSeen = r.LastSeen.Time
}
if id, err := uuid.Parse(r.ComponentID); err == nil {
issue.ComponentID = &id
}
issue.ComponentName = r.ComponentName
out = append(out, issue)
}
return out, nil
}
func windowSeconds(w string) int64 {
switch w {
case "1h":
return 3600
case "7d":
return 7 * 86400
case "30d":
return 30 * 86400
default:
return 86400
}
}
func bucketSeconds(window int64) int64 {
switch {
case window <= 3600:
return 60
case window <= 86400:
return 900
case window <= 7*86400:
return 3600
default:
return 86400
}
}
type EventSeriesBucket struct {
TS time.Time `json:"ts"`
Counts map[string]int64 `json:"counts"`
}
type MetricPoint struct {
TS time.Time `json:"ts"`
Avg *float64 `json:"avg"`
P50 *float64 `json:"p50"`
P95 *float64 `json:"p95"`
Max *float64 `json:"max"`
Count int64 `json:"count"`
}
type TelemetryStatsResult struct {
Window int64 `json:"window"`
BucketSec int64 `json:"bucket_sec"`
Series []EventSeriesBucket `json:"series"`
Components []ComponentSummary `json:"components"`
Errors int64 `json:"errors"`
Events int64 `json:"events"`
Metrics int64 `json:"metrics"`
MetricNames []string `json:"metric_names"`
}
func (s *PlatformService) TelemetryStats(ctx context.Context, actor *models.Actor, projectID uuid.UUID, window string) (*TelemetryStatsResult, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
win := windowSeconds(window)
bucket := bucketSeconds(win)
seriesRows, err := s.repo.Queries().EventTypeSeries(ctx, db.EventTypeSeriesParams{
ProjectID: projectID,
BucketSec: bucket,
WindowSec: win,
})
if err != nil {
return nil, err
}
series := make([]EventSeriesBucket, 0, len(seriesRows))
for _, r := range seriesRows {
n := len(series)
if n == 0 || !series[n-1].TS.Equal(r.Bucket.Time) {
series = append(series, EventSeriesBucket{TS: r.Bucket.Time, Counts: map[string]int64{}})
n++
}
series[n-1].Counts[r.Type] = r.Count
}
compRows, err := s.repo.Queries().ComponentHealthRows(ctx, projectID)
if err != nil {
return nil, err
}
components := make([]ComponentSummary, 0, len(compRows))
for _, r := range compRows {
c := ComponentSummary{ID: r.ID, Name: r.Name, Kind: r.Kind, Errors24h: r.Errors24h, Events24h: r.Events24h}
if r.LastSeenAt.Valid {
t := r.LastSeenAt.Time
c.LastSeenAt = &t
}
c.LastStatus = r.LastStatus
if len(r.Meta) > 0 {
_ = json.Unmarshal(r.Meta, &c.Meta)
}
components = append(components, c)
}
totals, err := s.repo.Queries().EventTotals(ctx, db.EventTotalsParams{ProjectID: projectID, WindowSec: win})
if err != nil {
return nil, err
}
names, err := s.repo.Queries().MetricNames(ctx, projectID)
if err != nil {
return nil, err
}
if names == nil {
names = []string{}
}
return &TelemetryStatsResult{
Window: win,
BucketSec: bucket,
Series: series,
Components: components,
Errors: totals.Errors,
Events: totals.Events,
Metrics: totals.Metrics,
MetricNames: names,
}, nil
}
func (s *PlatformService) MetricSeriesData(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name, window string) ([]MetricPoint, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if name == "" {
return nil, errors.New("name required")
}
win := windowSeconds(window)
rows, err := s.repo.Queries().MetricSeries(ctx, db.MetricSeriesParams{
ProjectID: projectID,
Name: name,
BucketSec: bucketSeconds(win),
WindowSec: win,
})
if err != nil {
return nil, err
}
out := make([]MetricPoint, 0, len(rows))
for _, r := range rows {
out = append(out, MetricPoint{TS: r.Bucket.Time, Avg: &r.Avg, P50: &r.P50, P95: &r.P95, Max: &r.Max, Count: r.Count})
}
return out, nil
}
func (s *PlatformService) ListComponents(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ComponentSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListComponents(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]ComponentSummary, 0, len(rows))
for _, r := range rows {
c := ComponentSummary{ID: r.ID, Name: r.Name, Kind: r.Kind}
if len(r.Meta) > 0 {
_ = json.Unmarshal(r.Meta, &c.Meta)
}
out = append(out, c)
}
return out, nil
}
func (s *PlatformService) DeleteComponent(ctx context.Context, actor *models.Actor, projectID, componentID uuid.UUID) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
return s.repo.Queries().DeleteComponent(ctx, db.DeleteComponentParams{ID: componentID, ProjectID: projectID})
}
func toTelemetryEvent(r db.ListEventsRow) TelemetryEvent {
ev := TelemetryEvent{
ID: r.ID,
ProjectID: r.ProjectID,
Type: r.Type,
Severity: r.Severity,
Message: r.Message,
Payload: r.Payload,
Fingerprint: r.Fingerprint,
TS: r.Ts.Time,
}
if r.ComponentID.Valid {
id := uuid.UUID(r.ComponentID.Bytes)
ev.ComponentID = &id
}
if r.ComponentName != nil {
ev.ComponentName = *r.ComponentName
}
return ev
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
+436
View File
@@ -13,6 +13,7 @@ tags:
- name: Storage
- name: 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]