Files
Tomas DvorakandDevin 9e9a3d5f98 feat: phase 7 automation & realtime — scheduled jobs, domain events, SSE
Scheduled jobs (migration 00009): cron expressions + @every descriptors,
in-process scheduler (15s scan, missed-run collapse), signed HTTP POST
delivery (X-Primora-Signature), AES-256-GCM sealed secrets returned once,
run history with status/duration/http-code, manual execution, retention
pruning. Viewer reads, admin/developer writes.

Domain events: publishEvent fans document/object/issue/deploy/job
mutations to realtime subscribers and matching webhooks through one
path. Webhook event filter extended to job.run, document.*, object.*.

Realtime: GET /projects/:id/realtime/stream SSE with 25s keepalives and
?token=/?api_key= auth for EventSource clients.

Automation dashboard page: Schedules tab (CRUD, run history, manual run,
secret-once banner) + Live events tab (live badge, pause/clear). Demo
mode parity. primora jobs:* CLI group, six primora_*_job* MCP tools,
AutomationService in the generated client.

Fixes folded in: gzip middleware buffered SSE flushes (broke EventSource
on both streams — */stream now bypasses compression); event payloads
double-marshaled json.RawMessage as base64; compose hardcoded localhost
trusted origins so non-default ports broke sign-in; frontend auth base
URL baked an absolute localhost instead of resolving /auth on origin.

Verified live: stack up on :8085, signed delivery HMAC-checked at sink,
@every 30s schedule firing, run history rendering, document.created
landing in the live events feed.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-18 15:15:01 +02:00

486 lines
13 KiB
Go

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
var newIssues []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(`{}`)
}
newFingerprint := false
if ev.Type == "error" && fp != "" {
seen, err := q.HasFingerprintSeen(ctx, db.HasFingerprintSeenParams{
ProjectID: projectID,
Fingerprint: fp,
})
if err == nil && !seen {
newFingerprint = true
}
}
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)
if newFingerprint {
newIssues = append(newIssues, 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)
}
// A fresh error fingerprint is a new issue group — notify webhooks + SSE.
for _, issue := range newIssues {
s.publishEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{
"fingerprint": issue.Fingerprint,
"message": issue.Message,
"severity": issue.Severity,
"component_id": issue.ComponentID,
"component_name": issue.ComponentName,
"event_id": issue.ID,
"first_seen": issue.TS,
})
}
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
}