Files
Primora/apps/backend/internal/handlers/telemetry.go
T
Tomas DvorakandDevin 8cfddf9184 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>
2026-09-16 16:21:03 +02:00

205 lines
5.3 KiB
Go

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