feat: add AI APM module for AI/LLM call telemetry
- internal/aiapm/types.go: AICallRecord, filter, summary, and stats types - internal/aiapm/pricing.go: vendor pricing tables (Anthropic, OpenAI, Google, Mistral, DeepSeek, Groq) - internal/aiapm/store.go: PostgreSQL storage with batch insert, filtered queries, aggregations, timeseries - internal/aiapm/collector.go: async collector with buffered channel and background batch writer - internal/api/aiapm_handlers.go: Fiber route handlers for ingest, summary, models, vendors, costs, calls, pricing - cmd/server/main.go: register AI APM routes and create ai_calls table at startup
This commit is contained in:
142
internal/api/aiapm_handlers.go
Normal file
142
internal/api/aiapm_handlers.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/bigtux/ophion/internal/aiapm"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// AIAPMHandlers holds dependencies for AI APM route handlers
|
||||
type AIAPMHandlers struct {
|
||||
db *sql.DB
|
||||
collector *aiapm.Collector
|
||||
}
|
||||
|
||||
// RegisterAIAPMRoutes registers all AI APM routes on the given router
|
||||
func RegisterAIAPMRoutes(api fiber.Router, db *sql.DB) *aiapm.Collector {
|
||||
collector := aiapm.NewCollector(db, 5000)
|
||||
h := &AIAPMHandlers{db: db, collector: collector}
|
||||
|
||||
g := api.Group("/ai-apm")
|
||||
g.Post("/ingest", h.Ingest)
|
||||
g.Get("/summary", h.Summary)
|
||||
g.Get("/models", h.Models)
|
||||
g.Get("/vendors", h.Vendors)
|
||||
g.Get("/costs", h.Costs)
|
||||
g.Get("/calls", h.Calls)
|
||||
g.Get("/pricing", h.Pricing)
|
||||
|
||||
return collector
|
||||
}
|
||||
|
||||
// Ingest receives AI call records (single or batch)
|
||||
func (h *AIAPMHandlers) Ingest(c *fiber.Ctx) error {
|
||||
var req aiapm.IngestRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
count := 0
|
||||
if req.Call != nil {
|
||||
h.collector.Collect(*req.Call)
|
||||
count = 1
|
||||
}
|
||||
if len(req.Calls) > 0 {
|
||||
h.collector.CollectBatch(req.Calls)
|
||||
count += len(req.Calls)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "no call records provided; use 'call' or 'calls' field"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"status": "accepted", "count": count})
|
||||
}
|
||||
|
||||
// parseFilter extracts common filter parameters from query string
|
||||
func parseFilter(c *fiber.Ctx) aiapm.AICallFilter {
|
||||
f := aiapm.AICallFilter{
|
||||
ServiceName: c.Query("service"),
|
||||
ProjectID: c.Query("project"),
|
||||
Vendor: c.Query("vendor"),
|
||||
Model: c.Query("model"),
|
||||
Status: c.Query("status"),
|
||||
}
|
||||
if from := c.Query("from"); from != "" {
|
||||
if t, err := time.Parse(time.RFC3339, from); err == nil {
|
||||
f.From = t
|
||||
}
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
if t, err := time.Parse(time.RFC3339, to); err == nil {
|
||||
f.To = t
|
||||
}
|
||||
}
|
||||
if f.From.IsZero() {
|
||||
f.From = time.Now().Add(-24 * time.Hour)
|
||||
}
|
||||
if f.To.IsZero() {
|
||||
f.To = time.Now()
|
||||
}
|
||||
f.Limit = c.QueryInt("limit", 100)
|
||||
f.Offset = c.QueryInt("offset", 0)
|
||||
return f
|
||||
}
|
||||
|
||||
// Summary returns aggregated usage statistics
|
||||
func (h *AIAPMHandlers) Summary(c *fiber.Ctx) error {
|
||||
filter := parseFilter(c)
|
||||
summary, err := aiapm.GetUsageSummary(h.db, filter)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(summary)
|
||||
}
|
||||
|
||||
// Models returns per-model statistics
|
||||
func (h *AIAPMHandlers) Models(c *fiber.Ctx) error {
|
||||
filter := parseFilter(c)
|
||||
stats, err := aiapm.GetModelStats(h.db, filter)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"models": stats})
|
||||
}
|
||||
|
||||
// Vendors returns per-vendor statistics
|
||||
func (h *AIAPMHandlers) Vendors(c *fiber.Ctx) error {
|
||||
filter := parseFilter(c)
|
||||
stats, err := aiapm.GetVendorStats(h.db, filter)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"vendors": stats})
|
||||
}
|
||||
|
||||
// Costs returns cost timeseries data
|
||||
func (h *AIAPMHandlers) Costs(c *fiber.Ctx) error {
|
||||
filter := parseFilter(c)
|
||||
interval := c.Query("interval", "1d")
|
||||
points, err := aiapm.GetCostTimeseries(h.db, filter, interval)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"timeseries": points, "interval": interval})
|
||||
}
|
||||
|
||||
// Calls returns recent AI call records (paginated)
|
||||
func (h *AIAPMHandlers) Calls(c *fiber.Ctx) error {
|
||||
filter := parseFilter(c)
|
||||
calls, err := aiapm.QueryCalls(h.db, filter)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"calls": calls, "count": len(calls)})
|
||||
}
|
||||
|
||||
// Pricing returns the current pricing table
|
||||
func (h *AIAPMHandlers) Pricing(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"pricing": aiapm.GetPricingTable()})
|
||||
}
|
||||
Reference in New Issue
Block a user