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:
2026-02-08 05:13:38 -03:00
parent f150ef6ac8
commit c9e68c5048
6 changed files with 781 additions and 0 deletions

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