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

@@ -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
}