Stage 1: backend foundation (Postgres, sessions, accounts, OTel)
Tests · Go / test (push) Successful in 11s
Tests · Integration / integration (push) Successful in 8s

- internal/postgres: pgx-over-database/sql pool (otelsql), embedded goose
  migrations into schema 'backend', committed go-jet code + cmd/jetgen tool.
- internal/account: durable accounts + unified telegram/email identities
  (UUIDv7 keys), find-or-create provisioning with unique-conflict handling.
- internal/session: opaque 256-bit tokens stored as a SHA-256 hash, revoke-only
  (no TTL); write-through cache gating /readyz; store + service.
- internal/telemetry: OTel tracer/meter providers (none/stdout) + request-timing
  middleware; internal/config gains Postgres + OTel env loading.
- internal/server: /api/v1 {public,user,internal,admin} skeleton + X-User-ID
  middleware; /readyz checks DB ping + cache; main wires
  telemetry -> db+migrate -> warm cache -> server.
- Tests: unit + integration (build tag 'integration', testcontainers
  postgres:17) for migrations, accounts, sessions, readyz; new integration.yaml.
- Docs: ARCHITECTURE, TESTING, PLAN refinements, root + backend READMEs.

Session/account REST handlers deferred to Stage 6 (gateway); OTLP + dashboards
to Stage 11.
This commit is contained in:
Ilia Denisov
2026-06-02 13:52:26 +02:00
parent da079b2bc6
commit eeaad62b10
45 changed files with 3461 additions and 92 deletions
+74
View File
@@ -0,0 +1,74 @@
package telemetry
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.uber.org/zap"
)
// tracerName names the instrumentation scope for backend HTTP spans.
const tracerName = "scrabble/backend/server"
// Middleware returns gin middleware that, for every request, opens a server
// span, measures server-side latency, and emits a structured access log
// correlated with the active trace. It uses the globally-registered tracer, so
// spans are exported only when an exporter is configured, while the timing log
// is always emitted. Probe paths (/healthz, /readyz) log at debug level to keep
// the default log clean.
func Middleware(logger *zap.Logger) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
tracer := otel.Tracer(tracerName)
return func(c *gin.Context) {
start := time.Now()
route := c.FullPath()
if route == "" {
route = c.Request.URL.Path
}
ctx, span := tracer.Start(c.Request.Context(), c.Request.Method+" "+route)
c.Request = c.Request.WithContext(ctx)
c.Next()
status := c.Writer.Status()
elapsed := time.Since(start)
span.SetAttributes(
attribute.String("http.request.method", c.Request.Method),
attribute.String("http.route", route),
attribute.Int("http.response.status_code", status),
)
if status >= http.StatusInternalServerError {
span.SetStatus(codes.Error, http.StatusText(status))
}
span.End()
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", route),
zap.Int("status", status),
zap.Duration("latency", elapsed),
}
fields = append(fields, TraceFieldsFromContext(ctx)...)
if isProbePath(c.Request.URL.Path) {
logger.Debug("http request", fields...)
return
}
logger.Info("http request", fields...)
}
}
// isProbePath reports whether path is one of the unauthenticated infrastructure
// probes, whose access logs are demoted to debug level.
func isProbePath(path string) bool {
return path == "/healthz" || path == "/readyz"
}