mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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>
205 lines
5.3 KiB
Go
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
|
|
}
|
|
})
|
|
}
|