feat(backend): bootstrap Go module, config, and data plane
Introduce configuration validation, Postgres migrations, Redis/storage adapters, and the seed sample video.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lightweight in-process Prometheus text metrics (no promhttp / compress deps).
|
||||
|
||||
type counterVec struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
help string
|
||||
labels []string
|
||||
values map[string]*atomic.Int64
|
||||
}
|
||||
|
||||
func newCounterVec(name, help string, labels []string) *counterVec {
|
||||
return &counterVec{
|
||||
name: name,
|
||||
help: help,
|
||||
labels: labels,
|
||||
values: map[string]*atomic.Int64{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *counterVec) WithLabelValues(vals ...string) *atomic.Int64 {
|
||||
key := strings.Join(vals, "\x00")
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if v, ok := c.values[key]; ok {
|
||||
return v
|
||||
}
|
||||
v := &atomic.Int64{}
|
||||
c.values[key] = v
|
||||
return v
|
||||
}
|
||||
|
||||
func (c *counterVec) Inc(vals ...string) {
|
||||
c.WithLabelValues(vals...).Add(1)
|
||||
}
|
||||
|
||||
func (c *counterVec) write(b *strings.Builder) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s counter\n", c.name, c.help, c.name)
|
||||
keys := make([]string, 0, len(c.values))
|
||||
for k := range c.values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
parts := strings.Split(k, "\x00")
|
||||
var labelPairs []string
|
||||
for i, lab := range c.labels {
|
||||
val := ""
|
||||
if i < len(parts) {
|
||||
val = parts[i]
|
||||
}
|
||||
labelPairs = append(labelPairs, fmt.Sprintf(`%s="%s"`, lab, escapeLabel(val)))
|
||||
}
|
||||
fmt.Fprintf(b, "%s{%s} %d\n", c.name, strings.Join(labelPairs, ","), c.values[k].Load())
|
||||
}
|
||||
}
|
||||
|
||||
type counter struct {
|
||||
name string
|
||||
help string
|
||||
value atomic.Int64
|
||||
}
|
||||
|
||||
func newCounter(name, help string) *counter {
|
||||
return &counter{name: name, help: help}
|
||||
}
|
||||
|
||||
func (c *counter) Inc() { c.value.Add(1) }
|
||||
|
||||
func (c *counter) write(b *strings.Builder) {
|
||||
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", c.name, c.help, c.name, c.name, c.value.Load())
|
||||
}
|
||||
|
||||
type histVec struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
help string
|
||||
buckets []float64
|
||||
obs map[string]*histObs
|
||||
}
|
||||
|
||||
type histObs struct {
|
||||
counts []atomic.Uint64
|
||||
sum atomic.Uint64 // milliseconds * 1000 for precision via float bits — use float64 via mutex instead
|
||||
sumMu sync.Mutex
|
||||
sumF float64
|
||||
count atomic.Uint64
|
||||
}
|
||||
|
||||
func newHistVec(name, help string, buckets []float64) *histVec {
|
||||
return &histVec{
|
||||
name: name,
|
||||
help: help,
|
||||
buckets: buckets,
|
||||
obs: map[string]*histObs{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *histVec) Observe(method, path string, seconds float64) {
|
||||
key := method + "\x00" + path
|
||||
h.mu.Lock()
|
||||
o, ok := h.obs[key]
|
||||
if !ok {
|
||||
o = &histObs{counts: make([]atomic.Uint64, len(h.buckets))}
|
||||
h.obs[key] = o
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for i, b := range h.buckets {
|
||||
if seconds <= b {
|
||||
o.counts[i].Add(1)
|
||||
}
|
||||
}
|
||||
o.count.Add(1)
|
||||
o.sumMu.Lock()
|
||||
o.sumF += seconds
|
||||
o.sumMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *histVec) write(b *strings.Builder) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s histogram\n", h.name, h.help, h.name)
|
||||
keys := make([]string, 0, len(h.obs))
|
||||
for k := range h.obs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
parts := strings.Split(k, "\x00")
|
||||
method, path := parts[0], ""
|
||||
if len(parts) > 1 {
|
||||
path = parts[1]
|
||||
}
|
||||
base := fmt.Sprintf(`method="%s",path="%s"`, escapeLabel(method), escapeLabel(path))
|
||||
o := h.obs[k]
|
||||
for i, bound := range h.buckets {
|
||||
fmt.Fprintf(b, `%s_bucket{%s,le="%g"} %d`+"\n", h.name, base, bound, o.counts[i].Load())
|
||||
}
|
||||
fmt.Fprintf(b, `%s_bucket{%s,le="+Inf"} %d`+"\n", h.name, base, o.count.Load())
|
||||
o.sumMu.Lock()
|
||||
sum := o.sumF
|
||||
o.sumMu.Unlock()
|
||||
fmt.Fprintf(b, "%s_sum{%s} %g\n", h.name, base, sum)
|
||||
fmt.Fprintf(b, "%s_count{%s} %d\n", h.name, base, o.count.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func escapeLabel(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
return s
|
||||
}
|
||||
|
||||
var (
|
||||
httpRequests = newCounterVec("pargar_http_requests_total", "Total HTTP requests", []string{"method", "path", "status"})
|
||||
httpDuration = newHistVec("pargar_http_request_duration_seconds", "HTTP request duration", []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10})
|
||||
quizSubmits = newCounterVec("pargar_quiz_submits_total", "Quiz submit outcomes", []string{"result"})
|
||||
lockouts = newCounter("pargar_account_lockouts_total", "Accounts locked after heart depletion")
|
||||
heartbeats = newCounter("pargar_watch_heartbeats_total", "Accepted watch heartbeats")
|
||||
videoUploads = newCounter("pargar_video_uploads_total", "Admin video uploads")
|
||||
logins = newCounterVec("pargar_auth_logins_total", "Login attempts", []string{"result"})
|
||||
)
|
||||
|
||||
// Compatibility wrappers matching previous call sites.
|
||||
|
||||
type labeledInc struct{ vec *counterVec }
|
||||
|
||||
func (l labeledInc) WithLabelValues(vals ...string) interface{ Inc() } {
|
||||
return labeledCounter{c: l.vec.WithLabelValues(vals...)}
|
||||
}
|
||||
|
||||
type labeledCounter struct{ c *atomic.Int64 }
|
||||
|
||||
func (l labeledCounter) Inc() { l.c.Add(1) }
|
||||
|
||||
var (
|
||||
HTTPRequests = labeledInc{httpRequests}
|
||||
QuizSubmits = labeledInc{quizSubmits}
|
||||
Logins = labeledInc{logins}
|
||||
Lockouts = lockouts
|
||||
Heartbeats = heartbeats
|
||||
VideoUploads = videoUploads
|
||||
)
|
||||
|
||||
// ObserveHTTP records request metrics.
|
||||
func ObserveHTTP(method, path string, status int, d time.Duration) {
|
||||
httpRequests.Inc(method, path, fmt.Sprintf("%d", status))
|
||||
httpDuration.Observe(method, path, d.Seconds())
|
||||
}
|
||||
|
||||
// MetricsHandler serves Prometheus text exposition.
|
||||
func MetricsHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b strings.Builder
|
||||
httpRequests.write(&b)
|
||||
httpDuration.write(&b)
|
||||
quizSubmits.write(&b)
|
||||
lockouts.write(&b)
|
||||
heartbeats.write(&b)
|
||||
videoUploads.write(&b)
|
||||
logins.write(&b)
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// Config holds optional OpenTelemetry settings.
|
||||
type Config struct {
|
||||
ServiceName string
|
||||
OTLPEndpoint string // empty = noop tracer
|
||||
}
|
||||
|
||||
var (
|
||||
logger *slog.Logger
|
||||
tracer trace.Tracer = noop.NewTracerProvider().Tracer("pargar")
|
||||
)
|
||||
|
||||
// SetupJSONLogger installs a JSON slog default logger.
|
||||
func SetupJSONLogger() *slog.Logger {
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
logger = slog.New(handler)
|
||||
slog.SetDefault(logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
func L() *slog.Logger {
|
||||
if logger == nil {
|
||||
return slog.Default()
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
// Activity emits a structured domain activity log line.
|
||||
func Activity(action string, attrs ...any) {
|
||||
args := make([]any, 0, len(attrs)+2)
|
||||
args = append(args, "action", action)
|
||||
args = append(args, attrs...)
|
||||
L().Info("activity", args...)
|
||||
}
|
||||
|
||||
// SetupTracing configures OTLP HTTP exporter when endpoint is set; otherwise noop.
|
||||
// Returns a shutdown func (always non-nil).
|
||||
func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error, error) {
|
||||
name := strings.TrimSpace(cfg.ServiceName)
|
||||
if name == "" {
|
||||
name = "pargar-api"
|
||||
}
|
||||
endpoint := strings.TrimSpace(cfg.OTLPEndpoint)
|
||||
if endpoint == "" {
|
||||
tp := noop.NewTracerProvider()
|
||||
otel.SetTracerProvider(tp)
|
||||
tracer = tp.Tracer(name)
|
||||
L().Info("otel tracing disabled (no OTEL_EXPORTER_OTLP_ENDPOINT)")
|
||||
return func(context.Context) error { return nil }, nil
|
||||
}
|
||||
|
||||
endpoint = strings.TrimPrefix(endpoint, "https://")
|
||||
endpoint = strings.TrimPrefix(endpoint, "http://")
|
||||
|
||||
exp, err := otlptracehttp.New(ctx,
|
||||
otlptracehttp.WithEndpoint(endpoint),
|
||||
otlptracehttp.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(name),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exp),
|
||||
sdktrace.WithResource(res),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.TraceContext{})
|
||||
tracer = tp.Tracer(name)
|
||||
L().Info("otel tracing enabled", "endpoint", endpoint, "service", name)
|
||||
return tp.Shutdown, nil
|
||||
}
|
||||
|
||||
func Tracer() trace.Tracer { return tracer }
|
||||
|
||||
// StartSpan starts a named span from context.
|
||||
func StartSpan(ctx context.Context, name string) (context.Context, trace.Span) {
|
||||
return Tracer().Start(ctx, name)
|
||||
}
|
||||
|
||||
// DurationMs helper for middleware logs.
|
||||
func DurationMs(start time.Time) int64 {
|
||||
return time.Since(start).Milliseconds()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package observability
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSetupTracingNoop(t *testing.T) {
|
||||
shutdown, err := SetupTracing(t.Context(), Config{ServiceName: "test", OTLPEndpoint: ""})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shutdown == nil {
|
||||
t.Fatal("expected shutdown func")
|
||||
}
|
||||
if err := shutdown(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, span := StartSpan(t.Context(), "test.span")
|
||||
_ = ctx
|
||||
span.End()
|
||||
}
|
||||
Reference in New Issue
Block a user