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]
|
||||
Reference in new issue
Block a user