Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ func main() {
// config + logger
cfg := config.Load()
logger := ilog.New()
defer logger.Sync()

r := chi.NewRouter()

r.Use(middleware.RequestID)
r.Use(apphttp.MetricsMiddleware)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(apphttp.RequestLogger(logger.Desugar()))
r.Use(middleware.Recoverer)

h := handlers.NewHandler(sqlDB)
h := handlers.NewHandler(sqlDB, logger)

apphttp.Register(r, h)

Expand Down
18 changes: 18 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
extra_hosts:
- "host.docker.internal:host-gateway" # Allows Linux containers to talk to host

grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin # Default password
depends_on:
- prometheus
22 changes: 20 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,27 @@ go 1.25.2

require (
github.com/go-chi/chi/v5 v5.2.3
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mattn/go-sqlite3 v1.14.32
github.com/pressly/goose/v3 v3.26.0
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)

require (
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/pressly/goose/v3 v3.26.0 // indirect
github.com/prometheus/client_golang v1.23.2
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.16.0 // indirect
Expand Down
55 changes: 55 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,14 +1,69 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
16 changes: 13 additions & 3 deletions internal/http/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ package handlers

import (
"database/sql"

lru "github.com/hashicorp/golang-lru/v2"
ilog "github.com/joeynolan/go-http-server/internal/platform/log"
)

type Handler struct {
DB *sql.DB
DB *sql.DB
Log *ilog.Logger
cache *lru.Cache[string, string]
}

func NewHandler(db *sql.DB) *Handler {
return &Handler{DB: db}
func NewHandler(db *sql.DB, logger *ilog.Logger) *Handler {
cache, _ := lru.New[string, string](32)
return &Handler{
DB: db,
Log: logger,
cache: cache,
}
}
29 changes: 16 additions & 13 deletions internal/http/handlers/redirect.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,33 @@ import (
"strings"

"github.com/go-chi/chi/v5"
ilog "github.com/joeynolan/go-http-server/internal/platform/log"
)

func (h *Handler) RedirectHandler(w http.ResponseWriter, r *http.Request) {
logger := ilog.New()

code := chi.URLParam(r, "code")
if code == "" {
WriteError(w, http.StatusBadRequest, "missing code")
return
}

var url string
logger.Infof("Code: %s", code)
err := h.DB.QueryRowContext(r.Context(), "SELECT url FROM links WHERE code = ?", code).Scan(&url)
logger.Infof("URL: %s", url)
if err == sql.ErrNoRows {
WriteError(w, http.StatusNotFound, "code not found")
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "lookup failed")
return
h.Log.Infof("Code: %s", code)
if val, ok := h.cache.Get(code); ok {
h.Log.Infof("Cache hit for code: %s", code)
url = val
} else {
err := h.DB.QueryRowContext(r.Context(), "SELECT url FROM links WHERE code = ?", code).Scan(&url)
if err == sql.ErrNoRows {
WriteError(w, http.StatusNotFound, "code not found")
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "lookup failed")
return
}
h.cache.Add(code, url)
}
h.Log.Infof("URL: %s", url)

target := url
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
Expand Down
33 changes: 33 additions & 0 deletions internal/http/logging.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package http

import (
"net/http"
"time"

"github.com/go-chi/chi/v5/middleware"
"go.uber.org/zap"
)

// RequestLogger logs request/response metadata with zap.
func RequestLogger(logger *zap.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
reqID := middleware.GetReqID(r.Context())

next.ServeHTTP(ww, r)

logger.Info("request completed",
zap.String("req_id", reqID),
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.Int("status", ww.Status()),
zap.Int("bytes", ww.BytesWritten()),
zap.Duration("latency", time.Since(start)),
zap.String("remote", r.RemoteAddr),
zap.String("user_agent", r.UserAgent()),
)
})
}
}
67 changes: 67 additions & 0 deletions internal/http/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package http

import (
"net/http"
"strconv"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
httpRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests processed.",
},
[]string{"method", "route", "status"},
)

httpRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds.",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "route", "status"},
)
)

// MetricsMiddleware records request count and latency by method/route/status.
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)

next.ServeHTTP(ww, r)

route := routePattern(r)
statusCode := ww.Status()
labels := prometheus.Labels{
"method": r.Method,
"route": route,
"status": strconv.Itoa(statusCode),
}

httpRequestsTotal.With(labels).Inc()
httpRequestDuration.With(labels).Observe(time.Since(start).Seconds())
})
}

// MetricsHandler exposes the Prometheus scrape endpoint.
func MetricsHandler() http.Handler {
return promhttp.Handler()
}

func routePattern(r *http.Request) string {
if rctx := chi.RouteContext(r.Context()); rctx != nil {
if rp := rctx.RoutePattern(); rp != "" {
return rp
}
}
return "unknown"
}
1 change: 1 addition & 0 deletions internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ func Register(r chi.Router, h *handlers.Handler) {
r.Get("/health", handlers.HealthHandler)
r.Get("/v1/r/{code}", h.RedirectHandler)
r.Post("/v1/shorten", h.ShortenHandler)
r.Handle("/metrics", MetricsHandler())
}
43 changes: 32 additions & 11 deletions internal/platform/log/log.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
package log

import (
"fmt"
"log"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

type Logger struct{}
// Logger wraps a zap logger so callers can keep using familiar sugar helpers.
type Logger struct {
*zap.SugaredLogger
base *zap.Logger
}

func New() *Logger { return &Logger{} }
// New returns a production-ready zap logger.
func New() *Logger {
cfg := zap.NewProductionConfig()
cfg.EncoderConfig.TimeKey = "ts"
cfg.EncoderConfig.MessageKey = "msg"
cfg.EncoderConfig.LevelKey = "level"
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder

func (l *Logger) Infof(format string, v ...any) { log.Printf("INFO "+format, v...) }
func (l *Logger) Errorf(format string, v ...any) { log.Printf("ERROR "+format, v...) }
base, err := cfg.Build()
if err != nil {
panic(err)
}

// Optional convenience
func (l *Logger) Println(v ...any) { log.Println(v...) }
func (l *Logger) Printf(f string, v ...any) { log.Printf(f, v...) }
return &Logger{
SugaredLogger: base.Sugar(),
base: base,
}
}

// Example usage: logger.Infof("starting on :%d", port)
func Sprintf(f string, v ...any) string { return fmt.Sprintf(f, v...) }
// Sync flushes any buffered log entries.
func (l *Logger) Sync() {
_ = l.base.Sync()
}

// Desugar exposes the underlying structured logger.
func (l *Logger) Desugar() *zap.Logger {
return l.base
}
7 changes: 7 additions & 0 deletions prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
global:
scrape_interval: 5s # Scrape often for dev/learning (default is usually 1m)

scrape_configs:
- job_name: 'go_app'
static_configs:
- targets: ['host.docker.internal:8080'] # <--- CRITICAL: Points to your host machine