init ingestion

This commit is contained in:
2026-05-24 22:59:24 +07:00
commit 4e8c11d545
80 changed files with 5639 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"github.com/go-playground/validator/v10"
"go.uber.org/zap"
"github.com/dbiz/cdp/ingestion/ingest/internal/apperr"
"github.com/dbiz/cdp/ingestion/ingest/internal/middleware"
"github.com/dbiz/cdp/ingestion/ingest/internal/model"
"github.com/dbiz/cdp/ingestion/ingest/internal/service"
)
type EventHandler struct {
svc *service.IngestService
val *validator.Validate
log *zap.Logger
}
func NewEventHandler(svc *service.IngestService, log *zap.Logger) *EventHandler {
return &EventHandler{
svc: svc,
val: validator.New(),
log: log,
}
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
// Single-event endpoints. They differ only in the `type` they force on the
// body, so they all funnel into one handler.
func (h *EventHandler) Track(w http.ResponseWriter, r *http.Request) {
h.handleSingle(w, r, model.EventTypeTrack)
}
func (h *EventHandler) Identify(w http.ResponseWriter, r *http.Request) {
h.handleSingle(w, r, model.EventTypeIdentify)
}
func (h *EventHandler) Page(w http.ResponseWriter, r *http.Request) {
h.handleSingle(w, r, model.EventTypePage)
}
func (h *EventHandler) Group(w http.ResponseWriter, r *http.Request) {
h.handleSingle(w, r, model.EventTypeGroup)
}
func (h *EventHandler) Alias(w http.ResponseWriter, r *http.Request) {
h.handleSingle(w, r, model.EventTypeAlias)
}
func (h *EventHandler) Screen(w http.ResponseWriter, r *http.Request) {
h.handleSingle(w, r, model.EventTypeScreen)
}
func (h *EventHandler) Batch(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
h.writeErr(w, apperr.PayloadTooLarge("payload too large"))
return
}
var env model.BatchEnvelope
if err := json.Unmarshal(body, &env); err != nil {
h.writeErr(w, apperr.BadRequest("invalid json body", "", err))
return
}
if err := h.val.Struct(&env); err != nil {
h.writeErr(w, apperr.BadRequest("validation failed", firstField(err), err))
return
}
ictx := h.ingestCtx(r, body)
results := h.svc.IngestBatch(r.Context(), ictx, env.Batch)
// Per-event status -- 200 OK, with an array of {message_id, ok, error}.
type item struct {
MessageID string `json:"messageId"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Field string `json:"field,omitempty"`
}
out := make([]item, len(env.Batch))
for i, e := range env.Batch {
it := item{MessageID: e.MessageID, OK: true}
if results[i] != nil {
it.OK = false
if ae, ok := apperr.As(results[i]); ok {
it.Error = ae.Message
it.Field = ae.Field
} else {
it.Error = "internal error"
}
}
out[i] = it
}
writeJSON(w, http.StatusOK, map[string]any{"results": out})
}
// ---------------------------------------------------------------------------
// Health / Ready
// ---------------------------------------------------------------------------
func (h *EventHandler) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *EventHandler) Ready(w http.ResponseWriter, r *http.Request) {
// Liveness is enough for k8s readiness in this scaffold. Wire in real
// dependency checks (PG ping, Kafka ping) when needed.
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
// ---------------------------------------------------------------------------
// shared helpers
// ---------------------------------------------------------------------------
func (h *EventHandler) handleSingle(w http.ResponseWriter, r *http.Request, t model.EventType) {
body, err := io.ReadAll(r.Body)
if err != nil {
h.writeErr(w, apperr.PayloadTooLarge("payload too large"))
return
}
var raw model.RawEvent
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&raw); err != nil {
h.writeErr(w, apperr.BadRequest("invalid json body", "", err))
return
}
if raw.Type == "" {
raw.Type = t
}
if err := h.val.Struct(&raw); err != nil {
h.writeErr(w, apperr.BadRequest("validation failed", firstField(err), err))
return
}
ictx := h.ingestCtx(r, body)
if err := h.svc.Ingest(r.Context(), ictx, &raw); err != nil {
h.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (h *EventHandler) ingestCtx(r *http.Request, body []byte) service.IngestContext {
wk := middleware.WriteKeyFromCtx(r.Context())
return service.IngestContext{
WorkspaceID: wk.WorkspaceID,
SourceID: wk.SourceID,
IP: clientIP(r),
UserAgent: r.UserAgent(),
RawBody: body,
}
}
func (h *EventHandler) writeErr(w http.ResponseWriter, err error) {
if ae, ok := apperr.As(err); ok {
if ae.RetryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(ae.RetryAfter))
}
writeJSON(w, ae.Code, errorResponse{Error: ae.Message, Field: ae.Field})
if ae.Err != nil {
h.log.Warn("request error",
zap.Int("code", ae.Code),
zap.String("msg", ae.Message),
zap.Error(ae.Err))
}
return
}
h.log.Error("unhandled error", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "internal server error"})
}
type errorResponse struct {
Error string `json:"error"`
Field string `json:"field,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func firstField(err error) string {
var verrs validator.ValidationErrors
if errors.As(err, &verrs) && len(verrs) > 0 {
return verrs[0].Field()
}
return ""
}
// clientIP duplicates middleware.clientIP -- intentionally small, no shared types.
func clientIP(r *http.Request) string {
if h := r.Header.Get("X-Forwarded-For"); h != "" {
return h
}
return r.RemoteAddr
}